From 549b84b0f8815737c93913b200dde3821a062cb0 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sat, 16 May 2026 19:51:40 -0500 Subject: mo2: catch up to upstream 2.5.3 Betas 3-11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 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) --- .../games/game_kingdomcomedeliverance2.py | 104 +++++++++++++++++++++ libs/basic_games/games/game_sts2.py | 81 ++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 libs/basic_games/games/game_kingdomcomedeliverance2.py create mode 100644 libs/basic_games/games/game_sts2.py (limited to 'libs/basic_games') 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 (preferred) or , 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"), + ] -- cgit v1.3.1