diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-03-07 01:55:46 -0600 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-03-07 01:55:46 -0600 |
| commit | 7b3a56ca04e818bd5c1d1998dabde977d960ffe0 (patch) | |
| tree | cc71f6f6cc8f1f63eaa0a8bcd973e4cc3e2de622 | |
| parent | 6e156e449a747f7ea8151a6e70c0eb33209c4770 (diff) | |
Replace lootcli with standalone LOOT GUI AppImage on Linux
- Download LOOT AppImage from GitHub releases on first sort
- Pre-seed LOOT settings.toml with game type, path, and masterlist URL
- Mount FUSE VFS before launching LOOT, unmount after exit
- Copy plugins.txt/loadorder.txt to LOOT local path and back
- Fix VFS file timestamps (separate error_code for mtime)
- Add directory timestamps to FUSE (was showing 1969)
- Stamp plugin files with load-order-based incrementing timestamps
- Remove WebEngine dependency on Linux (QTextBrowser fallback)
- Enable LOOT sorting for FalloutNV and FalloutTTW
- Add unmountVFS() to OrganizerCore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rwxr-xr-x | docker/build-inner.sh | 1 | ||||
| -rw-r--r-- | libs/game_bethesda/src/games/falloutnv/gamefalloutnv.cpp | 5 | ||||
| -rw-r--r-- | libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp | 5 | ||||
| -rw-r--r-- | src/src/CMakeLists.txt | 4 | ||||
| -rw-r--r-- | src/src/fuseconnector.cpp | 16 | ||||
| -rw-r--r-- | src/src/fuseconnector.h | 3 | ||||
| -rw-r--r-- | src/src/loot.cpp | 563 | ||||
| -rw-r--r-- | src/src/loot.h | 5 | ||||
| -rw-r--r-- | src/src/lootdialog.cpp | 56 | ||||
| -rw-r--r-- | src/src/organizercore.cpp | 31 | ||||
| -rw-r--r-- | src/src/organizercore.h | 1 | ||||
| -rw-r--r-- | src/src/sanitychecks.cpp | 4 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.cpp | 6 | ||||
| -rw-r--r-- | src/src/vfs/vfstree.cpp | 47 | ||||
| -rw-r--r-- | src/src/vfs/vfstree.h | 7 |
15 files changed, 730 insertions, 24 deletions
diff --git a/docker/build-inner.sh b/docker/build-inner.sh index 01a644d..1bc33e1 100755 --- a/docker/build-inner.sh +++ b/docker/build-inner.sh @@ -93,6 +93,7 @@ if [ -d "build/src/src/stylesheets" ]; then echo "Bundled stylesheets" fi + # ── 7z runtime ── SO7="build/src/src/dlls/7z.so" if [ -f "${SO7}" ]; then diff --git a/libs/game_bethesda/src/games/falloutnv/gamefalloutnv.cpp b/libs/game_bethesda/src/games/falloutnv/gamefalloutnv.cpp index effc806..7a147e5 100644 --- a/libs/game_bethesda/src/games/falloutnv/gamefalloutnv.cpp +++ b/libs/game_bethesda/src/games/falloutnv/gamefalloutnv.cpp @@ -200,9 +200,8 @@ QList<PluginSetting> GameFalloutNV::settings() const { return QList<PluginSetting>() << PluginSetting("enable_loot_sorting", - tr("While not recommended by the FNV modding community, " - "enables LOOT sorting"), - false); + tr("Enables LOOT sorting for Fallout New Vegas"), + true); } void GameFalloutNV::initializeProfile(const QDir& path, ProfileSettings settings) const diff --git a/libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp b/libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp index af1ea31..3ffa5a1 100644 --- a/libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp +++ b/libs/game_bethesda/src/games/ttw/gamefalloutttw.cpp @@ -209,9 +209,8 @@ QList<PluginSetting> GameFalloutTTW::settings() const { return QList<PluginSetting>() << PluginSetting("enable_loot_sorting", - tr("While not recommended by the TTW modding community, " - "enables LOOT sorting"), - false); + tr("Enables LOOT sorting for Tale of Two Wastelands"), + true); } void GameFalloutTTW::initializeProfile(const QDir& path, ProfileSettings settings) const diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt index f53f02a..e69a814 100644 --- a/src/src/CMakeLists.txt +++ b/src/src/CMakeLists.txt @@ -101,7 +101,7 @@ target_link_libraries(organizer PRIVATE mo2::nak_ffi # Qt6 Qt6::Widgets - $<$<BOOL:${Qt6WebEngineWidgets_FOUND}>:Qt6::WebEngineWidgets> + $<$<TARGET_EXISTS:Qt6::WebEngineWidgets>:Qt6::WebEngineWidgets> Qt6::WebSockets Qt6::Network # Boost @@ -116,7 +116,7 @@ target_link_libraries(organizer PRIVATE target_compile_definitions(organizer PRIVATE SPDLOG_USE_STD_FORMAT FUSE_USE_VERSION=35 - $<$<BOOL:${Qt6WebEngineWidgets_FOUND}>:MO2_WEBENGINE>) + $<$<TARGET_EXISTS:Qt6::WebEngineWidgets>:MO2_WEBENGINE>) if(NOT WIN32) option(MO2_BUNDLE_7Z_RUNTIME "Copy a Linux 7z module into organizer/dlls" ON) diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp index ab52687..f56340b 100644 --- a/src/src/fuseconnector.cpp +++ b/src/src/fuseconnector.cpp @@ -255,6 +255,12 @@ bool FuseConnector::mount( // Inject file-level data-dir mappings (e.g. plugins.txt, loadorder.txt) injectExtraFiles(*tree, m_extraVfsFiles); + + // Stamp plugin timestamps to match load order so LOOT sees unambiguous ordering + if (!m_pluginLoadOrder.empty()) { + stampPluginTimestamps(*tree, m_pluginLoadOrder); + } + { const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::steady_clock::now() - treeStart).count(); @@ -376,6 +382,11 @@ bool FuseConnector::isMounted() const return m_mounted; } +void FuseConnector::setPluginLoadOrder(const std::vector<std::string>& load_order) +{ + m_pluginLoadOrder = load_order; +} + void FuseConnector::rebuild( const std::vector<std::pair<std::string, std::string>>& mods, const QString& overwrite_dir, const QString& data_dir_name) @@ -399,6 +410,11 @@ void FuseConnector::rebuild( // Inject file-level data-dir mappings (e.g. plugins.txt, loadorder.txt) injectExtraFiles(*newTree, m_extraVfsFiles); + // Stamp plugin timestamps to match load order + if (!m_pluginLoadOrder.empty()) { + stampPluginTimestamps(*newTree, m_pluginLoadOrder); + } + std::unique_lock lock(m_context->tree_mutex); m_context->tree.swap(newTree); } diff --git a/src/src/fuseconnector.h b/src/src/fuseconnector.h index 9a34e55..fa82a27 100644 --- a/src/src/fuseconnector.h +++ b/src/src/fuseconnector.h @@ -39,6 +39,8 @@ public: const QString& game_dir, const QString& data_dir_name, const std::vector<std::pair<std::string, std::string>>& mods); + void setPluginLoadOrder(const std::vector<std::string>& load_order); + void unmount(); bool isMounted() const; @@ -74,6 +76,7 @@ private: std::string m_cachedDataDirPath; std::vector<std::pair<std::string, std::string>> m_lastMods; + std::vector<std::string> m_pluginLoadOrder; // Symlinks created for non-data-dir mappings (e.g. Paks, OBSE, UE4SS). std::vector<std::string> m_externalSymlinks; diff --git a/src/src/loot.cpp b/src/src/loot.cpp index 2454a34..5c8bc64 100644 --- a/src/src/loot.cpp +++ b/src/src/loot.cpp @@ -6,11 +6,20 @@ #include <log.h>
#include <report.h>
+#ifndef _WIN32
+#include "fluorinepaths.h"
+#include <poll.h>
+#include <fcntl.h>
+extern char** environ;
+#endif
+
using namespace MOBase;
using namespace json;
static QString LootReportPath = QDir::temp().absoluteFilePath("lootreport.json");
+#ifdef _WIN32
static const DWORD PipeTimeout = 500;
+#endif
#ifdef _WIN32
class AsyncPipe
@@ -193,12 +202,65 @@ private: }
};
#else
-// Linux stub: loot is not supported on Linux yet
+// Linux implementation using POSIX pipes
class AsyncPipe
{
public:
- env::HandlePtr create() { return {}; }
- std::string read() { return {}; }
+ AsyncPipe() : m_readFd(-1), m_writeFd(-1) {}
+
+ ~AsyncPipe()
+ {
+ if (m_readFd >= 0)
+ ::close(m_readFd);
+ if (m_writeFd >= 0)
+ ::close(m_writeFd);
+ }
+
+ env::HandlePtr create()
+ {
+ int fds[2];
+ if (::pipe(fds) != 0) {
+ log::error("pipe() failed: {}", strerror(errno));
+ return {};
+ }
+ m_readFd = fds[0];
+ m_writeFd = fds[1];
+
+ // set read end to non-blocking
+ int flags = ::fcntl(m_readFd, F_GETFL, 0);
+ ::fcntl(m_readFd, F_SETFL, flags | O_NONBLOCK);
+
+ // return a non-null HandlePtr to indicate success
+ return env::HandlePtr(reinterpret_cast<HANDLE>(static_cast<intptr_t>(1)));
+ }
+
+ int readFd() const { return m_readFd; }
+ int writeFd() const { return m_writeFd; }
+
+ void closeWriteEnd()
+ {
+ if (m_writeFd >= 0) {
+ ::close(m_writeFd);
+ m_writeFd = -1;
+ }
+ }
+
+ std::string read()
+ {
+ if (m_readFd < 0)
+ return {};
+
+ char buffer[50000];
+ ssize_t n = ::read(m_readFd, buffer, sizeof(buffer));
+ if (n > 0) {
+ return std::string(buffer, n);
+ }
+ return {};
+ }
+
+private:
+ int m_readFd;
+ int m_writeFd;
};
#endif
@@ -512,10 +574,79 @@ bool Loot::spawnLootcli(QWidget* parent, bool didUpdateMasterList, return true;
#else
Q_UNUSED(parent);
- Q_UNUSED(didUpdateMasterList);
Q_UNUSED(stdoutHandle);
- emit log(log::Levels::Error, tr("loot is not supported on Linux"));
- return false;
+
+ const auto logLevel = m_core.settings().diagnostics().lootLogLevel();
+
+ QStringList parameters;
+ parameters << "--game" << m_core.managedGame()->lootGameName()
+
+ << "--gamePath" << m_core.managedGame()->gameDirectory().absolutePath()
+
+ << "--pluginListPath"
+ << QString("%1/loadorder.txt").arg(m_core.profilePath())
+
+ << "--logLevel"
+ << QString::fromStdString(lootcli::logLevelToString(logLevel))
+
+ << "--out" << LootReportPath
+
+ << "--language" << m_core.settings().interface().language();
+
+ if (didUpdateMasterList) {
+ parameters << "--skipUpdateMasterlist";
+ }
+
+ QString binary = qApp->applicationDirPath() + "/lootcli";
+
+ if (!QFile::exists(binary)) {
+ emit log(log::Levels::Error, tr("lootcli not found at %1").arg(binary));
+ return false;
+ }
+
+ // build argv for posix_spawn
+ std::string binaryStr = binary.toStdString();
+ std::vector<std::string> argStrs;
+ argStrs.push_back(binaryStr);
+ for (const auto& p : parameters) {
+ argStrs.push_back(p.toStdString());
+ }
+
+ std::vector<char*> argv;
+ for (auto& s : argStrs) {
+ argv.push_back(s.data());
+ }
+ argv.push_back(nullptr);
+
+ int writeFd = m_pipe->writeFd();
+ int readFd = m_pipe->readFd();
+
+ pid_t pid = fork();
+ if (pid == -1) {
+ emit log(log::Levels::Error,
+ tr("failed to start lootcli: %1").arg(strerror(errno)));
+ return false;
+ }
+
+ if (pid == 0) {
+ // child process — only async-signal-safe calls allowed here
+ dup2(writeFd, STDOUT_FILENO);
+ close(writeFd);
+ close(readFd);
+
+ std::string workDir = qApp->applicationDirPath().toStdString();
+ chdir(workDir.c_str());
+
+ execv(binaryStr.c_str(), argv.data());
+ _exit(127);
+ }
+
+ // parent process
+ m_childPid = pid;
+ m_pipe->closeWriteEnd();
+
+ log::debug("lootcli spawned with pid {}", pid);
+ return true;
#endif
}
@@ -629,7 +760,60 @@ bool Loot::waitForCompletion() return true;
#else
- return false;
+ if (m_childPid <= 0) {
+ return false;
+ }
+
+ log::debug("loot thread waiting for completion on lootcli");
+
+ for (;;) {
+ // poll the pipe for data with 100ms timeout
+ struct pollfd pfd;
+ pfd.fd = m_pipe->readFd();
+ pfd.events = POLLIN;
+ ::poll(&pfd, 1, 100);
+
+ processStdout(m_pipe->read());
+
+ int status;
+ pid_t result = waitpid(m_childPid, &status, WNOHANG);
+
+ if (result == m_childPid) {
+ log::debug("lootcli has completed");
+
+ // read any remaining output
+ processStdout(m_pipe->read());
+
+ if (WIFEXITED(status)) {
+ int exitCode = WEXITSTATUS(status);
+ if (exitCode != 0) {
+ emit log(log::Levels::Error,
+ tr("Loot failed. Exit code was: 0x%1").arg(exitCode, 0, 16));
+ return false;
+ }
+ return true;
+ } else {
+ emit log(log::Levels::Error, tr("lootcli terminated abnormally"));
+ return false;
+ }
+ }
+
+ if (result == -1) {
+ log::error("waitpid failed: {}", strerror(errno));
+ return false;
+ }
+
+ if (m_cancel) {
+ log::debug("terminating lootcli process");
+ ::kill(m_childPid, SIGTERM);
+
+ log::debug("waiting for lootcli process to terminate");
+ waitpid(m_childPid, &status, 0);
+
+ log::debug("lootcli terminated");
+ return false;
+ }
+ }
#endif
}
@@ -916,10 +1100,372 @@ std::vector<QString> Loot::reportStringArray(const QJsonArray& array) const return v;
}
+#ifndef _WIN32
+
+static const QString LootGitHubRepo =
+ "SulfurNitride/loot-linux-build-for-fluorine";
+
+static QString lootAppImagePath()
+{
+ return fluorineDataDir() + "/bin/LOOT.AppImage";
+}
+
+// Fetch the download URL for the latest Linux AppImage from GitHub releases.
+static QString fetchLootDownloadUrl()
+{
+ QNetworkAccessManager nam;
+ QEventLoop loop;
+
+ QUrl apiUrl(QString("https://api.github.com/repos/%1/releases/latest")
+ .arg(LootGitHubRepo));
+ QNetworkRequest req(apiUrl);
+ req.setRawHeader("Accept", "application/vnd.github.v3+json");
+ req.setRawHeader("User-Agent", "Fluorine-Manager");
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
+ QNetworkRequest::NoLessSafeRedirectPolicy);
+
+ auto* reply = nam.get(req);
+ QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
+ loop.exec();
+
+ if (reply->error() != QNetworkReply::NoError) {
+ log::error("Failed to query GitHub releases: {} (HTTP {})",
+ reply->errorString(),
+ reply->attribute(QNetworkRequest::HttpStatusCodeAttribute)
+ .toInt());
+ reply->deleteLater();
+ return {};
+ }
+
+ auto doc = QJsonDocument::fromJson(reply->readAll());
+ reply->deleteLater();
+
+ auto assets = doc.object()["assets"].toArray();
+ for (const auto& asset : assets) {
+ auto obj = asset.toObject();
+ auto name = obj["name"].toString();
+ if (name.contains("linux", Qt::CaseInsensitive) &&
+ name.endsWith(".AppImage", Qt::CaseInsensitive)) {
+ return obj["browser_download_url"].toString();
+ }
+ }
+
+ // fallback: try any .AppImage
+ for (const auto& asset : assets) {
+ auto obj = asset.toObject();
+ auto name = obj["name"].toString();
+ if (name.endsWith(".AppImage", Qt::CaseInsensitive)) {
+ return obj["browser_download_url"].toString();
+ }
+ }
+
+ log::error("No Linux AppImage found in latest release of {}", LootGitHubRepo);
+ return {};
+}
+
+// Download the LOOT AppImage with a progress dialog.
+static bool downloadLootAppImage(QWidget* parent, const QString& url,
+ const QString& destPath)
+{
+ QNetworkAccessManager nam;
+ QEventLoop loop;
+ QProgressDialog progress(QObject::tr("Downloading LOOT..."), QObject::tr("Cancel"),
+ 0, 100, parent);
+ progress.setWindowModality(Qt::WindowModal);
+ progress.setMinimumDuration(0);
+ progress.setValue(0);
+
+ QNetworkRequest req{QUrl(url)};
+ req.setRawHeader("User-Agent", "Fluorine-Manager");
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
+ QNetworkRequest::NoLessSafeRedirectPolicy);
+
+ auto* reply = nam.get(req);
+
+ QObject::connect(reply, &QNetworkReply::downloadProgress,
+ [&](qint64 received, qint64 total) {
+ if (total > 0) {
+ progress.setMaximum(static_cast<int>(total / 1024));
+ progress.setValue(static_cast<int>(received / 1024));
+ }
+ });
+
+ QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
+ QObject::connect(&progress, &QProgressDialog::canceled, reply, &QNetworkReply::abort);
+ loop.exec();
+
+ if (reply->error() != QNetworkReply::NoError) {
+ if (reply->error() != QNetworkReply::OperationCanceledError) {
+ log::error("Failed to download LOOT: {}", reply->errorString());
+ }
+ reply->deleteLater();
+ return false;
+ }
+
+ QDir().mkpath(QFileInfo(destPath).absolutePath());
+
+ QFile f(destPath);
+ if (!f.open(QIODevice::WriteOnly)) {
+ log::error("Failed to write LOOT AppImage: {}", f.errorString());
+ reply->deleteLater();
+ return false;
+ }
+
+ f.write(reply->readAll());
+ f.close();
+ reply->deleteLater();
+
+ // Make executable
+ QFile::setPermissions(destPath,
+ QFileDevice::ReadOwner | QFileDevice::WriteOwner |
+ QFileDevice::ExeOwner | QFileDevice::ReadGroup |
+ QFileDevice::ExeGroup);
+
+ log::info("LOOT AppImage downloaded to {}", destPath);
+ return true;
+}
+
+// Ensure the LOOT AppImage is available, downloading if necessary.
+static bool ensureLootAvailable(QWidget* parent)
+{
+ QString path = lootAppImagePath();
+ if (QFile::exists(path)) {
+ return true;
+ }
+
+ log::info("LOOT AppImage not found, downloading from GitHub...");
+
+ QString url = fetchLootDownloadUrl();
+ if (url.isEmpty()) {
+ QMessageBox::critical(
+ parent, QObject::tr("LOOT"),
+ QObject::tr("Could not find a LOOT release to download.\n\n"
+ "Please check https://github.com/%1/releases")
+ .arg(LootGitHubRepo));
+ return false;
+ }
+
+ return downloadLootAppImage(parent, url, path);
+}
+
+// Map MO2's lootGameName() to LOOT's internal folder name (used by --game).
+// LOOT's getDefaultLootFolderName() uses full names for some games.
+static QString lootFolderName(const QString& mo2GameName)
+{
+ static const QMap<QString, QString> map = {
+ {"SkyrimSE", "Skyrim Special Edition"},
+ {"SkyrimVR", "Skyrim VR"},
+ {"EnderalSE", "Enderal Special Edition"},
+ {"Fallout4VR", "Fallout4VR"},
+ };
+ return map.value(mo2GameName, mo2GameName);
+}
+
+// Write (or update) LOOT's settings.toml so that --game / --game-path work.
+// We always overwrite the game path and local_path to match the current
+// Fluorine instance, since the user may switch between instances.
+static void ensureLootSettings(const QString& lootDataPath,
+ const QString& mo2GameName,
+ const QString& folderName,
+ const QString& gamePath)
+{
+ QDir().mkpath(lootDataPath);
+
+ // Create a local app data directory for this game. LOOT needs it for
+ // load order interaction (plugins.txt / loadorder.txt). On Linux the
+ // native local-appdata location (~/.local/share/<game>) doesn't exist
+ // because games run via Proton, so we create one under our data dir.
+ QString localPath = lootDataPath + "/local/" + folderName;
+ QDir().mkpath(localPath);
+
+ // Map MO2 game names to LOOT masterlist repository names.
+ static const QMap<QString, QString> masterlistRepos = {
+ {"FalloutNV", "falloutnv"}, {"Fallout3", "fallout3"},
+ {"Fallout4", "fallout4"}, {"Fallout4VR", "fallout4vr"},
+ {"Skyrim", "skyrim"}, {"SkyrimSE", "skyrimse"},
+ {"SkyrimVR", "skyrimvr"}, {"Enderal", "enderal"},
+ {"EnderalSE", "enderalse"}, {"Oblivion", "oblivion"},
+ {"Morrowind", "morrowind"}, {"Starfield", "starfield"},
+ {"Nehrim", "oblivion"},
+ };
+ QString repoName = masterlistRepos.value(mo2GameName, mo2GameName.toLower());
+ QString masterlistUrl =
+ QString("https://raw.githubusercontent.com/loot/%1/v0.21/masterlist.yaml")
+ .arg(repoName);
+
+ // Always rewrite the settings file so the path matches the current
+ // Fluorine instance. Use the "type" key (not "gameId") because LOOT's
+ // type parser accepts MO2's short names like "SkyrimSE".
+ QString toml = QString(
+ "updateMasterlist = true\n"
+ "\n"
+ "[[games]]\n"
+ "type = \"%1\"\n"
+ "name = \"%2\"\n"
+ "folder = \"%2\"\n"
+ "path = \"%3\"\n"
+ "local_path = \"%4\"\n"
+ "masterlistSource = \"%5\"\n"
+ "\n")
+ .arg(mo2GameName, folderName, gamePath, localPath, masterlistUrl);
+
+ QString settingsPath = lootDataPath + "/settings.toml";
+ QFile f(settingsPath);
+ if (f.open(QIODevice::WriteOnly | QIODevice::Text)) {
+ f.write(toml.toUtf8());
+ f.close();
+ log::info("Created LOOT settings at {}", settingsPath);
+ }
+}
+
+// Launch the full LOOT GUI and wait for it to exit.
+static bool launchLootGui(QWidget* parent, OrganizerCore& core)
+{
+ QString appImage = lootAppImagePath();
+ if (!QFile::exists(appImage)) {
+ return false;
+ }
+
+ QString mo2GameName = core.managedGame()->lootGameName();
+ QString folderName = lootFolderName(mo2GameName);
+ QString gamePath = core.managedGame()->gameDirectory().absolutePath();
+ QString lootDataPath = fluorineDataDir() + "/loot";
+ QString localPath = lootDataPath + "/local/" + folderName;
+
+ // Pre-seed LOOT settings so --game resolution works on first launch.
+ ensureLootSettings(lootDataPath, mo2GameName, folderName, gamePath);
+
+ // Copy the profile's load order files to LOOT's local path so LOOT sees
+ // the current load order.
+ QDir().mkpath(localPath);
+ QString profilePlugins = core.profilePath() + "/plugins.txt";
+ QString profileLoadOrder = core.profilePath() + "/loadorder.txt";
+ QString lootPlugins = localPath + "/plugins.txt";
+ QString lootLoadOrder = localPath + "/loadorder.txt";
+
+ QFile::remove(lootPlugins);
+ QFile::remove(lootLoadOrder);
+ if (QFile::exists(profilePlugins)) {
+ QFile::copy(profilePlugins, lootPlugins);
+ }
+ if (QFile::exists(profileLoadOrder)) {
+ QFile::copy(profileLoadOrder, lootLoadOrder);
+ }
+
+ // Mount the FUSE VFS so LOOT sees the merged mod files in the Data directory.
+ log::info("Mounting VFS for LOOT...");
+ core.prepareVFS();
+
+ QStringList args;
+ args << "--game" << folderName
+ << "--game-path" << gamePath
+ << "--loot-data-path" << lootDataPath;
+
+ log::info("Launching LOOT GUI: {} {}", appImage, args.join(" "));
+
+ QProcess lootProcess;
+ lootProcess.setProgram(appImage);
+ lootProcess.setArguments(args);
+
+ // Restore the original environment if running from AppImage so LOOT
+ // doesn't inherit Fluorine's bundled library paths.
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ QString origLdPath = env.value("FLUORINE_ORIG_LD_LIBRARY_PATH");
+ if (!origLdPath.isEmpty()) {
+ env.insert("LD_LIBRARY_PATH", origLdPath);
+ }
+ QString origPath = env.value("FLUORINE_ORIG_PATH");
+ if (!origPath.isEmpty()) {
+ env.insert("PATH", origPath);
+ }
+ lootProcess.setProcessEnvironment(env);
+
+ lootProcess.start();
+ if (!lootProcess.waitForStarted(5000)) {
+ log::error("Failed to start LOOT: {}", lootProcess.errorString());
+ QMessageBox::critical(
+ parent, QObject::tr("LOOT"),
+ QObject::tr("Failed to start LOOT:\n%1").arg(lootProcess.errorString()));
+ core.unmountVFS();
+ return false;
+ }
+
+ // Show a non-modal message while LOOT is running.
+ QMessageBox infoBox(QMessageBox::Information, QObject::tr("LOOT"),
+ QObject::tr("LOOT is running.\n\n"
+ "Sort your load order in LOOT, then close it "
+ "to apply the changes in Fluorine."),
+ QMessageBox::Cancel, parent);
+ infoBox.setWindowModality(Qt::WindowModal);
+
+ // Poll for LOOT to exit or user to cancel.
+ QTimer pollTimer;
+ QObject::connect(&pollTimer, &QTimer::timeout, [&]() {
+ if (lootProcess.state() == QProcess::NotRunning) {
+ infoBox.accept();
+ }
+ });
+ pollTimer.start(500);
+
+ int dialogResult = infoBox.exec();
+ pollTimer.stop();
+
+ if (dialogResult != QMessageBox::Accepted &&
+ lootProcess.state() == QProcess::Running) {
+ // User cancelled — kill LOOT
+ lootProcess.terminate();
+ if (!lootProcess.waitForFinished(3000)) {
+ lootProcess.kill();
+ lootProcess.waitForFinished(2000);
+ }
+ core.unmountVFS();
+ return false;
+ }
+
+ lootProcess.waitForFinished(-1);
+
+ // Unmount the VFS now that LOOT has finished.
+ log::info("Unmounting VFS after LOOT...");
+ core.unmountVFS();
+
+ int exitCode = lootProcess.exitCode();
+ log::info("LOOT exited with code {}", exitCode);
+
+ // Copy LOOT's updated load order files back to the profile.
+ if (QFile::exists(lootPlugins)) {
+ QFile::remove(profilePlugins);
+ QFile::copy(lootPlugins, profilePlugins);
+ log::info("Copied LOOT plugins.txt back to profile");
+ }
+ if (QFile::exists(lootLoadOrder)) {
+ QFile::remove(profileLoadOrder);
+ QFile::copy(lootLoadOrder, profileLoadOrder);
+ log::info("Copied LOOT loadorder.txt back to profile");
+ }
+
+ return true;
+}
+#endif
+
bool runLoot(QWidget* parent, OrganizerCore& core, bool didUpdateMasterList)
{
core.savePluginList();
+#ifndef _WIN32
+ // Linux: download and launch the full LOOT GUI
+ Q_UNUSED(didUpdateMasterList);
+
+ try {
+ if (!ensureLootAvailable(parent)) {
+ return false;
+ }
+
+ return launchLootGui(parent, core);
+ } catch (const std::exception& e) {
+ reportError(QObject::tr("failed to run loot: %1").arg(e.what()));
+ return false;
+ }
+#else
try {
Loot loot(core);
LootDialog dialog(parent, core, loot);
@@ -931,13 +1477,12 @@ bool runLoot(QWidget* parent, OrganizerCore& core, bool didUpdateMasterList) dialog.exec();
return dialog.result();
-#ifdef _WIN32
} catch (const UsvfsConnectorException& e) {
log::debug("{}", e.what());
return false;
-#endif
} catch (const std::exception& e) {
reportError(QObject::tr("failed to run loot: %1").arg(e.what()));
return false;
}
+#endif
}
diff --git a/src/src/loot.h b/src/src/loot.h index 1ba31cf..387ba76 100644 --- a/src/src/loot.h +++ b/src/src/loot.h @@ -7,6 +7,8 @@ #include <lootcli/lootcli.h>
#ifdef _WIN32
#include <windows.h>
+#else
+#include <sys/types.h>
#endif
Q_DECLARE_METATYPE(lootcli::Progress);
@@ -110,6 +112,9 @@ private: std::atomic<bool> m_cancel;
std::atomic<bool> m_result;
env::HandlePtr m_lootProcess;
+#ifndef _WIN32
+ pid_t m_childPid = -1;
+#endif
std::unique_ptr<AsyncPipe> m_pipe;
std::string m_outputBuffer;
std::vector<QString> m_errors, m_warnings;
diff --git a/src/src/lootdialog.cpp b/src/src/lootdialog.cpp index e4fd46b..9b36f2d 100644 --- a/src/src/lootdialog.cpp +++ b/src/src/lootdialog.cpp @@ -58,6 +58,57 @@ bool MarkdownPage::acceptNavigationRequest(const QUrl& url, NavigationType, bool }
#endif
+// Convert simple markdown to HTML for QTextBrowser
+static QString markdownToHtml(const QString& md)
+{
+ QString html = "<html><body style='font-family: sans-serif; font-size: 10pt;'>";
+
+ const auto lines = md.split('\n');
+ bool inList = false;
+
+ for (const auto& line : lines) {
+ QString trimmed = line.trimmed();
+
+ if (trimmed.isEmpty()) {
+ if (inList) {
+ html += "</ul>";
+ inList = false;
+ }
+ html += "<br>";
+ continue;
+ }
+
+ // headers
+ if (trimmed.startsWith("#### ")) {
+ if (inList) { html += "</ul>"; inList = false; }
+ html += "<h4>" + trimmed.mid(5).toHtmlEscaped() + "</h4>";
+ } else if (trimmed.startsWith("### ")) {
+ if (inList) { html += "</ul>"; inList = false; }
+ html += "<h3>" + trimmed.mid(4).toHtmlEscaped() + "</h3>";
+ } else if (trimmed.startsWith("## ")) {
+ if (inList) { html += "</ul>"; inList = false; }
+ html += "<h2>" + trimmed.mid(3).toHtmlEscaped() + "</h2>";
+ } else if (trimmed.startsWith(" - ") || trimmed.startsWith("- ")) {
+ if (!inList) { html += "<ul>"; inList = true; }
+ QString item = trimmed.startsWith(" - ") ? trimmed.mid(3) : trimmed.mid(2);
+ // bold
+ item.replace(QRegularExpression("\\*\\*(.+?)\\*\\*"), "<b>\\1</b>");
+ html += "<li>" + item + "</li>";
+ } else {
+ if (inList) { html += "</ul>"; inList = false; }
+ // bold
+ trimmed.replace(QRegularExpression("\\*\\*(.+?)\\*\\*"), "<b>\\1</b>");
+ // code
+ trimmed.replace(QRegularExpression("`(.+?)`"), "<code>\\1</code>");
+ html += "<p>" + trimmed + "</p>";
+ }
+ }
+
+ if (inList) html += "</ul>";
+ html += "</body></html>";
+ return html;
+}
+
LootDialog::LootDialog(QWidget* parent, OrganizerCore& core, Loot& loot)
: QDialog(parent, Qt::WindowMaximizeButtonHint), ui(new Ui::LootDialog),
m_core(core), m_loot(loot), m_finished(false), m_cancelling(false)
@@ -212,6 +263,11 @@ void LootDialog::createUI() } else {
log::error("can't open '{}', {}", path, f.errorString());
}
+#else
+ ui->report->setHtml(markdownToHtml(tr("Running LOOT...")));
+ connect(&m_report, &MarkdownDocument::textChanged, this, [this](const QString& md) {
+ ui->report->setHtml(markdownToHtml(md));
+ });
#endif
m_expander.set(ui->details, ui->detailsPanel);
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index 6fa0394..3d08395 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -625,9 +625,40 @@ void OrganizerCore::createOverwriteDirectories() void OrganizerCore::prepareVFS()
{
+#ifndef _WIN32
+ // Read the load order and pass it to the FUSE VFS so plugin files get
+ // incrementing timestamps matching their position. This prevents LOOT
+ // from reporting "ambiguous load order".
+ {
+ std::vector<std::string> loadOrder;
+ QFile loFile(m_CurrentProfile->getLoadOrderFileName());
+ if (!loFile.exists()) {
+ loFile.setFileName(m_CurrentProfile->getPluginsFileName());
+ }
+ if (loFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ QTextStream in(&loFile);
+ while (!in.atEnd()) {
+ QString line = in.readLine().trimmed();
+ if (line.isEmpty() || line.startsWith('#')) {
+ continue;
+ }
+ if (line.startsWith('*')) {
+ line = line.mid(1);
+ }
+ loadOrder.push_back(line.toStdString());
+ }
+ }
+ m_USVFS.setPluginLoadOrder(loadOrder);
+ }
+#endif
m_USVFS.updateMapping(fileMapping(m_CurrentProfile->name(), QString()));
}
+void OrganizerCore::unmountVFS()
+{
+ m_USVFS.unmount();
+}
+
void OrganizerCore::updateVFSParams(log::Levels logLevel,
env::CoreDumpTypes coreDumpType,
const QString& crashDumpsPath,
diff --git a/src/src/organizercore.h b/src/src/organizercore.h index 4b976b6..2526ce8 100644 --- a/src/src/organizercore.h +++ b/src/src/organizercore.h @@ -350,6 +350,7 @@ public: MOBase::DelayedFileWriter& pluginsWriter() { return m_PluginListsWriter; }
void prepareVFS();
+ void unmountVFS();
void updateVFSParams(MOBase::log::Levels logLevel, env::CoreDumpTypes coreDumpType,
const QString& coreDumpsPath, std::chrono::seconds spawnDelay,
diff --git a/src/src/sanitychecks.cpp b/src/src/sanitychecks.cpp index df0d785..6983739 100644 --- a/src/src/sanitychecks.cpp +++ b/src/src/sanitychecks.cpp @@ -180,9 +180,13 @@ int checkBlocked() int checkMissingFiles()
{
// files that are likely to be eaten
+#ifdef _WIN32
static const QStringList files(
{"helper.exe", "nxmhandler.exe", "usvfs_proxy_x64.exe", "usvfs_proxy_x86.exe",
"usvfs_x64.dll", "usvfs_x86.dll", "loot/loot.dll", "loot/lootcli.exe"});
+#else
+ static const QStringList files({});
+#endif
log::debug(" . missing files");
const auto dir = QCoreApplication::applicationDirPath();
diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp index 7b440f9..83c32f4 100644 --- a/src/src/vfs/mo2filesystem.cpp +++ b/src/src/vfs/mo2filesystem.cpp @@ -129,6 +129,12 @@ void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid) st->st_nlink = 2; st->st_uid = uid; st->st_gid = gid; + + const auto now = std::chrono::duration_cast<std::chrono::seconds>( + std::chrono::system_clock::now().time_since_epoch()); + st->st_mtim.tv_sec = now.count(); + st->st_atim.tv_sec = now.count(); + st->st_ctim.tv_sec = now.count(); } void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid, diff --git a/src/src/vfs/vfstree.cpp b/src/src/vfs/vfstree.cpp index 580d264..6e7f1d3 100644 --- a/src/src/vfs/vfstree.cpp +++ b/src/src/vfs/vfstree.cpp @@ -84,10 +84,11 @@ void addDirectoryToTree(VfsTree& tree, const fs::path& walkDir, } const auto size = entry.file_size(ec); - const auto mtime = entry.last_write_time(ec); - tree.root.insertFile(components, entry.path().string(), size, - ec ? std::chrono::system_clock::time_point{} - : fsTimeToSystemClock(mtime), + std::error_code mtimeEc; + const auto mtime = entry.last_write_time(mtimeEc); + tree.root.insertFile(components, entry.path().string(), ec ? 0ULL : size, + mtimeEc ? std::chrono::system_clock::time_point{} + : fsTimeToSystemClock(mtime), origin, is_backing); ++tree.file_count; } @@ -332,9 +333,10 @@ std::vector<CachedBaseFile> scanDataDir(const std::string& data_dir_path) continue; } cf.size = entry.file_size(ec); - const auto mtime = entry.last_write_time(ec); - cf.mtime = ec ? std::chrono::system_clock::time_point{} - : fsTimeToSystemClock(mtime); + std::error_code mtimeEc; + const auto mtime = entry.last_write_time(mtimeEc); + cf.mtime = mtimeEc ? std::chrono::system_clock::time_point{} + : fsTimeToSystemClock(mtime); } cache.push_back(std::move(cf)); @@ -397,3 +399,34 @@ void injectExtraFiles( ++tree.file_count; } } + +void stampPluginTimestamps(VfsTree& tree, + const std::vector<std::string>& load_order) +{ + if (load_order.empty()) { + return; + } + + // Start from a base time and increment by 60 seconds per plugin. + // This gives LOOT a clear, unambiguous ordering via file timestamps. + auto baseTime = std::chrono::system_clock::now() - + std::chrono::seconds(static_cast<long>(load_order.size()) * 60); + + for (size_t i = 0; i < load_order.size(); ++i) { + const auto components = splitPath(load_order[i]); + if (components.empty()) { + continue; + } + + const std::string key = normalizeForLookup(load_order[i]); + auto it = tree.root.dir_info.children.find(key); + if (it == tree.root.dir_info.children.end()) { + continue; + } + + VfsNode* node = it->second.get(); + if (node != nullptr && !node->is_directory) { + node->file_info.mtime = baseTime + std::chrono::seconds(static_cast<long>(i) * 60); + } + } +} diff --git a/src/src/vfs/vfstree.h b/src/src/vfs/vfstree.h index 3fb257e..12baeea 100644 --- a/src/src/vfs/vfstree.h +++ b/src/src/vfs/vfstree.h @@ -84,4 +84,11 @@ void injectExtraFiles( VfsTree& tree, const std::vector<std::pair<std::string, std::string>>& extra_files); +// Stamp plugin files (.esp/.esm/.esl) in the VFS with incrementing +// timestamps matching their position in the load order. This ensures +// tools like LOOT see an unambiguous timestamp-based ordering that +// matches plugins.txt. +void stampPluginTimestamps(VfsTree& tree, + const std::vector<std::string>& load_order); + #endif |
