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..ac1373b --- /dev/null +++ b/games/game_spacerangershd.py @@ -0,0 +1,507 @@ +# 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. 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)) + # 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()