aboutsummaryrefslogtreecommitdiff
path: root/libs
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-16 19:51:40 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-16 19:51:40 -0500
commit549b84b0f8815737c93913b200dde3821a062cb0 (patch)
treef6bfd96729016847438beda9416c93f447838077 /libs
parenta0747cabb420d699be3a1dd4c63f47c5153bd7b9 (diff)
mo2: catch up to upstream 2.5.3 Betas 3-11
Multi-area sync against the upstream MO2 2.5.3 beta line. Roughly three logical sections share the diff: Catch-up (Betas 3-11): - nxmaccessmanager: drop "supporter" from validRoles so non-premium supporters get the right download path (Beta 10) - mainwindow: disable tutorial triggers at the trigger sites; Qt 6.11 lockup on child windows (Beta 4) - basic_games: add Kingdom Come Deliverance 2, Slay the Spire 2 (Betas 11, 6) - uibase: add NXM collections parsing fields/accessors + (?:nxm|modl) scheme support in NXMUrl (Beta 9) - Starfield: blueprintPrefix() on IPluginGame; plugins.txt write suppression for blueprint plugins; hasInvalidBlueprint / hasUnpairedBlueprint diagnoses; Title-keyed content catalog consolidation (Betas 3-5) - downloadmanager + systemtraymanager + organizercore + iuserinterface + settings: add Beta 11 "show notifications when downloads complete or fail" toggle and the showNotification plumbing - nxmaccessmanager: restore upstream re-entry guard for OAuth refresh and the styled OAuth response page (Beta 11) - organizer_en.ts and game-bethesda *_en.ts: pull upstream HEAD strings Workarounds tab removed: - Delete settingsdialogworkarounds.{h,cpp}; drop the tab in settingsdialog.ui; move "Enable archives parsing" into General - Hardcode GameSettings::forceEnableCoreFiles() to true so the primary-master toggle-off bug (DLCs unchecked on tab refresh) can't recur; setter becomes a no-op - No-op stubs for offlineMode, useProxy, useCustomBrowser, Steam appID/credentials, executablesBlacklist, skipFileSuffixes, skipDirectories — UI gone, accessors keep call sites green - settingsdialognexus drops the custom-browser wiring; spawn drops the "Change the blacklist" Retry path Beta 9 download manager port: - fileID-first match in nxmFilesAvailable and nxmFileInfoFromMd5Available; filename remains the fallback - NexusInterface::isActiveFileStatus() helper; rewrite of nxmUpdatesAvailable to use pickNewestVersion / findUpdateChainSuccessors / resolveInstalledFileId. ARCHIVED_HIDDEN now correctly treated as inactive - std::optional<unsigned int> reservedID on DownloadInfo factories and on the three addDownload overloads; startDownloadURLs and startDownloadURLWithMeta return the canonical DownloadID instead of m_ActiveDownloads.size()-1 (which was the index, not an ID) - QHash<unsigned int, DownloadInfo*> m_ByID maintained alongside m_ActiveDownloads at every mutation site; downloadInfoByID is now O(1) via m_ByID.value(id, nullptr) LOOT remains intentionally stripped (lootcli build cruft cleaned up separately). BSA wide-path skipped — Linux UTF-8 paths already work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'libs')
-rw-r--r--libs/basic_games/games/game_kingdomcomedeliverance2.py104
-rw-r--r--libs/basic_games/games/game_sts2.py81
-rw-r--r--libs/game_bethesda/src/games/starfield/gamestarfield.cpp66
-rw-r--r--libs/game_bethesda/src/games/starfield/gamestarfield.h9
-rw-r--r--libs/game_bethesda/src/games/starfield/starfieldgameplugins.cpp74
-rw-r--r--libs/game_bethesda/src/games/starfield/starfieldsavegame.h5
-rw-r--r--libs/game_bethesda/src/games/starfield/starfieldunmanagedmods.cpp65
-rw-r--r--libs/game_bethesda/src/games/starfield/starfieldunmanagedmods.h1
-rw-r--r--libs/uibase/include/uibase/iplugingame.h7
-rw-r--r--libs/uibase/include/uibase/nxmurl.h18
-rw-r--r--libs/uibase/src/nxmurl.cpp33
11 files changed, 424 insertions, 39 deletions
diff --git a/libs/basic_games/games/game_kingdomcomedeliverance2.py b/libs/basic_games/games/game_kingdomcomedeliverance2.py
new file mode 100644
index 0000000..cef7e23
--- /dev/null
+++ b/libs/basic_games/games/game_kingdomcomedeliverance2.py
@@ -0,0 +1,104 @@
+import os
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from PyQt6.QtCore import QDir, qInfo, qWarning
+
+import mobase
+
+from ..basic_features import BasicModDataChecker, GlobPatterns
+from ..basic_game import BasicGame
+
+
+class KingdomComeDeliverance2Game(BasicGame):
+ Name = "Kingdom Come: Deliverance 2 Support Plugin"
+ Author = "TheForgotten69"
+ Version = "1.0.0"
+
+ GameName = "Kingdom Come: Deliverance II"
+ GameShortName = "kingdomcomedeliverance2"
+ GameNexusName = "kingdomcomedeliverance2"
+ GameNexusId = 7286
+ GameSteamId = [1771300]
+ GameBinary = "bin/Win64MasterMasterSteamPGO/KingdomCome.exe"
+ GameDataPath = "Mods"
+ GameSaveExtension = "whs"
+ GameDocumentsDirectory = "%GAME_PATH%"
+ GameSavesDirectory = "%USERPROFILE%/Saved Games/kingdomcome2/saves"
+
+ def init(self, organizer: mobase.IOrganizer) -> bool:
+ super().init(organizer)
+ self._register_feature(BasicModDataChecker(GlobPatterns(valid=["*"])))
+ organizer.onAboutToRun(self._write_mod_order)
+ return True
+
+ @staticmethod
+ def _get_mod_id(mod_path: Path) -> str | None:
+ """Return the mod ID for a KCD2 mod.
+
+ Checks mod.manifest for <modid> (preferred) or <name>, falling back
+ to the game mod folder name (the subfolder inside the MO2 mod directory).
+ """
+ for manifest in mod_path.rglob("mod.manifest"):
+ try:
+ root = ET.parse(manifest).getroot()
+ for tag in ("modid", "name"):
+ elem = root.find(f".//{tag}")
+ if elem is not None and elem.text and elem.text.strip():
+ return elem.text.strip()
+ except ET.ParseError:
+ qWarning(f"KCD2: failed to parse {manifest}")
+ # Fall back to the folder that contains mod.manifest
+ return manifest.parent.name
+ # No manifest — use first subdirectory name (the game mod folder)
+ for subdir in mod_path.iterdir():
+ if subdir.is_dir():
+ return subdir.name
+ return None
+
+ def _write_mod_order(self, app_path: str, wd: QDir, args: str) -> bool:
+ if not self.isActive():
+ return True
+
+ modlist = self._organizer.modList()
+ mods_path = Path(self._organizer.modsPath())
+ mod_order_path = Path(self._organizer.overwritePath()) / "mod_order.txt"
+
+ mod_ids: list[str] = []
+ for mod_name in modlist.allModsByProfilePriority():
+ if not (modlist.state(mod_name) & mobase.ModState.ACTIVE):
+ continue
+ mod_id = self._get_mod_id(mods_path / mod_name)
+ if mod_id:
+ qInfo(f"KCD2: mod '{mod_name}' -> id '{mod_id}'")
+ mod_ids.append(mod_id)
+ else:
+ qWarning(f"KCD2: could not resolve id for mod '{mod_name}', skipping")
+
+ if not mod_ids:
+ mod_order_path.unlink(missing_ok=True)
+ return True
+
+ # MO2 priority 1 = top = highest priority. KCD2 is last-loaded-wins,
+ # so highest priority must be last in the file.
+ mod_ids.reverse()
+ mod_order_path.parent.mkdir(parents=True, exist_ok=True)
+ mod_order_path.write_text("\n".join(mod_ids))
+ qInfo(f"KCD2: wrote mod_order.txt with {len(mod_ids)} mods: {mod_ids}")
+ return True
+
+ def iniFiles(self):
+ return ["custom.cfg", "system.cfg", "user.cfg"]
+
+ def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting):
+ for iniFile in self.iniFiles():
+ iniPath = self.documentsDirectory().absoluteFilePath(iniFile)
+ if not os.path.exists(iniPath):
+ with open(iniPath, "w") as _:
+ pass
+
+ modsPath = self.dataDirectory().absolutePath()
+ if not os.path.exists(modsPath):
+ os.mkdir(modsPath)
+
+ super().initializeProfile(directory, settings)
diff --git a/libs/basic_games/games/game_sts2.py b/libs/basic_games/games/game_sts2.py
new file mode 100644
index 0000000..fe992e8
--- /dev/null
+++ b/libs/basic_games/games/game_sts2.py
@@ -0,0 +1,81 @@
+from pathlib import Path
+
+from PyQt6.QtCore import QDir, QFileInfo
+
+import mobase
+
+from ..basic_features import BasicModDataChecker, GlobPatterns
+from ..basic_features.basic_save_game_info import BasicGameSaveGame
+from ..basic_game import BasicGame
+
+
+class SlayTheSpire2ModDataChecker(BasicModDataChecker):
+ def __init__(self):
+ super().__init__(
+ GlobPatterns(
+ valid=[
+ "*.pck",
+ "*.dll",
+ "*.json",
+ ],
+ move={
+ "*/*.pck": "",
+ "*/*.dll": "",
+ "*/*.json": "",
+ },
+ )
+ )
+
+
+class SlayTheSpire2Game(BasicGame):
+ Name = "Slay the Spire 2 Support Plugin"
+ Author = "Azlle"
+ Version = "1.0.1"
+
+ GameName = "Slay the Spire 2"
+ GameShortName = "slaythespire2"
+ GameNexusName = "slaythespire2"
+ GameNexusId = 8916
+ GameSteamId = 2868840
+ GameBinary = "SlayTheSpire2.exe"
+ GameDataPath = "mods"
+ GameDocumentsDirectory = "%USERPROFILE%/AppData/Roaming/SlayTheSpire2"
+
+ def init(self, organizer: mobase.IOrganizer) -> bool:
+ super().init(organizer)
+ self._register_feature(SlayTheSpire2ModDataChecker())
+ return True
+
+ def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting):
+ mods_path = Path(self.dataDirectory().absolutePath())
+ mods_path.mkdir(exist_ok=True)
+ super().initializeProfile(directory, settings)
+
+ def savesDirectory(self) -> QDir:
+ docs = QDir(self.documentsDirectory())
+ steam_dir = Path(docs.absoluteFilePath("steam"))
+ if steam_dir.exists():
+ entries = [e for e in steam_dir.iterdir() if e.is_dir()]
+ if entries:
+ return QDir(str(entries[0]))
+ return QDir(str(steam_dir))
+
+ def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]:
+ base = Path(folder.absolutePath())
+ return [
+ BasicGameSaveGame(save)
+ for save in base.rglob("*")
+ if save.is_file() and save.suffix in (".save", ".run")
+ ]
+
+ def executables(self):
+ return [
+ mobase.ExecutableInfo(
+ "Slay the Spire 2",
+ QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())),
+ ),
+ mobase.ExecutableInfo(
+ "Slay the Spire 2 (OpenGL)",
+ QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())),
+ ).withArgument("--rendering-driver opengl3"),
+ ]
diff --git a/libs/game_bethesda/src/games/starfield/gamestarfield.cpp b/libs/game_bethesda/src/games/starfield/gamestarfield.cpp
index ebec948..06a6d79 100644
--- a/libs/game_bethesda/src/games/starfield/gamestarfield.cpp
+++ b/libs/game_bethesda/src/games/starfield/gamestarfield.cpp
@@ -22,6 +22,7 @@
#include <QDir>
#include <QFileInfo>
#include <QList>
+#include <QRegularExpression>
#include <QObject>
#include <QString>
#include <QStringList>
@@ -337,15 +338,23 @@ QStringList GameStarfield::CCPlugins() const
// as Creations. The StarfieldUnmanagedMods class handles parsing mod names and files.
if (unmanagedMods.get()) {
auto contentCatalog = unmanagedMods->parseContentCatalog();
- for (const auto& mod : contentCatalog) {
- if (!plugins.contains(mod.first, Qt::CaseInsensitive)) {
- plugins.append(mod.first);
+ for (auto& mod : contentCatalog) {
+ QStringList pluginFiles = mod.second.files.filter(QRegularExpression(
+ "\\.es(m|p|l)$", QRegularExpression::CaseInsensitiveOption));
+ if (!pluginFiles.isEmpty()) {
+ plugins += pluginFiles;
}
}
}
+ plugins.removeDuplicates();
return plugins;
}
+QString GameStarfield::blueprintPrefix() const
+{
+ return "blueprintships-";
+}
+
IPluginGame::SortMechanism GameStarfield::sortMechanism() const
{
return IPluginGame::SortMechanism::LOOT;
@@ -380,6 +389,10 @@ std::vector<unsigned int> GameStarfield::activeProblems() const
if (testFilePresent())
result.push_back(PROBLEM_TEST_FILE);
}
+ if (hasInvalidBlueprint())
+ result.push_back(PROBLEM_INVALID_BLUEPRINT);
+ if (hasUnpairedBlueprint())
+ result.push_back(PROBLEM_UNPAIRED_BLUEPRINT);
}
return result;
}
@@ -412,6 +425,38 @@ bool GameStarfield::testFilePresent() const
return false;
}
+bool GameStarfield::hasInvalidBlueprint() const
+{
+ auto plugins = m_Organizer->pluginList()->pluginNames();
+ for (auto plugin : plugins) {
+ if (m_Organizer->pluginList()->isBlueprintFlagged(plugin)) {
+ if (!plugin.startsWith(blueprintPrefix(), Qt::CaseInsensitive) ||
+ !m_Organizer->pluginList()->hasMasterExtension(plugin))
+ return true;
+ } else if (plugin.startsWith(blueprintPrefix(), Qt::CaseInsensitive)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool GameStarfield::hasUnpairedBlueprint() const
+{
+ auto plugins = m_Organizer->pluginList()->pluginNames();
+ for (auto plugin : plugins) {
+ if (plugin.startsWith(blueprintPrefix(), Qt::CaseInsensitive) &&
+ m_Organizer->pluginList()->hasMasterExtension(plugin)) {
+ QString parent = plugin.mid(blueprintPrefix().size(),
+ plugin.size() - blueprintPrefix().size() - 4);
+ auto mainPlugin = plugins.filter(QRegularExpression(
+ "^" + parent + "\\.es(m|p|l)$", QRegularExpression::CaseInsensitiveOption));
+ if (mainPlugin.isEmpty())
+ return true;
+ }
+ }
+ return false;
+}
+
QString GameStarfield::shortDescription(unsigned int key) const
{
switch (key) {
@@ -419,6 +464,10 @@ QString GameStarfield::shortDescription(unsigned int key) const
return tr("You have active ESP plugins in Starfield");
case PROBLEM_TEST_FILE:
return tr("sTestFile entries are present");
+ case PROBLEM_INVALID_BLUEPRINT:
+ return tr("Invalid blueprint plugins found");
+ case PROBLEM_UNPAIRED_BLUEPRINT:
+ return tr("Unpaired blueprint plugins found");
}
return "";
}
@@ -446,6 +495,17 @@ QString GameStarfield::fullDescription(unsigned int key) const
"settings in your StarfieldCustom.ini. These must be removed or the game "
"will not read the plugins.txt file. Management is still disabled.</p>");
}
+ case PROBLEM_INVALID_BLUEPRINT:
+ return tr(
+ "<p>You have a blueprint file that is invalid. Blueprints require the "
+ "blueprint flag and prefix and must have the ESM extension to be valid.</p>");
+ case PROBLEM_UNPAIRED_BLUEPRINT:
+ return tr(
+ "<p>You have a valid blueprint file that has no paired main plugin. The only "
+ "way to load blueprint files is by enabling a main plugin with the same base "
+ "name. This is the part after the prefix and before the extension.</p><p>eg. "
+ "<strong>BlueprintShips-example.esm</strong> should have a paired main plugin "
+ "<strong>example.esm</strong></p>");
}
return "";
}
diff --git a/libs/game_bethesda/src/games/starfield/gamestarfield.h b/libs/game_bethesda/src/games/starfield/gamestarfield.h
index f9724c0..a512b36 100644
--- a/libs/game_bethesda/src/games/starfield/gamestarfield.h
+++ b/libs/game_bethesda/src/games/starfield/gamestarfield.h
@@ -41,6 +41,7 @@ public: // IPluginGame interface
virtual bool prepareIni(const QString& exec) override;
virtual QStringList DLCPlugins() const override;
virtual QStringList CCPlugins() const override;
+ virtual QString blueprintPrefix() const override;
virtual SortMechanism sortMechanism() const override;
virtual LoadOrderMechanism loadOrderMechanism() const override;
virtual int nexusModOrganizerID() const override;
@@ -72,10 +73,14 @@ private:
QStringList CCCPlugins() const;
bool activeESP() const;
bool testFilePresent() const;
+ bool hasInvalidBlueprint() const;
+ bool hasUnpairedBlueprint() const;
private:
- static const unsigned int PROBLEM_ESP = 1;
- static const unsigned int PROBLEM_TEST_FILE = 2;
+ static const unsigned int PROBLEM_ESP = 1;
+ static const unsigned int PROBLEM_TEST_FILE = 2;
+ static const unsigned int PROBLEM_INVALID_BLUEPRINT = 3;
+ static const unsigned int PROBLEM_UNPAIRED_BLUEPRINT = 4;
mutable std::set<QString> m_Active_ESPs;
};
diff --git a/libs/game_bethesda/src/games/starfield/starfieldgameplugins.cpp b/libs/game_bethesda/src/games/starfield/starfieldgameplugins.cpp
index 6b4e6ab..efd1bad 100644
--- a/libs/game_bethesda/src/games/starfield/starfieldgameplugins.cpp
+++ b/libs/game_bethesda/src/games/starfield/starfieldgameplugins.cpp
@@ -1,5 +1,9 @@
#include "starfieldgameplugins.h"
+#include <ipluginlist.h>
+#include <report.h>
+#include <safewritefile.h>
+
using namespace MOBase;
StarfieldGamePlugins::StarfieldGamePlugins(MOBase::IOrganizer* organizer)
@@ -19,10 +23,76 @@ bool StarfieldGamePlugins::blueprintPluginsAreSupported()
void StarfieldGamePlugins::writePluginList(const IPluginList* pluginList,
const QString& filePath)
{
- if (m_Organizer->managedGame()->loadOrderMechanism() ==
+ if (m_Organizer->managedGame()->loadOrderMechanism() !=
MOBase::IPluginGame::LoadOrderMechanism::PluginsTxt) {
- CreationGamePlugins::writePluginList(pluginList, filePath);
+ return;
+ }
+
+ SafeWriteFile file(filePath);
+
+ QStringEncoder encoder(QStringConverter::Encoding::System);
+
+ file->resize(0);
+
+ file->write(encoder.encode(
+ "# This file was automatically generated by Mod Organizer.\r\n"));
+
+ bool invalidFileNames = false;
+ int writtenCount = 0;
+
+ QStringList plugins = pluginList->pluginNames();
+ std::sort(plugins.begin(), plugins.end(),
+ [pluginList](const QString& lhs, const QString& rhs) {
+ return pluginList->priority(lhs) < pluginList->priority(rhs);
+ });
+
+ QStringList PrimaryPlugins = organizer()->managedGame()->primaryPlugins();
+ QStringList DLCPlugins = organizer()->managedGame()->DLCPlugins();
+ QSet<QString> ManagedMods =
+ QSet<QString>(PrimaryPlugins.begin(), PrimaryPlugins.end());
+ QSet<QString> DLCSet = QSet<QString>(DLCPlugins.begin(), DLCPlugins.end());
+ ManagedMods.subtract(DLCSet);
+ PrimaryPlugins.append(QList<QString>(ManagedMods.begin(), ManagedMods.end()));
+
+ // TODO: do not write plugins in OFFICIAL_FILES container
+ for (const QString& pluginName : plugins) {
+ if (!PrimaryPlugins.contains(pluginName, Qt::CaseInsensitive) &&
+ !pluginList->isBlueprintFlagged(pluginName) &&
+ !pluginName.startsWith(m_Organizer->managedGame()->blueprintPrefix(),
+ Qt::CaseInsensitive)) {
+ if (pluginList->state(pluginName) == IPluginList::STATE_ACTIVE) {
+ auto result = encoder.encode(pluginName);
+ if (encoder.hasError()) {
+ invalidFileNames = true;
+ qCritical("invalid plugin name %s", qUtf8Printable(pluginName));
+ } else {
+ file->write("*");
+ file->write(result);
+ }
+ file->write("\r\n");
+ ++writtenCount;
+ } else {
+ auto result = encoder.encode(pluginName);
+ if (encoder.hasError()) {
+ invalidFileNames = true;
+ qCritical("invalid plugin name %s", qUtf8Printable(pluginName));
+ } else {
+ file->write(result);
+ }
+ file->write("\r\n");
+ ++writtenCount;
+ }
+ }
}
+
+ if (invalidFileNames) {
+ reportError(QObject::tr("Some of your plugins have invalid names! These "
+ "plugins can not be loaded by the game. Please see "
+ "mo_interface.log for a list of affected plugins "
+ "and rename them."));
+ }
+
+ file->commit();
}
QStringList StarfieldGamePlugins::readPluginList(MOBase::IPluginList* pluginList)
diff --git a/libs/game_bethesda/src/games/starfield/starfieldsavegame.h b/libs/game_bethesda/src/games/starfield/starfieldsavegame.h
index 7bd07e0..7b1427d 100644
--- a/libs/game_bethesda/src/games/starfield/starfieldsavegame.h
+++ b/libs/game_bethesda/src/games/starfield/starfieldsavegame.h
@@ -12,6 +12,11 @@ class StarfieldSaveGame : public GamebryoSaveGame
public:
StarfieldSaveGame(QString const& fileName, GameStarfield const* game);
+ const QStringList v122CorePlugins = {"Starfield.esm", "Constellation.esm",
+ "OldMars.esm", "BlueprintShips-Starfield.esm",
+ "SFBGS003.esm", "SFBGS006.esm",
+ "SFBGS007.esm", "SFBGS008.esm"};
+
protected:
// Fetch easy-to-access information.
void getData(FileWrapper& file) const;
diff --git a/libs/game_bethesda/src/games/starfield/starfieldunmanagedmods.cpp b/libs/game_bethesda/src/games/starfield/starfieldunmanagedmods.cpp
index 30abb38..82207c3 100644
--- a/libs/game_bethesda/src/games/starfield/starfieldunmanagedmods.cpp
+++ b/libs/game_bethesda/src/games/starfield/starfieldunmanagedmods.cpp
@@ -9,7 +9,7 @@
StarfieldUnmanagedMods::StarfieldUnmanagedMods(const GameStarfield* game,
const QString& appDataFolder)
- : GamebryoUnmangedMods(game), m_AppDataFolder(appDataFolder)
+ : GamebryoUnmangedMods(game), m_Game(game), m_AppDataFolder(appDataFolder)
{}
StarfieldUnmanagedMods::~StarfieldUnmanagedMods() {}
@@ -25,29 +25,52 @@ QStringList StarfieldUnmanagedMods::mods(bool onlyOfficial) const
pluginList.removeAll(plugin);
}
QMap<QString, QDir> directories = {{"data", game()->dataDirectory()}};
+ auto contentCatalog = parseContentCatalog();
directories.insert(game()->secondaryDataDirectories());
for (QDir directory : directories) {
for (const QString& fileName : directory.entryList({"*.esp", "*.esl", "*.esm"})) {
if (!pluginList.contains(fileName, Qt::CaseInsensitive)) {
if (!onlyOfficial || pluginList.contains(fileName, Qt::CaseInsensitive)) {
- result.append(fileName.chopped(4)); // trims the extension off
+ bool found = false;
+ for (const auto& mod : contentCatalog) {
+ if (mod.second.files.contains(fileName, Qt::CaseInsensitive)) {
+ result.append(mod.second.name);
+ found = true;
+ }
+ }
+ if (!found && !fileName.startsWith("blueprintships-", Qt::CaseInsensitive)) {
+ result.append(fileName.chopped(4)); // trims the extension off
+ }
}
}
}
}
-
+ result.removeDuplicates();
return result;
}
QFileInfo StarfieldUnmanagedMods::referenceFile(const QString& modName) const
{
- QFileInfoList files;
+ QStringList modFiles;
+ auto contentCatalog = parseContentCatalog();
+ if (contentCatalog.contains(modName)) {
+ modFiles = contentCatalog[modName].files;
+ }
+ QDir dataDir = m_Game->secondaryDataDirectories().first();
+ QStringList plugins = modFiles.filter(
+ QRegularExpression("\\.es(m|p|l)$", QRegularExpression::CaseInsensitiveOption));
QMap<QString, QDir> directories = {{"data", game()->dataDirectory()}};
directories.insert(game()->secondaryDataDirectories());
+ QFileInfoList pluginFiles;
+ QFileInfoList files;
for (QDir directory : directories) {
+ if (!plugins.isEmpty())
+ pluginFiles += dataDir.entryInfoList(plugins);
files += directory.entryInfoList(QStringList() << modName + ".es*");
}
- if (files.size() > 0) {
+ if (!pluginFiles.isEmpty()) {
+ return pluginFiles.at(0);
+ } else if (!files.isEmpty()) {
return files.at(0);
} else {
return QFileInfo();
@@ -86,11 +109,10 @@ StarfieldUnmanagedMods::parseContentCatalog() const
pluginList.append(fileName);
}
}
- for (const auto& plugin : pluginList) {
- contentCatalog[plugin] = ContentCatalog();
- contentCatalog[plugin].files = files;
- contentCatalog[plugin].name = modInfo.value("Title").toString();
- }
+ QString name = modInfo.value("Title").toString();
+ contentCatalog[name] = ContentCatalog();
+ contentCatalog[name].files = files;
+ contentCatalog[name].name = name;
}
}
}
@@ -101,17 +123,16 @@ QStringList StarfieldUnmanagedMods::secondaryFiles(const QString& modName) const
{
QStringList files;
auto contentCatalog = parseContentCatalog();
- for (const auto& mod : contentCatalog) {
- if (mod.first.startsWith(modName, Qt::CaseInsensitive)) {
- files += mod.second.files;
- break;
- }
+ if (contentCatalog.contains(modName)) {
+ return contentCatalog[modName].files;
}
// file extension in FO4 is .ba2 instead of bsa
QMap<QString, QDir> directories = {{"data", game()->dataDirectory()}};
directories.insert(game()->secondaryDataDirectories());
for (QDir directory : directories) {
- for (const QString& archiveName : directory.entryList({modName + "*.ba2"})) {
+ for (const QString& archiveName :
+ directory.entryList({modName + "*.ba2", "blueprintships-" + modName + ".esm",
+ "blueprintships-" + modName + "*.ba2"})) {
files.append(directory.absoluteFilePath(archiveName));
}
}
@@ -121,12 +142,12 @@ QStringList StarfieldUnmanagedMods::secondaryFiles(const QString& modName) const
QString StarfieldUnmanagedMods::displayName(const QString& modName) const
{
auto contentCatalog = parseContentCatalog();
- for (const auto& mod : contentCatalog) {
- if (mod.first.startsWith(modName, Qt::CaseInsensitive)) {
- return mod.second.name;
- }
+ if (contentCatalog.contains(modName)) {
+ return contentCatalog[modName].name;
}
- // unlike in earlier games, in fallout 4 the file name doesn't correspond to
- // the public name
+ if (modName == "ShatteredSpace")
+ return "Shattered Space";
+ else if (modName == "SFBGS050")
+ return "Terran Armada";
return modName;
}
diff --git a/libs/game_bethesda/src/games/starfield/starfieldunmanagedmods.h b/libs/game_bethesda/src/games/starfield/starfieldunmanagedmods.h
index abe14ef..81fd63d 100644
--- a/libs/game_bethesda/src/games/starfield/starfieldunmanagedmods.h
+++ b/libs/game_bethesda/src/games/starfield/starfieldunmanagedmods.h
@@ -30,6 +30,7 @@ private:
std::map<QString, ContentCatalog> parseContentCatalog() const;
private:
+ const GameStarfield* m_Game;
QString m_AppDataFolder;
};
diff --git a/libs/uibase/include/uibase/iplugingame.h b/libs/uibase/include/uibase/iplugingame.h
index 93ee09f..c7760f1 100644
--- a/libs/uibase/include/uibase/iplugingame.h
+++ b/libs/uibase/include/uibase/iplugingame.h
@@ -314,6 +314,13 @@ public:
*/
virtual QStringList CCPlugins() const { return {}; }
+ /**
+ * @brief Get the blueprint plugin prefix for blueprint autoloading
+ *
+ * @node Primarily used by Starfield
+ */
+ virtual QString blueprintPrefix() const { return ""; }
+
/*
* @brief determine the load order mechanism used by this game.
*
diff --git a/libs/uibase/include/uibase/nxmurl.h b/libs/uibase/include/uibase/nxmurl.h
index bd06d3d..dd1935e 100644
--- a/libs/uibase/include/uibase/nxmurl.h
+++ b/libs/uibase/include/uibase/nxmurl.h
@@ -75,6 +75,21 @@ public:
*/
int userId() const { return m_UserId; }
+ /**
+ * @return if collection link
+ */
+ bool isCollection() const { return m_Collection; }
+
+ /**
+ * @return collection ID
+ */
+ QString collectionId() const { return m_CollectionId; }
+
+ /**
+ * @return collection revision
+ */
+ int collectionRevision() const { return m_CollectionRevision; }
+
private:
QString m_Game;
QString m_Key;
@@ -82,6 +97,9 @@ private:
int m_FileId;
int m_Expires;
int m_UserId;
+ bool m_Collection;
+ QString m_CollectionId;
+ int m_CollectionRevision;
};
#endif // NXMURL_H
diff --git a/libs/uibase/src/nxmurl.cpp b/libs/uibase/src/nxmurl.cpp
index 33bb22f..bdb9f7b 100644
--- a/libs/uibase/src/nxmurl.cpp
+++ b/libs/uibase/src/nxmurl.cpp
@@ -29,16 +29,29 @@ NXMUrl::NXMUrl(const QString& url)
{
QUrl nxm(url);
QUrlQuery query(nxm);
- QRegularExpression exp("(?:nxm|modl)://[a-z0-9]+/mods/(\\d+)/files/(\\d+)",
- QRegularExpression::CaseInsensitiveOption);
- auto match = exp.match(url);
- if (!match.hasMatch()) {
+
+ static const QRegularExpression modRegex(
+ "(?:nxm|modl)://[a-z0-9]+/mods/(\\d+)/files/(\\d+)",
+ QRegularExpression::CaseInsensitiveOption);
+ static const QRegularExpression collectionRegex(
+ "(?:nxm|modl)://[a-z0-9]+/collections/([a-z0-9]+)/revisions/(\\d+)",
+ QRegularExpression::CaseInsensitiveOption);
+
+ m_Game = nxm.host();
+
+ if (const auto modMatch = modRegex.match(url); modMatch.hasMatch()) {
+ m_Collection = false;
+ m_ModId = modMatch.captured(1).toInt();
+ m_FileId = modMatch.captured(2).toInt();
+ m_Key = query.queryItemValue("key");
+ m_Expires = query.queryItemValue("expires").toInt();
+ m_UserId = query.queryItemValue("user_id").toInt();
+ } else if (const auto collectionMatch = collectionRegex.match(url);
+ collectionMatch.hasMatch()) {
+ m_Collection = true;
+ m_CollectionId = collectionMatch.captured(1);
+ m_CollectionRevision = collectionMatch.captured(2).toInt();
+ } else {
throw MOBase::InvalidNXMLinkException(url);
}
- m_Game = nxm.host();
- m_ModId = match.captured(1).toInt();
- m_FileId = match.captured(2).toInt();
- m_Key = query.queryItemValue("key");
- m_Expires = query.queryItemValue("expires").toInt();
- m_UserId = query.queryItemValue("user_id").toInt();
}