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) | - mod load order sync
- Priority equalization
- native mod import
- mod data checker
- Conflict/Dependence display
- mod bug report
- crash notification
- profile specific ini file
- save game support
|
| 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) | - Empire at War: [game_starwars-empire-at-war.py](games/game_starwars-empire-at-war.py)
- Force of Corruption: [game_starwars-empire-at-war-foc.py](games/game_starwars-empire-at-war-foc.py)
| |
| 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) | - mod data checker
- save game preview
|
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()