From 23504fac129ba2134fd13a3dbd91895bfec95047 Mon Sep 17 00:00:00 2001 From: ringill Date: Sat, 15 Aug 2026 22:41:47 +0300 Subject: [PATCH 1/2] [Space Rangers HD]: Add game plugin with native mod import, load order sync and save support Why: SRHD loads mods only through the closed-source Rangers.exe with fixed rules (CurrentMod in ModCFG.txt), there is no separate enabled flag and no official MO2 game plugin for it. Native integration is needed without patching the game.What changed:- BasicGame plugin (SpaceRangersHDGame): game recognition, Nexus ID 920, VFS delivery of mod copies.- Native mod import (MigrateTool) as copies into MO2's mods directory, leaving the game folder untouched.- Two-way sync of order/enabled state CurrentMod <-> modlist.txt and Priority=1 equalization so QueryWrongOrderFix does not appear.- Conflict=/Dependence= surfaced via Problems, row color and Notes.- Nexus archive installer, meta.ini metadata, INI editor (CFG.TXT), saves with previews.- Mod bug report and post-run crash notification.- README: game entry in the table. --- README.md | 1 + games/game_spacerangershd.py | 503 ++++++++++++++++++ games/spacerangershd/__init__.py | 2 + games/spacerangershd/contracts.py | 289 ++++++++++ games/spacerangershd/crash_log.py | 78 +++ games/spacerangershd/installer.py | 356 +++++++++++++ games/spacerangershd/mod_data_checker.py | 95 ++++ games/spacerangershd/modcfg.py | 208 ++++++++ games/spacerangershd/modlist.py | 43 ++ games/spacerangershd/paths.py | 44 ++ games/spacerangershd/plugins/__init__.py | 16 + games/spacerangershd/tool_bugreport.py | 644 +++++++++++++++++++++++ games/spacerangershd/tool_migrate.py | 351 ++++++++++++ 13 files changed, 2630 insertions(+) create mode 100644 games/game_spacerangershd.py create mode 100644 games/spacerangershd/__init__.py create mode 100644 games/spacerangershd/contracts.py create mode 100644 games/spacerangershd/crash_log.py create mode 100644 games/spacerangershd/installer.py create mode 100644 games/spacerangershd/mod_data_checker.py create mode 100644 games/spacerangershd/modcfg.py create mode 100644 games/spacerangershd/modlist.py create mode 100644 games/spacerangershd/paths.py create mode 100644 games/spacerangershd/plugins/__init__.py create mode 100644 games/spacerangershd/tool_bugreport.py create mode 100644 games/spacerangershd/tool_migrate.py diff --git a/README.md b/README.md index 6fbed59..1dbc45c 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ You can rename `modorganizer-basic_games-xxx` to whatever you want (e.g., `basic | No Man's Sky - [GOG](https://www.gog.com/game/no_mans_sky) / [Steam](https://store.steampowered.com/app/275850/No_Mans_Sky/)|[EzioTheDeadPoet](https://eziothedeadpoet.github.io/AboutMe/)|[game_nomanssky.py](games/game_nomanssky.py)| | | Slay the Spire 2 — [STEAM](s.team/a/2868840) | [Azlle](https://github.com/Azlle) | [game_sts2.py](games/game_sts2.py) | | | S.T.A.L.K.E.R. Anomaly — [MOD](https://www.stalker-anomaly.com/) | [Qudix](https://github.com/Qudix) | [game_stalkeranomaly.py](games/game_stalkeranomaly.py) | | +| Space Rangers HD: A War Apart — [GOG](https://www.gog.com/en/game/space_rangers_hd_a_war_apart) / [STEAM](https://store.steampowered.com/app/214730/Space_Rangers_HD_A_War_Apart/) / [NEXUS](https://www.nexusmods.com/spacerangersawarapart) | [ringill](https://github.com/ringill) | [game_spacerangershd.py](games/game_spacerangershd.py) | | | Stardew Valley — [GOG](https://www.gog.com/game/stardew_valley) / [STEAM](https://store.steampowered.com/app/413150/Stardew_Valley/) | [Syer10](https://github.com/Syer10), [Holt59](https://github.com/holt59/) | [game_stardewvalley.py](games/game_stardewvalley.py) | | | STAR WARS™ Empire at War: Gold Pack - [GOG](https://www.gog.com/game/star_wars_empire_at_war_gold_pack) / [STEAM](https://store.steampowered.com/app/32470/) | [erri120](https://github.com/erri120) | | | | Subnautica — [STEAM](https://store.steampowered.com/app/264710/) / [Epic](https://store.epicgames.com/p/subnautica) | [dekart811](https://github.com/dekart811), [Zash](https://github.com/ZashIn) | [game_subnautica.py](games/game_subnautica.py) | | diff --git a/games/game_spacerangershd.py b/games/game_spacerangershd.py new file mode 100644 index 0000000..c2c49df --- /dev/null +++ b/games/game_spacerangershd.py @@ -0,0 +1,503 @@ +# Copyright (c) 2026 ringill +# SPDX-License-Identifier: MIT + +"""MO2 game plugin for Space Rangers HD: A War Apart. + +Subclasses ``BasicGame`` so the standard ``basic_games`` games loop registers it in +MO2's game list, but overrides ``mappings``/``dataDirectory`` and the SRHD sync +helpers because SRHD loads mods from a two-level ``Mods\\\\`` layout +that does not fit ``BasicGame``'s "one subfolder = one mod" assumption. + +Ownership model: MO2 owns the mod files. Native SRHD mods live in the MO2 mods +directory (one folder per mod, named ``__`` — see ``paths.py``), so +the left mod list is populated natively by MO2's ``ModInfo::updateFromDisc``. The +game folder keeps only ``Mods\\ModCFG.txt``; base game files are untouched. The +plugin: + +- delivers each MO2-enabled mod into the engine's nested ``Mods\\\\`` + path via ``mappings()`` (usvfs VFS), and disables MO2's flat auto-map of mod + contents onto the data root by returning an empty ``getModMappings()`` so no flat + ``Mods\\`` copies leak into the VFS alongside the nested destinations, +- syncs load order and enabled state between ``ModCFG.txt`` and the MO2 profile's + ``modlist.txt`` for every profile, +- equalizes ``Priority`` by writing ``Priority=1`` to every MO2-enabled mod's + ``ModuleInfo.txt`` **in the MO2 copy** (not the game folder) so the engine's + load order matches what MO2 shows. + +A standalone run without MO2 loads no mods (they have been moved into MO2) — a +documented tradeoff of the ownership model. +""" + +from __future__ import annotations + +import struct +import zlib +from enum import IntEnum, auto +from pathlib import Path + +from PyQt6.QtCore import QDir, QStandardPaths +from PyQt6.QtGui import QImage +from PyQt6.QtWidgets import QMessageBox, QWidget + +import mobase + +from ..basic_features.basic_save_game_info import BasicGameSaveGameInfo +from ..basic_game import BasicGame +from .spacerangershd.contracts import ( + IssueKind, + ModIssue, + apply_contract_meta, + evaluate_contracts, +) +from .spacerangershd.crash_log import crash_tail, run_span +from .spacerangershd.mod_data_checker import SpaceRangersHDModDataChecker +from .spacerangershd.modcfg import ( + PRIORITY_EQUALIZE_VALUE, + read_current_mod, + set_priority, + write_current_mod, +) +from .spacerangershd.modlist import enabled_names, read_modlist, write_modlist +from .spacerangershd.paths import engine_path_to_mod_name, mod_name_to_engine_path + +_GAME_NAME = "Space Rangers HD: A War Apart" +_GAME_SHORT_NAME = "spacerangersawarapart" +_GAME_BINARY = "Rangers.exe" +_GAME_DATA_DIR = "Mods" +_GAME_NEXUS_ID = 920 +_GAME_STEAM_ID = 214730 +# GOG Galaxy product ID (registry key HKLM\Software\Wow6432Node\GOG.com\Games): +_GAME_GOG_ID = 1207667113 + +# Upper bound (bytes) on the .sav text header to scan for the first zlib stream. +# The header holds short UTF-16LE strings (name, planet, etc.), so the screenshot +# block always begins well within this limit. +_SAV_HEADER_SCAN_LIMIT = 4096 + +# SRHD writes the last run's log to this literal filename (eight ``#`` characters) +# in the documents directory; previous runs are archived into ``Errors``. +_CRASH_LOG_NAME = "########.log" + + +class Problems(IntEnum): + """Diagnosable problems reported via :class:`mobase.IPluginDiagnose`.""" + + # The last run's log (########.log) contains an "Exception " line. + LAST_RUN_CRASHED = auto() + # An enabled native mod has an active Conflict= partner (also enabled). + ACTIVE_CONFLICT = auto() + # An enabled native mod's Dependence= target is disabled or not installed. + UNMET_DEPENDENCE = auto() + + +def _sav_preview(save_path: Path) -> QImage | None: + """Extract the embedded screenshot from an SRHD ``.sav`` file. + + An SRHD save is a short UTF-16LE text header followed by a sequence of + zlib-compressed blocks; the first block is the save screenshot: a u32 width, + u32 height, u32 stride (= width*3, 24bpp), then raw top-down RGB888 pixels. + Returns a ``QImage`` for MO2's saves-tab preview, or ``None`` when the file + isn't a readable SRHD save. + """ + try: + data = save_path.read_bytes() + except OSError: + return None + if len(data) < 16: + return None + + end = min(len(data) - 2, _SAV_HEADER_SCAN_LIMIT) + for i in range(end): + # Cheap zlib header test: CM=8 and (b0*256+b1) % 31 == 0. + if (data[i] & 0x0F) != 8: + continue + if (data[i] * 256 + data[i + 1]) % 31 != 0: + continue + try: + block = zlib.decompressobj().decompress(data[i:]) + except zlib.error: + continue + if len(block) < 12: + continue + width, height, stride = struct.unpack_from(" bool: + self._organizer = organizer + self._register_feature(BasicGameSaveGameInfo(get_preview=_sav_preview)) + # Register a ModDataChecker so MO2's basic installer can validate an + # archive as an SRHD mod data tree (anchored by ModuleInfo.txt). Without + # one, gameFeature() is null and the installer can't + # determine the data folder — it wraps archives in the pseudo-root + # and reports "Cannot check the content of ". + self._register_feature(SpaceRangersHDModDataChecker()) + organizer.onAboutToRun(lambda app: self.aboutToRun(app)) + organizer.onFinishedRun(self._onFinishedRun) + organizer.onUserInterfaceInitialized(self._on_user_interface_initialized) + # Recompute the Conflict=/Dependence= contract violations whenever a mod's + # state (enabled/disabled) changes, so the Problems indicator stays instant + # and the meta.ini colours/notes track the current toggle (ADR D12). + organizer.modList().onModStateChanged(self._on_mod_state_changed) + return True + + def description(self) -> str: + return f"Adds support for {_GAME_NAME}" + + # --- IPluginGame interface ---------------------------------------------- + + # detectGame(): inherited from BasicGame — uses the configured GameSteamId + # to auto-locate a Steam installation of SRHD. + + def nexusGameID(self) -> int: + return _GAME_NEXUS_ID + + def getSupportURL(self) -> str: + return "https://www.nexusmods.com/games/spacerangersawarapart" + + def initializeProfile( + self, directory: QDir, settings: mobase.ProfileSetting + ) -> None: + # Let BasicGame copy CFG.TXT into the profile when MO2 asks for it, so its + # INI editor has a profile-local copy to work on. + super().initializeProfile(directory, settings) + # CurrentMod holds ``Category\\Mod`` engine paths; the MO2 modlist is keyed + # by the MO2 folder name ``Category__Mod``, so convert each path first. + current_paths = read_current_mod(self._modcfg_path()) + current_names = [engine_path_to_mod_name(path) for path in current_paths] + entries = self._build_modlist_entries(current_names) + write_modlist(Path(directory.absolutePath()) / "modlist.txt", entries) + + def documentsDirectory(self) -> QDir: + # SRHD keeps its settings (CFG.TXT) in %Documents%\SpaceRangersHD, not in + # the game install folder. + docs = QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.DocumentsLocation + ) + return QDir(f"{docs}/SpaceRangersHD") + + def savesDirectory(self) -> QDir: + # Real saves live in %Documents%\SpaceRangersHD\Save (.sav files). + docs = QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.DocumentsLocation + ) + return QDir(f"{docs}/SpaceRangersHD/Save") + + def getModMappings(self) -> dict[str, list[str]]: + # SRHD loads mods from nested ``Mods\\`` paths, so the flat + # auto-map MO2 applies by default (each mod folder onto the + # dataDirectory()="Mods" root, see OrganizerCore::fileMapping) would leak + # flat copies of mod contents into the data root in addition to the nested + # destinations. Returning an empty map disables that auto-map entirely: the + # VFS overlay is defined solely by mappings() below. + return {} + + # --- IPluginFileMapper interface ---------------------------------------- + + def mappings(self) -> list[mobase.Mapping]: + data_dir = Path(self.dataDirectory().absolutePath()) + if not data_dir.is_dir(): + return [] + mappings: list[mobase.Mapping] = [] + modlist = self._organizer.modList() + for name in modlist.allMods(): + if not modlist.state(name) & mobase.ModState.ACTIVE: + continue + engine_path = mod_name_to_engine_path(name) + if engine_path is None: + continue # not a native SRHD mod — leave it out of the VFS overlay + mod = modlist.getMod(name) + if not mod: + continue + mod_path = Path(mod.absolutePath()) + if not mod_path.is_dir(): + continue + # Each MO2 mod folder mirrors the data directory: its files live under + # a ``\`` subfolder (see paths.py), so the VFS overlay + # maps just that subfolder onto the engine's ``Mods\\`` + # destination. Mapping the subfolder (not the whole mod folder) keeps + # MO2's own root metadata (meta.ini / generated ModuleInfo.txt) out of + # the game's data directory. + mappings.append( + mobase.Mapping( + str(mod_path / engine_path), + str(data_dir / engine_path), + True, + True, + ) + ) + return mappings + + # --- SRHD sync helpers --------------------------------------------------- + + def _modcfg_path(self) -> Path: + return Path(self.dataDirectory().absolutePath()) / "ModCFG.txt" + + def _build_modlist_entries(self, current_mods: list[str]) -> list[tuple[str, bool]]: + """Build ``(name, enabled)`` entries for a profile's ``modlist.txt``. + + Mods listed in ``CurrentMod`` are enabled, in engine order; any other native + mod in the MO2 mods directory is appended as disabled so it shows up in MO2 + for toggling. Names are the MO2 folder names (``Category__Mod``); non-native + mods are left for MO2 to manage itself. + """ + entries: list[tuple[str, bool]] = [(name, True) for name in current_mods] + known = set(current_mods) + modlist = self._organizer.modList() + for name in modlist.allMods(): + if mod_name_to_engine_path(name) is None: + continue + if name not in known: + entries.append((name, False)) + known.add(name) + return entries + + def aboutToRun(self, app: str) -> bool: + # The active MO2 profile's modlist.txt is the source of truth for order and + # enabled state; write it back into ModCFG.txt, then equalize Priority in the + # MO2 copies so the engine's load order matches what MO2 shows. The ``if + # order:`` guard keeps the game folder untouched when no mod is enabled. + profile = self._organizer.profile() + modlist_path = Path(profile.absolutePath()) / "modlist.txt" + order = enabled_names(read_modlist(modlist_path)) + if order: + # modlist.txt holds MO2 folder names; map them back to engine + # ``Category\\Mod`` paths before writing CurrentMod. Names that don't + # follow the convention are skipped (never written into CurrentMod). + engine_order = [ + path + for name in order + if (path := mod_name_to_engine_path(name)) is not None + ] + write_current_mod(self._modcfg_path(), engine_order) + self._set_enabled_priority(order) + return True + + def _set_enabled_priority(self, enabled: list[str]) -> None: + """Set every enabled mod's ``Priority`` to the equalization value. + + The engine sorts enabled mods ascending by ``Priority``; with every enabled + mod equal the stable sort is a no-op, so ``CurrentMod`` order (driven by + MO2 drag&drop) fully determines load order and the ``QueryWrongOrderFix`` + dialog never appears. The previous ``Priority`` value is not preserved. + + Targets the ``ModuleInfo.txt`` in each mod's **MO2 copy** (its folder in the + MO2 mods directory), never the game folder. + """ + enabled_set = set(enabled) + modlist = self._organizer.modList() + for name in enabled_set: + mod = modlist.getMod(name) + if not mod: + continue + mod_path = Path(mod.absolutePath()) + engine_path = mod_name_to_engine_path(name) + if engine_path is None: + continue # not a native SRHD mod — nothing to equalize + # The mod's own ModuleInfo.txt lives under the ``\`` + # subfolder (the MO2 mod folder mirrors the data directory); writing + # Priority there targets the copy the engine actually reads. + set_priority( + mod_path / engine_path / "ModuleInfo.txt", PRIORITY_EQUALIZE_VALUE + ) + + # --- Conflict=/Dependence= contract display (ADR D12) -------------------- + + def _recompute_contracts(self) -> dict[str, ModIssue]: + """Recompute contract violations and push them into ``meta.ini``. + + Reads each enabled native mod's ``Conflict=``/``Dependence=`` (via + ``contracts.evaluate_contracts``), caches the result for the Problems + dialog, and writes the legend ``color=``/``comments=`` into every enabled + mod's root ``meta.ini``. MO2 re-reads ``meta.ini`` only via + ``ModInfo::updateFromDisc`` (Refresh/launch), so colour+Notes appear there; + the Problems indicator is refreshed separately with ``_invalidate()``. + """ + issues = evaluate_contracts(self._organizer) + self._contract_issue_cache = issues + apply_contract_meta(self._organizer, issues) + return issues + + def _on_mod_state_changed(self, mods: dict[str, mobase.ModState]) -> None: + """Re-evaluate contracts after a mod toggle and refresh the Problems light. + + Subscribed in ``init()`` via ``organizer.modList().onModStateChanged``; the + callback receives ``{name: new_state}`` for the mods that changed. The state + change does not make MO2 re-read ``meta.ini``, so we re-write the colours + here ourselves (task 12.6) and call ``_invalidate()`` so the Problems button + reflects the new state instantly (task 12.4). + """ + self._recompute_contracts() + # IPluginDiagnose._invalidate() triggers the plugincontainer's + # diagnosisUpdate() signal, which re-polls activeProblems() and updates the + # Problems button (see plugincontainer.cpp, mainwindow.cpp). The binding + # exposes this method with a leading underscore. + self._invalidate() + + # --- Crash notification (########.log) ---------------------------------- + + def _crash_log_path(self) -> Path: + return Path(self.documentsDirectory().absolutePath()) / _CRASH_LOG_NAME + + def _last_run_crash_tail(self) -> str | None: + """Return the last run's crash tail, or ``None`` when it didn't crash. + + Reads ``########.log`` in the documents directory and returns the text from + the first ``Exception `` line to end of file. ``None`` means the log is + missing/unreadable or clean (no ``Exception `` line). + """ + return crash_tail(self._crash_log_path()) + + def _last_run_span(self) -> str | None: + """Return the last run's `` - `` local timestamps, or ``None``. + + ``start`` is the moment ``########.log`` was created (the launch), ``end`` + its last modification (the run's end), both in the client's local time. + ``None`` when the file is missing or its times cannot be read. + """ + span = run_span(self._crash_log_path()) + if span is None: + return None + return f"{span[0]} - {span[1]}" + + def _on_user_interface_initialized(self, window: QWidget) -> None: + # Parent for the crash dialog; parenting the QMessageBox keeps the widget + # alive and makes it window-modal (non-blocking to the calling code). + self._parentWidget = window + # The mod list is populated by the time the UI shows, so run one initial + # recompute+apply: pre-existing conflicts get their meta.ini colours/notes + # without waiting for a state toggle (ADR D12). + self._recompute_contracts() + + def _onFinishedRun(self, path: str, exit_code: int) -> None: + """Show a crash notification after the game process exits. + + Fires via ``organizer.onFinishedRun`` when a process MO2 launched ends. + We only react to the game itself (by its binary name). The exit code is not + a reliable crash signal for SRHD, so the decision is based on the last + run's log instead: if it contains a line starting with ``Exception ``, show + a dialog with the tail from that line to end of file. A clean or missing + log means no notification (see task 10 / spec ``mo2-game-plugin``). + + The box is parented to the MO2 main window and shown non-blocking with + ``show()``: the parent makes Qt own the C++ dialog, so it is not + garbage-collected when this method returns (a parentless locally-created + ``QMessageBox`` would be collected and never appear). + """ + if not path.endswith(self.binaryName()): + return # not the SRHD game process + tail = self._last_run_crash_tail() + if not tail: + return # clean run, missing, or unreadable log + box = QMessageBox(self._parentWidget) + box.setIcon(QMessageBox.Icon.Critical) + box.setWindowTitle("Space Rangers HD: likely crash") + box.setText(f"Errors were detected in the game log: {self._crash_log_path()}") + span = self._last_run_span() + span_line = f"Run: {span}" if span else "Run: (timestamps unavailable)" + box.setDetailedText( + f"{span_line}\n\nLog tail (from the first Exception):\n{tail}" + ) + box.setStandardButtons(QMessageBox.StandardButton.Ok) + box.show() + + # --- IPluginDiagnose interface ------------------------------------------ + + def activeProblems(self) -> list[int]: + # Surface a persistent Problems-button indicator whenever the last run + # crashed, so MO2 shows it on startup (and periodically) without needing to + # wait for the game to run again. On top of that, flag active conflicts and + # unmet dependencies so the button also advertises the contract violations + # this plugin surfaces (ADR D12). The issue cache is kept fresh by + # ``_recompute_contracts`` (called on every state change and on UI init). + problems: list[int] = [] + if self._last_run_crash_tail() is not None: + problems.append(Problems.LAST_RUN_CRASHED) + if any( + issue.kind is IssueKind.CONFLICT + for issue in self._contract_issue_cache.values() + ): + problems.append(Problems.ACTIVE_CONFLICT) + if any( + issue.kind is IssueKind.DEPENDENCE + for issue in self._contract_issue_cache.values() + ): + problems.append(Problems.UNMET_DEPENDENCE) + return problems + + def shortDescription(self, key: int) -> str: + if key == Problems.LAST_RUN_CRASHED: + return "Errors were noticed in the log of the last launch." + if key == Problems.ACTIVE_CONFLICT: + return "Active conflicts were detected between enabled mods." + if key == Problems.UNMET_DEPENDENCE: + return "Some enabled mods have unmet dependencies." + return "" + + def fullDescription(self, key: int) -> str: + if key == Problems.LAST_RUN_CRASHED: + tail = self._last_run_crash_tail() or "(log unreadable)" + span = self._last_run_span() + span_line = f"Run: {span}" if span else "Run: (timestamps unavailable)" + return ( + f"Errors were detected in the game log: {self._crash_log_path()}" + f"\n{span_line}\n\n{tail}" + ) + if key == Problems.ACTIVE_CONFLICT: + kind = IssueKind.CONFLICT + elif key == Problems.UNMET_DEPENDENCE: + kind = IssueKind.DEPENDENCE + else: + return "" + # One line per offending mod: "who conflicts with whom" / "what is missing". + lines = [ + issue.detail + for issue in self._contract_issue_cache.values() + if issue.kind is kind + ] + return "\n".join(lines) if lines else "" + + def hasGuidedFix(self, key: int) -> bool: + # A crash is diagnostic only — there is no automated fix MO2 could apply. + return False + + def startGuidedFix(self, key: int) -> None: + pass diff --git a/games/spacerangershd/__init__.py b/games/spacerangershd/__init__.py new file mode 100644 index 0000000..630de80 --- /dev/null +++ b/games/spacerangershd/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 ringill +# SPDX-License-Identifier: MIT diff --git a/games/spacerangershd/contracts.py b/games/spacerangershd/contracts.py new file mode 100644 index 0000000..50f9d0a --- /dev/null +++ b/games/spacerangershd/contracts.py @@ -0,0 +1,289 @@ +# Copyright (c) 2026 ringill +# SPDX-License-Identifier: MIT + +"""Evaluate the engine's ``Conflict=``/``Dependence=`` contracts for MO2 display. + +The engine reads these two fields from each mod's ``ModuleInfo.txt`` but MO2's +core knows nothing about them (there are no Conflict/Dependence columns and +``ModList::EColumn`` is a fixed compile-time enum). Only this plugin surfaces +them, via the three existing mechanisms described in ADR D12: a Problems-button +indicator (``IPluginDiagnose``), a row colour written as ``color=`` in +``meta.ini`` (rendered only in the Notes column), and a Notes reason written as +``comments= ()``. + +This module holds the pure evaluation (registry + violation detection) and the +``meta.ini`` writer. It talks to MO2 only through the ``organizer`` object, so the +``_evaluate`` core is free of MO2 types and testable on its own. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +import mobase + +from .modcfg import read_conflict_dependence, read_mod_display_name +from .paths import mod_name_to_engine_path + +# MO2 stores these as ``color=`` in meta.ini (QColor.name() hex, see modinfo.cpp); +# it renders the colour only in the mod list's Notes column (modlist.cpp:375). +# Legend (ADR D12): red = active conflict, yellow = unmet dependence. +COLOR_CONFLICT = "#ff0000" +COLOR_DEPENDENCE = "#ffff00" + +# Reason labels shown in the Notes column after the mod's own display name. +_CONFLICT_REASON = "conflicts with: " +_DEPENDENCE_REASON = "depends on: " + + +class IssueKind(Enum): + CONFLICT = "conflict" + DEPENDENCE = "dependence" + + +@dataclass(frozen=True) +class ModContract: + """One native mod and the engine contracts its ``ModuleInfo.txt`` declares.""" + + mo2_name: str + engine_path: str # ``Category\\Mod`` (identity convention, see paths.py) + display_name: str # ``Name=`` from ModuleInfo.txt (folder name as fallback) + conflict: tuple[str, ...] # referenced mod folder names + dependence: tuple[str, ...] # referenced mod folder names + + +@dataclass(frozen=True) +class ModIssue: + """The legend colour + Notes text + detail for one enabled native mod.""" + + mo2_name: str + display_name: str + kind: IssueKind + color: str # one of COLOR_CONFLICT / COLOR_DEPENDENCE + note: str # " ()" — written as ``comments=`` in meta.ini + detail: str # one-line sentence for the Problems dialog + + +def _qualified(contract: ModContract) -> str: + """Return the disambiguated ``Name (\\)`` form of a mod.""" + return f"{contract.display_name} ({contract.engine_path})" + + +def _collect(organizer: mobase.IOrganizer) -> dict[str, ModContract]: + """Map every native mod's MO2 name to its engine contract. + + Only mods whose MO2 folder follows the ``__`` convention are + included; hand-added/non-native mods are left out, mirroring the VFS mapping. + """ + modlist = organizer.modList() + contracts: dict[str, ModContract] = {} + for mo2_name in modlist.allMods(): + engine_path = mod_name_to_engine_path(mo2_name) + if engine_path is None: + continue + mod = modlist.getMod(mo2_name) + if not mod: + continue + module_info = Path(mod.absolutePath()) / engine_path / "ModuleInfo.txt" + conflict, dependence = read_conflict_dependence(module_info) + display_name = ( + read_mod_display_name(module_info) or engine_path.rsplit("\\", 1)[-1] + ) + contracts[mo2_name] = ModContract( + mo2_name=mo2_name, + engine_path=engine_path, + display_name=display_name, + conflict=tuple(conflict), + dependence=tuple(dependence), + ) + return contracts + + +def _enabled_names(organizer: mobase.IOrganizer) -> set[str]: + modlist = organizer.modList() + return { + name + for name in modlist.allMods() + if modlist.state(name) & mobase.ModState.ACTIVE + } + + +def evaluate_contracts(organizer: mobase.IOrganizer) -> dict[str, ModIssue]: + """Return the current violations, keyed by the offending mod's MO2 name. + + A reference in ``Conflict=``/``Dependence=`` is a mod folder name (the last + component of the engine ``Category\\Mod`` path); it is resolved through the + same identity mapping the rest of the plugin uses (see ``paths.py``). Only + enabled mods are checked. A mod with an active conflict is red; otherwise an + unmet dependence makes it yellow (red wins over yellow, ADR D12). + """ + contracts = _collect(organizer) + enabled = _enabled_names(organizer) + return _evaluate(contracts, enabled) + + +def _evaluate( + contracts: dict[str, ModContract], enabled: set[str] +) -> dict[str, ModIssue]: + # Resolve a referenced folder name to the first native mod with that folder + # name (the convention assumes folder names are unique within a game). + by_folder: dict[str, ModContract] = {} + for contract in contracts.values(): + folder = contract.engine_path.rsplit("\\", 1)[-1] + by_folder.setdefault(folder, contract) + + issues: dict[str, ModIssue] = {} + for mo2_name in enabled: + contract = contracts.get(mo2_name) + if contract is None: + continue + + # Red first: an enabled partner in Conflict= makes this an active conflict. + partners = [ + ref + for ref in contract.conflict + if (target := by_folder.get(ref)) is not None and target.mo2_name in enabled + ] + if partners: + partner = by_folder[partners[0]] + issues[mo2_name] = ModIssue( + mo2_name=mo2_name, + display_name=contract.display_name, + kind=IssueKind.CONFLICT, + color=COLOR_CONFLICT, + note=( + f"{contract.display_name} " + f"({_CONFLICT_REASON}{partner.display_name})" + ), + detail=( + f"{contract.display_name} conflicts with enabled mod " + f"{_qualified(partner)}" + ), + ) + continue + + # Yellow: a Dependence= target that is disabled or not installed at all. + missing = [ + ref + for ref in contract.dependence + if (target := by_folder.get(ref)) is None or target.mo2_name not in enabled + ] + if missing: + target = by_folder.get(missing[0]) + target_label = target.display_name if target is not None else missing[0] + detail_target = _qualified(target) if target is not None else missing[0] + issues[mo2_name] = ModIssue( + mo2_name=mo2_name, + display_name=contract.display_name, + kind=IssueKind.DEPENDENCE, + color=COLOR_DEPENDENCE, + note=(f"{contract.display_name} ({_DEPENDENCE_REASON}{target_label})"), + detail=( + f"{contract.display_name} depends on {detail_target}, " + "which is disabled or not installed" + ), + ) + return issues + + +def apply_contract_meta( + organizer: mobase.IOrganizer, issues: dict[str, ModIssue] +) -> None: + """Write ``color=``/``comments=`` into each native mod's ``meta.ini``. + + For an enabled mod with an issue this sets the legend colour and the + `` ()`` note; for a clean enabled mod it restores the plain + display name and drops any stale ``color=``. A disabled mod that was coloured + by an earlier recompute is cleaned the same way, so turning off the partner of + a conflict clears the other mod's red too — the legend must track the current + toggle everywhere, not just on the mod that was clicked. Disabled mods with no + ``meta.ini`` are skipped (never created). The Notes column and row colour only + refresh once MO2 re-reads ``meta.ini`` (``updateFromDisc``), which happens on + Refresh/launch. Only files whose content actually changes are written, so a + no-op never churns ``meta.ini``. + """ + modlist = organizer.modList() + contracts = _collect(organizer) + enabled = _enabled_names(organizer) + for mo2_name, contract in contracts.items(): + mod = modlist.getMod(mo2_name) + if not mod: + continue + mod_dir = Path(mod.absolutePath()) + issue = issues.get(mo2_name) + if issue is not None: + write_mod_meta(mod_dir, issue.note, issue.color) + elif mo2_name in enabled or (mod_dir / "meta.ini").exists(): + # Clean enabled mod, or a disabled mod with a stale legend: restore + # the plain display name and drop the colour. + write_mod_meta(mod_dir, contract.display_name, None) + + +def write_mod_meta(mod_dir: Path, comments: str, color: str | None) -> None: + """Set ``comments=`` and ``color=`` in a mod's root ``meta.ini``. + + Only the ``comments`` and ``color`` keys of the ``[General]`` section are + touched; every other key and section is preserved byte-for-byte (same targeted + approach as the import tool's ``_write_mod_notes``). ``color=None`` removes + the ``color`` key (so a mod that went clean no longer stays coloured). A + missing file is created in MO2's format (opening with a ``[General]`` header, + as MO2's own ``meta.ini`` does; QSettings otherwise stores ungrouped keys under + ``[General]``). ``meta.ini`` is UTF-8, unlike the mod's UTF-16 + ``ModuleInfo.txt``. The file is only written when the resulting content + differs, so repeated no-op recomputes never touch it. + """ + path = mod_dir / "meta.ini" + existing = path.read_text("utf-8") if path.exists() else "" + lines = existing.splitlines() + comments_line = f"comments={comments}" + color_line = f"color={color}" if color is not None else None + + in_general = True # keys before any section header belong to [General] + general_header = -1 # index of a literal "[General]" line, if present + comments_replaced = False + color_seen = False + out: list[str] = [] + for line in lines: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + in_general = stripped == "[General]" + if in_general: + general_header = len(out) + out.append(line) + continue + if in_general: + if stripped.lower().startswith("comments="): + out.append(comments_line) + comments_replaced = True + continue + if stripped.lower().startswith("color="): + color_seen = True + if color_line is not None: + out.append(color_line) + continue # colour line dropped when color is None + out.append(line) + + # Insert any keys that were not already present. They must land in the + # [General] section: after a literal "[General]" header when one exists, + # otherwise we prepend the header explicitly (MO2's own meta.ini always opens + # with [General], and a leading run of ungrouped keys also belongs to it). + missing: list[str] = [] + if not comments_replaced: + missing.append(comments_line) + if color_line is not None and not color_seen: + missing.append(color_line) + + if missing: + if general_header >= 0: + idx = general_header + 1 + else: + out.insert(0, "[General]") + idx = 1 + for offset, key in enumerate(missing): + out.insert(idx + offset, key) + + result = "\n".join(out) + "\n" + if result != existing: + path.write_text(result, "utf-8") diff --git a/games/spacerangershd/crash_log.py b/games/spacerangershd/crash_log.py new file mode 100644 index 0000000..6fc5ab1 --- /dev/null +++ b/games/spacerangershd/crash_log.py @@ -0,0 +1,78 @@ +# Copyright (c) 2026 ringill +# SPDX-License-Identifier: MIT + +"""Reading SRHD's last-run crash log (``########.log``). + +SRHD writes the current run's log to ``%Documents%\\SpaceRangersHD\\########.log`` +(ASCII/ANSI text without a BOM, first line ``Start``); previous runs are archived +into ``Errors``. The crash indicator is a log line starting with ``Exception `` — +a clean run contains none, every crashed run ends with one. + +These helpers read only the **last** run's log (``########.log``), never the +``Errors`` history: ``crash_tail`` returns the text from the first ``Exception`` +line to end of file. +""" + +from __future__ import annotations + +import os +from datetime import datetime +from pathlib import Path + +# The log is ASCII/ANSI with no BOM. latin-1 decodes every byte losslessly so the +# tail text and the ``Exception`` marker are preserved exactly as on disk. +_ENCODING = "latin-1" + +_CRASH_MARKER = "Exception " + +# Human-readable local-time format for the run span (``2026-08-15 21:04:32``). The +# timestamps are formatted with ``datetime.fromtimestamp``, which converts to the +# client's local wall-clock time (its timezone and environment), not UTC/ISO. +_SPAN_FORMAT = "%Y-%m-%d %H:%M:%S" + + +def read_log(log_path: Path) -> str | None: + """Return the full log text, or ``None`` when the file is missing/unreadable.""" + try: + return log_path.read_text(encoding=_ENCODING, errors="replace") + except OSError: + return None + + +def crash_tail(log_path: Path) -> str | None: + """Return the log text from the first ``Exception`` line to end of file. + + Returns ``None`` when the log is missing/unreadable, or when it contains no + line starting with ``Exception `` (a clean run). The returned string keeps the + original line breaks; trailing blank lines are stripped. + """ + text = read_log(log_path) + if text is None: + return None + lines = text.splitlines() + start = next( + (i for i, line in enumerate(lines) if line.startswith(_CRASH_MARKER)), + None, + ) + if start is None: + return None + return "\n".join(lines[start:]).rstrip() + + +def run_span(log_path: Path) -> tuple[str, str] | None: + """Return ``(start, end)`` local timestamps of the run the log covers. + + ``start`` is the log file's creation time (the game's launch moment), ``end`` + its last modification time (the moment the run finished). Both are converted to + the client's local wall-clock time via ``datetime.fromtimestamp``. Returns + ``None`` when the file is missing or its times cannot be read. + """ + try: + created = os.path.getctime(log_path) + modified = os.path.getmtime(log_path) + except OSError: + return None + return ( + datetime.fromtimestamp(created).strftime(_SPAN_FORMAT), + datetime.fromtimestamp(modified).strftime(_SPAN_FORMAT), + ) diff --git a/games/spacerangershd/installer.py b/games/spacerangershd/installer.py new file mode 100644 index 0000000..5989875 --- /dev/null +++ b/games/spacerangershd/installer.py @@ -0,0 +1,356 @@ +# Copyright (c) 2026 ringill +# SPDX-License-Identifier: MIT + +"""Archive installer that places Nexus SRHD mod archives into the MO2 mods directory. + +A downloaded SRHD mod archive can wrap its content in any number of folders, so +the first job is to locate the mod's root: the engine anchors every mod on a +``ModuleInfo.txt`` at the mod folder's root, so we search the archive for that +file (top level first, then descending into nested folders). The mod's root is +the folder that directly contains ``ModuleInfo.txt``; everything it needs is +whatever sits alongside that file. + +That root is then restructured into this plugin's identity convention (see +``paths.py``): the mod folder is named ``__`` and its data +mirrors the engine's ``Mods\\\\`` layout under a nested +``\\`` subfolder. Because the MO2 mod folder thereby mirrors the +data directory, ``mappings()`` in ``game_spacerangershd.py`` picks the mod up +immediately with no further adjustment: + +- ```` is the folder that held ``ModuleInfo.txt`` in the archive; if the + file sat at the archive root (no wrapping folder), ``Name=`` from the file is + used instead. Both are sanitized (whitespace and Windows-invalid path + characters removed) before becoming a folder name. +- ```` comes from the ``SectionEng=`` field in ``ModuleInfo.txt``. + +An optional ``meta.ini`` found next to ``ModuleInfo.txt`` is not copied into the +mod. Instead the installer reads its values and merges them into the ``meta.ini`` +MO2 forms when it records the nexus download, so download metadata and mod +authored metadata end up in one file: + +- a key MO2's ``meta.ini`` lacks but the archive's has is added; +- a key both have, with a value in both, keeps MO2's value; +- a key MO2 has empty but the archive has populated is filled from the archive. + +The merged ``meta.ini`` is placed at the mod folder root; every other file and +folder that sat beside ``ModuleInfo.txt`` (including ``ModuleInfo.txt`` itself) +goes under ``\\\\``. + +A broken archive (no ``ModuleInfo.txt``, no ``SectionEng=``, or an empty +```` after sanitizing) fails the installation. MO2 calls +``isArchiveSupported`` on every installer for every archive install regardless of +the managed game, so this installer self-gates on the game being SRHD. +""" + +from __future__ import annotations + +from pathlib import Path + +import mobase + +from ..game_spacerangershd import SpaceRangersHDGame +from .modcfg import read_mod_display_name, read_mod_section + +# Marker file that anchors a mod's root (see mod_data_checker.py). +_MODULE_INFO = "moduleinfo.txt" +# Optional mod-level metadata file whose values are merged into the MO2-formed +# meta.ini on install (never copied into the mod itself). +META_INI = "meta.ini" +# Characters forbidden in Windows paths (Path invalid + directory separators). +_WINDOWS_INVALID_CHARS = set('<>:"/\\|?*') + + +def _sanitize(value: str) -> str: + """Strip whitespace and characters that cannot appear in a Windows path. + + Spaces are removed (per the archive naming convention) and the engine's + ``Category\\Mod`` path forbids ``<>:"/\\|?*`` plus control characters. Returns + an empty string when nothing usable remains, signalling a broken archive. + """ + return "".join( + ch + for ch in value + if not ch.isspace() and ch not in _WINDOWS_INVALID_CHARS and ord(ch) >= 32 + ) + + +def _find_mod_root( + tree: mobase.IFileTree, +) -> tuple[mobase.IFileTree, mobase.FileTreeEntry] | None: + """Return ``(mod_root, module_info)`` of the first ``ModuleInfo.txt`` found. + + ``mod_root`` is the tree that directly contains ``module_info``. The search + checks the top level first, then descends into nested folders in order + (preorder depth-first), so the first match is the archive's shallowest mod + root. + """ + for entry in tree: + if entry.isFile() and entry.name().casefold() == _MODULE_INFO: + return tree, entry + if isinstance(entry, mobase.IFileTree): + found = _find_mod_root(entry) + if found is not None: + return found + return None + + +def _find_meta_ini(mod_root: mobase.IFileTree) -> mobase.FileTreeEntry | None: + """Return the ``meta.ini`` entry sitting beside ``ModuleInfo.txt``, if any.""" + for entry in mod_root: + if entry.isFile() and entry.name().casefold() == META_INI: + return entry + return None + + +def _prune_wrappers(root: mobase.IFileTree, entry: mobase.IFileTree) -> None: + """Detach ``entry`` and any now-empty ancestors up to (not including) ``root``. + + After the mod's files are moved out of a wrapper folder the folder is empty + and would otherwise remain as a dangling container in the archive, so it is + removed together with any ancestors that become empty as a result. + """ + current: mobase.IFileTree | None = entry + while current is not None and current is not root: + parent = current.parent() + if len(current) == 0: + current.detach() + else: + break + current = parent if isinstance(parent, mobase.IFileTree) else None + + +def read_meta_ini_text(path: Path) -> str: + """Decode an SRHD ``meta.ini`` regardless of its on-disk encoding. + + SRHD meta files may be UTF-16 LE with a BOM, UTF-8, or single-byte + windows-1251; try each in turn and return the first that decodes cleanly. + """ + for encoding in ("utf-8-sig", "utf-16", "cp1251"): + try: + return path.read_text(encoding=encoding) + except (UnicodeDecodeError, ValueError): + continue + return "" + + +def parse_meta_ini(text: str) -> dict[str, str]: + """Parse a flat ``key=value`` INI body into a dict (section headers skipped).""" + result: dict[str, str] = {} + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("[") or "=" not in stripped: + continue + key, _, value = stripped.partition("=") + key = key.strip() + if key: + result[key] = value.strip() + return result + + +def _read_meta_ini( + manager: mobase.IInstallationManager, entry: mobase.FileTreeEntry +) -> dict[str, str]: + """Read the ``key=value`` pairs from an archive ``meta.ini`` entry. + + The file is extracted to a temporary path that MO2 cleans up after the + install; nothing is copied into the mod. Returns an empty dict when the file + could not be read. + """ + tmp = manager.extractFile(entry, silent=True) + if not tmp: + return {} + return parse_meta_ini(read_meta_ini_text(Path(tmp))) + + +def _mo2_meta_ini(version: str, nexus_id: int) -> dict[str, str]: + """The ``meta.ini`` keys MO2 has already formed for this nexus download. + + MO2 passes these to ``install()``; the rest of its ``meta.ini`` (gameName, + repository, author, ...) is only added by MO2 after ``install()`` returns, so + those are not part of the merge base here. + """ + mo2: dict[str, str] = {} + if version: + mo2["version"] = version + if nexus_id: + mo2["modid"] = str(nexus_id) + return mo2 + + +def merge_meta_ini(mo2: dict[str, str], archive: dict[str, str]) -> dict[str, str]: + """Merge archive ``meta.ini`` values into MO2's under the installer rules. + + ``mo2`` holds the keys MO2 has already formed (the nexus download's ``version`` + and ``modid``); ``archive`` holds the values read from the mod archive's + ``meta.ini``. A key missing from ``mo2`` is added from ``archive``; a key both + have keeps MO2's value unless MO2's is empty, in which case the archive's + populated value is used. Empty archive values are ignored. + """ + merged = dict(mo2) + for key, value in archive.items(): + if value and (key not in merged or not merged[key]): + merged[key] = value + return merged + + +def _format_meta_ini(data: dict[str, str]) -> str: + """Serialize a dict to a ``key=value`` INI body, one pair per CRLF line.""" + body = "\r\n".join(f"{key}={value}" for key, value in data.items()) + return body + ("\r\n" if body else "") + + +def _write_meta_ini( + manager: mobase.IInstallationManager, + tree: mobase.IFileTree, + data: dict[str, str], +) -> None: + """Create the merged ``meta.ini`` at the mod folder root via ``createFile``. + + A synthetic file entry (``addFile``) carries no archive index, so ``extract`` + ignores it; MO2 instead copies the temp file from ``createFile`` to the mod + folder root (``target/meta.ini``) after extraction, where its own QSettings + merge runs on top. ``data`` is written UTF-8 with a BOM so MO2's QSettings + reads it back as UTF-8. + """ + entry = tree.addFile(META_INI) + tmp = manager.createFile(entry) + if not tmp: + return + body = _format_meta_ini(data) + # newline="" on write stops \n from being translated to os.linesep (CRLF on + # Windows), which would corrupt the explicit \r\n into \r\r\n. + with Path(tmp).open("w", encoding="utf-8-sig", newline="") as f: + f.write(body) + + +def _restructure( + tree: mobase.IFileTree, + mod_root: mobase.IFileTree, + name: str, + section: str, + meta_ini: mobase.FileTreeEntry | None, +) -> None: + """Rearrange the archive so the mod folder holds ``\\``. + + The mod folder (the archive root, which MO2 extracts to the mod folder) is + left holding the merged ``meta.ini`` written by ``_write_meta_ini`` and a + single ``\\`` subfolder containing every file and folder + that sat beside ``ModuleInfo.txt`` (including ``ModuleInfo.txt`` itself). The + archive's own ``meta.ini`` (``meta_ini``) is detached rather than moved, so it + is never copied into the mod; its values were already read for the merge. When + the archive already uses the target layout, the only changes are dropping the + archive ``meta.ini`` and adding the merged one. + """ + data_dir = tree.addDirectory(f"{section}\\{name}") + children = list(mod_root) + for child in children: + if child is data_dir: + continue + if child is meta_ini: + # meta.ini is mod-level metadata: read (not copied) for the merge, so + # detach it from the tree to keep it out of the extracted output. + child.detach() + continue + if data_dir is not mod_root: + # Move each remaining sibling of ModuleInfo.txt into the data folder. + data_dir.move(child, "", mobase.IFileTree.InsertPolicy.MERGE) + if data_dir is not mod_root: + _prune_wrappers(tree, mod_root) + + +class SpaceRangersHDInstaller(mobase.IPluginInstallerSimple, mobase.IPlugin): + """Install SRHD Nexus archives, restructuring them to this plugin's convention.""" + + _organizer: mobase.IOrganizer + + def __init__(self): + mobase.IPluginInstallerSimple.__init__(self) + mobase.IPlugin.__init__(self) + + def init(self, organizer: mobase.IOrganizer) -> bool: + self._organizer = organizer + return True + + def name(self) -> str: + return "SRHD: Space Rangers HD archive installer" + + def author(self) -> str: + return "ringill" + + def description(self) -> str: + return ( + "Installs Space Rangers HD mod archives: finds the folder anchored by " + "ModuleInfo.txt and restructures it into the " + "__\\\\ layout the plugin's VFS " + "mapping expects, merging the archive's meta.ini values into MO2's." + ) + + def version(self) -> mobase.VersionInfo: + return mobase.VersionInfo("0.1.0") + + def settings(self) -> list[mobase.PluginSetting]: + return [] + + def priority(self) -> int: + # High so this installer wins over the generic installers for SRHD mods. + return 1000 + + def isManualInstaller(self) -> bool: + return False + + def isArchiveSupported(self, tree: mobase.IFileTree) -> bool: + # MO2 asks every installer for every archive, regardless of the managed + # game, so gate on the game being SRHD as well as on the archive having a + # ModuleInfo.txt. + game = self._organizer.managedGame() + if game.gameShortName() != SpaceRangersHDGame.GameShortName: + return False + return _find_mod_root(tree) is not None + + def install( + self, + name: mobase.GuessedString, + tree: mobase.IFileTree, + version: str, + nexus_id: int, + ) -> mobase.IFileTree | mobase.InstallResult: + found = _find_mod_root(tree) + if found is None: + return mobase.InstallResult.FAILED + mod_root, module_info = found + + # Read Name= / SectionEng= from the archive's ModuleInfo.txt. The file is + # extracted to a temporary location that MO2 cleans up after the install. + tmp = self._manager().extractFile(module_info, silent=True) + if not tmp: + return mobase.InstallResult.FAILED + module_info_path = Path(tmp) + display_name = read_mod_display_name(module_info_path) + section = read_mod_section(module_info_path) + + # is the folder that held ModuleInfo.txt; if the file sat at the + # archive root, fall back to the Name= value from the file. + if mod_root is not tree: + name_value = _sanitize(mod_root.name()) + else: + name_value = _sanitize(display_name or "") + section_value = _sanitize(section or "") + if not name_value or not section_value: + # Broken archive: without a valid or the mod cannot + # be mapped into the engine's Mods\\ layout. + return mobase.InstallResult.FAILED + + name.update(f"{section_value}__{name_value}", mobase.GuessQuality.USER) + + meta_ini = _find_meta_ini(mod_root) + if meta_ini is not None: + # Read the archive's meta.ini (never copied), merge its values into the + # meta.ini MO2 forms for this download, and write the result to the mod + # folder root so MO2's own meta.ini merge runs on top of it. + archive = _read_meta_ini(self._manager(), meta_ini) + merged = merge_meta_ini(_mo2_meta_ini(version, nexus_id), archive) + _restructure(tree, mod_root, name_value, section_value, meta_ini) + _write_meta_ini(self._manager(), tree, merged) + else: + _restructure(tree, mod_root, name_value, section_value, None) + return tree diff --git a/games/spacerangershd/mod_data_checker.py b/games/spacerangershd/mod_data_checker.py new file mode 100644 index 0000000..a48a4bc --- /dev/null +++ b/games/spacerangershd/mod_data_checker.py @@ -0,0 +1,95 @@ +# Copyright (c) 2026 ringill +# SPDX-License-Identifier: MIT + +"""ModDataChecker for Space Rangers HD: A War Apart. + +SRHD loads mods from ``Mods\\\\``; every native mod folder is +anchored by a ``ModuleInfo.txt`` at its root (the engine reads it for the mod's +Name/Priority/Conflict/Dependence). The content around it is otherwise arbitrary +(``CFG\\``, ``DATA\\``, ``colored_assets.pkg``, ...), so a mod's data tree is +recognised by the presence of that file, not by enumerating folders. + +Registering this feature (via ``SpaceRangersHDGame.init``) is what lets MO2's +basic installer decide an archive is a valid SRHD mod data tree. Without a +``ModDataChecker``, ``gameFeature()`` returns null and the +installer cannot determine the data-folder layout — it wraps the archive in the +```` pseudo-root and reports "Cannot check the content of ". +""" + +from __future__ import annotations + +import mobase + +# Marker file that anchors every SRHD mod's data tree. +_MODULE_INFO = "moduleinfo.txt" + + +def _is_data_tree(filetree: mobase.IFileTree) -> bool: + """Return whether ``filetree`` is a valid SRHD mod data tree. + + A data tree is anchored by ``ModuleInfo.txt`` at its root; everything else + (``CFG``, ``DATA``, loose files, ...) is allowed to vary between mods. + """ + return any( + entry.isFile() and entry.name().casefold() == _MODULE_INFO for entry in filetree + ) + + +def _contains_data_tree(filetree: mobase.IFileTree) -> bool: + """Return whether ``filetree`` contains a data tree at any depth. + + Recurses through subfolders because an installed mod's data tree sits nested + under the mod folder in the ``\\`` layout; only the mod + folder's root is handed to ``dataLooksValid``. + """ + if _is_data_tree(filetree): + return True + return any( + isinstance(entry, mobase.IFileTree) and _contains_data_tree(entry) + for entry in filetree + ) + + +def _single_wrapper(filetree: mobase.IFileTree) -> mobase.IFileTree | None: + """Return the only top-level directory of ``filetree``, or ``None``.""" + children = list(filetree) + if len(children) != 1: + return None + only = children[0] + if not isinstance(only, mobase.IFileTree): + return None + return only + + +class SpaceRangersHDModDataChecker(mobase.ModDataChecker): + """Game feature that lets MO2's basic installer validate SRHD mod archives.""" + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + # Direct data tree: ModuleInfo.txt sits at the archive root. + if _is_data_tree(filetree): + return mobase.ModDataChecker.VALID + # A single top-level folder that is itself a data tree: the mod is wrapped + # in its own folder (the common Nexus archive layout). ``fix()`` unwraps it. + wrapper = _single_wrapper(filetree) + if wrapper is not None and _is_data_tree(wrapper): + return mobase.ModDataChecker.FIXABLE + # Installed layout: the mod folder is ``__`` and its data + # tree sits nested under ``\\`` (plus a top-level + # ``meta.ini``), so ModuleInfo.txt is not at the root and the folder is not + # a single wrapper. Recurse to recognise the tree as valid — otherwise every + # installed mod would report "No valid game data" in MO2's Flags column. + if _contains_data_tree(filetree): + return mobase.ModDataChecker.VALID + return mobase.ModDataChecker.INVALID + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + # Unwrap a single folder that wraps a data tree: move its contents up into + # the archive root, then drop the empty wrapper (same shape as + # ``BasicModDataChecker``'s ``unfold`` handling). + wrapper = _single_wrapper(filetree) + if wrapper is not None and _is_data_tree(wrapper): + filetree.merge(wrapper) + wrapper.detach() + return filetree diff --git a/games/spacerangershd/modcfg.py b/games/spacerangershd/modcfg.py new file mode 100644 index 0000000..4d657d6 --- /dev/null +++ b/games/spacerangershd/modcfg.py @@ -0,0 +1,208 @@ +# Copyright (c) 2026 ringill +# SPDX-License-Identifier: MIT + +"""Parsing and writing of Space Rangers HD mod control files. + +The engine reads the load order from ``Mods\\ModCFG.txt``: the ``CurrentMod=`` key +holds a comma separated, ordered list of enabled mod entries. Each mod's +``ModuleInfo.txt`` is UTF-16 LE with a BOM; ``ModCFG.txt`` is single-byte UTF-8, +both with CRLF line endings. +""" + +from __future__ import annotations + +from pathlib import Path + +# Python's ``utf-16`` codec reads/writes the BOM automatically. ``ModCFG.txt`` is +# plain UTF-8 (not UTF-16), so the two files use different codecs. +_ENCODING = "utf-16" +_MODCFG_ENCODING = "utf-8" +_CURRENT_MOD_KEY = "CurrentMod=" +_PRIORITY_KEY = "Priority=" +_NAME_KEY = "Name=" +_SECTIONENG_KEY = "SectionEng=" +_CONFLICT_KEY = "Conflict=" +_DEPENDENCE_KEY = "Dependence=" + +# Value written as ``Priority=`` into every MO2-enabled mod's ``ModuleInfo.txt``. +# Equalizing all enabled mods to one value makes the engine's stable sort a no-op, +# so load order follows ``CurrentMod`` exactly. Single place to tweak the +# equalization value (e.g. to ``-1`` during testing) — change it here and it applies +# everywhere (default argument + plugin call site). +PRIORITY_EQUALIZE_VALUE = 1 + + +def _read_text(path: Path, encoding: str) -> str: + """Read ``path`` with ``newline=""`` so CRLF/LF endings are preserved exactly. + + ``Path.read_text()`` only gained a ``newline`` parameter in Python 3.10, but + MO2 2.5.x embeds an older interpreter, so use ``Path.open()`` (mirrors the + builtin ``open()``, which supports ``newline`` in every supported version). + """ + with path.open("r", encoding=encoding, newline="") as f: + return f.read() + + +def _write_text(path: Path, data: str, encoding: str) -> None: + """Write ``data`` with ``newline=""`` so preserved endings aren't translated. + + Same interpreter-compatibility reason as ``_read_text``: ``newline=""`` on + write stops ``\n`` from being translated to ``os.linesep`` (CRLF on Windows), + which would otherwise corrupt preserved CRLF endings into CRCRLF. + """ + with path.open("w", encoding=encoding, newline="") as f: + f.write(data) + + +def set_priority(module_info: Path, value: int = PRIORITY_EQUALIZE_VALUE) -> None: + """Set the ``Priority=`` field in a mod's ``ModuleInfo.txt``. + + The previous value is not preserved; the field is replaced if present. A mod + without a ``Priority`` field is left untouched (minimal intervention). + Equalizing ``Priority`` across every enabled mod is what hands full + load-order control to ``CurrentMod``: the engine's stable sort becomes a + no-op and the ``QueryWrongOrderFix`` dialog never appears. + + The rewrite is minimal: only the ``Priority`` field changes. Line endings + (CRLF/LF) and a trailing newline on the last line are preserved as-is, and a + file that already holds the target value — or has no ``Priority`` field at + all — is left byte-for-byte untouched. + """ + if not module_info.exists(): + return + key = f"{_PRIORITY_KEY}{value}" + # newline="" disables universal-newline translation so CRLF/LF endings are + # preserved exactly; otherwise read_text would fold CRLF into LF and the + # rewrite would silently normalise the file to LF. + lines = _read_text(module_info, _ENCODING).splitlines(keepends=True) + for index, line in enumerate(lines): + content = line.rstrip("\r\n") + if content.strip() == key: + return # already equalized — leave the file untouched + if content.lstrip().startswith(_PRIORITY_KEY): + lines[index] = key + line[len(content) :] + break + else: + return # no Priority field — minimal intervention: leave the file untouched + # newline="" on write too: the default None translates ``\n`` to os.linesep + # (CRLF on Windows), which would corrupt preserved CRLF endings into CRCRLF. + _write_text(module_info, "".join(lines), _ENCODING) + + +def read_current_mod(path: Path) -> list[str]: + """Return the ordered list of enabled mod names from a ``ModCFG.txt`` file. + + The order follows the file, which is the engine's load order (left to right + meaning first loaded to last loaded / overriding). + """ + if not path.exists(): + return [] + for line in path.read_text(encoding=_MODCFG_ENCODING).splitlines(): + stripped = line.strip() + if stripped.startswith(_CURRENT_MOD_KEY): + value = stripped[len(_CURRENT_MOD_KEY) :] + return [entry.strip() for entry in value.split(",") if entry.strip()] + return [] + + +def write_current_mod(path: Path, mod_names: list[str]) -> None: + """Write the given ordered mod list as ``CurrentMod=`` in ``ModCFG.txt``. + + Any existing ``CurrentMod=`` value is replaced; unrelated lines are preserved. + Line endings (CRLF/LF) and a trailing newline are kept as-is, and a file that + already holds the target value is left byte-for-byte untouched. Entries are + joined with ``", "`` to match the format the game writes. + """ + key_line = f"{_CURRENT_MOD_KEY}{', '.join(mod_names)}" + if not path.exists(): + _write_text(path, key_line, _MODCFG_ENCODING) + return + + lines = _read_text(path, _MODCFG_ENCODING).splitlines(keepends=True) + for index, line in enumerate(lines): + content = line.rstrip("\r\n") + if content.strip() == key_line: + return # already current — leave the file untouched + if content.strip().startswith(_CURRENT_MOD_KEY): + lines[index] = key_line + line[len(content) :] + break + else: + # No CurrentMod line: append one, reusing a line ending from the file + # (default CRLF). If the last line has no newline, add one so the appended + # entry starts on a fresh line. + ending = "\r\n" + for last in reversed(lines): + if last.endswith("\r\n"): + ending = "\r\n" + break + if last.endswith("\n"): + ending = "\n" + break + if lines and not lines[-1].endswith(("\n", "\r\n")): + lines[-1] += ending + lines.append(key_line + ending) + _write_text(path, "".join(lines), _MODCFG_ENCODING) + + +def read_mod_display_name(module_info: Path) -> str | None: + """Return the ``Name=`` value from a mod's ``ModuleInfo.txt``. + + This is the mod's own display name (e.g. ``Deutsch Modifikation``). Returns + ``None`` when the file is missing or has no ``Name=`` field, so callers can + fall back to the folder name. + """ + if not module_info.exists(): + return None + for line in module_info.read_text(encoding=_ENCODING).splitlines(): + stripped = line.strip() + if stripped.startswith(_NAME_KEY): + return stripped[len(_NAME_KEY) :].strip() + return None + + +def read_mod_section(module_info: Path) -> str | None: + """Return the ``SectionEng=`` value from a mod's ``ModuleInfo.txt``. + + The engine groups mods into sections (e.g. ``AnotherMods``); ``SectionEng`` is + the section's folder name, which this plugin uses as the ```` half of + the ``__`` identity convention (see ``paths.py``). Returns + ``None`` when the file is missing or has no ``SectionEng=`` field. + """ + if not module_info.exists(): + return None + for line in module_info.read_text(encoding=_ENCODING).splitlines(): + stripped = line.strip() + if stripped.startswith(_SECTIONENG_KEY): + return stripped[len(_SECTIONENG_KEY) :].strip() + return None + + +def _read_mod_name_list(module_info: Path, key: str) -> list[str]: + """Return the comma-separated mod list of a ``ModuleInfo.txt`` field. + + ``key`` is e.g. ``Conflict=`` or ``Dependence=``; each entry is a ``Mod`` + folder name. An empty or missing field yields an empty list, mirroring how + the game treats such fields as "no contract". + """ + if not module_info.exists(): + return [] + for line in module_info.read_text(encoding=_ENCODING).splitlines(): + stripped = line.strip() + if stripped.startswith(key): + value = stripped[len(key) :] + return [entry.strip() for entry in value.split(",") if entry.strip()] + return [] + + +def read_conflict_dependence(module_info: Path) -> tuple[list[str], list[str]]: + """Return the ``Conflict=`` and ``Dependence=`` mod lists of a mod. + + ``Conflict=`` lists mutually exclusive mods; ``Dependence=`` lists required + mods. Each is a comma-separated list of ``Mod`` folder names (``Mod`` here is + the two-level ``Category\\Mod`` or a bare ``Mod``, as the game writes them). + Empty or missing fields yield empty lists. + """ + return ( + _read_mod_name_list(module_info, _CONFLICT_KEY), + _read_mod_name_list(module_info, _DEPENDENCE_KEY), + ) diff --git a/games/spacerangershd/modlist.py b/games/spacerangershd/modlist.py new file mode 100644 index 0000000..a89edd3 --- /dev/null +++ b/games/spacerangershd/modlist.py @@ -0,0 +1,43 @@ +# Copyright (c) 2026 ringill +# SPDX-License-Identifier: MIT + +"""Read/write of MO2 profile ``modlist.txt`` files. + +MO2 records each mod on its own line, prefixed by ``+`` (enabled) or ``-`` +(disabled), in top-to-bottom order matching the left pane. The bottom of the list +is the highest-priority (last loaded / overriding) mod. +""" + +from __future__ import annotations + +from pathlib import Path + + +def read_modlist(path: Path) -> list[tuple[str, bool]]: + """Return ``(mod name, enabled)`` entries from a ``modlist.txt`` file, in order. + + Blank lines and separator comments are ignored. + """ + entries: list[tuple[str, bool]] = [] + if not path.exists(): + return entries + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + stripped = line.strip() + if not stripped: + continue + enabled = stripped[0] == "+" + name = stripped[1:] + if name: + entries.append((name, enabled)) + return entries + + +def write_modlist(path: Path, entries: list[tuple[str, bool]]) -> None: + """Write the given entries to a ``modlist.txt`` file, in order.""" + lines = [f"{'+' if enabled else '-'}{name}" for name, enabled in entries] + path.write_text("\n".join(lines), encoding="utf-8") + + +def enabled_names(entries: list[tuple[str, bool]]) -> list[str]: + """Return the names of enabled mods, preserving their order.""" + return [name for name, enabled in entries if enabled] diff --git a/games/spacerangershd/paths.py b/games/spacerangershd/paths.py new file mode 100644 index 0000000..1d1ccda --- /dev/null +++ b/games/spacerangershd/paths.py @@ -0,0 +1,44 @@ +# Copyright (c) 2026 ringill +# SPDX-License-Identifier: MIT + +"""Mapping between the engine's ``Category\\Mod`` path and the MO2 mod identity. + +This plugin owns native SRHD mods inside the MO2 mods directory, one folder per +mod. The engine still loads each mod from the nested ``Mods\\\\`` +path (see ``CurrentMod=`` in ``ModCFG.txt``), so every MO2 folder name must +encode which category and mod it maps to. This module is the single source of +truth for that convention, shared by the plugin (``mappings``/sync helpers) and +the one-time migration script. + +Convention: the MO2 folder is named ``__``. Encoding the category +as a prefix of a single folder name keeps it unambiguous and means the category +is never represented as its own mod entry in MO2's left pane. +""" + +from __future__ import annotations + +_SEPARATOR = "__" + + +def engine_path_to_mod_name(engine_path: str) -> str: + """Encode an engine ``Category\\Mod`` path as an MO2 mod folder name. + + Accepts either backslash (``ModCFG.txt`` engine paths) or forward slash + (``Path.as_posix()``) separators, so callers don't have to normalize first. + """ + return engine_path.replace("\\", _SEPARATOR).replace("/", _SEPARATOR) + + +def mod_name_to_engine_path(mod_name: str) -> str | None: + """Decode an MO2 mod folder name back to its engine ``Category\\Mod`` path. + + Returns ``None`` when the name does not follow the convention, so non-native + mods (or a mod a user hand-added) are left alone rather than force-mapped. + """ + parts = mod_name.split(_SEPARATOR, 1) + if len(parts) != 2: + return None + category, mod = parts + if not category or not mod: + return None + return f"{category}\\{mod}" diff --git a/games/spacerangershd/plugins/__init__.py b/games/spacerangershd/plugins/__init__.py new file mode 100644 index 0000000..f9670ea --- /dev/null +++ b/games/spacerangershd/plugins/__init__.py @@ -0,0 +1,16 @@ +# Copyright (c) 2026 ringill +# SPDX-License-Identifier: MIT + +import mobase + +from ..installer import SpaceRangersHDInstaller +from ..tool_bugreport import BugReportTool +from ..tool_migrate import MigrateTool + + +def createPlugins() -> list[mobase.IPlugin]: + # The installer is a mobase.IPluginInstallerSimple; the tools are + # mobase.IPluginTool. The basic_games loader registers every returned + # mobase.IPlugin with MO2's plugincontainer, which picks installers out via + # qobject_cast. + return [SpaceRangersHDInstaller(), MigrateTool(), BugReportTool()] diff --git a/games/spacerangershd/tool_bugreport.py b/games/spacerangershd/tool_bugreport.py new file mode 100644 index 0000000..7a4a679 --- /dev/null +++ b/games/spacerangershd/tool_bugreport.py @@ -0,0 +1,644 @@ +# Copyright (c) 2026 ringill +# SPDX-License-Identifier: MIT + +"""MO2 tool-menu action that assembles a Space Rangers HD mod bug-report archive. + +The game's crash notification (see ``game_spacerangershd.py``) surfaces the last +run's crash, but a mod author still needs context to reproduce a bug: which mod +the user suspects, which save was involved, the game's own log, and any +screenshots of the problem. This tool opens a small form split into sections — +what happened, where it happened, how to reproduce, who the user suspects, plus +two attachment sections (user screenshots and automatically collected technical +files) — then packs everything into a ``.zip`` on "Save". + +Nothing on the form is mandatory. The user may leave the mod and/or save unset +even though the form recommends them; skipped fields simply don't end up in the +archive. The archive always carries at least the game version from +``Rangers.exe`` (via the managed game's ``gameVersion()``, which calls +``mobase.getFileVersion``) and the full text of the last run's ``########.log`` +(``read_log`` in ``crash_log.py``). The exact set of fields/files is expected to +grow after the first MVP. +""" + +from __future__ import annotations + +import os +import zipfile +from collections.abc import Callable +from datetime import datetime +from pathlib import Path + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QIcon +from PyQt6.QtWidgets import ( + QAbstractItemView, + QComboBox, + QCompleter, + QDialog, + QDialogButtonBox, + QFileDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMessageBox, + QPlainTextEdit, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) + +import mobase + +from .crash_log import read_log, run_span +from .modcfg import read_mod_display_name +from .paths import mod_name_to_engine_path + +_NAME = "SRHD: Report a mod bug" +_VERSION = mobase.VersionInfo("0.1.0") + +# Placeholder first entry of each filterable combo is blank: an unset field is +# simply empty, and the value is omitted from the archive. +_NONE_MOD = "" +_NONE_SAVE = "" + +_REPORT_FILENAME = "report.txt" + +# Screenshots live wherever the user keeps them on disk (outside MO2); this filter +# is offered in the multi-select dialog. Technical files (log, modlist, ModCFG) +# may be any file on disk, so the technical section offers a broad filter. +_SCREENSHOT_FILTER = "Images (*.png *.jpg *.jpeg *.bmp *.webp)" +_TECH_FILTER = "All files (*)" + + +def _combo_data(combo: QComboBox) -> object: + """Return the ``itemData`` of the item matching the combo's current text. + + The ``data`` is the stable identity read back for the report and archive. + ``None`` is returned when nothing valid is selected: the combo is empty, or + the text does not exactly match one of the items. + """ + text = combo.currentText().strip() + if not text: + return None + for i in range(combo.count()): + if combo.itemText(i) == text: + return combo.itemData(i) + return None + + +def _current_reporter(organizer: mobase.IOrganizer) -> str: + """Best-effort default for the Reporter field. + + Prefer the Nexus username when MO2 is authenticated there, else the current + Windows user from the environment, else empty. The returned string may be + edited or cleared by the user on the form. + """ + # ``getCurrentUsername`` is not declared in the mobase stubs, so reach it via + # getattr and stay graceful if the runtime MO2 lacks the method entirely. + username = getattr(organizer, "getCurrentUsername", None) + if callable(username): + try: + name = username() + except Exception: + name = None + if isinstance(name, str) and name.strip(): + return name.strip() + for key in ("USERNAME", "USER"): + name = os.environ.get(key, "").strip() + if name: + return name + return "" + + +def _filename_slug(value: str) -> str: + """Strip characters that are not allowed in a Windows file name.""" + for ch in ("<", ">", ":", '"', "/", "\\", "|", "?", "*"): + value = value.replace(ch, "") + return value.strip().strip(" .") + + +def _attachments_group( + parent: QWidget, + title: str, + hint: str, + file_filter: str, + seed: list[Path], + *, + button_text: str = "Add files…", +) -> tuple[QGroupBox, Callable[[], list[Path]]]: + """Build an editable file list section and return it with a path getter. + + The returned ``QGroupBox`` holds a multi-select ``QListWidget`` pre-populated + with ``seed`` plus Add / Remove selected / Clear all buttons. The caller reads + back the final list of paths (full path stored in ``UserRole``) via the getter. + """ + group = QGroupBox(title, parent) + layout = QVBoxLayout(group) + hint_label = QLabel(hint, group) + hint_label.setWordWrap(True) + layout.addWidget(hint_label) + + listing = QListWidget(group) + listing.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + listing.setMinimumHeight(72) + for path in seed: + item = QListWidgetItem(path.name) + item.setData(Qt.ItemDataRole.UserRole, str(path)) + item.setToolTip(str(path)) + listing.addItem(item) + + add_button = QPushButton(button_text, group) + remove_button = QPushButton("Remove selected", group) + clear_button = QPushButton("Clear all", group) + + def _paths() -> list[Path]: + result: list[Path] = [] + for i in range(listing.count()): + item = listing.item(i) + if item is not None: + data = item.data(Qt.ItemDataRole.UserRole) + if isinstance(data, str): + result.append(Path(data)) + return result + + def _on_add() -> None: + chosen, _filter = QFileDialog.getOpenFileNames( + parent, title, str(Path.home()), file_filter + ) + existing = set(_paths()) + for text in chosen: + path = Path(text) + if path in existing: + continue + item = QListWidgetItem(path.name) + item.setData(Qt.ItemDataRole.UserRole, str(path)) + item.setToolTip(str(path)) + listing.addItem(item) + existing.add(path) + + def _on_remove() -> None: + for item in listing.selectedItems(): + listing.takeItem(listing.row(item)) + + def _on_clear() -> None: + listing.clear() + + add_button.clicked.connect(_on_add) # type: ignore + remove_button.clicked.connect(_on_remove) # type: ignore + clear_button.clicked.connect(_on_clear) # type: ignore + + buttons = QHBoxLayout() + buttons.addWidget(add_button) + buttons.addWidget(remove_button) + buttons.addWidget(clear_button) + buttons.addStretch() + layout.addLayout(buttons) + layout.addWidget(listing) + return group, _paths + + +def _filterable_combo(parent: QDialog, items: list[tuple[str, object]]) -> QComboBox: + """Build an editable ``QComboBox`` with substring filtering over ``items``. + + Each ``items`` entry is ``(label, data)``; the data is the stable identity the + tool reads back via ``currentData()``, decoupled from whatever the user typed. + Editable + ``QCompleter`` with ``MatchContains`` (case-insensitive) lets the + user type a few characters to filter a long mod/save list instead of scrolling + it. + """ + combo = QComboBox(parent) + combo.setEditable(True) + combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + combo.setMaxVisibleItems(15) + for label, data in items: + combo.addItem(label, data) + completer = QCompleter(combo.model(), combo) + completer.setFilterMode(Qt.MatchFlag.MatchContains) + completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + combo.setCompleter(completer) + return combo + + +class BugReportTool(mobase.IPluginTool, mobase.IPlugin): + """Opens a mod bug-report form and packs the collected data into a ``.zip``.""" + + _organizer: mobase.IOrganizer + + def __init__(self): + mobase.IPluginTool.__init__(self) + mobase.IPlugin.__init__(self) + + def init(self, organizer: mobase.IOrganizer) -> bool: + self._organizer = organizer + return True + + def name(self) -> str: + return _NAME + + def displayName(self) -> str: + return _NAME + + def author(self) -> str: + return "ringill" + + def description(self) -> str: + return ( + "Assemble a Space Rangers HD mod bug-report archive: describe the " + "problem, optionally pick a suspected mod and a save, and save a .zip " + "with the game version, the last run's log, and any attached data." + ) + + def version(self) -> mobase.VersionInfo: + return _VERSION + + def settings(self) -> list[mobase.PluginSetting]: + return [] + + def tooltip(self) -> str: + return self.description() + + def icon(self) -> QIcon: + return QIcon() + + # --- Form construction ------------------------------------------------ + + def _enabled_mods(self, modlist: mobase.IModList) -> list[str]: + """Return the MO2 names of all enabled mods, sorted alphabetically.""" + return sorted( + name + for name in modlist.allMods() + if modlist.state(name) & mobase.ModState.ACTIVE + ) + + def _mod_label(self, modlist: mobase.IModList, name: str) -> str: + """Human-readable label for a mod: display name, or its folder name. + + Native mods (folder following the ``__`` convention) are + qualified with their engine path; any other enabled mod is labelled by the + ``Name`` from its own ``ModuleInfo.txt`` when present, else its folder name. + """ + mod = modlist.getMod(name) + engine_path = mod_name_to_engine_path(name) + if engine_path is not None: + module_info = Path(mod.absolutePath()) / engine_path / "ModuleInfo.txt" + display = read_mod_display_name(module_info) + return f"{display or name} ({engine_path})" + module_info = Path(mod.absolutePath()) / "ModuleInfo.txt" + display = read_mod_display_name(module_info) + return display or name + + def _save_files(self, saves_dir: Path) -> list[Path]: + """Return the existing ``.sav`` files in the game's saves directory.""" + if not saves_dir.is_dir(): + return [] + return sorted( + p for p in saves_dir.iterdir() if p.is_file() and p.suffix == ".sav" + ) + + def display(self) -> None: + game = self._organizer.managedGame() + if not game: + self._notify("No game is being managed.", QMessageBox.Icon.Warning) + return + modlist = self._organizer.modList() + + mod_names = self._enabled_mods(modlist) + mod_items: list[tuple[str, object]] = [(_NONE_MOD, None)] + for name in mod_names: + mod_items.append((self._mod_label(modlist, name), name)) + + save_paths = self._save_files(Path(game.savesDirectory().absolutePath())) + save_items: list[tuple[str, object]] = [(_NONE_SAVE, None)] + for path in save_paths: + save_items.append((path.name, path)) + + dialog = QDialog(self._parentWidget()) + dialog.setWindowTitle(_NAME) + dialog.setMinimumWidth(560) + dialog.resize(620, 720) + + reporter_label = QLabel("Reporter:", dialog) + reporter_edit = QLineEdit(dialog) + reporter_edit.setText(_current_reporter(self._organizer)) + + intro = QLabel( + "Describe the mod bug you encountered in Space Rangers HD. Everything is " + "optional — only what you fill in is included in the archive. The game " + "version, the last run's log, and the technical files are always attached." + ) + intro.setWordWrap(True) + + mod_combo = _filterable_combo(dialog, mod_items) + save_combo = _filterable_combo(dialog, save_items) + + # --- Section: What happened ---------------------------------------- + what_box = QGroupBox("What happened", dialog) + what_layout = QVBoxLayout(what_box) + what_text = QPlainTextEdit(what_box) + what_text.setPlaceholderText( + "Briefly describe the problem in one or two lines, e.g. the game freezes " + "when opening the planet map." + ) + what_layout.addWidget(what_text) + + # --- Section: Where it happened ------------------------------------ + where_box = QGroupBox("Where it happened", dialog) + where_layout = QVBoxLayout(where_box) + where_hint = QLabel( + "Which save was involved? Pick from your existing saves, or leave unset. " + "Type to filter." + ) + where_hint.setWordWrap(True) + where_layout.addWidget(where_hint) + where_layout.addWidget(save_combo) + + # --- Section: How to reproduce ------------------------------------- + how_box = QGroupBox("How to reproduce", dialog) + how_layout = QVBoxLayout(how_box) + how_hint = QLabel( + "Detail the exact steps so it can be reproduced: what you did, in what " + "order, and what happened." + ) + how_hint.setWordWrap(True) + how_layout.addWidget(how_hint) + how_text = QPlainTextEdit(how_box) + how_text.setPlaceholderText( + "Step by step: 1. Start a new game… 2. … 3. The problem appears." + ) + how_layout.addWidget(how_text) + + # --- Section: Suspected mod ---------------------------------------- + who_box = QGroupBox("Suspected mod", dialog) + who_layout = QVBoxLayout(who_box) + who_hint = QLabel( + "Pick the mod you believe is causing the problem — this bug report is " + "sent to that mod's author. Choose from the enabled mods, or leave " + "unset if you're not sure. Type to filter." + ) + who_hint.setWordWrap(True) + who_layout.addWidget(who_hint) + who_layout.addWidget(mod_combo) + + # --- Section: Attachments (screenshots) ---------------------------- + shots_box, screenshots_getter = _attachments_group( + dialog, + "Attachments (screenshots)", + "Attach screenshots of the issue if you have any. You can add 0, 1, or " + "more image files from anywhere on your computer.", + _SCREENSHOT_FILTER, + seed=[], + button_text="Add screenshots…", + ) + + # --- Section: Attachments (technical) ------------------------------ + tech_box, tech_getter = _attachments_group( + dialog, + "Attachments (technical)", + "These files are collected automatically: the last run's log, the " + "active profile's modlist, and the game's ModCFG. Remove any you don't " + "want, or add other technical files (screenshots, logs, configs).", + _TECH_FILTER, + seed=self._technical_files(game), + ) + + # --- Scrollable body + buttons ------------------------------------- + content = QWidget(dialog) + content_layout = QVBoxLayout(content) + for box in (what_box, where_box, how_box, who_box, shots_box, tech_box): + content_layout.addWidget(box) + content_layout.addStretch() + + scroll = QScrollArea(dialog) + scroll.setWidgetResizable(True) + scroll.setWidget(content) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Save + | QDialogButtonBox.StandardButton.Cancel, + dialog, + ) + save_button = buttons.button(QDialogButtonBox.StandardButton.Save) + if save_button is not None: + save_button.setText("Save archive") + buttons.accepted.connect(dialog.accept) # type: ignore + buttons.rejected.connect(dialog.reject) # type: ignore + + layout = QVBoxLayout(dialog) + reporter_row = QHBoxLayout() + reporter_row.addWidget(reporter_label) + reporter_row.addWidget(reporter_edit, 1) + layout.addLayout(reporter_row) + layout.addWidget(intro) + layout.addWidget(scroll, 1) + layout.addWidget(buttons) + + if dialog.exec() != QDialog.DialogCode.Accepted: + return + + mod_name = _combo_data(mod_combo) + save_path = _combo_data(save_combo) + screenshots = screenshots_getter() + tech_files = tech_getter() + report = self._build_report( + game, + modlist, + reporter_edit.text(), + mod_name if isinstance(mod_name, str) else "", + save_path if isinstance(save_path, Path) else None, + what_text.toPlainText(), + how_text.toPlainText(), + screenshots, + tech_files, + ) + self._save_archive( + game, + report, + modlist, + reporter_edit.text(), + mod_name if isinstance(mod_name, str) else "", + save_path if isinstance(save_path, Path) else None, + screenshots, + tech_files, + ) + + # --- Report + archive -------------------------------------------------- + + def _build_report( + self, + game: mobase.IPluginGame, + modlist: mobase.IModList, + reporter: str, + mod_name: str, + save_path: Path | None, + what_text: str, + how_text: str, + screenshots: list[Path], + tech_files: list[Path], + ) -> str: + """Compose the human-readable ``report.txt`` contents.""" + lines: list[str] = [ + "Space Rangers HD - Mod bug report", + f"Created: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + f"Reporter: {reporter.strip() or '(not provided)'}", + "", + f"Game version: {game.gameVersion()}", + ] + span = run_span(self._log_path(game)) + lines.append( + f"Last run: {span[0]} - {span[1]}" + if span is not None + else "Last run: (timestamps unavailable)" + ) + lines.append("") + lines.append("What happened:") + lines.append(what_text.strip() or "(not provided)") + lines.append("") + lines.append("Where it happened:") + lines.append(save_path.name if save_path else "(not selected)") + lines.append("") + lines.append("How to reproduce:") + lines.append(how_text.strip() or "(not provided)") + lines.append("") + lines.append("Suspected mod:") + lines.append(self._mod_label_for_report(modlist, mod_name) or "(not selected)") + lines.append("") + shot_names = ", ".join(p.name for p in screenshots) + lines.append("Attachments (screenshots):") + lines.append(shot_names or "(none)") + lines.append("") + lines.append("Attachments (technical):") + lines.append(", ".join(p.name for p in tech_files)) + return "\n".join(lines) + + def _technical_files(self, game: mobase.IPluginGame) -> list[Path]: + """Files collected automatically and offered in the technical attachment list. + + The last run's log, the active profile's ``modlist.txt``, and the game's + ``ModCFG.txt``. The user may remove or extend this list; each file is stored + in the archive under its own file name. + """ + profile_dir = Path(self._organizer.profile().absolutePath()) + game_dir = Path(game.dataDirectory().absolutePath()) + return [ + self._log_path(game), + profile_dir / "modlist.txt", + game_dir / "ModCFG.txt", + ] + + def _mod_label_for_report(self, modlist: mobase.IModList, mod_name: str) -> str: + if not mod_name: + return _NONE_MOD + mod = modlist.getMod(mod_name) + label = self._mod_label(modlist, mod_name) + if mod.version().isValid(): + label += f" v{mod.version().displayString()}" + return label + + def _log_path(self, game: mobase.IPluginGame) -> Path: + return Path(game.documentsDirectory().absolutePath()) / "########.log" + + def _save_archive( + self, + game: mobase.IPluginGame, + report: str, + modlist: mobase.IModList, + reporter: str, + mod_name: str, + save_path: Path | None, + screenshots: list[Path], + tech_files: list[Path], + ) -> None: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + name_parts = [f"spacerangershd-mod-bugreport-{stamp}"] + if reporter.strip(): + name_parts.append(_filename_slug(reporter)) + if mod_name.strip(): + name_parts.append(_filename_slug(mod_name)) + default_name = "-".join(name_parts) + ".zip" + dest, _filter = QFileDialog.getSaveFileName( + self._parentWidget(), + "Save mod bug-report archive", + str(Path.home() / default_name), + "ZIP archive (*.zip)", + ) + if not dest: + return + if not dest.lower().endswith(".zip"): + dest += ".zip" + + log_path = self._log_path(game) + try: + with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr(_REPORT_FILENAME, report) + for path in tech_files: + if path == log_path: + if path.is_file(): + log_text = read_log(path) + archive.writestr( + path.name, + log_text + if log_text is not None + else "(the last run's log could not be read)", + ) + else: + archive.writestr( + path.name, + "(the last run's log could not be read)", + ) + continue + if path.is_file(): + try: + archive.write(str(path), path.name) + except OSError: + continue + if mod_name: + self._zip_mod(archive, modlist, mod_name) + if save_path and save_path.is_file(): + archive.write(str(save_path), f"saves/{save_path.name}") + for path in screenshots: + if not path.is_file(): + continue + try: + archive.write(str(path), f"screenshots/{path.name}") + except OSError: + continue + except OSError as exc: + self._notify( + f"Could not write the archive:\n{exc}", QMessageBox.Icon.Warning + ) + return + + self._notify(f"Mod bug-report archive saved to:\n{dest}") + + def _zip_mod( + self, archive: zipfile.ZipFile, modlist: mobase.IModList, mod_name: str + ) -> None: + """Add the chosen mod's copy (from the MO2 mods directory) to the archive.""" + mod = modlist.getMod(mod_name) + mod_root = Path(mod.absolutePath()) + prefix = f"mod/{mod_name}" + if not mod_root.is_dir(): + return + for path in mod_root.rglob("*"): + if not path.is_file(): + continue + try: + archive.write( + str(path), f"{prefix}/{path.relative_to(mod_root).as_posix()}" + ) + except OSError: + continue + + def _notify( + self, text: str, icon: QMessageBox.Icon = QMessageBox.Icon.Information + ) -> None: + box = QMessageBox(self._parentWidget()) + box.setWindowTitle(_NAME) + box.setIcon(icon) + box.setText(text) + box.exec() diff --git a/games/spacerangershd/tool_migrate.py b/games/spacerangershd/tool_migrate.py new file mode 100644 index 0000000..b4da9f2 --- /dev/null +++ b/games/spacerangershd/tool_migrate.py @@ -0,0 +1,351 @@ +# Copyright (c) 2026 ringill +# SPDX-License-Identifier: MIT + +"""MO2 tool-menu action that imports native SRHD mods into the mods directory. + +Copy ownership model (see ``proposal.md`` / ``design.md``): the game keeps its +native mods in ``game\\Mods\\\\`` and MO2 overlays them via VFS, +so the files stay untouched. The missing piece was a visible way to get those +native mods into MO2's left pane, which is populated from the instance mods +directory. This tool does exactly that: it copies each native mod into +``\\mods\\__`` using the identity convention in +``paths.py``. + +Copying (not moving) keeps the game folder intact. The game loads mods only per +``CurrentMod`` in ``ModCFG.txt``, which the plugin maps from MO2's ``modlist.txt`` +on launch, so native mods are listed, enabled and ordered purely through MO2 even +though their source files remain in the game folder. + +Re-running the tool is a re-sync: existing copies are overwritten from the current +game folder contents, and the active profile's ``modlist.txt`` is rewritten so its +enabled/disabled state and order match ``CurrentMod`` (this also bootstraps a +missing or empty ``modlist.txt``). A progress dialog keeps the user informed while +the copies run, since a large mod set can take a moment. + +MO2 shows a mod's ``comments`` (from its root ``meta.ini``) in the mod list's Notes +column and in the hover tooltip, so the tool also writes the mod's own ``Name=`` +(from its UTF-16 ``ModuleInfo.txt``) into ``meta.ini``. That surfaces the +human-readable name (e.g. ``Deutsch Modifikation``) in the list without renaming +the ``__`` folder the plugin's VFS mapping depends on. +""" + +from __future__ import annotations + +import shutil +from collections.abc import Callable +from pathlib import Path + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QIcon +from PyQt6.QtWidgets import QApplication, QMessageBox, QProgressDialog + +import mobase + +from .installer import META_INI, merge_meta_ini, parse_meta_ini, read_meta_ini_text +from .modcfg import read_current_mod, read_mod_display_name +from .modlist import read_modlist, write_modlist +from .paths import engine_path_to_mod_name, mod_name_to_engine_path + +_NAME = "SRHD: Import native mods" +_VERSION = mobase.VersionInfo("0.1.0") + + +def _native_mod_jobs( + game_mods_root: Path, mo2_mods_dir: Path +) -> list[tuple[str, Path, Path, Path]]: + """Return ``(label, source, mod_dir, nested)`` copy jobs for each native mod. + + Only directories under each ``game\\Mods\\`` are considered; + ``ModCFG.txt`` and other loose files stay in place. The destination folder + name follows the ``__`` identity convention; the mod's files + are nested under a ``\\`` subfolder inside it, so the MO2 mod + folder mirrors the data directory (see ``paths.py``). + """ + jobs: list[tuple[str, Path, Path, Path]] = [] + if not game_mods_root.is_dir(): + return jobs + for category in sorted(game_mods_root.iterdir()): + if not category.is_dir(): + continue # e.g. ModCFG.txt stays in place + for source in sorted(category.iterdir()): + if not source.is_dir(): + continue + rel = source.relative_to(game_mods_root) # Category\\Mod + mod_dir = mo2_mods_dir / engine_path_to_mod_name(rel.as_posix()) + nested = mod_dir / rel + # Label the copy with the mod's own ``Name=`` (from its ModuleInfo.txt), + # falling back to the source folder name when the file is absent. + label = read_mod_display_name(source / "ModuleInfo.txt") or source.name + jobs.append((label, source, mod_dir, nested)) + return jobs + + +def copy_native_mods( + game_mods_root: Path, + mo2_mods_dir: Path, + on_progress: Callable[[int, int, str], None] | None = None, +) -> tuple[int, int]: + """Copy each native mod into the MO2 mods directory, overwriting existing copies. + + Returns ``(created, updated)``. Each mod's files are copied into the nested + ``\\`` subfolder of its MO2 folder, so the folder mirrors the + data directory; MO2-owned files such as ``meta.ini`` are preserved while + matching source files overwrite the previous copy — re-running is a re-sync. + The mod's own ``Name=`` is written into its root ``meta.ini`` ``comments`` and + the source mod's ``meta.ini`` values (next to ``ModuleInfo.txt`` in the game + folder) are merged into it, so MO2's Notes column shows the human-readable + name and the Info window / update checks see ``version``. A source + ``meta.ini`` is never left in the nested data folder. + ``on_progress(index, total, label)`` is invoked before each copy when given. + """ + jobs = _native_mod_jobs(game_mods_root, mo2_mods_dir) + created = 0 + updated = 0 + total = len(jobs) + for index, (_label, source, mod_dir, nested) in enumerate(jobs, start=1): + if on_progress is not None: + on_progress(index, total, _label) + if nested.exists(): + updated += 1 + else: + created += 1 + shutil.copytree(str(source), str(nested), dirs_exist_ok=True) + # Merge the source mod's meta.ini (next to ModuleInfo.txt) into the mod + # root and keep its values out of the data folder: meta.ini is mod-level + # metadata that MO2 reads only from the mod folder root. + _write_root_meta_ini(mod_dir, _label, source / META_INI) + nested_meta = nested / META_INI + if nested_meta.exists(): + nested_meta.unlink() + return created, updated + + +def _general_keys(lines: list[str]) -> dict[str, str]: + """Lowercased ``key -> value`` map of the ``[General]`` keys in ``lines``. + + Keys appearing before any section header belong to ``[General]`` too, so the + walk starts in ``[General]`` and stops collecting once a later section is + entered. This is the merge base for the source mod's ``meta.ini`` values. + """ + general: dict[str, str] = {} + in_general = True + for line in lines: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + in_general = stripped == "[General]" + continue + if not in_general: + continue + key, sep, value = stripped.partition("=") + if sep and key.strip(): + general[key.strip().lower()] = value.strip() + return general + + +def _source_meta_values(source_meta: Path) -> dict[str, str]: + """The ``key=value`` pairs of the source mod's ``meta.ini``, or ``{}``. + + The file sits next to ``ModuleInfo.txt`` in the game folder and may be UTF-16, + UTF-8, or windows-1251; ``read_meta_ini_text`` decodes each in turn. Keys are + lowercased so the merge base stays uniform with ``_general_keys`` and a source + ``Version=`` cannot collide with an existing ``version=`` into a duplicate. + Absent files yield an empty dict so the merge is a no-op. + """ + if not source_meta.exists(): + return {} + raw = parse_meta_ini(read_meta_ini_text(source_meta)) + return {key.lower(): value for key, value in raw.items()} + + +def _write_root_meta_ini(mod_dir: Path, name: str, source_meta: Path) -> None: + """Merge the source mod's ``meta.ini`` into the mod's root ``meta.ini``. + + MO2 reads a mod's metadata only from the mod folder root ``meta.ini``: it + renders ``comments`` in the list's Notes column and tooltip, and parses + ``version`` for the Info window and update checks. This writes the mod's own + ``Name=`` into ``comments`` (so the human-readable name shows without renaming + the ``__`` folder the VFS mapping depends on) and merges the + source ``meta.ini`` values (next to ``ModuleInfo.txt`` in the game folder) + into the ``[General]`` section under the installer rules + (``merge_meta_ini``): an existing non-empty value wins, the source fills a + missing or empty key, and empty source values are ignored. + + Only ``[General]`` is touched; other sections and keys in an existing + ``meta.ini`` are preserved verbatim. A missing file is created in MO2's format + (ungrouped keys sit under ``[General]``). ``meta.ini`` is UTF-8, unlike the + mod's UTF-16 ``ModuleInfo.txt``. + """ + path = mod_dir / "meta.ini" + lines = path.read_text("utf-8").splitlines() if path.exists() else [] + + merged = merge_meta_ini(_general_keys(lines), _source_meta_values(source_meta)) + # comments is the mod's own Name=, always overwritten (shown in Notes). + merged["comments"] = name + + existing = _general_keys(lines) + new_keys = [key for key in merged if key not in existing] + + if not lines: + body = "\n".join(["[General]"] + [f"{key}={merged[key]}" for key in merged]) + path.write_text(body + "\n", "utf-8") + return + + # Update existing [General] key values in place, keeping each key's original + # casing; leave other sections and keys untouched. + out = list(lines) + in_general = True + for idx, line in enumerate(out): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + in_general = stripped == "[General]" + continue + key, sep, _ = stripped.partition("=") + if in_general and sep and key.strip().lower() in merged: + out[idx] = f"{key.strip()}={merged[key.strip().lower()]}" + + # Append merged keys that had no line, into [General] (before the first + # section that follows it, or at the end of the file). + if new_keys: + block = [f"{key}={merged[key]}" for key in new_keys] + insert_at = len(out) + seen_general = False + for idx, line in enumerate(out): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + if stripped == "[General]": + seen_general = True + elif seen_general or idx > 0: + insert_at = idx + break + out[insert_at:insert_at] = block + + path.write_text("\n".join(out) + "\n", "utf-8") + + +def _native_mod_names(mo2_mods_dir: Path) -> list[str]: + """Return MO2 folder names of native-mod copies present in the mods directory.""" + if not mo2_mods_dir.is_dir(): + return [] + return [ + entry.name + for entry in sorted(mo2_mods_dir.iterdir()) + if entry.is_dir() and mod_name_to_engine_path(entry.name) is not None + ] + + +def _sync_modlist( + modlist_path: Path, current_names: list[str], mo2_mods_dir: Path +) -> None: + """Rewrite ``modlist.txt`` so enabled state and order match ``CurrentMod``. + + Mods listed in ``CurrentMod`` are enabled, in engine order; every other native + copy in the MO2 mods directory is appended as disabled so it shows up for + toggling. Non-native mods are left for MO2 to manage. The file is only written + when the content differs, so a no-op never clobbers MO2's own copy. + """ + entries: list[tuple[str, bool]] = [(name, True) for name in current_names] + known = set(current_names) + for name in _native_mod_names(mo2_mods_dir): + if name not in known: + entries.append((name, False)) + known.add(name) + if read_modlist(modlist_path) == entries: + return + write_modlist(modlist_path, entries) + + +class MigrateTool(mobase.IPluginTool, mobase.IPlugin): + """Copies native SRHD mods from the game folder into the MO2 mods directory.""" + + _organizer: mobase.IOrganizer + + def __init__(self): + mobase.IPluginTool.__init__(self) + mobase.IPlugin.__init__(self) + + def init(self, organizer: mobase.IOrganizer) -> bool: + self._organizer = organizer + return True + + def name(self) -> str: + return _NAME + + def displayName(self) -> str: + return _NAME + + def author(self) -> str: + return "ringill" + + def description(self) -> str: + return ( + "Copy native Space Rangers HD mods from the game folder into the MO2 " + "mods directory so they appear in the left pane, then sync the profile's " + "modlist.txt to the enabled/disabled state in ModCFG.txt." + ) + + def version(self) -> mobase.VersionInfo: + return _VERSION + + def settings(self) -> list[mobase.PluginSetting]: + return [] + + def tooltip(self) -> str: + return self.description() + + def icon(self) -> QIcon: + return QIcon() + + def display(self) -> None: + game = self._organizer.managedGame() + if not game: + self._notify("No game is being managed.", QMessageBox.Icon.Warning) + return + game_mods_root = Path(game.gameDirectory().absolutePath()) / "Mods" + mo2_mods_dir = Path(self._organizer.basePath()) / "mods" + + jobs = _native_mod_jobs(game_mods_root, mo2_mods_dir) + if not jobs: + self._notify(f"No native mods found in {game_mods_root}.") + return + + progress = QProgressDialog( + "Copying native SRHD mods…", "", 0, len(jobs), self._parentWidget() + ) + progress.setWindowTitle(_NAME) + progress.setWindowModality(Qt.WindowModality.ApplicationModal) + progress.setMinimumDuration(0) + progress.setCancelButton(None) + progress.show() + + def _step(index: int, total: int, label: str) -> None: + progress.setLabelText(f"Copying {label} ({index} of {total})…") + progress.setValue(index) + QApplication.processEvents() + + created, updated = copy_native_mods( + game_mods_root, mo2_mods_dir, on_progress=_step + ) + progress.close() + + profile = self._organizer.profile() + modlist_path = Path(profile.absolutePath()) / "modlist.txt" + current_names = [ + engine_path_to_mod_name(path) + for path in read_current_mod(game_mods_root / "ModCFG.txt") + ] + _sync_modlist(modlist_path, current_names, mo2_mods_dir) + + text = ( + f"Done: {created} copied, {updated} updated (overwritten). " + "Profile modlist.txt synced to enabled state from ModCFG.txt." + ) + self._notify(text) + + def _notify( + self, text: str, icon: QMessageBox.Icon = QMessageBox.Icon.Information + ) -> None: + box = QMessageBox(self._parentWidget()) + box.setWindowTitle(_NAME) + box.setIcon(icon) + box.setText(text) + box.exec() From 8cb31914be4dfde79450af6bda0926a3a2a1f5cb Mon Sep 17 00:00:00 2001 From: ringill Date: Sun, 16 Aug 2026 15:01:44 +0300 Subject: [PATCH 2/2] [Space Rangers HD]: bug fix: Sync CurrentMod from modlist.txt even when no mod is enabled --- games/game_spacerangershd.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/games/game_spacerangershd.py b/games/game_spacerangershd.py index c2c49df..ac1373b 100644 --- a/games/game_spacerangershd.py +++ b/games/game_spacerangershd.py @@ -293,22 +293,26 @@ def _build_modlist_entries(self, current_mods: list[str]) -> list[tuple[str, boo def aboutToRun(self, app: str) -> bool: # The active MO2 profile's modlist.txt is the source of truth for order and # enabled state; write it back into ModCFG.txt, then equalize Priority in the - # MO2 copies so the engine's load order matches what MO2 shows. The ``if - # order:`` guard keeps the game folder untouched when no mod is enabled. + # MO2 copies so the engine's load order matches what MO2 shows. CurrentMod is + # rewritten even to an empty list, so disabling the last enabled mod removes + # it from ModCFG.txt too. Only when the profile has no modlist.txt at all is + # there no MO2 state to sync — then the game folder is left untouched (a + # standalone run without MO2 keeps the native mods it already knows about). profile = self._organizer.profile() modlist_path = Path(profile.absolutePath()) / "modlist.txt" + if not modlist_path.exists(): + return True order = enabled_names(read_modlist(modlist_path)) - if order: - # modlist.txt holds MO2 folder names; map them back to engine - # ``Category\\Mod`` paths before writing CurrentMod. Names that don't - # follow the convention are skipped (never written into CurrentMod). - engine_order = [ - path - for name in order - if (path := mod_name_to_engine_path(name)) is not None - ] - write_current_mod(self._modcfg_path(), engine_order) - self._set_enabled_priority(order) + # modlist.txt holds MO2 folder names; map them back to engine + # ``Category\\Mod`` paths before writing CurrentMod. Names that don't + # follow the convention are skipped (never written into CurrentMod). + engine_order = [ + path + for name in order + if (path := mod_name_to_engine_path(name)) is not None + ] + write_current_mod(self._modcfg_path(), engine_order) + self._set_enabled_priority(order) return True def _set_enabled_priority(self, enabled: list[str]) -> None: