aboutsummaryrefslogtreecommitdiff
path: root/libs/installer_fomod_plus/share/FOMODData
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-14 02:45:12 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-14 02:45:25 -0600
commit817e8f5cd26739c69d930d21cd9dc4c0b6e4984e (patch)
tree52aed11d6751bb7d20b06acf197d56fac113203d /libs/installer_fomod_plus/share/FOMODData
parent51a9f8f197727f00896e5de44569b098923527dd (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'libs/installer_fomod_plus/share/FOMODData')
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h160
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/FomodDB.h146
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h129
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/FomodRescan.h277
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/PluginReader.h119
5 files changed, 831 insertions, 0 deletions
diff --git a/libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h b/libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h
new file mode 100644
index 0000000..65cd0ae
--- /dev/null
+++ b/libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h
@@ -0,0 +1,160 @@
+#pragma once
+
+#include "stringutil.h"
+
+#include <QDir>
+#include <QFileInfo>
+#include <QString>
+#include <QTemporaryDir>
+#include <archive.h>
+#include <filesystem>
+#include <functional>
+#include <iostream>
+#include <memory>
+#include <optional>
+
+struct ExtractionResult {
+ bool success = false;
+ QString moduleConfigPath;
+ std::vector<QString> pluginPaths;
+ QString errorMessage;
+ std::unique_ptr<QTemporaryDir> tempDir; // Owns the temp directory lifetime
+};
+
+/**
+ * Utility class to extract FOMOD data from archives without being in an installer context.
+ * Used by the Patch Wizard's rescan functionality.
+ */
+class ArchiveExtractor {
+public:
+ using ProgressCallback = std::function<void(const QString& fileName)>;
+
+ /**
+ * Extract ModuleConfig.xml and plugin files from an archive.
+ * @param archiveFilePath Full path to the archive file
+ * @param progressCallback Optional callback for progress updates
+ * @return ExtractionResult containing paths to extracted files
+ */
+ static ExtractionResult extractFomodData(
+ const QString& archiveFilePath,
+ const ProgressCallback& progressCallback = nullptr)
+ {
+ ExtractionResult result;
+ result.tempDir = std::make_unique<QTemporaryDir>();
+
+ if (!result.tempDir->isValid()) {
+ result.errorMessage = "Failed to create temporary directory";
+ return result;
+ }
+
+ const auto archive = CreateArchive();
+ if (!archive->isValid()) {
+ result.errorMessage = "Failed to load archive module";
+ return result;
+ }
+
+ if (!archive->open(archiveFilePath.toStdWString(), nullptr)) {
+ result.errorMessage = QString("Failed to open archive (error %1)")
+ .arg(static_cast<int>(archive->getLastError()));
+ return result;
+ }
+
+ // Get file list and mark files for extraction
+ const auto& fileList = archive->getFileList();
+ QString moduleConfigInArchive;
+
+ for (auto* fileData : fileList) {
+ const auto entryPath = QString::fromStdWString(fileData->getArchiveFilePath());
+
+ // Check for ModuleConfig.xml
+ if (entryPath.toLower().endsWith("fomod/moduleconfig.xml") ||
+ entryPath.toLower().endsWith("fomod\\moduleconfig.xml")) {
+ moduleConfigInArchive = entryPath;
+ // Set output path relative to output directory for extract()
+ fileData->addOutputFilePath(L"ModuleConfig.xml");
+ result.moduleConfigPath = result.tempDir->filePath("ModuleConfig.xml");
+ }
+ // Check for plugin files
+ else if (isPluginFile(entryPath)) {
+ const auto fileName = QFileInfo(entryPath).fileName();
+ const auto relativePath = QString("plugins/") + fileName;
+ fileData->addOutputFilePath(relativePath.toStdWString());
+ result.pluginPaths.push_back(result.tempDir->filePath(relativePath));
+ }
+ }
+
+ if (moduleConfigInArchive.isEmpty()) {
+ result.errorMessage = "No ModuleConfig.xml found in archive";
+ return result;
+ }
+
+ // Create plugins subdirectory
+ QDir(result.tempDir->path()).mkpath("plugins");
+
+ // Extract the files
+ Archive::FileChangeCallback fileChangeCallback = [&progressCallback](
+ Archive::FileChangeType, const std::wstring& fileName) {
+ if (progressCallback) {
+ progressCallback(QString::fromStdWString(fileName));
+ }
+ };
+
+ Archive::ErrorCallback errorCallback = [&result](const std::wstring& error) {
+ result.errorMessage = QString::fromStdWString(error);
+ };
+
+ const bool extractSuccess = archive->extract(
+ result.tempDir->path().toStdWString(),
+ Archive::ProgressCallback{}, // progress callback
+ fileChangeCallback,
+ errorCallback
+ );
+
+ if (!extractSuccess) {
+ if (result.errorMessage.isEmpty()) {
+ result.errorMessage = "Extraction failed";
+ }
+ return result;
+ }
+
+ // Verify ModuleConfig.xml was extracted
+ if (!QFile::exists(result.moduleConfigPath)) {
+ result.errorMessage = "ModuleConfig.xml extraction failed";
+ return result;
+ }
+
+ // Filter to only existing plugin files
+ std::vector<QString> existingPlugins;
+ for (const auto& path : result.pluginPaths) {
+ if (QFile::exists(path)) {
+ existingPlugins.push_back(path);
+ }
+ }
+ result.pluginPaths = std::move(existingPlugins);
+
+ result.success = true;
+ return result;
+ }
+
+ /**
+ * Check if an archive contains FOMOD files without extracting.
+ * @param archiveFilePath Full path to the archive file
+ * @return true if the archive contains fomod/ModuleConfig.xml
+ */
+ static bool hasFomodFiles(const QString& archiveFilePath)
+ {
+ const auto archive = CreateArchive();
+ if (!archive->isValid() || !archive->open(archiveFilePath.toStdWString(), nullptr)) {
+ return false;
+ }
+
+ for (const auto* fileData : archive->getFileList()) {
+ const auto path = QString::fromStdWString(fileData->getArchiveFilePath());
+ if (path.toLower().endsWith("fomod/moduleconfig.xml") ||
+ path.toLower().endsWith("fomod\\moduleconfig.xml")) {
+ return true;
+ }
+ }
+ return false;
+ }
+};
diff --git a/libs/installer_fomod_plus/share/FOMODData/FomodDB.h b/libs/installer_fomod_plus/share/FOMODData/FomodDB.h
new file mode 100644
index 0000000..d1a9aa2
--- /dev/null
+++ b/libs/installer_fomod_plus/share/FOMODData/FomodDB.h
@@ -0,0 +1,146 @@
+#pragma once
+
+#include <fstream>
+#include <stringutil.h>
+
+#include "FomodDBEntry.h"
+
+#include <xml/ModuleConfiguration.h>
+
+#include "PluginReader.h"
+
+using FOMODDBEntries = std::vector<std::shared_ptr<FomodDbEntry> >;
+
+constexpr std::string FOMOD_DB_FILE = "fomod.db";
+
+class FomodDB {
+public:
+ /**
+ *
+ * @param moBasePath The organizer instance's basePath() value
+ * @param dbName The filename of the db. Only settable for testing purposes.
+ */
+ explicit FomodDB(const std::string &moBasePath, const std::string &dbName = FOMOD_DB_FILE) {
+ dbFilePath = (std::filesystem::path(moBasePath) / dbName).string();
+ loadFromFile();
+ }
+
+ // TODO: Also pull from non install steps (requiredInstallFiles or whatever, and optional);
+ static std::shared_ptr<FomodDbEntry> getEntryFromFomod(ModuleConfiguration *fomod, std::vector<QString> pluginPaths,
+ int modId) {
+ std::vector<FomodOption> options;
+ for (const auto &installStep: fomod->installSteps.installSteps) {
+ for (const auto &group: installStep.optionalFileGroups.groups) {
+ for (const auto &plugin: group.plugins.plugins) {
+ // Create a DB entry for the given plugin if it has an ESP
+ std::cout << "\nPlugin: " << plugin.name << std::endl;
+
+ for (auto file: plugin.files.files) {
+ if (file.isFolder || !isPluginFile(file.source)) {
+ continue;
+ }
+
+ // Find the path in pluginPaths that ends with this path
+ // PluginPaths is gathered from the archive contents.
+ auto it = std::ranges::find_if(pluginPaths, [&file](const QString &path) {
+ return path.endsWith(file.source.c_str());
+ });
+ if (it == pluginPaths.end()) {
+ continue;
+ }
+ const auto &pluginPath = *it;
+ const auto masters = PluginReader::readMasters(pluginPath.toStdString(), true);
+ options.emplace_back(
+ plugin.name,
+ file.source,
+ masters,
+ installStep.name,
+ group.name
+ );
+ }
+ }
+ }
+ }
+ return std::make_shared<FomodDbEntry>(modId, fomod->moduleName, options);
+ }
+
+ void addEntry(const std::shared_ptr<FomodDbEntry> &entry, const bool upsert = true) {
+ // TODO: Test this upsert.
+ if (upsert) {
+ const auto it = std::ranges::find_if(entries, [&entry](const std::shared_ptr<FomodDbEntry> &e) {
+ return e->getModId() == entry->getModId();
+ });
+ if (it != entries.end()) {
+ *it = entry;
+ } else {
+ entries.emplace_back(entry);
+ }
+ } else {
+ entries.emplace_back(entry);
+ }
+ }
+
+ [[nodiscard]] const FOMODDBEntries &getEntries() { return entries; }
+
+ void saveToFile() const {
+ try {
+ std::ofstream file(dbFilePath);
+ if (!file.is_open()) {
+ return;
+ }
+
+ file << toJson().dump(2); // Pretty-print with 2-space indentation
+ file.close();
+ } catch ([[maybe_unused]] const std::exception &e) {
+ // Handle saving errors
+ }
+ }
+
+ [[nodiscard]] nlohmann::json toJson() const {
+ nlohmann::json jsonArray = nlohmann::json::array();
+
+ for (const auto &entry: entries) {
+ jsonArray.push_back(entry->toJson());
+ }
+
+ return jsonArray;
+ }
+
+private:
+ FOMODDBEntries entries;
+ std::string dbFilePath;
+
+ void loadFromFile() {
+ entries.clear();
+
+ // Create empty file if it doesn't exist
+ if (!std::filesystem::exists(dbFilePath)) {
+ std::ofstream file(dbFilePath);
+ file << "[]"; // Empty JSON array
+ file.close();
+ return; // No entries to load
+ }
+
+ try {
+ // Read and parse the JSON file
+ std::ifstream file(dbFilePath);
+ if (!file.is_open()) {
+ return;
+ }
+
+ nlohmann::json jsonArray = nlohmann::json::parse(file);
+
+ // Ensure it's an array
+ if (!jsonArray.is_array()) {
+ return;
+ }
+
+ // Process each entry in the array
+ for (const auto &entryJson: jsonArray) {
+ entries.push_back(std::make_unique<FomodDbEntry>(entryJson));
+ }
+ } catch ([[maybe_unused]] const std::exception &e) {
+ // Handle parsing errors (leave entries empty)
+ }
+ }
+};
diff --git a/libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h b/libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h
new file mode 100644
index 0000000..5d39f95
--- /dev/null
+++ b/libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h
@@ -0,0 +1,129 @@
+#pragma once
+
+#include <string>
+#include <utility>
+#include <vector>
+#include <nlohmann/json.hpp>
+
+/*
+The following JSON will be part of an array of similar objects in the root level "JSON DB" for FOMOD Plus.
+It contains information to resolve the identity of a given mod (names can change), and then the options
+in the FOMOD with their respective masters.
+{
+ modId: 12345,
+ displayName: "Lux (Patch Hub)",
+ options: [
+ {
+ "name" "JK's The Hag's Cure",
+ "fileName": "Lux - JK's The Hag's Cure patch.esp",
+ "masters": [
+ "Skyrim.esm",
+ "JK's The Hag's Cure.esp",
+ "Lux - Resources.esp",
+ "Lux.esp"
+ ],
+ "step": "Page One",
+ "group": "Group One",
+ "selectionState": "Available"
+ }
+ ]
+}
+*/
+
+enum class SelectionState {
+ Unknown, // Not yet matched to choices
+ Selected, // User selected this option
+ Deselected, // User manually deselected
+ Available // Present but user didn't interact (or choices not recorded)
+};
+
+inline std::string selectionStateToString(SelectionState state) {
+ switch (state) {
+ case SelectionState::Unknown: return "Unknown";
+ case SelectionState::Selected: return "Selected";
+ case SelectionState::Deselected: return "Deselected";
+ case SelectionState::Available: return "Available";
+ default: return "Unknown";
+ }
+}
+
+inline SelectionState stringToSelectionState(const std::string& str) {
+ if (str == "Selected") return SelectionState::Selected;
+ if (str == "Deselected") return SelectionState::Deselected;
+ if (str == "Available") return SelectionState::Available;
+ return SelectionState::Unknown;
+}
+
+struct FomodOption {
+ std::string name;
+ std::string fileName;
+ std::vector<std::string> masters;
+ std::string step;
+ std::string group;
+ SelectionState selectionState = SelectionState::Unknown;
+
+ FomodOption(std::string n, std::string fn, std::vector<std::string> m, std::string s, std::string g,
+ SelectionState state = SelectionState::Unknown)
+ : name(std::move(n)), fileName(std::move(fn)), masters(std::move(m)), step(std::move(s)), group(std::move(g)),
+ selectionState(state) {}
+};
+
+class FomodDbEntry {
+public:
+ explicit FomodDbEntry(nlohmann::json json) {
+ modId = json["modId"];
+ displayName = json["displayName"];
+ for (auto &option: json["options"]) {
+ // create an option from this object
+ SelectionState state = SelectionState::Unknown;
+ if (option.contains("selectionState")) {
+ state = stringToSelectionState(option["selectionState"]);
+ }
+ FomodOption fomodOption(
+ option["name"],
+ option["fileName"],
+ option["masters"],
+ option["step"],
+ option["group"],
+ state
+ );
+ options.push_back(fomodOption);
+ }
+ }
+
+ explicit FomodDbEntry(const int modId, std::string displayName, const std::vector<FomodOption> &options)
+ : modId(modId), displayName(std::move(displayName)), options(options) {
+ }
+
+
+ [[nodiscard]] int getModId() const { return modId; }
+ [[nodiscard]] std::string getDisplayName() const { return displayName; }
+ [[nodiscard]] const std::vector<FomodOption>& getOptions() const { return options; }
+ [[nodiscard]] std::vector<FomodOption>& getOptionsMutable() { return options; }
+
+ [[nodiscard]] nlohmann::json toJson() const {
+ nlohmann::json result;
+ result["modId"] = modId;
+ result["displayName"] = displayName;
+
+ nlohmann::json optionsArray = nlohmann::json::array();
+ for (const auto &[name, fileName, masters, step, group, selectionState]: options) {
+ nlohmann::json optionJson;
+ optionJson["name"] = name;
+ optionJson["fileName"] = fileName;
+ optionJson["masters"] = masters;
+ optionJson["step"] = step;
+ optionJson["group"] = group;
+ optionJson["selectionState"] = selectionStateToString(selectionState);
+ optionsArray.push_back(optionJson);
+ }
+
+ result["options"] = optionsArray;
+ return result;
+ }
+
+private:
+ int modId;
+ std::string displayName;
+ std::vector<FomodOption> options;
+};
diff --git a/libs/installer_fomod_plus/share/FOMODData/FomodRescan.h b/libs/installer_fomod_plus/share/FOMODData/FomodRescan.h
new file mode 100644
index 0000000..c7d53a2
--- /dev/null
+++ b/libs/installer_fomod_plus/share/FOMODData/FomodRescan.h
@@ -0,0 +1,277 @@
+#pragma once
+
+#include "ArchiveExtractor.h"
+#include "FomodDB.h"
+#include "stringutil.h"
+#include "xml/ModuleConfiguration.h"
+
+#include <QDir>
+#include <QString>
+#include <functional>
+#include <imodinterface.h>
+#include <imoinfo.h>
+#include <nlohmann/json.hpp>
+
+struct RescanResult {
+ int totalModsProcessed = 0;
+ int successfullyScanned = 0;
+ int missingArchives = 0;
+ int parseErrors = 0;
+ std::vector<std::string> failedMods;
+};
+
+/**
+ * Orchestrates rescanning of all mods with stored FOMOD choices to repopulate the database.
+ * Used when fomod.db is missing or needs to be regenerated from existing installations.
+ */
+class FomodRescan {
+public:
+ using ProgressCallback = std::function<void(int current, int total, const QString& modName)>;
+
+ FomodRescan(MOBase::IOrganizer* organizer, FomodDB* db)
+ : mOrganizer(organizer), mFomodDb(db) {}
+
+ /**
+ * Scan all mods that have stored FOMOD Plus choices and repopulate the database.
+ * @param progressCallback Optional callback for progress updates
+ * @return RescanResult with statistics about the scan
+ */
+ RescanResult scanAllModsWithChoices(const ProgressCallback& progressCallback = nullptr)
+ {
+ RescanResult result;
+
+ const auto modList = mOrganizer->modList();
+ if (!modList) {
+ return result;
+ }
+
+ // First pass: gather all mods with stored choices
+ std::vector<MOBase::IModInterface*> modsWithChoices;
+ for (const auto& modName : modList->allMods()) {
+ auto* mod = modList->getMod(modName);
+ if (mod && hasStoredChoices(mod)) {
+ modsWithChoices.push_back(mod);
+ }
+ }
+
+ result.totalModsProcessed = static_cast<int>(modsWithChoices.size());
+
+ // Second pass: process each mod
+ int current = 0;
+ for (auto* mod : modsWithChoices) {
+ current++;
+ if (progressCallback) {
+ progressCallback(current, result.totalModsProcessed, mod->name());
+ }
+
+ const auto scanResult = processMod(mod);
+ switch (scanResult) {
+ case ScanOutcome::Success:
+ result.successfullyScanned++;
+ break;
+ case ScanOutcome::MissingArchive:
+ result.missingArchives++;
+ result.failedMods.push_back(mod->name().toStdString() + " (missing archive)");
+ break;
+ case ScanOutcome::ParseError:
+ result.parseErrors++;
+ result.failedMods.push_back(mod->name().toStdString() + " (parse error)");
+ break;
+ case ScanOutcome::NoFomod:
+ result.failedMods.push_back(mod->name().toStdString() + " (no FOMOD)");
+ break;
+ }
+ }
+
+ // Save the database
+ mFomodDb->saveToFile();
+
+ return result;
+ }
+
+private:
+ MOBase::IOrganizer* mOrganizer;
+ FomodDB* mFomodDb;
+
+ enum class ScanOutcome {
+ Success,
+ MissingArchive,
+ ParseError,
+ NoFomod
+ };
+
+ /**
+ * Check if a mod has stored FOMOD Plus choices (non-zero pluginSetting).
+ */
+ bool hasStoredChoices(MOBase::IModInterface* mod) const
+ {
+ const auto fomodData = mod->pluginSetting(
+ StringConstants::Plugin::NAME.data(), "fomod", 0);
+
+ if (!fomodData.isValid() || fomodData.isNull()) {
+ return false;
+ }
+
+ // Check if it's actually valid JSON with steps
+ try {
+ const auto json = nlohmann::json::parse(fomodData.toString().toStdString());
+ return json.contains("steps") && json["steps"].is_array() && !json["steps"].empty();
+ } catch (...) {
+ return false;
+ }
+ }
+
+ /**
+ * Get the stored choices JSON from a mod's pluginSetting.
+ */
+ nlohmann::json getStoredChoices(MOBase::IModInterface* mod) const
+ {
+ const auto fomodData = mod->pluginSetting(
+ StringConstants::Plugin::NAME.data(), "fomod", 0);
+
+ try {
+ return nlohmann::json::parse(fomodData.toString().toStdString());
+ } catch (...) {
+ return nlohmann::json();
+ }
+ }
+
+ /**
+ * Process a single mod: extract archive, parse FOMOD, create DB entry with selection states.
+ */
+ ScanOutcome processMod(MOBase::IModInterface* mod)
+ {
+ // Get the archive path
+ const auto installationFile = mod->installationFile();
+ if (installationFile.isEmpty()) {
+ return ScanOutcome::MissingArchive;
+ }
+
+ const auto downloadsPath = mOrganizer->downloadsPath();
+ const auto archivePath = QDir(installationFile).isAbsolute()
+ ? installationFile
+ : downloadsPath + "/" + installationFile;
+
+ if (!QFile::exists(archivePath)) {
+ return ScanOutcome::MissingArchive;
+ }
+
+ // Extract FOMOD data from archive
+ auto extractionResult = ArchiveExtractor::extractFomodData(archivePath);
+ if (!extractionResult.success) {
+ return ScanOutcome::ParseError;
+ }
+
+ // Parse ModuleConfiguration
+ auto moduleConfig = std::make_unique<ModuleConfiguration>();
+ try {
+ if (!moduleConfig->deserialize(extractionResult.moduleConfigPath)) {
+ return ScanOutcome::ParseError;
+ }
+ } catch (...) {
+ return ScanOutcome::ParseError;
+ }
+
+ // Get the mod's Nexus ID
+ const int modId = mod->nexusId();
+
+ // Create FomodDbEntry using existing logic
+ auto entry = FomodDB::getEntryFromFomod(
+ moduleConfig.get(),
+ extractionResult.pluginPaths,
+ modId
+ );
+
+ if (!entry || entry->getOptions().empty()) {
+ return ScanOutcome::NoFomod;
+ }
+
+ // Apply selection states from stored choices
+ const auto choices = getStoredChoices(mod);
+ applySelectionsToEntry(*entry, choices);
+
+ // Add to database (upsert)
+ mFomodDb->addEntry(entry, true);
+
+ return ScanOutcome::Success;
+ }
+
+ /**
+ * Apply user selection states to a FomodDbEntry based on stored choices JSON.
+ *
+ * Choices JSON format:
+ * {
+ * "steps": [{
+ * "name": "Step Name",
+ * "groups": [{
+ * "name": "Group Name",
+ * "plugins": ["Selected Plugin 1"],
+ * "deselected": ["Manually Deselected Plugin"]
+ * }]
+ * }]
+ * }
+ */
+ void applySelectionsToEntry(FomodDbEntry& entry, const nlohmann::json& choices)
+ {
+ if (!choices.contains("steps") || !choices["steps"].is_array()) {
+ // No choices data - mark all as Available
+ for (auto& option : entry.getOptionsMutable()) {
+ option.selectionState = SelectionState::Available;
+ }
+ return;
+ }
+
+ // Build a lookup map for quick matching: stepName/groupName/pluginName -> state
+ struct PluginState {
+ bool selected = false;
+ bool deselected = false;
+ };
+ std::map<std::string, PluginState> stateMap;
+
+ for (const auto& step : choices["steps"]) {
+ if (!step.contains("name") || !step.contains("groups")) continue;
+ const std::string stepName = step["name"];
+
+ for (const auto& group : step["groups"]) {
+ if (!group.contains("name")) continue;
+ const std::string groupName = group["name"];
+
+ // Process selected plugins
+ if (group.contains("plugins") && group["plugins"].is_array()) {
+ for (const auto& plugin : group["plugins"]) {
+ const std::string pluginName = plugin;
+ const auto key = stepName + "/" + groupName + "/" + pluginName;
+ stateMap[key].selected = true;
+ }
+ }
+
+ // Process deselected plugins
+ if (group.contains("deselected") && group["deselected"].is_array()) {
+ for (const auto& plugin : group["deselected"]) {
+ const std::string pluginName = plugin;
+ const auto key = stepName + "/" + groupName + "/" + pluginName;
+ stateMap[key].deselected = true;
+ }
+ }
+ }
+ }
+
+ // Apply states to options
+ for (auto& option : entry.getOptionsMutable()) {
+ const auto key = option.step + "/" + option.group + "/" + option.name;
+
+ if (auto it = stateMap.find(key); it != stateMap.end()) {
+ if (it->second.selected) {
+ option.selectionState = SelectionState::Selected;
+ } else if (it->second.deselected) {
+ option.selectionState = SelectionState::Deselected;
+ } else {
+ option.selectionState = SelectionState::Available;
+ }
+ } else {
+ // Plugin not found in choices - mark as Available
+ option.selectionState = SelectionState::Available;
+ }
+ }
+ }
+};
diff --git a/libs/installer_fomod_plus/share/FOMODData/PluginReader.h b/libs/installer_fomod_plus/share/FOMODData/PluginReader.h
new file mode 100644
index 0000000..dade7e1
--- /dev/null
+++ b/libs/installer_fomod_plus/share/FOMODData/PluginReader.h
@@ -0,0 +1,119 @@
+#pragma once
+
+#include <fstream>
+#include <string>
+#include <vector>
+#include <cstdint>
+#include <unordered_set>
+
+static const std::unordered_set<std::string> VANILLA_MASTERS = {
+ "Skyrim.esm",
+ "Update.esm",
+ "Dawnguard.esm",
+ "HearthFires.esm",
+ "Dragonborn.esm"
+};
+
+class PluginReader {
+public:
+
+ /**
+ * Reads the master files from a Bethesda plugin file (ESP/ESM/ESL)
+ * @param filePath Path to the plugin file
+ * @param trimVanilla Exclude the vanilla game masters or not. Mostly to save DB space.
+ * @return Vector of master filenames
+ */
+ static std::vector<std::string> readMasters(const std::string& filePath, const bool trimVanilla = false)
+ {
+ std::vector<std::string> masters;
+ std::ifstream file(filePath, std::ios::binary);
+
+ if (!file) {
+ return masters;
+ }
+
+ // Check TES4 record signature
+ char signature[4];
+ file.read(signature, 4);
+ if (strncmp(signature, "TES4", 4) != 0) {
+ return masters;
+ }
+
+ // Read record size
+ uint32_t recordSize;
+ file.read(reinterpret_cast<char*>(&recordSize), 4);
+
+ constexpr uint32_t skipSize = sizeof(uint32_t) // flags
+ + sizeof(uint32_t) // formId
+ + sizeof(uint16_t) // timestamp
+ + sizeof(uint16_t) // version control
+ + sizeof(uint16_t) // internal version
+ + sizeof(uint16_t); // unknown
+
+ // Skip header flags, formID, etc. (total 8 bytes)
+ file.seekg(skipSize, std::ios::cur);
+
+ // Calculate where the TES4 record ends
+ std::streampos recordEnd = file.tellg() + static_cast<std::streampos>(recordSize);
+
+ // Read subrecords until we reach the end of the TES4 record
+ while (file && file.tellg() < recordEnd) {
+ char subRecordType[4];
+ uint16_t subRecordSize;
+
+ // Read subrecord type and size
+ file.read(subRecordType, 4);
+ file.read(reinterpret_cast<char*>(&subRecordSize), 2);
+
+ if (strncmp(subRecordType, "MAST", 4) == 0) {
+ // Read master filename (null-terminated string)
+ std::string masterName;
+ masterName.resize(subRecordSize);
+ file.read(masterName.data(), subRecordSize);
+
+ // Remove null terminator if present
+ if (!masterName.empty() && masterName.back() == '\0') {
+ masterName.pop_back();
+ }
+
+ // Only add if it's not a vanilla master or if we're not trimming
+ if (!trimVanilla || !VANILLA_MASTERS.contains(masterName)) {
+ masters.push_back(masterName);
+ }
+
+ // Each MAST is followed by a DATA subrecord
+ char dataType[4];
+ uint16_t dataSize;
+ file.read(dataType, 4);
+ file.read(reinterpret_cast<char*>(&dataSize), 2);
+
+ // Skip DATA content (usually an 8-byte value)
+ file.seekg(dataSize, std::ios::cur);
+ } else {
+ // Skip other subrecord types
+ file.seekg(subRecordSize, std::ios::cur);
+ }
+ }
+
+ return masters;
+ }
+
+ /**
+ * Checks if a file is a valid Bethesda plugin (ESP/ESM/ESL)
+ * @param filePath Path to the file
+ * @return True if the file is a valid plugin
+ */
+ static bool isValidPlugin(const std::string& filePath)
+ {
+ std::ifstream file(filePath, std::ios::binary);
+
+ if (!file) {
+ return false;
+ }
+
+ char signature[4];
+ file.read(signature, 4);
+
+ return strncmp(signature, "TES4", 4) == 0;
+ }
+}; \ No newline at end of file