Files
modorganizer-basic_games/games/game_subnautica.py
T
Mikaël Capelle f502fb468f Add pre-commit hook (#179)
* Update dev dependencies.
* Add pre-commit hook.
2025-04-25 08:23:10 +02:00

278 lines
9.5 KiB
Python

from __future__ import annotations
import fnmatch
import os
from collections.abc import Iterable
from enum import Enum
from pathlib import Path
from PyQt6.QtCore import QDir, qWarning
import mobase
from ..basic_features import BasicModDataChecker, GlobPatterns
from ..basic_features.basic_save_game_info import (
BasicGameSaveGame,
BasicGameSaveGameInfo,
)
from ..basic_features.utils import is_directory
from ..basic_game import BasicGame
class SubnauticaModDataChecker(BasicModDataChecker):
use_qmods: bool = False
def __init__(self, patterns: GlobPatterns | None = None, use_qmods: bool = False):
super().__init__(
GlobPatterns(
unfold=["BepInExPack_Subnautica"],
valid=[
"BepInEx",
"doorstop_libs",
"doorstop_config.ini",
"run_bepinex.sh",
"winhttp.dll",
"QMods",
".doorstop_version", # Added in Tobey's BepInEx Pack for Subnautica v5.4.23
"changelog.txt",
"libdoorstop.dylib",
],
delete=[
"*.txt",
"*.md",
"icon.png",
"license",
"manifest.json",
],
move={
"plugins": "BepInEx/",
"patchers": "BepInEx/",
"CustomCraft2SML": "QMods/" if use_qmods else "BepInEx/plugins/",
"CustomCraft3": "QMods/" if use_qmods else "BepInEx/plugins/",
},
).merge(patterns or GlobPatterns()),
)
self.use_qmods = use_qmods
def dataLooksValid(
self, filetree: mobase.IFileTree
) -> mobase.ModDataChecker.CheckReturn:
# fix: single root folders get traversed by Simple Installer
parent = filetree.parent()
if parent is not None and self.dataLooksValid(parent) is self.FIXABLE:
return self.FIXABLE
check_return = super().dataLooksValid(filetree)
# A single unknown folder with a dll file in is to be moved to BepInEx/plugins/
if (
check_return is self.INVALID
and len(filetree) == 1
and is_directory(folder := filetree[0])
and any(fnmatch.fnmatch(entry.name(), "*.dll") for entry in folder)
):
return self.FIXABLE
return check_return
def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree:
filetree = super().fix(filetree)
if (
self.dataLooksValid(filetree) is self.FIXABLE
and len(filetree) == 1
and is_directory(folder := filetree[0])
and any(fnmatch.fnmatch(entry.name(), "*.dll") for entry in folder)
):
filetree.move(folder, "QMods/" if self.use_qmods else "BepInEx/plugins/")
return filetree
class SubnauticaGame(BasicGame, mobase.IPluginFileMapper):
Name = "Subnautica Support Plugin"
Author = "dekart811, Zash"
Version = "2.2"
GameName = "Subnautica"
GameShortName = "subnautica"
GameNexusName = "subnautica"
GameSteamId = 264710
GameEpicId = "Jaguar"
GameBinary = "Subnautica.exe"
GameDataPath = "_ROOT" # Custom mappings to actual root folders below.
GameDocumentsDirectory = r"%GAME_PATH%"
GameSupportURL = (
r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/"
"Game:-Subnautica"
)
GameSavesDirectory = r"%GAME_PATH%\SNAppData\SavedGames"
_game_extra_save_paths = [
r"%USERPROFILE%\Appdata\LocalLow\Unknown Worlds"
r"\Subnautica\Subnautica\SavedGames"
]
_forced_libraries = ["winhttp.dll"]
_root_blacklist = {GameDataPath.casefold()}
class MapType(Enum):
FILE = 0
FOLDER = 1
_root_extra_overwrites: dict[str, MapType] = {
"qmodmanager_log-Subnautica.txt": MapType.FILE,
"qmodmanager-config.json": MapType.FILE,
"BepInEx_Shim_Backup": MapType.FOLDER,
}
"""Extra files & folders created in game root by mods / BepInEx after game launch,
but not included in the mod archives.
"""
def __init__(self):
super().__init__()
mobase.IPluginFileMapper.__init__(self)
def init(self, organizer: mobase.IOrganizer) -> bool:
super().init(organizer)
self._set_mod_data_checker()
self._register_feature(
BasicGameSaveGameInfo(lambda s: Path(s or "", "screenshot.jpg"))
)
organizer.onPluginSettingChanged(self._settings_change_callback)
return True
def _set_mod_data_checker(
self, extra_patterns: GlobPatterns | None = None, use_qmod: bool | None = None
):
self._register_feature(
SubnauticaModDataChecker(
patterns=(GlobPatterns() if extra_patterns is None else extra_patterns),
use_qmods=(
bool(self._organizer.pluginSetting(self.name(), "use_qmods"))
if use_qmod is None
else use_qmod
),
)
)
def _settings_change_callback(
self,
plugin_name: str,
setting: str,
old: mobase.MoVariant,
new: mobase.MoVariant,
):
if plugin_name == self.name() and setting == "use_qmods":
self._set_mod_data_checker(use_qmod=bool(new))
def settings(self) -> list[mobase.PluginSetting]:
return [
mobase.PluginSetting(
"use_qmods",
(
"Install */.dll mods in legacy QMods folder,"
" instead of BepInEx/plugins (default)."
),
default_value=False,
)
]
def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]:
return [
BasicGameSaveGame(folder)
for save_path in (
folder.absolutePath(),
*(os.path.expandvars(p) for p in self._game_extra_save_paths),
)
for folder in Path(save_path).glob("slot*")
]
def executables(self) -> list[mobase.ExecutableInfo]:
binary = self.gameDirectory().absoluteFilePath(self.binaryName())
return [
mobase.ExecutableInfo(
self.gameName(),
binary,
).withArgument("-vrmode none"),
mobase.ExecutableInfo(
f"{self.gameName()} VR",
self.gameDirectory().absoluteFilePath(self.binaryName()),
),
]
def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]:
return [
mobase.ExecutableForcedLoadSetting(self.binaryName(), lib).withEnabled(True)
for lib in self._forced_libraries
]
def mappings(self) -> list[mobase.Mapping]:
game = self._organizer.managedGame()
game_path = Path(game.gameDirectory().absolutePath())
overwrite_path = Path(self._organizer.overwritePath())
return [
*(
# Extra overwrites
self._overwrite_mapping(
overwrite_path / name,
dest,
is_dir=(map_type is self.MapType.FOLDER),
)
for name, map_type in self._root_extra_overwrites.items()
if not (dest := game_path / name).exists()
),
*self._root_mappings(game_path, overwrite_path),
]
def _root_mappings(
self, game_path: Path, overwrite_path: Path
) -> Iterable[mobase.Mapping]:
for mod_path in self._active_mod_paths():
mod_name = mod_path.name
for child in mod_path.iterdir():
# Check blacklist
if child.name.casefold() in self._root_blacklist:
qWarning(f"Skipping {child.name} ({mod_name})")
continue
destination = game_path / child.name
# Check existing
if destination.exists():
qWarning(
f"Overwriting of existing game files/folders is not supported! "
f"{destination.as_posix()} ({mod_name})"
)
continue
# Mapping: mod -> root
yield mobase.Mapping(
source=str(child),
destination=str(destination),
is_directory=child.is_dir(),
create_target=False,
)
if child.is_dir():
# Mapping: overwrite <-> root
yield self._overwrite_mapping(
overwrite_path / child.name, destination, is_dir=True
)
def _active_mod_paths(self) -> Iterable[Path]:
mods_parent_path = Path(self._organizer.modsPath())
modlist = self._organizer.modList().allModsByProfilePriority()
for mod in modlist:
if self._organizer.modList().state(mod) & mobase.ModState.ACTIVE:
yield mods_parent_path / mod
def _overwrite_mapping(
self, overwrite_source: Path, destination: Path, is_dir: bool
) -> mobase.Mapping:
"""Mapping: overwrite <-> root"""
if is_dir:
# Root folders in overwrite need to exits.
overwrite_source.mkdir(parents=True, exist_ok=True)
return mobase.Mapping(
str(overwrite_source),
str(destination),
is_dir,
create_target=True,
)