diff options
| author | SulfurNitride <lukew19@proton.me> | 2026-06-24 23:20:46 -0500 |
|---|---|---|
| committer | SulfurNitride <lukew19@proton.me> | 2026-06-24 23:20:46 -0500 |
| commit | 91aef6e4d96cc88b3d71a4a7a6b365650c401f5c (patch) | |
| tree | faf2a83a440952cb3178d05a83b7895604daf5c1 /src | |
| parent | e4fefc7f186a77174c93d6894b5c9252785b98cc (diff) | |
Fix VFS empty dirs in overwrite and add LOOT Sort button
VFS: Remove physical directory creation from mo2_lookup auto-mkdir.
Previously, any dotless path component that missed the VFS tree was
immediately created on disk, leaving hundreds of empty directories in
overwrite (Shaders/, STRINGS/, DLLPlugins/, etc.) after a session.
Now a phantom virtual node is inserted instead — createFile() already
calls create_directories on the parent path, so the real directory
materialises on disk only when a file is actually written inside it.
mo2_rmdir already handles phantom (disk-absent) directories correctly.
LOOT: Add lootmanager.h/cpp which downloads the latest win64 .7z from
the GitHub releases API, extracts it to ~/.local/share/fluorine/tools/loot/
using the bundled 7zz or any system 7z tool, and exposes isLootInstalled()
/ getLootExePath(). Add a Sort button to the plugins tab that downloads
LOOT on first use (same progress-dialog pattern as SLR) then launches
LOOT.exe hooked through the active Proton via ProcessRunner so it sees
the full merged VFS. Button is hidden for games that don't use LOOT
sorting.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/src/CMakeLists.txt | 2 | ||||
| -rw-r--r-- | src/src/lootmanager.cpp | 235 | ||||
| -rw-r--r-- | src/src/lootmanager.h | 23 | ||||
| -rw-r--r-- | src/src/mainwindow.cpp | 58 | ||||
| -rw-r--r-- | src/src/mainwindow.h | 1 | ||||
| -rw-r--r-- | src/src/mainwindow.ui | 10 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.cpp | 58 |
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; } } |
