aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/src/CMakeLists.txt2
-rw-r--r--src/src/lootmanager.cpp235
-rw-r--r--src/src/lootmanager.h23
-rw-r--r--src/src/mainwindow.cpp58
-rw-r--r--src/src/mainwindow.h1
-rw-r--r--src/src/mainwindow.ui10
-rw-r--r--src/src/vfs/mo2filesystem.cpp58
7 files changed, 359 insertions, 28 deletions
diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt
index 8c67f6e..484c343 100644
--- a/src/src/CMakeLists.txt
+++ b/src/src/CMakeLists.txt
@@ -43,6 +43,7 @@ file(GLOB ORGANIZER_QRC
list(APPEND ORGANIZER_SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/fluorineconfig.cpp
${CMAKE_CURRENT_SOURCE_DIR}/fluorinepaths.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/lootmanager.cpp
${CMAKE_CURRENT_SOURCE_DIR}/prefixsetupdialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/prefixsetuprunner.cpp
${CMAKE_CURRENT_SOURCE_DIR}/protonlauncher.cpp
@@ -51,6 +52,7 @@ list(APPEND ORGANIZER_SOURCES
list(APPEND ORGANIZER_HEADERS
${CMAKE_CURRENT_SOURCE_DIR}/fluorineconfig.h
${CMAKE_CURRENT_SOURCE_DIR}/fluorinepaths.h
+ ${CMAKE_CURRENT_SOURCE_DIR}/lootmanager.h
${CMAKE_CURRENT_SOURCE_DIR}/prefixsetupdialog.h
${CMAKE_CURRENT_SOURCE_DIR}/prefixsetuprunner.h
${CMAKE_CURRENT_SOURCE_DIR}/protonlauncher.h
diff --git a/src/src/lootmanager.cpp b/src/src/lootmanager.cpp
new file mode 100644
index 0000000..cd3874b
--- /dev/null
+++ b/src/src/lootmanager.cpp
@@ -0,0 +1,235 @@
+#include "lootmanager.h"
+#include "fluorinepaths.h"
+
+#include <QDir>
+#include <QEventLoop>
+#include <QFile>
+#include <QFileInfo>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QNetworkRequest>
+#include <QProcess>
+#include <QStandardPaths>
+#include <QTemporaryDir>
+#include <QTimer>
+
+#include <uibase/log.h>
+
+namespace
+{
+
+QByteArray httpGet(const QString& url, const int* cancelFlag,
+ const std::function<void(float)>& progressCb = nullptr,
+ const QString& destFile = {})
+{
+ QNetworkAccessManager mgr;
+ QNetworkRequest req{QUrl(url)};
+ req.setRawHeader("User-Agent", "Fluorine-Manager/loot");
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
+ QNetworkRequest::NoLessSafeRedirectPolicy);
+ req.setAttribute(QNetworkRequest::Http2AllowedAttribute, false);
+
+ QNetworkReply* reply = mgr.get(req);
+ QEventLoop loop;
+
+ QFile outFile;
+ if (!destFile.isEmpty()) {
+ outFile.setFileName(destFile);
+ if (!outFile.open(QIODevice::WriteOnly))
+ return {};
+ }
+
+ QByteArray inMemory;
+ qint64 total = -1, received = 0;
+
+ QObject::connect(reply, &QNetworkReply::readyRead, [&]() {
+ if (total < 0)
+ total = reply->header(QNetworkRequest::ContentLengthHeader).toLongLong();
+ QByteArray chunk = reply->readAll();
+ received += chunk.size();
+ if (outFile.isOpen())
+ outFile.write(chunk);
+ else
+ inMemory.append(chunk);
+ if (progressCb && total > 0)
+ progressCb(static_cast<float>(received) / static_cast<float>(total));
+ });
+ QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
+
+ QTimer cancelTimer;
+ if (cancelFlag) {
+ QObject::connect(&cancelTimer, &QTimer::timeout, [&]() {
+ if (*cancelFlag) { reply->abort(); loop.quit(); }
+ });
+ cancelTimer.start(200);
+ }
+
+ loop.exec();
+ if (outFile.isOpen()) outFile.close();
+
+ if (reply->error() != QNetworkReply::NoError) {
+ MOBase::log::warn("LOOT download request failed: {} ({})", url,
+ reply->errorString());
+ reply->deleteLater();
+ if (!destFile.isEmpty()) QFile::remove(destFile);
+ return {};
+ }
+ reply->deleteLater();
+ return inMemory;
+}
+
+// Try available 7z tools in order: bundled 7zz, then system 7z/7za/7zz.
+bool extract7z(const QString& archivePath, const QString& destDir)
+{
+ QStringList candidates;
+ const QString bundled = fluorineDataDir() + "/bin/7zz";
+ if (QFileInfo::exists(bundled))
+ candidates << bundled;
+ for (const QString& n : {QStringLiteral("7z"), QStringLiteral("7za"),
+ QStringLiteral("7zz")}) {
+ const QString found = QStandardPaths::findExecutable(n);
+ if (!found.isEmpty() && !candidates.contains(found))
+ candidates << found;
+ }
+
+ for (const QString& exe : candidates) {
+ QProcess proc;
+ proc.start(exe, {QStringLiteral("x"), archivePath,
+ QStringLiteral("-o") + destDir, QStringLiteral("-y")});
+ if (proc.waitForFinished(300000) &&
+ proc.exitStatus() == QProcess::NormalExit && proc.exitCode() == 0)
+ return true;
+ }
+ return false;
+}
+
+// Recursively find a file by name (case-insensitive) under dir.
+QString findFileInDir(const QString& dir, const QString& name)
+{
+ for (const auto& entry :
+ QDir(dir).entryInfoList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot)) {
+ if (entry.isFile() && entry.fileName().compare(name, Qt::CaseInsensitive) == 0)
+ return entry.filePath();
+ if (entry.isDir()) {
+ const QString found = findFileInDir(entry.filePath(), name);
+ if (!found.isEmpty()) return found;
+ }
+ }
+ return {};
+}
+
+// Recursively move all entries from src into dst.
+bool moveDirContents(const QString& src, const QString& dst)
+{
+ QDir().mkpath(dst);
+ for (const auto& entry :
+ QDir(src).entryInfoList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot)) {
+ const QString dstPath = dst + "/" + entry.fileName();
+ if (entry.isDir()) {
+ if (!moveDirContents(entry.filePath(), dstPath)) return false;
+ QDir(entry.filePath()).removeRecursively();
+ } else {
+ QFile::remove(dstPath);
+ if (!QFile::rename(entry.filePath(), dstPath)) return false;
+ }
+ }
+ return true;
+}
+
+} // namespace
+
+QString lootInstallDir()
+{
+ return fluorineDataDir() + "/tools/loot";
+}
+
+bool isLootInstalled()
+{
+ return QFileInfo::exists(getLootExePath());
+}
+
+QString getLootExePath()
+{
+ const QString path = lootInstallDir() + "/LOOT.exe";
+ return QFileInfo::exists(path) ? path : QString{};
+}
+
+QString downloadLoot(const std::function<void(float)>& progressCb,
+ const std::function<void(const QString&)>& statusCb,
+ const int* cancelFlag)
+{
+ auto status = [&](const QString& msg) { if (statusCb) statusCb(msg); };
+ auto progress = [&](float p) { if (progressCb) progressCb(p); };
+
+ status(QStringLiteral("Checking latest LOOT release..."));
+
+ const QByteArray apiData = httpGet(
+ QStringLiteral("https://api.github.com/repos/loot/loot/releases/latest"),
+ cancelFlag);
+ if (apiData.isEmpty())
+ return QStringLiteral("Failed to fetch LOOT release info from GitHub");
+
+ const QJsonObject release = QJsonDocument::fromJson(apiData).object();
+ const QJsonArray assets = release[QStringLiteral("assets")].toArray();
+
+ QString downloadUrl;
+ QString assetName;
+ for (const auto& a : assets) {
+ const QJsonObject asset = a.toObject();
+ const QString name = asset[QStringLiteral("name")].toString();
+ // Prefer win64 portable archive (not installer .exe)
+ if (name.contains(QStringLiteral("win64"), Qt::CaseInsensitive) &&
+ (name.endsWith(QStringLiteral(".7z"), Qt::CaseInsensitive) ||
+ name.endsWith(QStringLiteral(".zip"), Qt::CaseInsensitive))) {
+ downloadUrl = asset[QStringLiteral("browser_download_url")].toString();
+ assetName = name;
+ break;
+ }
+ }
+ if (downloadUrl.isEmpty())
+ return QStringLiteral("Could not find a LOOT win64 archive in the latest release");
+
+ const QString tagName = release[QStringLiteral("tag_name")].toString();
+ MOBase::log::info("Downloading LOOT {} ({})", tagName, assetName);
+ status(QStringLiteral("Downloading LOOT %1...").arg(tagName));
+
+ QTemporaryDir staging(fluorineDataDir() + "/tools/loot-download-XXXXXX");
+ if (!staging.isValid())
+ return QStringLiteral("Failed to create temporary download directory");
+
+ const QString archivePath = staging.filePath(assetName);
+ httpGet(downloadUrl, cancelFlag, progress, archivePath);
+ progress(1.0f);
+
+ if (!QFileInfo::exists(archivePath))
+ return QStringLiteral("LOOT download failed or was cancelled");
+
+ status(QStringLiteral("Extracting LOOT..."));
+ const QString extractDir = staging.filePath(QStringLiteral("extracted"));
+ QDir().mkpath(extractDir);
+
+ if (!extract7z(archivePath, extractDir))
+ return QStringLiteral("Failed to extract LOOT archive (no 7z tool found)");
+
+ // Find LOOT.exe in the extraction output (may be directly or in a subdirectory).
+ const QString lootExe = findFileInDir(extractDir, QStringLiteral("LOOT.exe"));
+ if (lootExe.isEmpty())
+ return QStringLiteral("LOOT.exe not found after extraction");
+
+ const QString lootRoot = QFileInfo(lootExe).absolutePath();
+
+ // Install to lootInstallDir(), replacing any previous version.
+ const QString installDir = lootInstallDir();
+ QDir(installDir).removeRecursively();
+ QDir().mkpath(installDir);
+
+ if (!moveDirContents(lootRoot, installDir))
+ return QStringLiteral("Failed to move LOOT files to install directory");
+
+ MOBase::log::info("LOOT installed to {}", installDir.toStdString());
+ status(QStringLiteral("LOOT installed successfully"));
+ return {};
+}
diff --git a/src/src/lootmanager.h b/src/src/lootmanager.h
new file mode 100644
index 0000000..d33135a
--- /dev/null
+++ b/src/src/lootmanager.h
@@ -0,0 +1,23 @@
+#ifndef LOOTMANAGER_H
+#define LOOTMANAGER_H
+
+#include <QString>
+#include <functional>
+
+/// Returns the LOOT install directory: ~/.local/share/fluorine/tools/loot
+QString lootInstallDir();
+
+/// Returns true if LOOT.exe is present in the tools directory.
+bool isLootInstalled();
+
+/// Returns the path to LOOT.exe, or empty if not installed.
+QString getLootExePath();
+
+/// Download and install LOOT from the latest GitHub release.
+/// Finds the win64 .7z asset, extracts to lootInstallDir().
+/// Returns empty string on success, or an error message.
+QString downloadLoot(const std::function<void(float)>& progressCb,
+ const std::function<void(const QString&)>& statusCb,
+ const int* cancelFlag = nullptr);
+
+#endif // LOOTMANAGER_H
diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp
index 2f7193f..acdde48 100644
--- a/src/src/mainwindow.cpp
+++ b/src/src/mainwindow.cpp
@@ -92,6 +92,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#include "shared/filesorigin.h"
#include "slrmanager.h"
+#include "lootmanager.h"
#include <QAbstractItemDelegate>
#include <QAction>
#include <QApplication>
@@ -349,6 +350,11 @@ MainWindow::MainWindow(Settings& settings, OrganizerCore& organizerCore,
if (!features.gameFeature<GamePlugins>()) {
ui->tabWidget->removeTab(ui->tabWidget->indexOf(ui->espTab));
}
+ if (m_OrganizerCore.managedGame() &&
+ m_OrganizerCore.managedGame()->sortMechanism() !=
+ MOBase::IPluginGame::SortMechanism::LOOT) {
+ ui->sortButton->setVisible(false);
+ }
if (!features.gameFeature<DataArchives>()) {
ui->tabWidget->removeTab(ui->tabWidget->indexOf(ui->bsaTab));
}
@@ -4020,6 +4026,58 @@ bool MainWindow::createBackup(const QString& filePath, const QDateTime& time)
}
}
+void MainWindow::on_sortButton_clicked()
+{
+ if (!isLootInstalled()) {
+ auto* progress = new QProgressDialog(
+ tr("Downloading LOOT...\nThis is required for plugin sorting."),
+ tr("Cancel"), 0, 0, this);
+ progress->setWindowTitle(tr("LOOT"));
+ progress->setWindowModality(Qt::WindowModal);
+ progress->setMinimumDuration(0);
+
+ int cancelFlag = 0;
+ connect(progress, &QProgressDialog::canceled, this,
+ [&cancelFlag] { cancelFlag = 1; });
+
+ QFutureWatcher<QString> watcher;
+ QEventLoop loop;
+ connect(&watcher, &QFutureWatcher<QString>::finished, &loop, &QEventLoop::quit);
+ watcher.setFuture(
+ QtConcurrent::run([&cancelFlag]() -> QString {
+ return downloadLoot(nullptr, nullptr, &cancelFlag);
+ }));
+ progress->show();
+ loop.exec();
+ progress->close();
+ progress->deleteLater();
+
+ if (cancelFlag)
+ return;
+ const QString err = watcher.result();
+ if (!err.isEmpty()) {
+ log::error("[LOOT] Download failed: {}", err);
+ QMessageBox::warning(this, tr("LOOT"),
+ tr("LOOT download failed:\n%1").arg(err));
+ return;
+ }
+ }
+
+ const QString lootExe = getLootExePath();
+ if (lootExe.isEmpty()) {
+ QMessageBox::warning(this, tr("LOOT"),
+ tr("LOOT executable not found after install."));
+ return;
+ }
+
+ m_OrganizerCore.processRunner()
+ .setBinary(QFileInfo(lootExe))
+ .setCurrentDirectory(QDir(lootInstallDir()))
+ .setHooked(true)
+ .setWaitForCompletion(ProcessRunner::TriggerRefresh)
+ .run();
+}
+
void MainWindow::on_saveButton_clicked()
{
m_OrganizerCore.savePluginList();
diff --git a/src/src/mainwindow.h b/src/src/mainwindow.h
index 6443862..64214d9 100644
--- a/src/src/mainwindow.h
+++ b/src/src/mainwindow.h
@@ -480,6 +480,7 @@ private slots: // ui slots
void on_showHiddenBox_toggled(bool checked);
void on_bsaList_itemChanged(QTreeWidgetItem* item, int column);
+ void on_sortButton_clicked();
void on_saveButton_clicked();
void on_restoreButton_clicked();
void on_restoreModsButton_clicked();
diff --git a/src/src/mainwindow.ui b/src/src/mainwindow.ui
index 317057b..de462fa 100644
--- a/src/src/mainwindow.ui
+++ b/src/src/mainwindow.ui
@@ -820,6 +820,16 @@
</spacer>
</item>
<item>
+ <widget class="QPushButton" name="sortButton">
+ <property name="toolTip">
+ <string>Sort plugins using LOOT.</string>
+ </property>
+ <property name="text">
+ <string>Sort</string>
+ </property>
+ </widget>
+ </item>
+ <item>
<widget class="QPushButton" name="restoreButton">
<property name="toolTip">
<string>Restore a backup.</string>
diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp
index 5fa170e..fc8e0e1 100644
--- a/src/src/vfs/mo2filesystem.cpp
+++ b/src/src/vfs/mo2filesystem.cpp
@@ -1390,6 +1390,12 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name)
// auto-create it in staging so the kernel can continue resolving the path.
// createDirectory uses create_directories internally, so staging parent dirs
// are also created if the parent came from overwrite rather than this session.
+ // USVFS-parity: games sometimes skip mkdir for intermediate directories and
+ // go straight to CreateFile("ShaderCache/Lighting/X.pso"). The kernel's
+ // path-resolver needs a positive inode for each component before it can
+ // call mo2_create. Insert a phantom virtual directory — no physical creation.
+ // createFile() calls create_directories on the parent path, so the real dir
+ // will materialize on disk only when a file is actually written inside it.
const std::string_view nameView(name);
if (nameView.find('.') == std::string_view::npos) {
bool parentOk = false;
@@ -1397,35 +1403,31 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name)
if (parentOk) {
const std::string childName = canonicalChildName(ctx, parentPath, name);
const std::string childPath = joinPath(parentPath, childName);
- std::error_code createErr;
- if (ctx->overwrite->createDirectory(childPath, &createErr)) {
- {
- std::unique_lock lock(ctx->tree_mutex);
- ctx->tree->root.insertDirectory(splitPath(childPath));
- ++ctx->tree->dir_count;
- invalidateNodeCache(ctx, childPath);
- }
- invalidateDirCache(ctx, parentPath);
- fuse_ino_t dirIno;
- {
- std::unique_lock lock(ctx->inode_mutex);
- dirIno = ctx->inodes->getOrCreate(childPath);
- }
- struct fuse_entry_param e;
- std::memset(&e, 0, sizeof(e));
- e.ino = dirIno;
- e.attr_timeout = TTL_SECONDS;
- e.entry_timeout = TTL_SECONDS;
- fillStatForDir(&e.attr, dirIno, ctx->uid, ctx->gid);
- {
- std::scoped_lock cacheLock(ctx->lookup_cache_mutex);
- ctx->lookup_cache[cacheKey] =
- Mo2FsContext::LookupCacheEntry{.child_ino=dirIno, .entry=e};
- }
- std::fprintf(stderr, "[VFS] auto-mkdir staging: %s\n", childPath.c_str());
- fuse_reply_entry(req, &e);
- return;
+ {
+ std::unique_lock lock(ctx->tree_mutex);
+ ctx->tree->root.insertDirectory(splitPath(childPath));
+ ++ctx->tree->dir_count;
+ invalidateNodeCache(ctx, childPath);
}
+ invalidateDirCache(ctx, parentPath);
+ fuse_ino_t dirIno;
+ {
+ std::unique_lock lock(ctx->inode_mutex);
+ dirIno = ctx->inodes->getOrCreate(childPath);
+ }
+ struct fuse_entry_param e;
+ std::memset(&e, 0, sizeof(e));
+ e.ino = dirIno;
+ e.attr_timeout = TTL_SECONDS;
+ e.entry_timeout = TTL_SECONDS;
+ fillStatForDir(&e.attr, dirIno, ctx->uid, ctx->gid);
+ {
+ std::scoped_lock cacheLock(ctx->lookup_cache_mutex);
+ ctx->lookup_cache[cacheKey] =
+ Mo2FsContext::LookupCacheEntry{.child_ino=dirIno, .entry=e};
+ }
+ fuse_reply_entry(req, &e);
+ return;
}
}