From 817e8f5cd26739c69d930d21cd9dc4c0b6e4984e Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sat, 14 Feb 2026 02:45:12 -0600 Subject: Add FUSE external mapping support, BG3/Oblivion Remastered fixes, fomod-plus and NaK integration FUSE VFS now deploys non-data-dir mod mappings (Paks, OBSE, UE4SS, etc.) via real symlinks and injects file-level data-dir mappings (plugins.txt, loadorder.txt) into the VFS tree. Fixes game launches for Oblivion Remastered (Root Builder path resolution, script extender support) and BG3 (Wine prefix documents directory, file mapper symlinks on Linux). Vendors mo2-fomod-plus plugin and NaK crate for FOMOD installer and game finder/runtime support. Co-Authored-By: Claude Opus 4.6 --- .../patchwizard/CMakeLists.txt | 45 +++++ .../patchwizard/FomodPlusPatchWizard.cpp | 181 +++++++++++++++++++++ .../patchwizard/FomodPlusPatchWizard.h | 54 ++++++ .../patchwizard/fomod_plus_patch_wizard_en.ts | 96 +++++++++++ .../patchwizard/fomod_plus_patchwizard_de.ts | 19 +++ .../patchwizard/fomodpluspatchwizard.json | 8 + .../patchwizard/lib/PatchFinder.cpp | 86 ++++++++++ .../patchwizard/lib/PatchFinder.h | 49 ++++++ .../installer_fomod_plus/patchwizard/resources.qrc | 6 + .../patchwizard/resources/fomod_icon.png | Bin 0 -> 1782 bytes .../patchwizard/resources/infoscroll.png | Bin 0 -> 3682 bytes 11 files changed, 544 insertions(+) create mode 100644 libs/installer_fomod_plus/patchwizard/CMakeLists.txt create mode 100644 libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp create mode 100644 libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h create mode 100644 libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts create mode 100644 libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts create mode 100644 libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json create mode 100644 libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp create mode 100644 libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h create mode 100644 libs/installer_fomod_plus/patchwizard/resources.qrc create mode 100644 libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png create mode 100644 libs/installer_fomod_plus/patchwizard/resources/infoscroll.png (limited to 'libs/installer_fomod_plus/patchwizard') diff --git a/libs/installer_fomod_plus/patchwizard/CMakeLists.txt b/libs/installer_fomod_plus/patchwizard/CMakeLists.txt new file mode 100644 index 0000000..be46b32 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.16) +project(fomod_plus_patch_wizard) + +include(FetchContent) +set(project_type plugin) + +file(GLOB_RECURSE PATCHWIZARD_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/*.ui + ${CMAKE_CURRENT_SOURCE_DIR}/*.qrc +) +file(GLOB SHARE_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/../share/**/*.cpp") + +add_library(fomod_plus_patch_wizard SHARED ${PATCHWIZARD_SOURCES} ${SHARE_SOURCES}) + +FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz) +FetchContent_Declare(pugixml GIT_REPOSITORY https://github.com/zeux/pugixml GIT_TAG v1.14) +FetchContent_MakeAvailable(pugixml json) + +target_include_directories( + fomod_plus_patch_wizard + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/../share + ${CMAKE_CURRENT_SOURCE_DIR}/../share/FOMODData + ${CMAKE_CURRENT_SOURCE_DIR}/../share/xml + ${MO2_ARCHIVE_INCLUDE_DIRS} +) + +if (MSVC) + target_compile_options( + fomod_plus_patch_wizard + PRIVATE + /bigobj + /W4 + /WX + /wd4201 + /wd4458 + ) +endif () + +target_link_libraries(fomod_plus_patch_wizard PRIVATE mo2::uibase pugixml nlohmann_json::nlohmann_json) +mo2_configure_plugin(fomod_plus_patch_wizard NO_SOURCES WARNINGS OFF PRIVATE_DEPENDS archive) +mo2_install_target(fomod_plus_patch_wizard) diff --git a/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp new file mode 100644 index 0000000..00be8d5 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp @@ -0,0 +1,181 @@ +#include "FomodPlusPatchWizard.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "lib/PatchFinder.h" +#include + +bool FomodPlusPatchWizard::init(IOrganizer* organizer) +{ + mOrganizer = organizer; + mDialog = new QDialog(); + mDialog->setWindowTitle(tr("Patch Wizard")); + mDialog->setMinimumSize(400, 200); + log.setLogFilePath(QDir::currentPath().toStdString() + "/logs/fomodplus-patchwizard.log"); + + mOrganizer->onUserInterfaceInitialized([this](QMainWindow*) { + logMessage(DEBUG, "patches populated."); + mPatchFinder = std::make_unique(mOrganizer); + mPatchFinder->populateInstalledPlugins(); + mAvailablePatches = mPatchFinder->getAvailablePatchesForModList(); + logMessage(DEBUG, "Available Patches: " + std::to_string(mAvailablePatches.size())); + }); + + return true; +} + +void FomodPlusPatchWizard::display() const +{ + // Clear any existing layout + if (mDialog->layout() != nullptr) { + QLayoutItem* item; + while ((item = mDialog->layout()->takeAt(0)) != nullptr) { + delete item->widget(); + delete item; + } + delete mDialog->layout(); + } + + if (mAvailablePatches.empty()) { + setupEmptyState(); + } else { + setupPatchList(); + } + + mDialog->exec(); +} + +void FomodPlusPatchWizard::setupEmptyState() const +{ + auto* mainLayout = new QVBoxLayout(mDialog); + mainLayout->setAlignment(Qt::AlignCenter); + + auto* contentWidget = new QWidget(mDialog); + auto* contentLayout = new QHBoxLayout(contentWidget); + contentLayout->setAlignment(Qt::AlignCenter); + contentLayout->setSpacing(16); + + auto* imageLabel = new QLabel(contentWidget); + imageLabel->setPixmap(QPixmap(":/fomod/infoscroll").scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + + auto* textLabel = new QLabel( + tr("Nothing of interest yet. The wizard gets wiser as you\ninstall FOMODs, so check back later!"), + contentWidget + ); + textLabel->setAlignment(Qt::AlignVCenter | Qt::AlignLeft); + + contentLayout->addWidget(imageLabel); + contentLayout->addWidget(textLabel); + + mainLayout->addWidget(contentWidget); + + auto* rescanButton = new QPushButton(tr("Rescan Load Order"), mDialog); + // Use const_cast since setupEmptyState is const but onRescanClicked modifies state + connect(rescanButton, &QPushButton::clicked, const_cast(this), + &FomodPlusPatchWizard::onRescanClicked); + mainLayout->addWidget(rescanButton, 0, Qt::AlignCenter); +} + +void FomodPlusPatchWizard::onRescanClicked() +{ + const auto confirmResult = QMessageBox::question( + mDialog, + tr("Rescan Load Order"), + tr("Rescanning will populate as many existing choices and options as we can, " + "but if some downloads are deleted it may be missing things! " + "It may take a few minutes depending on the size of your load order and all that."), + QMessageBox::Ok | QMessageBox::Cancel, + QMessageBox::Cancel + ); + + if (confirmResult != QMessageBox::Ok) { + return; + } + + logMessage(DEBUG, "Rescan requested by user"); + + // Create progress dialog + QProgressDialog progress(tr("Scanning mods..."), tr("Cancel"), 0, 100, mDialog); + progress.setWindowModality(Qt::WindowModal); + progress.setMinimumDuration(0); + progress.setValue(0); + + bool cancelled = false; + + // Perform the rescan + FomodRescan rescan(mOrganizer, mPatchFinder->mFomodDb.get()); + auto result = rescan.scanAllModsWithChoices([&](int current, int total, const QString& modName) { + if (progress.wasCanceled()) { + cancelled = true; + return; + } + const int percent = total > 0 ? (current * 100 / total) : 0; + progress.setValue(percent); + progress.setLabelText(tr("Scanning: %1 (%2/%3)").arg(modName).arg(current).arg(total)); + QApplication::processEvents(); + }); + + progress.setValue(100); + + if (cancelled) { + QMessageBox::information( + mDialog, + tr("Rescan Cancelled"), + tr("The rescan was cancelled. Partial results may have been saved.") + ); + logMessage(INFO, "Rescan cancelled by user"); + } else { + // Show result summary + QString summary = tr("Rescan complete!\n\n" + "Mods processed: %1\n" + "Successfully scanned: %2\n" + "Missing archives: %3\n" + "Parse errors: %4") + .arg(result.totalModsProcessed) + .arg(result.successfullyScanned) + .arg(result.missingArchives) + .arg(result.parseErrors); + + if (!result.failedMods.empty() && result.failedMods.size() <= 10) { + summary += tr("\n\nFailed mods:"); + for (const auto& mod : result.failedMods) { + summary += QString("\n- %1").arg(QString::fromStdString(mod)); + } + } else if (result.failedMods.size() > 10) { + summary += tr("\n\n%1 mods failed (see log for details)").arg(result.failedMods.size()); + for (const auto& mod : result.failedMods) { + logMessage(INFO, "Failed mod: " + mod); + } + } + + QMessageBox::information(mDialog, tr("Rescan Complete"), summary); + + logMessage(INFO, "Rescan complete: " + std::to_string(result.successfullyScanned) + + "/" + std::to_string(result.totalModsProcessed) + " successful"); + } + + // Refresh available patches + mPatchFinder->populateInstalledPlugins(); + mAvailablePatches = mPatchFinder->getAvailablePatchesForModList(); + logMessage(DEBUG, "Available Patches after rescan: " + std::to_string(mAvailablePatches.size())); + + // Refresh the UI + display(); +} + +void FomodPlusPatchWizard::setupPatchList() const +{ + auto* mainLayout = new QVBoxLayout(mDialog); + + auto* label = new QLabel(tr("Available patches: %1").arg(mAvailablePatches.size()), mDialog); + mainLayout->addWidget(label); + + // TODO: Implement actual patch list UI +} \ No newline at end of file diff --git a/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h new file mode 100644 index 0000000..2324fb0 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h @@ -0,0 +1,54 @@ +#pragma once +#include "../installer/lib/Logger.h" +#include "lib/PatchFinder.h" + +#include +#include + +using namespace MOBase; + +class FomodPlusPatchWizard final : public IPluginTool { + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginTool) +#if QT_VERSION >= QT_VERSION_CHECK(5, 0, 0) + Q_PLUGIN_METADATA(IID "io.clearing.FomodPlusPatchWizard" FILE "fomodpluspatchwizard.json") +#endif + +public: + bool init(IOrganizer* organizer) override; + + [[nodiscard]] QString name() const override { return tr("Patch Wizard"); }; + + [[nodiscard]] QString author() const override { return "clearing"; }; + + [[nodiscard]] QString description() const override { return tr("Find missing patches from FOMODs in your load order."); }; + + [[nodiscard]] VersionInfo version() const override { return { 1, 0, 0, VersionInfo::RELEASE_BETA }; }; + + [[nodiscard]] QList settings() const override { return {}; }; + + [[nodiscard]] QString displayName() const override { return tr("Patch Wizard"); }; + + [[nodiscard]] QString tooltip() const override { return tr("Find missing patches from FOMODs in your load order."); }; + + [[nodiscard]] QIcon icon() const override { return QIcon(":/fomod/hat"); } + + void display() const override; + +private: + Logger& log = Logger::getInstance(); + QDialog* mDialog{ nullptr }; + IOrganizer* mOrganizer{ nullptr }; + std::unique_ptr mPatchFinder{ nullptr }; + std::vector mAvailablePatches; + + void setupEmptyState() const; + void setupPatchList() const; + void onRescanClicked(); + + void logMessage(const LogLevel level, const std::string& message) const + { + log.logMessage(level, "[PATCHFINDER] " + message); + } + +}; \ No newline at end of file diff --git a/libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts b/libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts new file mode 100644 index 0000000..7da04f1 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts @@ -0,0 +1,96 @@ + + + + + FomodPlusPatchWizard + + + + + Patch Wizard + + + + + Nothing of interest yet. The wizard gets wiser as you +install FOMODs, so check back later! + + + + + + Rescan Load Order + + + + + Rescanning will populate as many existing choices and options as we can, but if some downloads are deleted it may be missing things! It may take a few minutes depending on the size of your load order and all that. + + + + + Scanning mods... + + + + + Cancel + + + + + Scanning: %1 (%2/%3) + + + + + Rescan Cancelled + + + + + The rescan was cancelled. Partial results may have been saved. + + + + + Rescan complete! + +Mods processed: %1 +Successfully scanned: %2 +Missing archives: %3 +Parse errors: %4 + + + + + + +Failed mods: + + + + + + +%1 mods failed (see log for details) + + + + + Rescan Complete + + + + + Available patches: %1 + + + + + + Find missing patches from FOMODs in your load order. + + + + diff --git a/libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts b/libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts new file mode 100644 index 0000000..aaa26e2 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts @@ -0,0 +1,19 @@ + + + + + FomodPlusPatchWizard + + + + + Patch Wizard + + + + + Finde Patches, die du aus den FOMODs in deiner Modliste installieren möchtest. + + + + diff --git a/libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json b/libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json new file mode 100644 index 0000000..2d6025f --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json @@ -0,0 +1,8 @@ +{ + "author": "clearing", + "date": "2025/02/06", + "name": "FOMOD Plus - Patch Wizard", + "version": "1.0.0", + "des": "Find missing patches in your modlist", + "dependencies": [] +} diff --git a/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp new file mode 100644 index 0000000..cd06b3a --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp @@ -0,0 +1,86 @@ +#include "PatchFinder.h" + +std::vector PatchFinder::getAvailablePatchesForMod(const MOBase::IModInterface* mod) +{ + std::vector available_patches = {}; + + // Verify that the mod has plugins in some shape or form. + if (const auto it = m_installedPlugins.find(mod); it == m_installedPlugins.end()) { + return available_patches; + } + + // Look through the database + for (const auto& fomodDbEntries = mFomodDb->getEntries(); const auto& entry : fomodDbEntries) { + for (const auto& option : entry->getOptions()) { + // Skip options that are already selected/installed + if (option.selectionState == SelectionState::Selected) { + continue; + } + + // Extract just the filename from the path (handle both / and \) + const auto fileName = option.fileName.substr(option.fileName.find_last_of("/\\") + 1); + + // Skip if already installed in the modlist + if (m_installedPluginsCacheSet.contains(fileName)) { + continue; + } + + // Technically without this check, we're doing a lookup for the entire modlist, which actually + // kinda works, but isn't the intention of this function. Might want to consider reworking + // it to map to mod names with one pass of the DB instead of a DB pass per mod. + if (!std::ranges::any_of(option.masters, [&mod](const std::string& master) { + return master == mod->name().toStdString(); + })) { + continue; + } + + // If we have all of this patch's masters in our modlist, and it's not installed, add it to the results. + if (std::ranges::all_of(option.masters, [this](const std::string& master) { + return m_installedPluginsCacheSet.contains(master); + })) { + AvailablePatch patch{ + option, + entry->getDisplayName(), + mod->name().toStdString(), + false, // not installed + false, // not hidden + option.selectionState == SelectionState::Deselected // userDeselected + }; + available_patches.push_back(patch); + } + } + } + + return available_patches; +} + +std::vector PatchFinder::getAvailablePatchesForModList() +{ + std::vector available_patches = {}; + for (const auto& modName : m_organizer->modList()->allMods()) { + const auto mod = m_organizer->modList()->getMod(modName); + if (mod == nullptr) { + continue; + } + for (const auto& available_patch : getAvailablePatchesForMod(mod)) { + available_patches.emplace_back(available_patch); + } + } + + return available_patches; +} + +void PatchFinder::populateInstalledPlugins() +{ + for (const auto& modName : m_organizer->modList()->allMods()) { + const auto mod = m_organizer->modList()->getMod(modName); + const auto mod_tree = mod->fileTree(); + for (auto it = mod_tree->begin(); it != mod_tree->end(); ++it) { + if ((*it)->isFile() && isPluginFile((*it)->name())) { + std::cout << "Plugin: " << (*it)->name().toStdString() << std::endl; + m_installedPlugins[mod].emplace_back((*it)->name().toStdString()); + m_installedPluginsCacheSet.insert((*it)->name().toStdString()); + } + } + } +} \ No newline at end of file diff --git a/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h new file mode 100644 index 0000000..02327e3 --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h @@ -0,0 +1,49 @@ +#pragma once + +#include "../../installer/lib/Logger.h" + +#include +#include +#include +#include + +struct AvailablePatch { + FomodOption fomod_option; + std::string installer_name; + std::string patch_for_mod; + bool installed = false; + bool hidden = false; + bool userDeselected = false; // True if user explicitly chose not to install +}; + +class PatchFinder { +friend class FomodPlusPatchWizard; + +public: + explicit PatchFinder(MOBase::IOrganizer* m_organizer) : m_organizer(m_organizer) + { + mFomodDb = std::make_unique(m_organizer->basePath().toStdString()); + logMessage(DEBUG, "mFomodDb loaded."); + } + + std::vector getAvailablePatchesForMod(const MOBase::IModInterface* mod); + std::vector getAvailablePatchesForModList(); + +protected: + void populateInstalledPlugins(); + +private: + Logger& log = Logger::getInstance(); + MOBase::IOrganizer* m_organizer; + std::unique_ptr mFomodDb; + + // Map of { pluginPtr: [1.esp, 2.esp, 3.esp] } + std::unordered_map > m_installedPlugins; + std::unordered_set m_installedPluginsCacheSet; + + void logMessage(const LogLevel level, const std::string& message) const + { + log.logMessage(level, "[PATCHFINDER] " + message); + } + +}; \ No newline at end of file diff --git a/libs/installer_fomod_plus/patchwizard/resources.qrc b/libs/installer_fomod_plus/patchwizard/resources.qrc new file mode 100644 index 0000000..fe6971e --- /dev/null +++ b/libs/installer_fomod_plus/patchwizard/resources.qrc @@ -0,0 +1,6 @@ + + + resources/fomod_icon.png + resources/infoscroll.png + + diff --git a/libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png b/libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png new file mode 100644 index 0000000..e044052 Binary files /dev/null and b/libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png differ diff --git a/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png b/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png new file mode 100644 index 0000000..0c31777 Binary files /dev/null and b/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png differ -- cgit v1.3.1