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 --- .../installer/FomodPlusInstaller.cpp | 512 +++++++++++++++++++++ 1 file changed, 512 insertions(+) create mode 100644 libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp (limited to 'libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp') diff --git a/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp b/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp new file mode 100644 index 0000000..690bdbb --- /dev/null +++ b/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp @@ -0,0 +1,512 @@ +#include "FomodPlusInstaller.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FomodInstallerWindow.h" +#include "stringutil.h" +#include "integration/FomodDataContent.h" +#include "ui/Colors.h" +#include "ui/FomodViewModel.h" +#include "lib/CrashHandler.h" + +#include +#include + +using namespace Qt::Literals::StringLiterals; + +/* +-------------------------------------------------------------------------------- + Init +-------------------------------------------------------------------------------- +*/ +#pragma region Initialization + +bool FomodPlusInstaller::init(IOrganizer* organizer) +{ + CrashHandler::initialize(); + mOrganizer = organizer; + mFomodContent = make_shared(organizer); + log.setLogFilePath(QDir::currentPath().toStdString() + "/logs/fomodplus.log"); + std::cout << "QDir::currentPath(): " << QDir::currentPath().toStdString() << std::endl; + std::cout << "mOrganizer->basePath() : " << mOrganizer->basePath().toStdString() << std::endl; + + // REMEMBER: This mFomodDB persists beyond the scope of an individual install. Do not do anything nasty to it. + mFomodDb = std::make_unique(mOrganizer->basePath().toStdString()); + setupUiInjection(); + return true; +} + +void FomodPlusInstaller::setupUiInjection() const +{ + if (shouldShowSidebarFilter()) { + mOrganizer->gameFeatures()->registerFeature(mFomodContent, 9999, false); + } + + mOrganizer->onPluginSettingChanged([this](const QString& pluginName, const QString& key, const QVariant& oldValue, const QVariant& newValue) { + if (pluginName == name() && key == "show_fomod_filter") { + toggleFeature(newValue.toBool()); + } + }); +} + +void FomodPlusInstaller::toggleFeature(const bool enabled) const +{ + if (enabled) { + mOrganizer->gameFeatures()->registerFeature(mFomodContent, 9999, false); + } else { + mOrganizer->gameFeatures()->unregisterFeature(mFomodContent); + } + mOrganizer->refresh(); +} + +#pragma endregion + +/* +-------------------------------------------------------------------------------- + Settings +-------------------------------------------------------------------------------- +*/ +#pragma region Settings +bool FomodPlusInstaller::shouldFallbackToLegacyInstaller() const +{ + return mOrganizer->pluginSetting(name(), "fallback_to_legacy").value(); +} + +bool FomodPlusInstaller::shouldShowImages() const +{ + return mOrganizer->pluginSetting(name(), "show_images").value(); +} + +bool FomodPlusInstaller::shouldShowNotifications() const +{ + return mOrganizer->pluginSetting(name(), "show_notifications").value(); +} + +bool FomodPlusInstaller::shouldShowSidebarFilter() const +{ + return mOrganizer->pluginSetting(name(), "show_fomod_filter").value(); +} + +bool FomodPlusInstaller::shouldAutoRestoreChoices() const +{ + return mOrganizer->pluginSetting(name(), "always_restore_choices").value(); +} + +bool FomodPlusInstaller::isWizardIntegrated() const +{ + return mOrganizer->pluginSetting(name(), "wizard_integration").value(); +} + +void FomodPlusInstaller::toggleShouldShowImages() const +{ + const bool showImages = shouldShowImages(); + mOrganizer->setPluginSetting(name(), "show_images", !showImages); +} + +QString FomodPlusInstaller::getSelectedColor() const +{ + const auto colorName = mOrganizer->pluginSetting(name(), "color_theme").toString(); + const auto it = UiColors::colorStyles.find(colorName); + return it != UiColors::colorStyles.end() ? it->first : "Blue"; +} + +std::vector > FomodPlusInstaller::requirements() const +{ + return { Requirements::gameDependency( + { u"Morrowind"_s, u"Oblivion"_s, u"Fallout 3"_s, u"New Vegas"_s, u"Skyrim"_s, u"Enderal"_s, + u"Fallout 4"_s, u"Skyrim Special Edition"_s, u"Enderal Special Edition"_s, + u"Skyrim VR"_s, u"Fallout 4 VR"_s, u"Starfield"_s }) }; +} + +QList FomodPlusInstaller::settings() const +{ + return { + { u"fallback_to_legacy"_s, u"When hitting cancel, fall back to the legacy FOMOD installer."_s, false }, + { u"always_restore_choices"_s, u"Restore previous choices without clicking the magic button"_s, false }, + { u"show_images"_s, u"Show image previews and the image carousel in installer windows."_s, true }, + { u"color_theme"_s, u"Select the color theme for the installer"_s, QString("Blue") }, // Default color name + { u"show_notifications"_s, u"Show the notifications panel"_s, false }, //WIP + { u"wizard_integration"_s, u"Integrate the installer with patch wizard."_s, true }, //WIP + { u"show_fomod_filter"_s, u"Show the filter in the sidebar (may break other content filters)"_s, true } + }; +} + +#pragma endregion + +bool FomodPlusInstaller::isArchiveSupported(std::shared_ptr tree) const +{ + tree = findFomodDirectory(tree); + if (tree != nullptr) { + return tree->exists(StringConstants::FomodFiles::MODULE_CONFIG.data(), FileTreeEntry::FILE); + } + return false; +} + + +std::pair FomodPlusInstaller::getExistingFomodJson(const GuessedValue& modName, + const int& nexusId, + const int& stepsInCurrentFomod) const +{ + logMessage(DEBUG, "FomodPlusInstaller::getExistingFomodJson - modName: " + modName->toStdString() + + " nexusId: " + std::to_string(nexusId)); + + // Need to have better mod matching based on presence of FOMOD plugin data. + struct ModMatch { + MOBase::IModInterface* mod; + bool hasFomodData; + int stepCount; + nlohmann::json fomodJson; + }; + std::vector matches; + + auto parseStepCount = [](const QVariant& fomodData) -> std::pair { + try { + if (fomodData.isValid() && !fomodData.isNull()) { + if (auto json = nlohmann::json::parse(fomodData.toString().toStdString()); json.contains("steps") && json["steps"].is_array()) { + return { true, static_cast(json["steps"].size()) }; + } + } + } catch (...) { + // Ignore parsing errors + } + return { false, 0 }; + }; + + // Check exact match first + const auto modList = mOrganizer->modList(); + if (modList == nullptr) { + return {}; + } + + if (const auto exactMod = modList->getMod(modName)) { + const auto fomodData = exactMod->pluginSetting(name(), "fomod", 0); + if (auto [valid, stepCount] = parseStepCount(fomodData); valid) { + matches.push_back({ + exactMod, + true, + stepCount, + nlohmann::json::parse(fomodData.toString().toStdString()) + }); + } else { + matches.push_back({ + exactMod, + false, + 0, + nlohmann::json() + }); + } + } + + // Check all variants + for (const auto& variant : modName.variants()) { + if (const auto variantMod = modList->getMod(variant)) { + const auto fomodData = variantMod->pluginSetting(name(), "fomod", 0); + if (auto [valid, stepCount] = parseStepCount(fomodData); valid) { + matches.push_back({ + variantMod, + true, + stepCount, + nlohmann::json::parse(fomodData.toString().toStdString()) + }); + } else { + matches.push_back({ + variantMod, + false, + 0, + nlohmann::json() + }); + } + } + } + + // No matches found + if (matches.empty()) { + return {}; + } + + // First try to find exact step count match + const auto exactMatch = ranges::find_if(matches, + [stepsInCurrentFomod](const ModMatch& match) { + return match.hasFomodData && match.stepCount == stepsInCurrentFomod; + }); + + if (exactMatch != matches.end()) { + logMessage(DEBUG, "Found exact step count match in mod: " + + exactMatch->mod->name().toStdString() + + " with " + std::to_string(exactMatch->stepCount) + " steps"); + return std::make_pair(exactMatch->fomodJson, exactMatch->mod); + } + + // Find the closest step count among mods with FOMOD data + const auto closestMatch = ranges::min_element(matches, + [stepsInCurrentFomod](const ModMatch& a, const ModMatch& b) { + if (!a.hasFomodData || !b.hasFomodData) + return false; + // The min difference between the step counts + return std::abs(a.stepCount - stepsInCurrentFomod) < + std::abs(b.stepCount - stepsInCurrentFomod); + }); + + if (closestMatch != matches.end() && closestMatch->hasFomodData) { + logMessage(DEBUG, "Using closest step count match from mod: " + + closestMatch->mod->name().toStdString() + + " with " + std::to_string(closestMatch->stepCount) + " steps"); + return std::make_pair(closestMatch->fomodJson, closestMatch->mod); + } + + // Fallback to first mod with any FOMOD data + const auto anyFomod = ranges::find_if(matches, + [](const ModMatch& match) { return match.hasFomodData; }); + + if (anyFomod != matches.end()) { + logMessage(DEBUG, "Using first available FOMOD data from mod: " + + anyFomod->mod->name().toStdString()); + return std::make_pair(anyFomod->fomodJson, anyFomod->mod); + } + + logMessage(DEBUG, "No matching FOMOD data found"); + return {}; +} + +void FomodPlusInstaller::clearPriorInstallData() +{ + mInstallerUsed = false; + mFomodJson = nullptr; + mFomodPath = ""; +} + +/** + * + * @param modName + * @param tree + * @param version + * @param nexusID + * @return + */ +IPluginInstaller::EInstallResult FomodPlusInstaller::install(GuessedValue& modName, + std::shared_ptr& tree, QString& version, + int& nexusID) +{ + clearPriorInstallData(); + + logMessage(INFO, std::format("FomodPlusInstaller::install - modName: {}, version: {}, nexusID: {}", + modName->toStdString(), + version.toStdString(), + nexusID + )); + logMessage(INFO, std::format("FomodPlusInstaller::install - tree size: {}", tree->size())); + + auto [infoFile, moduleConfigFile, filePaths] = parseFomodFiles(tree); + + if (infoFile == nullptr || moduleConfigFile == nullptr) { + return RESULT_FAILED; + } + + std::vector pluginPaths = {}; + for (const auto& filePath : filePaths) { + if (isPluginFile(filePath)) { + pluginPaths.emplace_back(filePath); + } + } + + const auto dbEntry = mFomodDb->getEntryFromFomod(moduleConfigFile.get(), pluginPaths, nexusID); + + // create ui & pass xml classes to ui + auto [json, matchMod] = getExistingFomodJson(modName, nexusID, static_cast(moduleConfigFile->installSteps.installSteps.size())); + if (matchMod != nullptr) { + modName.update(matchMod->name(), GUESS_USER); + } + auto fomodViewModel = FomodViewModel::create(mOrganizer, std::move(moduleConfigFile), std::move(infoFile)); + const auto window = std::make_shared(this, modName, tree, mFomodPath, fomodViewModel, json); + + // ReSharper disable once CppTooWideScopeInitStatement + const QDialog::DialogCode result = showInstallerWindow(window); + if (result == QDialog::Accepted) { + // modname was updated in window + mInstallerUsed = true; + const std::shared_ptr installTree = window->getFileInstaller()->install(); + tree = installTree; + mFomodJson = std::make_shared(window->getFileInstaller()->generateFomodJson()); + + try { + mFomodDb->addEntry(dbEntry); + mFomodDb->saveToFile(); + } catch ([[maybe_unused]] Exception& e) { + logMessage(ERR, "Failed to add FomodDB entries."); + logMessage(ERR, e.what()); + } + return RESULT_SUCCESS; + } + if (window->isManualInstall()) { + return RESULT_MANUALREQUESTED; + } + if (shouldFallbackToLegacyInstaller()) { + return RESULT_NOTATTEMPTED; + } + return RESULT_CANCELED; +} + + +/** + * + * @param tree + * @return + */ +ParsedFilesTuple FomodPlusInstaller::parseFomodFiles(const std::shared_ptr& tree) +{ + const auto emptyResult = std::make_tuple(nullptr, nullptr, QStringList()); + + const auto fomodDir = findFomodDirectory(tree); + if (fomodDir == nullptr) { + logMessage(ERR, "FomodPlusInstaller::install - fomod directory not found"); + return emptyResult; + } + + // This is a strange place to set this value but okay for now. + mFomodPath = fomodDir->parent()->path(); + + const auto infoXML = fomodDir->find( + StringConstants::FomodFiles::INFO_XML.data(), + FileTreeEntry::FILE + ); + const auto moduleConfig = fomodDir->find( + StringConstants::FomodFiles::MODULE_CONFIG.data(), + FileTreeEntry::FILE + ); + + // Extract files first. + vector > toExtract = {}; + if (moduleConfig) { + toExtract.push_back(moduleConfig); + } else { + logMessage(ERR, "FomodPlusInstaller::install - error parsing moduleConfig.xml: Not Present"); + return emptyResult; + } + if (infoXML) { + toExtract.push_back(infoXML); + } + appendImageFiles(toExtract, tree); + appendPluginFiles(toExtract, tree); // For patch wizard data collection + auto paths = manager()->extractFiles(toExtract); + // Normalize backslash separators from MO2's internal file tree to forward slashes for Linux + for (auto& p : paths) { + p.replace('\\', '/'); + } + + auto moduleConfiguration = std::make_unique(); + try { + moduleConfiguration->deserialize(paths.at(0)); + } catch (XmlParseException& e) { + logMessage(ERR, std::format("FomodPlusInstaller::install - error parsing moduleConfig.xml: {}", e.what())); + return emptyResult; + } + + auto infoFile = std::make_unique(); + if (infoXML) { + try { + infoFile->deserialize(paths.at(1)); + } catch (XmlParseException& e) { + logMessage(ERR, std::format("FomodPlusInstaller::install - error parsing info.xml: {}", e.what())); + } + } + + return std::make_tuple(std::move(infoFile), std::move(moduleConfiguration), paths); +} + +// Taken from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/installerfomod.cpp#L123 +void FomodPlusInstaller::appendImageFiles(vector >& entries, + const shared_ptr& tree) +{ + static std::set imageSuffixes{ "png", "jpg", "jpeg", "gif", "bmp" }; + for (auto entry : *tree) { + if (entry->isDir()) { + appendImageFiles(entries, entry->astree()); + } else if (imageSuffixes.contains(entry->suffix())) { + entries.push_back(entry); + } + } +} + +void FomodPlusInstaller::appendPluginFiles(vector >& entries, + const shared_ptr& tree) +{ + // Don't bother with this stuff if the user doesn't care about the wizard. + // It may slightly bloat the temp file directory if not needed. + if (isWizardIntegrated()) { + for (auto entry : *tree) { + if (entry->isDir()) { + appendPluginFiles(entries, entry->astree()); + } else if (isPluginFile(entry->suffix())) { + entries.push_back(entry); + } + } + } +} + +void FomodPlusInstaller::onInstallationStart(QString const& archive, const bool reinstallation, + IModInterface* currentMod) +{ + IPluginInstallerSimple::onInstallationStart(archive, reinstallation, currentMod); +} + +void FomodPlusInstaller::onInstallationEnd(const EInstallResult result, IModInterface* newMod) +{ + IPluginInstallerSimple::onInstallationEnd(result, newMod); + + // Update the meta.ini file with the fomod information + if (mFomodJson != nullptr && result == RESULT_SUCCESS && newMod != nullptr && mInstallerUsed) { + newMod->setPluginSetting(this->name(), "fomod", mFomodJson->dump().c_str()); + mOrganizer->refresh(); + } + clearPriorInstallData(); +} + +// Borrowed from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/installerfomod.cpp +std::shared_ptr FomodPlusInstaller::findFomodDirectory(const std::shared_ptr& tree) +{ + // ReSharper disable once CppTooWideScopeInitStatement + const auto entry = tree->find(StringConstants::FomodFiles::FOMOD_DIR.data(), FileTreeEntry::DIRECTORY); + + if (entry != nullptr) { + return entry->astree(); + } + + if (tree->size() == 1 && tree->at(0)->isDir()) { + return findFomodDirectory(tree->at(0)->astree()); + } + return nullptr; +} + +QDialog::DialogCode FomodPlusInstaller::showInstallerWindow(const std::shared_ptr& window) +{ + QEventLoop loop; + connect(window.get(), SIGNAL(accepted()), &loop, SLOT(quit())); + connect(window.get(), SIGNAL(rejected()), &loop, SLOT(quit())); + window->show(); + loop.exec(); + return static_cast(window->result()); +} + +void showError(const Exception& e) +{ + const QString errorText = "Mod Name: \n" "Nexus ID: " "\n" "Exception: " + QString(e.what()) + "\n"; + + QMessageBox msgBox; + msgBox.setWindowTitle("FOMOD Plus Error :("); + msgBox.setTextFormat(Qt::RichText); + msgBox.setText("Sorry this happened. Please copy the following error and report it to me on Nexus or GitHub.\n" + "
"
+        + errorText +
+        "
" + ); + msgBox.setIcon(QMessageBox::Critical); + msgBox.exec(); + +} -- cgit v1.3.1