diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-05-16 19:51:40 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-05-16 19:51:40 -0500 |
| commit | 549b84b0f8815737c93913b200dde3821a062cb0 (patch) | |
| tree | f6bfd96729016847438beda9416c93f447838077 /libs | |
| parent | a0747cabb420d699be3a1dd4c63f47c5153bd7b9 (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.py | 104 | ||||
| -rw-r--r-- | libs/basic_games/games/game_sts2.py | 81 | ||||
| -rw-r--r-- | libs/game_bethesda/src/games/starfield/gamestarfield.cpp | 66 | ||||
| -rw-r--r-- | libs/game_bethesda/src/games/starfield/gamestarfield.h | 9 | ||||
| -rw-r--r-- | libs/game_bethesda/src/games/starfield/starfieldgameplugins.cpp | 74 | ||||
| -rw-r--r-- | libs/game_bethesda/src/games/starfield/starfieldsavegame.h | 5 | ||||
| -rw-r--r-- | libs/game_bethesda/src/games/starfield/starfieldunmanagedmods.cpp | 65 | ||||
| -rw-r--r-- | libs/game_bethesda/src/games/starfield/starfieldunmanagedmods.h | 1 | ||||
| -rw-r--r-- | libs/uibase/include/uibase/iplugingame.h | 7 | ||||
| -rw-r--r-- | libs/uibase/include/uibase/nxmurl.h | 18 | ||||
| -rw-r--r-- | libs/uibase/src/nxmurl.cpp | 33 |
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(); } |
