From 7ee008e150bc5bcf76082d726f719ee0fdfda982 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Wed, 11 Feb 2026 02:37:39 -0600 Subject: Fluorine Manager: full Linux port of Mod Organizer 2 Complete native Linux port with FUSE-based virtual filesystem, Proton/umu-run integration, and Flatpak packaging. Key features: - FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak) - Proton/GE-Proton/umu-run launcher with env var forwarding - Flatpak support (sandbox-aware VFS, NXM handler, umu-run) - Wine prefix management UI - Case-insensitive path resolution for Linux filesystems - QSettings-safe INI handling (avoids Bethesda INI corruption) - Portable instance support with auto-generated launcher scripts Co-Authored-By: Claude Opus 4.6 --- libs/basic_games/games/__init__.py | 0 libs/basic_games/games/baldursgate3/__init__.py | 0 .../games/baldursgate3/bg3_data_checker.py | 58 ++ .../games/baldursgate3/bg3_data_content.py | 54 ++ .../games/baldursgate3/bg3_file_mapper.py | 131 ++++ libs/basic_games/games/baldursgate3/bg3_utils.py | 274 +++++++ .../games/baldursgate3/lslib_retriever.py | 162 +++++ libs/basic_games/games/baldursgate3/pak_parser.py | 295 ++++++++ .../games/baldursgate3/plugins/__init__.py | 13 + .../games/baldursgate3/plugins/bg3_tool_plugin.py | 49 ++ .../plugins/check_for_lslib_updates_plugin.py | 15 + .../plugins/convert_jsons_to_yaml_plugin.py | 58 ++ .../games/baldursgate3/plugins/icons.py | 42 ++ .../plugins/reparse_pak_metadata_plugin.py | 17 + libs/basic_games/games/game_arkhamcity.py | 87 +++ libs/basic_games/games/game_assettocorsa.py | 18 + libs/basic_games/games/game_baldursgate3.py | 239 ++++++ libs/basic_games/games/game_blackandwhite2.py | 341 +++++++++ libs/basic_games/games/game_bladeandsorcery.py | 98 +++ libs/basic_games/games/game_borderlands1.py | 153 ++++ libs/basic_games/games/game_control.py | 63 ++ libs/basic_games/games/game_cyberpunk2077.py | 798 +++++++++++++++++++++ libs/basic_games/games/game_da2.py | 35 + libs/basic_games/games/game_daggerfallunity.py | 56 ++ libs/basic_games/games/game_dao.py | 31 + libs/basic_games/games/game_darkestdungeon.py | 245 +++++++ .../games/game_darkmessiahofmightandmagic.py | 48 ++ libs/basic_games/games/game_darksouls.py | 22 + libs/basic_games/games/game_darksouls2sotfs.py | 22 + libs/basic_games/games/game_dispatch.py | 102 +++ libs/basic_games/games/game_divinityoriginalsin.py | 39 + .../games/game_divinityoriginalsinee.py | 119 +++ .../games/game_dragonsdogmadarkarisen.py | 19 + libs/basic_games/games/game_dungeonsiege1.py | 92 +++ libs/basic_games/games/game_dungeonsiege2.py | 92 +++ libs/basic_games/games/game_f123.py | 13 + libs/basic_games/games/game_fantasylifei.py | 102 +++ .../basic_games/games/game_finalfantasy7rebirth.py | 157 ++++ libs/basic_games/games/game_finalfantasy7remake.py | 78 ++ libs/basic_games/games/game_gta-3-de.py | 72 ++ libs/basic_games/games/game_gta-san-andreas-de.py | 72 ++ libs/basic_games/games/game_gta-vice-city-de.py | 72 ++ libs/basic_games/games/game_kerbalspaceprogram.py | 61 ++ .../games/game_kingdomcomedeliverance.py | 48 ++ libs/basic_games/games/game_masterduel.py | 89 +++ libs/basic_games/games/game_metalgearsolid2mc.py | 17 + libs/basic_games/games/game_metalgearsolid3mc.py | 15 + libs/basic_games/games/game_mirrorsedge.py | 19 + libs/basic_games/games/game_monsterhunterrise.py | 15 + libs/basic_games/games/game_monsterhunterworld.py | 21 + libs/basic_games/games/game_mountandblade2.py | 100 +++ libs/basic_games/games/game_msfs2020.py | 34 + libs/basic_games/games/game_nfshs.py | 28 + libs/basic_games/games/game_nierautomata.py | 14 + libs/basic_games/games/game_nomanssky.py | 38 + libs/basic_games/games/game_oblivion_remaster.py | 441 ++++++++++++ libs/basic_games/games/game_readyornot.py | 24 + libs/basic_games/games/game_schedule1.py | 112 +++ .../games/game_sekiroshadowsdietwice.py | 14 + libs/basic_games/games/game_silenthill2remake.py | 113 +++ libs/basic_games/games/game_silksong.py | 121 ++++ libs/basic_games/games/game_sims4.py | 153 ++++ libs/basic_games/games/game_stalkeranomaly.py | 293 ++++++++ libs/basic_games/games/game_stardewvalley.py | 57 ++ libs/basic_games/games/game_starsector.py | 30 + .../games/game_starwars-empire-at-war-foc.py | 33 + .../games/game_starwars-empire-at-war.py | 33 + .../games/game_subnautica-below-zero.py | 32 + libs/basic_games/games/game_subnautica.py | 279 +++++++ libs/basic_games/games/game_tdu.py | 36 + libs/basic_games/games/game_tdu2.py | 35 + .../games/game_thebindingofisaacrebirth.py | 23 + libs/basic_games/games/game_thps3.py | 38 + libs/basic_games/games/game_thps4.py | 40 ++ libs/basic_games/games/game_thug.py | 36 + libs/basic_games/games/game_thug2.py | 54 ++ libs/basic_games/games/game_tmuf.py | 35 + libs/basic_games/games/game_trainsimulator.py | 36 + libs/basic_games/games/game_valheim.py | 427 +++++++++++ libs/basic_games/games/game_valkyriachronicles.py | 15 + libs/basic_games/games/game_vampirebloodlines.py | 103 +++ libs/basic_games/games/game_witcher1.py | 93 +++ libs/basic_games/games/game_witcher2.py | 67 ++ libs/basic_games/games/game_witcher3.py | 52 ++ libs/basic_games/games/game_x4.py | 17 + libs/basic_games/games/game_xplane11.py | 16 + libs/basic_games/games/game_zeusandposeidon.py | 70 ++ .../games/oblivion_remaster/__init__.py | 0 .../games/oblivion_remaster/constants.py | 14 + .../games/oblivion_remaster/game_plugins.py | 207 ++++++ .../games/oblivion_remaster/mod_data_checker.py | 451 ++++++++++++ .../games/oblivion_remaster/mod_data_content.py | 109 +++ .../games/oblivion_remaster/paks/__init__.py | 0 .../games/oblivion_remaster/paks/model.py | 253 +++++++ .../games/oblivion_remaster/paks/view.py | 33 + .../games/oblivion_remaster/paks/widget.py | 213 ++++++ .../games/oblivion_remaster/script_extender.py | 37 + .../games/oblivion_remaster/ue4ss/__init__.py | 0 .../games/oblivion_remaster/ue4ss/model.py | 125 ++++ .../games/oblivion_remaster/ue4ss/view.py | 31 + .../games/oblivion_remaster/ue4ss/widget.py | 175 +++++ .../games/quarantine/game_masseffectlegendary.py | 24 + libs/basic_games/games/quarantine/readme.txt | 3 + libs/basic_games/games/stalkeranomaly/XRIO.py | 148 ++++ libs/basic_games/games/stalkeranomaly/XRMath.py | 37 + libs/basic_games/games/stalkeranomaly/XRNET.py | 45 ++ libs/basic_games/games/stalkeranomaly/XRObject.py | 277 +++++++ libs/basic_games/games/stalkeranomaly/XRSave.py | 164 +++++ libs/basic_games/games/stalkeranomaly/__init__.py | 38 + 109 files changed, 10264 insertions(+) create mode 100644 libs/basic_games/games/__init__.py create mode 100644 libs/basic_games/games/baldursgate3/__init__.py create mode 100644 libs/basic_games/games/baldursgate3/bg3_data_checker.py create mode 100644 libs/basic_games/games/baldursgate3/bg3_data_content.py create mode 100644 libs/basic_games/games/baldursgate3/bg3_file_mapper.py create mode 100644 libs/basic_games/games/baldursgate3/bg3_utils.py create mode 100644 libs/basic_games/games/baldursgate3/lslib_retriever.py create mode 100644 libs/basic_games/games/baldursgate3/pak_parser.py create mode 100644 libs/basic_games/games/baldursgate3/plugins/__init__.py create mode 100644 libs/basic_games/games/baldursgate3/plugins/bg3_tool_plugin.py create mode 100644 libs/basic_games/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py create mode 100644 libs/basic_games/games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py create mode 100644 libs/basic_games/games/baldursgate3/plugins/icons.py create mode 100644 libs/basic_games/games/baldursgate3/plugins/reparse_pak_metadata_plugin.py create mode 100644 libs/basic_games/games/game_arkhamcity.py create mode 100644 libs/basic_games/games/game_assettocorsa.py create mode 100644 libs/basic_games/games/game_baldursgate3.py create mode 100644 libs/basic_games/games/game_blackandwhite2.py create mode 100644 libs/basic_games/games/game_bladeandsorcery.py create mode 100644 libs/basic_games/games/game_borderlands1.py create mode 100644 libs/basic_games/games/game_control.py create mode 100644 libs/basic_games/games/game_cyberpunk2077.py create mode 100644 libs/basic_games/games/game_da2.py create mode 100644 libs/basic_games/games/game_daggerfallunity.py create mode 100644 libs/basic_games/games/game_dao.py create mode 100644 libs/basic_games/games/game_darkestdungeon.py create mode 100644 libs/basic_games/games/game_darkmessiahofmightandmagic.py create mode 100644 libs/basic_games/games/game_darksouls.py create mode 100644 libs/basic_games/games/game_darksouls2sotfs.py create mode 100644 libs/basic_games/games/game_dispatch.py create mode 100644 libs/basic_games/games/game_divinityoriginalsin.py create mode 100644 libs/basic_games/games/game_divinityoriginalsinee.py create mode 100644 libs/basic_games/games/game_dragonsdogmadarkarisen.py create mode 100644 libs/basic_games/games/game_dungeonsiege1.py create mode 100644 libs/basic_games/games/game_dungeonsiege2.py create mode 100644 libs/basic_games/games/game_f123.py create mode 100644 libs/basic_games/games/game_fantasylifei.py create mode 100644 libs/basic_games/games/game_finalfantasy7rebirth.py create mode 100644 libs/basic_games/games/game_finalfantasy7remake.py create mode 100644 libs/basic_games/games/game_gta-3-de.py create mode 100644 libs/basic_games/games/game_gta-san-andreas-de.py create mode 100644 libs/basic_games/games/game_gta-vice-city-de.py create mode 100644 libs/basic_games/games/game_kerbalspaceprogram.py create mode 100644 libs/basic_games/games/game_kingdomcomedeliverance.py create mode 100644 libs/basic_games/games/game_masterduel.py create mode 100644 libs/basic_games/games/game_metalgearsolid2mc.py create mode 100644 libs/basic_games/games/game_metalgearsolid3mc.py create mode 100644 libs/basic_games/games/game_mirrorsedge.py create mode 100644 libs/basic_games/games/game_monsterhunterrise.py create mode 100644 libs/basic_games/games/game_monsterhunterworld.py create mode 100644 libs/basic_games/games/game_mountandblade2.py create mode 100644 libs/basic_games/games/game_msfs2020.py create mode 100644 libs/basic_games/games/game_nfshs.py create mode 100644 libs/basic_games/games/game_nierautomata.py create mode 100644 libs/basic_games/games/game_nomanssky.py create mode 100644 libs/basic_games/games/game_oblivion_remaster.py create mode 100644 libs/basic_games/games/game_readyornot.py create mode 100644 libs/basic_games/games/game_schedule1.py create mode 100644 libs/basic_games/games/game_sekiroshadowsdietwice.py create mode 100644 libs/basic_games/games/game_silenthill2remake.py create mode 100644 libs/basic_games/games/game_silksong.py create mode 100644 libs/basic_games/games/game_sims4.py create mode 100644 libs/basic_games/games/game_stalkeranomaly.py create mode 100644 libs/basic_games/games/game_stardewvalley.py create mode 100644 libs/basic_games/games/game_starsector.py create mode 100644 libs/basic_games/games/game_starwars-empire-at-war-foc.py create mode 100644 libs/basic_games/games/game_starwars-empire-at-war.py create mode 100644 libs/basic_games/games/game_subnautica-below-zero.py create mode 100644 libs/basic_games/games/game_subnautica.py create mode 100644 libs/basic_games/games/game_tdu.py create mode 100644 libs/basic_games/games/game_tdu2.py create mode 100644 libs/basic_games/games/game_thebindingofisaacrebirth.py create mode 100644 libs/basic_games/games/game_thps3.py create mode 100644 libs/basic_games/games/game_thps4.py create mode 100644 libs/basic_games/games/game_thug.py create mode 100644 libs/basic_games/games/game_thug2.py create mode 100644 libs/basic_games/games/game_tmuf.py create mode 100644 libs/basic_games/games/game_trainsimulator.py create mode 100644 libs/basic_games/games/game_valheim.py create mode 100644 libs/basic_games/games/game_valkyriachronicles.py create mode 100644 libs/basic_games/games/game_vampirebloodlines.py create mode 100644 libs/basic_games/games/game_witcher1.py create mode 100644 libs/basic_games/games/game_witcher2.py create mode 100644 libs/basic_games/games/game_witcher3.py create mode 100644 libs/basic_games/games/game_x4.py create mode 100644 libs/basic_games/games/game_xplane11.py create mode 100644 libs/basic_games/games/game_zeusandposeidon.py create mode 100644 libs/basic_games/games/oblivion_remaster/__init__.py create mode 100644 libs/basic_games/games/oblivion_remaster/constants.py create mode 100644 libs/basic_games/games/oblivion_remaster/game_plugins.py create mode 100644 libs/basic_games/games/oblivion_remaster/mod_data_checker.py create mode 100644 libs/basic_games/games/oblivion_remaster/mod_data_content.py create mode 100644 libs/basic_games/games/oblivion_remaster/paks/__init__.py create mode 100644 libs/basic_games/games/oblivion_remaster/paks/model.py create mode 100644 libs/basic_games/games/oblivion_remaster/paks/view.py create mode 100644 libs/basic_games/games/oblivion_remaster/paks/widget.py create mode 100644 libs/basic_games/games/oblivion_remaster/script_extender.py create mode 100644 libs/basic_games/games/oblivion_remaster/ue4ss/__init__.py create mode 100644 libs/basic_games/games/oblivion_remaster/ue4ss/model.py create mode 100644 libs/basic_games/games/oblivion_remaster/ue4ss/view.py create mode 100644 libs/basic_games/games/oblivion_remaster/ue4ss/widget.py create mode 100644 libs/basic_games/games/quarantine/game_masseffectlegendary.py create mode 100644 libs/basic_games/games/quarantine/readme.txt create mode 100644 libs/basic_games/games/stalkeranomaly/XRIO.py create mode 100644 libs/basic_games/games/stalkeranomaly/XRMath.py create mode 100644 libs/basic_games/games/stalkeranomaly/XRNET.py create mode 100644 libs/basic_games/games/stalkeranomaly/XRObject.py create mode 100644 libs/basic_games/games/stalkeranomaly/XRSave.py create mode 100644 libs/basic_games/games/stalkeranomaly/__init__.py (limited to 'libs/basic_games/games') diff --git a/libs/basic_games/games/__init__.py b/libs/basic_games/games/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/basic_games/games/baldursgate3/__init__.py b/libs/basic_games/games/baldursgate3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/basic_games/games/baldursgate3/bg3_data_checker.py b/libs/basic_games/games/baldursgate3/bg3_data_checker.py new file mode 100644 index 0000000..695d21c --- /dev/null +++ b/libs/basic_games/games/baldursgate3/bg3_data_checker.py @@ -0,0 +1,58 @@ +from pathlib import Path + +import mobase + +from ...basic_features import BasicModDataChecker, GlobPatterns, utils +from . import bg3_utils + + +class BG3ModDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + valid=[ + "*.pak", + str(Path("Mods") / "*.pak"), # standard mods + "bin", # native mods / Script Extender + "Script Extender", # mods which are configured via jsons in this folder + "Data", # loose file mods + ] + + [str(Path("*") / f) for f in bg3_utils.loose_file_folders], + move={ + "Root/": "", # root builder not needed + "*.dll": "bin/", + "ScriptExtenderSettings.json": "bin/", + } + | {f: "Data/" for f in bg3_utils.loose_file_folders}, + delete=["info.json", "*.txt"], + ) + ) + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + status = mobase.ModDataChecker.INVALID + rp = self._regex_patterns + for entry in filetree: + name = entry.name().casefold() + if rp.unfold.match(name): + if utils.is_directory(entry): + status = self.dataLooksValid(entry) + else: + status = mobase.ModDataChecker.INVALID + break + elif rp.valid.match(name): + if status is mobase.ModDataChecker.INVALID: + status = mobase.ModDataChecker.VALID + elif isinstance(entry, mobase.IFileTree): + status = ( + mobase.ModDataChecker.VALID + if all(rp.valid.match(e.pathFrom(filetree)) for e in entry) + else mobase.ModDataChecker.INVALID + ) + elif rp.delete.match(name) or rp.move_match(name) is not None: + status = mobase.ModDataChecker.FIXABLE + else: + status = mobase.ModDataChecker.INVALID + break + return status diff --git a/libs/basic_games/games/baldursgate3/bg3_data_content.py b/libs/basic_games/games/baldursgate3/bg3_data_content.py new file mode 100644 index 0000000..c378520 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/bg3_data_content.py @@ -0,0 +1,54 @@ +from enum import IntEnum, auto + +import mobase + +from . import bg3_utils + + +class Content(IntEnum): + PAK = auto() + WORKSPACE = auto() + NATIVE = auto() + LOOSE_FILES = auto() + SE_FILES = auto() + + +class BG3DataContent(mobase.ModDataContent): + BG3_CONTENTS: list[tuple[Content, str, str, bool] | tuple[Content, str, str]] = [ + (Content.WORKSPACE, "Mod workspace", ":/MO/gui/content/script"), + (Content.PAK, "Pak", ":/MO/gui/content/bsa"), + (Content.LOOSE_FILES, "Loose file override mod", ":/MO/gui/content/texture"), + (Content.SE_FILES, "Script Extender Files", ":/MO/gui/content/inifile"), + (Content.NATIVE, "Native DLL mod", ":/MO/gui/content/plugin"), + ] + + def getAllContents(self) -> list[mobase.ModDataContent.Content]: + return [ + mobase.ModDataContent.Content(id, name, icon, *filter_only) + for id, name, icon, *filter_only in self.BG3_CONTENTS + ] + + def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]: + contents: set[int] = set() + for entry in filetree: + if isinstance(entry, mobase.IFileTree): + match entry.name(): + case "Script Extender": + contents.add(Content.SE_FILES) + case "Data": + contents.add(Content.LOOSE_FILES) + case "Mods": + for e in entry: + if e.name().endswith(".pak"): + contents.add(Content.PAK) + break + case "bin": + contents.add(Content.NATIVE) + case _: + for e in entry: + if e.name() in bg3_utils.loose_file_folders: + contents.add(Content.WORKSPACE) + break + elif entry.name().endswith(".pak"): + contents.add(Content.PAK) + return list(contents) diff --git a/libs/basic_games/games/baldursgate3/bg3_file_mapper.py b/libs/basic_games/games/baldursgate3/bg3_file_mapper.py new file mode 100644 index 0000000..0ae6ef8 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/bg3_file_mapper.py @@ -0,0 +1,131 @@ +import functools +import os +from pathlib import Path +from typing import Callable, Optional + +from PyQt6.QtCore import QDir, QLoggingCategory, qDebug, qInfo, qWarning +from PyQt6.QtWidgets import QApplication + +import mobase + +from . import bg3_utils + + +class BG3FileMapper(mobase.IPluginFileMapper): + current_mappings: list[mobase.Mapping] = [] + + def __init__(self, utils: bg3_utils.BG3Utils, doc_dir: Callable[[], QDir]): + super().__init__() + self._utils = utils + self.doc_dir = doc_dir + + @functools.cached_property + def doc_path(self): + return Path(self.doc_dir().path()) + + def mappings(self) -> list[mobase.Mapping]: + qInfo("creating custom bg3 mappings") + self.current_mappings.clear() + active_mods = self._utils.active_mods() + if not active_mods: + return [] + progress = self._utils.create_progress_window( + "Mapping files to documents folder", len(active_mods) + 1 + ) + docs_path_mods = self.doc_path / "Mods" + docs_path_se = self.doc_path / "Script Extender" + for mod in active_mods: + modpath = Path(mod.absolutePath()) + self.map_files(modpath, dest=docs_path_mods, pattern="*.pak", rel=False) + self.map_files(modpath / "Script Extender", dest=docs_path_se) + if self._utils.convert_yamls_to_json: + self.map_files(modpath / "bin", only_convert=True) + progress.setValue(progress.value() + 1) + QApplication.processEvents() + if progress.wasCanceled(): + qWarning("mapping canceled by user") + return self.current_mappings + (self._utils.overwrite_path / "Script Extender").mkdir( + parents=True, exist_ok=True + ) + (self._utils.overwrite_path / "Stats").mkdir(parents=True, exist_ok=True) + (self._utils.overwrite_path / "Temp").mkdir(parents=True, exist_ok=True) + (self._utils.overwrite_path / "LevelCache").mkdir(parents=True, exist_ok=True) + (self._utils.overwrite_path / "Stats").mkdir(parents=True, exist_ok=True) + (self._utils.overwrite_path / "Mods").mkdir(parents=True, exist_ok=True) + (self._utils.overwrite_path / "GMCampaigns").mkdir(parents=True, exist_ok=True) + self.map_files(self._utils.overwrite_path) + self.create_mapping( + self._utils.modsettings_path, + self.doc_path + / "PlayerProfiles" + / "Public" + / self._utils.modsettings_path.name, + ) + progress.setValue(len(active_mods) + 1) + QApplication.processEvents() + progress.close() + cat = QLoggingCategory.defaultCategory() + if cat is not None and cat.isDebugEnabled(): + qDebug( + f"resolved mappings: { {m.source: m.destination for m in self.current_mappings} }" + ) + return self.current_mappings + + def map_files( + self, + path: Path, + dest: Optional[Path] = None, + pattern: str = "*", + rel: bool = True, + only_convert: bool = False, + ): + dest = dest if dest else self.doc_path + dest_func: Callable[[Path], str] = ( + (lambda f: os.path.relpath(f, path)) if rel else lambda f: f.name + ) + found_jsons: set[Path] = set() + for file in list(path.rglob(pattern)): + if self._utils.convert_yamls_to_json and ( + file.name.endswith(".yaml") or file.name.endswith(".yml") + ): + converted_path = file.parent / file.name.replace( + ".yaml", ".json" + ).replace(".yml", ".json") + try: + if not converted_path.exists() or os.path.getmtime( + file + ) > os.path.getmtime(converted_path): + import json + + import yaml + + with open(file, "r") as yaml_file: + with open(converted_path, "w") as json_file: + json.dump( + yaml.safe_load(yaml_file), json_file, indent=2 + ) + qDebug(f"Converted {file} to JSON") + found_jsons.add(converted_path) + except OSError as e: + qWarning(f"Error accessing file {converted_path}: {e}") + elif file.name.endswith(".json"): + found_jsons.add(file) + elif not only_convert: + self.create_mapping(file, dest / dest_func(file)) + if only_convert: + return + for file in found_jsons: + self.create_mapping(file, dest / dest_func(file)) + + def create_mapping(self, file: Path, dest: Path): + bg3_utils.create_dir_if_needed(dest) + + self.current_mappings.append( + mobase.Mapping( + source=str(file), + destination=str(dest), + is_directory=file.is_dir(), + create_target=True, + ) + ) diff --git a/libs/basic_games/games/baldursgate3/bg3_utils.py b/libs/basic_games/games/baldursgate3/bg3_utils.py new file mode 100644 index 0000000..958d173 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/bg3_utils.py @@ -0,0 +1,274 @@ +import functools +import shutil +import typing +from pathlib import Path +from time import sleep + +from PyQt6.QtCore import ( + QCoreApplication, + QDir, + QEventLoop, + QRunnable, + Qt, + QThread, + QThreadPool, + qInfo, + qWarning, +) +from PyQt6.QtWidgets import QApplication, QMainWindow, QProgressDialog + +import mobase + +loose_file_folders = { + "Public", + "Mods", + "Generated", + "Localization", + "ScriptExtender", +} + + +def get_node_string( + folder: str = "", + md5: str = "", + name: str = "", + publish_handle: str = "0", + uuid: str = "", + version64: str = "0", +) -> str: + return f""" + + + + + + + + """ + + +class BG3Utils: + _mod_settings_xml_start = """\ + + + + + + + + """ + get_node_string( + folder="GustavX", + name="GustavX", + uuid="cb555efe-2d9e-131f-8195-a89329d218ea", + version64="36028797018963968", + ) + _mod_settings_xml_end = """ + + + + + +""" + + def __init__(self, name: str): + self.main_window = None + self._name = name + from . import lslib_retriever, pak_parser + + self.lslib_retriever = lslib_retriever.LSLibRetriever(self) + self._pak_parser = pak_parser.BG3PakParser(self) + + def init(self, organizer: mobase.IOrganizer): + self._organizer = organizer + + @functools.cached_property + def autobuild_paks(self): + return bool(self.get_setting("autobuild_paks")) + + @functools.cached_property + def extract_full_package(self): + return bool(self.get_setting("extract_full_package")) + + @functools.cached_property + def remove_extracted_metadata(self): + return bool(self.get_setting("remove_extracted_metadata")) + + @functools.cached_property + def force_load_dlls(self): + return bool(self.get_setting("force_load_dlls")) + + @functools.cached_property + def log_diff(self): + return bool(self.get_setting("log_diff")) + + @functools.cached_property + def convert_yamls_to_json(self): + return bool(self.get_setting("convert_yamls_to_json")) + + @functools.cached_property + def log_dir(self): + return create_dir_if_needed(Path(self._organizer.basePath()) / "logs") + + @functools.cached_property + def modsettings_backup(self): + return create_dir_if_needed(self.plugin_data_path / "temp" / "modsettings.lsx") + + @functools.cached_property + def modsettings_path(self): + return create_dir_if_needed( + Path(self._organizer.profilePath()) / "modsettings.lsx" + ) + + @functools.cached_property + def plugin_data_path(self) -> Path: + """Gets the path to the data folder for the current plugin.""" + return create_dir_if_needed( + Path(self._organizer.pluginDataPath(), self._name).absolute() + ) + + @functools.cached_property + def tools_dir(self): + return create_dir_if_needed(self.plugin_data_path / "tools") + + @functools.cached_property + def overwrite_path(self): + return create_dir_if_needed(Path(self._organizer.overwritePath())) + + def active_mods(self) -> list[mobase.IModInterface]: + modlist = self._organizer.modList() + return [ + modlist.getMod(mod_name) + for mod_name in filter( + lambda mod: modlist.state(mod) & mobase.ModState.ACTIVE, + modlist.allModsByProfilePriority(), + ) + ] + + def _set_setting(self, key: str, value: mobase.MoVariant): + self._organizer.setPluginSetting(self._name, key, value) + + def get_setting(self, key: str) -> mobase.MoVariant: + return self._organizer.pluginSetting(self._name, key) + + def tr(self, trstr: str) -> str: + return QCoreApplication.translate(self._name, trstr) + + def create_progress_window( + self, title: str, max_progress: int, msg: str = "", cancelable: bool = True + ) -> QProgressDialog: + progress = QProgressDialog( + self.tr(msg if msg else title), + self.tr("Cancel") if cancelable else None, + 0, + max_progress, + self.main_window, + ) + progress.setWindowTitle(self.tr(f"BG3 Plugin: {title}")) + progress.setWindowModality(Qt.WindowModality.ApplicationModal) + progress.show() + return progress + + def on_user_interface_initialized(self, window: QMainWindow) -> None: + self.main_window = window + + def on_settings_changed( + self, + plugin_name: str, + setting: str, + old: mobase.MoVariant, + new: mobase.MoVariant, + ) -> None: + if self._name != plugin_name: + return + if setting in { + "extract_full_package", + "autobuild_paks", + "remove_extracted_metadata", + "force_load_dlls", + "log_diff", + "convert_yamls_to_json", + } and hasattr(self, setting): + delattr(self, setting) + + def construct_modsettings_xml( + self, + exec_path: str = "", + working_dir: typing.Optional[QDir] = None, + args: str = "", + force_reparse_metadata: bool = False, + ) -> bool: + if ( + "bin/bg3" not in exec_path + or not self.lslib_retriever.download_lslib_if_missing() + ): + return True + active_mods = self.active_mods() + progress = self.create_progress_window( + "Generating modsettings.xml", len(active_mods) + ) + threadpool = QThreadPool.globalInstance() + if threadpool is None: + return False + metadata: dict[str, str] = {} + + def retrieve_mod_metadata_in_new_thread(mod: mobase.IModInterface): + return lambda: metadata.update( + self._pak_parser.get_metadata_for_files_in_mod( + mod, force_reparse_metadata + ) + ) + + for mod in active_mods: + if progress.wasCanceled(): + qWarning("processing canceled by user") + return False + threadpool.start(QRunnable.create(retrieve_mod_metadata_in_new_thread(mod))) + count = 0 + num_active_mods = len(active_mods) + total_intervals_to_wait = (num_active_mods * 2) + 20 + while len(metadata.keys()) < num_active_mods: + progress.setValue(len(metadata.keys())) + QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 100) + count += 1 + if count == total_intervals_to_wait or progress.wasCanceled(): + remaining_mods = {mod.name() for mod in active_mods} - metadata.keys() + qWarning(f"processing did not finish in time for: {remaining_mods}") + progress.close() + break + QThread.msleep(100) + progress.setValue(num_active_mods) + QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 100) + progress.close() + qInfo(f"writing mod load order to {self.modsettings_path}") + self.modsettings_path.parent.mkdir(parents=True, exist_ok=True) + self.modsettings_path.write_text( + ( + self._mod_settings_xml_start + + "".join( + metadata[mod.name()] + for mod in active_mods + if mod.name() in metadata + ) + + self._mod_settings_xml_end + ) + ) + qInfo( + f"backing up generated file {self.modsettings_path} to {self.modsettings_backup}, " + f"check the backup after the executable runs for differences with the file used by the game if you encounter issues" + ) + self.modsettings_backup.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(self.modsettings_path, self.modsettings_backup) + sleep(0.5) + return True + + def on_mod_installed(self, mod: mobase.IModInterface) -> None: + if self.lslib_retriever.download_lslib_if_missing(): + self._pak_parser.get_metadata_for_files_in_mod(mod, True) + + +def create_dir_if_needed(path: Path) -> Path: + if "." not in path.name[1:]: + path.mkdir(parents=True, exist_ok=True) + else: + path.parent.mkdir(parents=True, exist_ok=True) + return path diff --git a/libs/basic_games/games/baldursgate3/lslib_retriever.py b/libs/basic_games/games/baldursgate3/lslib_retriever.py new file mode 100644 index 0000000..ee1122c --- /dev/null +++ b/libs/basic_games/games/baldursgate3/lslib_retriever.py @@ -0,0 +1,162 @@ +import json +import shutil +import traceback +import urllib.request +import zipfile +from functools import cached_property + +from PyQt6.QtCore import qDebug, qWarning +from PyQt6.QtWidgets import QApplication, QMessageBox + +from . import bg3_utils + + +class LSLibRetriever: + def __init__(self, utils: bg3_utils.BG3Utils): + self._utils = utils + + @cached_property + def _needed_lslib_files(self): + return { + self._utils.tools_dir / x + for x in { + "CommandLineArgumentsParser.dll", + "Divine.dll", + "Divine.dll.config", + "Divine.exe", + "Divine.runtimeconfig.json", + "K4os.Compression.LZ4.dll", + "K4os.Compression.LZ4.Streams.dll", + "LSLib.dll", + "LSLibNative.dll", + "LZ4.dll", + "Newtonsoft.Json.dll", + "System.IO.Hashing.dll", + "ZstdSharp.dll", + } + } + + def download_lslib_if_missing(self, force: bool = False) -> bool: + if not force and all(x.exists() for x in self._needed_lslib_files): + return True + try: + self._utils.tools_dir.mkdir(exist_ok=True, parents=True) + downloaded = False + + def reporthook(block_num: int, block_size: int, total_size: int) -> None: + if total_size > 0: + progress.setValue( + min(int(block_num * block_size * 100 / total_size), 100) + ) + QApplication.processEvents() + + with urllib.request.urlopen( + "https://api.github.com/repos/Norbyte/lslib/releases/latest" + ) as response: + assets = json.loads(response.read().decode("utf-8"))["assets"][0] + zip_path = self._utils.tools_dir / assets["name"] + if not zip_path.exists(): + old_archives = list(self._utils.tools_dir.glob("*.zip")) + msg_box = QMessageBox(self._utils.main_window) + msg_box.setWindowTitle( + self._utils.tr("Baldur's Gate 3 Plugin - Missing dependencies") + ) + if old_archives: + msg_box.setText(self._utils.tr("LSLib update available.")) + else: + msg_box.setText( + self._utils.tr( + "LSLib tools are missing.\nThese are necessary for the plugin to create the load order file for BG3." + ) + ) + msg_box.addButton( + self._utils.tr("Download"), + QMessageBox.ButtonRole.DestructiveRole, + ) + exit_btn = msg_box.addButton( + self._utils.tr("Exit"), QMessageBox.ButtonRole.ActionRole + ) + msg_box.setIcon(QMessageBox.Icon.Warning) + msg_box.exec() + + if msg_box.clickedButton() == exit_btn: + if not old_archives: + err = QMessageBox(self._utils.main_window) + err.setIcon(QMessageBox.Icon.Critical) + err.setText( + "LSLib tools are required for the proper generation of the modsettings.xml file, file will not be generated" + ) + err.exec() + return False + else: + progress = self._utils.create_progress_window( + "Downloading LSLib", 100, cancelable=False + ) + urllib.request.urlretrieve( + assets["browser_download_url"], str(zip_path), reporthook + ) + progress.close() + downloaded = True + for archive in old_archives: + archive.unlink() + old_archives = [] + else: + old_archives = [] + new_msg = QMessageBox(self._utils.main_window) + new_msg.setIcon(QMessageBox.Icon.Information) + new_msg.setText( + self._utils.tr("Latest version of LSLib already downloaded!") + ) + new_msg.exec() + + except Exception as e: + qDebug(f"Download failed: {e}") + err = QMessageBox(self._utils.main_window) + err.setIcon(QMessageBox.Icon.Critical) + err.setText( + self._utils.tr( + f"Failed to download LSLib tools:\n{traceback.format_exc()}" + ) + ) + err.exec() + return False + try: + if old_archives: + zip_path = sorted(old_archives)[-1] + if old_archives or not downloaded: + dialog_message = "Ensuring all necessary LSLib files have been extracted from archive..." + win_title = "Verifying LSLib files" + else: + dialog_message = "Extracting/Updating LSLib files..." + win_title = "Extracting LSLib" + x_progress = self._utils.create_progress_window( + win_title, len(self._needed_lslib_files), msg=dialog_message + ) + with zipfile.ZipFile(zip_path, "r") as zip_ref: + for file in self._needed_lslib_files: + if downloaded or not file.exists(): + shutil.move( + zip_ref.extract( + f"Packed/Tools/{file.name}", self._utils.tools_dir + ), + file, + ) + x_progress.setValue(x_progress.value() + 1) + QApplication.processEvents() + if x_progress.wasCanceled(): + qWarning("processing canceled by user") + return False + x_progress.close() + shutil.rmtree(self._utils.tools_dir / "Packed", ignore_errors=True) + except Exception as e: + qDebug(f"Extraction failed: {e}") + err = QMessageBox(self._utils.main_window) + err.setIcon(QMessageBox.Icon.Critical) + err.setText( + self._utils.tr( + f"Failed to extract LSLib tools:\n{traceback.format_exc()}" + ) + ) + err.exec() + return False + return True diff --git a/libs/basic_games/games/baldursgate3/pak_parser.py b/libs/basic_games/games/baldursgate3/pak_parser.py new file mode 100644 index 0000000..18598a9 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/pak_parser.py @@ -0,0 +1,295 @@ +import configparser +import hashlib +import os +import re +import shutil +import subprocess +import traceback +from functools import cached_property +from pathlib import Path +from typing import Callable +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +from PyQt6.QtCore import ( + qDebug, + qInfo, + qWarning, +) + +import mobase + +from . import bg3_utils + + +class BG3PakParser: + def __init__(self, utils: bg3_utils.BG3Utils): + self._utils = utils + + _mod_cache: dict[Path, bool] = {} + _types = { + "Folder": "", + "MD5": "", + "Name": "", + "PublishHandle": "0", + "UUID": "", + "Version64": "0", + } + + @cached_property + def _divine_command(self): + return f"{self._utils.tools_dir / 'Divine.exe'} -g bg3 -l info" + + @cached_property + def _folder_pattern(self): + return re.compile("Data|Script Extender|bin|Mods") + + def get_metadata_for_files_in_mod( + self, mod: mobase.IModInterface, force_reparse_metadata: bool + ): + return { + mod.name(): "".join( + [ + self._get_metadata_for_file(mod, file, force_reparse_metadata) + for file in sorted( + list(Path(mod.absolutePath()).rglob("*.pak")) + + ( + [ + f + for f in Path(mod.absolutePath()).glob("*") + if f.is_dir() + ] + if self._utils.autobuild_paks + else [] + ) + ) + ] + ) + } + + def _get_metadata_for_file( + self, + mod: mobase.IModInterface, + file: Path, + force_reparse_metadata: bool, + ) -> str: + meta_ini = Path(mod.absolutePath()) / "meta.ini" + config = configparser.ConfigParser(interpolation=None) + config.read(meta_ini, encoding="utf-8") + try: + if file.name.endswith("pak"): + meta_file = ( + self._utils.plugin_data_path + / "temp" + / "extracted_metadata" + / f"{file.name[: int(len(file.name) / 2)]}-{hashlib.md5(str(file).encode(), usedforsecurity=False).hexdigest()[:5]}.lsx" + ) + try: + if ( + not force_reparse_metadata + and config.has_section(file.name) + and ( + "override" in config[file.name].keys() + or "Folder" in config[file.name].keys() + ) + ): + return get_module_short_desc(config, file) + meta_file.parent.mkdir(parents=True, exist_ok=True) + meta_file.unlink(missing_ok=True) + out_dir = ( + str(meta_file)[:-4] if self._utils.extract_full_package else "" + ) + can_continue = True + if self.run_divine( + f'{"extract-package" if self._utils.extract_full_package else "extract-single-file -f meta.lsx"} -d "{meta_file if not self._utils.extract_full_package else out_dir}"', + file, + ).returncode: + can_continue = False + if can_continue and self._utils.extract_full_package: + qDebug(f"archive {file} extracted to {out_dir}") + if self.run_divine( + f'convert-resources -d "{out_dir}" -i lsf -o lsx -x "*.lsf"', + out_dir, + ).returncode: + qDebug( + f"failed to convert lsf files in {out_dir} to readable lsx" + ) + extracted_meta_files = list(Path(out_dir).rglob("meta.lsx")) + if len(extracted_meta_files) == 0: + qInfo( + f"No meta.lsx files found in {file.name}, {file.name} determined to be an override mod" + ) + can_continue = False + else: + shutil.copyfile( + extracted_meta_files[0], + meta_file, + ) + elif can_continue and not meta_file.exists(): + qInfo( + f"No meta.lsx files found in {file.name}, {file.name} determined to be an override mod" + ) + can_continue = False + return self.metadata_to_ini( + config, file, mod, meta_ini, can_continue, lambda: meta_file + ) + finally: + if self._utils.remove_extracted_metadata: + meta_file.unlink(missing_ok=True) + if self._utils.extract_full_package: + Path(str(meta_file)[:-4]).unlink(missing_ok=True) + elif file.is_dir(): + if self._folder_pattern.search(file.name): + return "" + for folder in bg3_utils.loose_file_folders: + if next(file.glob(f"{folder}/*"), False): + break + else: + return "" + qInfo(f"packable dir: {file}") + if (file.parent / f"{file.name}.pak").exists() or ( + file.parent / "Mods" / f"{file.name}.pak" + ).exists(): + qInfo( + f"pak with same name as packable dir exists in mod directory. not packing dir {file}" + ) + return "" + parent_mod_name = file.parent.name.replace(" ", "_") + pak_path = ( + self._utils.overwrite_path + / f"Mods/{parent_mod_name}_{file.name}.pak" + ) + build_pak = True + if pak_path.exists(): + try: + pak_creation_time = os.path.getmtime(pak_path) + for root, _, files in file.walk(): + for f in files: + file_path = root.joinpath(f) + try: + if os.path.getmtime(file_path) > pak_creation_time: + break + except OSError as e: + qDebug(f"Error accessing file {file_path}: {e}") + break + else: + build_pak = False + except OSError as e: + qDebug(f"Error accessing file {pak_path}: {e}") + build_pak = False + if build_pak: + pak_path.unlink(missing_ok=True) + if self.run_divine( + f'create-package -d "{pak_path}"', file + ).returncode: + return "" + meta_files = list(file.glob("Mods/*/meta.lsx")) + return self.metadata_to_ini( + config, + file, + mod, + meta_ini, + len(meta_files) > 0, + lambda: meta_files[0], + ) + else: + return "" + except Exception: + qWarning(traceback.format_exc()) + return "" + + def run_divine( + self, action: str, source: Path | str + ) -> subprocess.CompletedProcess[str]: + command = f'{self._divine_command} -a {action} -s "{source}"' + result = subprocess.run( + command, + creationflags=subprocess.CREATE_NO_WINDOW, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode: + qWarning( + f"{command.replace(str(Path.home()), '~', 1).replace(str(Path.home()), '$HOME')}" + f" returned stdout: {result.stdout}, stderr: {result.stderr}, code {result.returncode}" + ) + return result + + def get_attr_value(self, root: Element, attr_id: str) -> str: + default_val = self._types.get(attr_id) or "" + attr = root.find(f".//attribute[@id='{attr_id}']") + return default_val if attr is None else attr.get("value", default_val) + + def metadata_to_ini( + self, + config: configparser.ConfigParser, + file: Path, + mod: mobase.IModInterface, + meta_ini: Path, + condition: bool, + to_parse: Callable[[], Path], + ): + config[file.name] = {} + if condition: + root = ( + ElementTree.parse(to_parse()) + .getroot() + .find(".//node[@id='ModuleInfo']") + ) + if root is None: + qInfo(f"No ModuleInfo node found in meta.lsx for {mod.name()} ") + else: + section = config[file.name] + folder_name = self.get_attr_value(root, "Folder") + if file.is_dir(): + self._mod_cache[file] = ( + len(list(file.glob(f"*/{folder_name}/**"))) > 1 + or len( + list(file.glob("Public/Engine/Timeline/MaterialGroups/*")) + ) + > 0 + ) + elif file not in self._mod_cache: + # a mod which has a meta.lsx and is not an override mod meets at least one of three conditions: + # 1. it has files in Public/Engine/Timeline/MaterialGroups, or + # 2. it has files in Mods// other than the meta.lsx file, or + # 3. it has files in Public/ + result = self.run_divine( + f'list-package --use-regex -x "(/{re.escape(folder_name)}/(?!meta\\.lsx))|(Public/Engine/Timeline/MaterialGroups)"', + file, + ) + self._mod_cache[file] = ( + result.returncode == 0 and result.stdout.strip() != "" + ) + if self._mod_cache[file]: + for key in self._types: + section[key] = self.get_attr_value(root, key) + else: + qInfo(f"pak {file.name} determined to be an override mod") + section["override"] = "True" + section["Folder"] = folder_name + else: + config[file.name]["override"] = "True" + with open(meta_ini, "w+", encoding="utf-8") as f: + config.write(f) + return get_module_short_desc(config, file) + + +def get_module_short_desc(config: configparser.ConfigParser, file: Path) -> str: + if not config.has_section(file.name): + return "" + section: configparser.SectionProxy = config[file.name] + return ( + "" + if "override" in section.keys() or "Name" not in section.keys() + else bg3_utils.get_node_string( + folder=section["Folder"], + md5=section["MD5"], + name=section["Name"], + publish_handle=section["PublishHandle"], + uuid=section["UUID"], + version64=section["Version64"], + ) + ) diff --git a/libs/basic_games/games/baldursgate3/plugins/__init__.py b/libs/basic_games/games/baldursgate3/plugins/__init__.py new file mode 100644 index 0000000..1b69faf --- /dev/null +++ b/libs/basic_games/games/baldursgate3/plugins/__init__.py @@ -0,0 +1,13 @@ +import mobase + +from .check_for_lslib_updates_plugin import BG3ToolCheckForLsLibUpdates +from .convert_jsons_to_yaml_plugin import BG3ToolConvertJsonsToYaml +from .reparse_pak_metadata_plugin import BG3ToolReparsePakMetadata + + +def createPlugins() -> list[mobase.IPluginTool]: + return [ + BG3ToolCheckForLsLibUpdates(), + BG3ToolReparsePakMetadata(), + BG3ToolConvertJsonsToYaml(), + ] diff --git a/libs/basic_games/games/baldursgate3/plugins/bg3_tool_plugin.py b/libs/basic_games/games/baldursgate3/plugins/bg3_tool_plugin.py new file mode 100644 index 0000000..fc258a6 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/plugins/bg3_tool_plugin.py @@ -0,0 +1,49 @@ +from PyQt6.QtCore import QCoreApplication +from PyQt6.QtGui import QIcon, QPixmap + +import mobase + + +class BG3ToolPlugin(mobase.IPluginTool, mobase.IPlugin): + desc = sub_name = "" + icon_bytes: bytes + + def __init__(self): + mobase.IPluginTool.__init__(self) + mobase.IPlugin.__init__(self) + self._pluginName = self._displayName = "BG3 Tools" + self._pluginVersion = mobase.VersionInfo(1, 0, 0) + pixmap = QPixmap() + pixmap.loadFromData(self.icon_bytes, "SVG") + self.qicon = QIcon(pixmap) + + def init(self, organizer: mobase.IOrganizer) -> bool: + self._organizer = organizer + return True + + def version(self): + return self._pluginVersion + + def author(self): + return "daescha" + + def name(self): + return f"{self._pluginName}: {self.sub_name}" + + def displayName(self): + return f"{self._displayName}/{self.sub_name}" + + def tooltip(self): + return self.description() + + def enabledByDefault(self): + return self._organizer.managedGame().name() == "Baldur's Gate 3 Plugin" + + def settings(self) -> list[mobase.PluginSetting]: + return [] + + def icon(self) -> QIcon: + return self.qicon + + def description(self) -> str: + return QCoreApplication.translate(self._pluginName, self.desc) diff --git a/libs/basic_games/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py b/libs/basic_games/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py new file mode 100644 index 0000000..b534e8a --- /dev/null +++ b/libs/basic_games/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py @@ -0,0 +1,15 @@ +from .bg3_tool_plugin import BG3ToolPlugin +from .icons import download + + +class BG3ToolCheckForLsLibUpdates(BG3ToolPlugin): + icon_bytes = download + sub_name = "Check For LsLib Updates" + desc = "Check to see if there has been a new release of LSLib and create download dialog if so." + + def display(self): + from ...game_baldursgate3 import BG3Game + + game_plugin = self._organizer.managedGame() + if isinstance(game_plugin, BG3Game): + game_plugin.utils.lslib_retriever.download_lslib_if_missing(True) diff --git a/libs/basic_games/games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py b/libs/basic_games/games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py new file mode 100644 index 0000000..fde3431 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py @@ -0,0 +1,58 @@ +import json +import os +from pathlib import Path + +from PyQt6.QtCore import qInfo, qWarning +from PyQt6.QtWidgets import QApplication + +from .bg3_tool_plugin import BG3ToolPlugin +from .icons import exchange + + +class BG3ToolConvertJsonsToYaml(BG3ToolPlugin): + icon_bytes = exchange + sub_name = "Convert JSONS to YAML" + desc = "Convert all jsons in active mods to yaml immediately." + + def display(self): + from ...game_baldursgate3 import BG3Game + + game_plugin = self._organizer.managedGame() + if not isinstance(game_plugin, BG3Game): + return + utils = game_plugin.utils + qInfo("converting all json files to yaml") + active_mods = utils.active_mods() + progress = utils.create_progress_window( + "Converting all json files to yaml", len(active_mods) + 1 + ) + for mod in active_mods: + _convert_jsons_in_dir_to_yaml(Path(mod.absolutePath())) + progress.setValue(progress.value() + 1) + QApplication.processEvents() + if progress.wasCanceled(): + qWarning("conversion canceled by user") + return + _convert_jsons_in_dir_to_yaml(utils.overwrite_path) + progress.setValue(len(active_mods) + 1) + QApplication.processEvents() + progress.close() + + +def _convert_jsons_in_dir_to_yaml(path: Path): + for file in list(path.rglob("*.json")): + converted_path = file.parent / file.name.replace(".json", ".yaml") + try: + if not converted_path.exists() or os.path.getmtime(file) > os.path.getmtime( + converted_path + ): + import yaml + + with open(file, "r") as json_file: + with open(converted_path, "w") as yaml_file: + yaml.dump( + json.load(json_file), yaml_file, indent=2, sort_keys=False + ) + qInfo(f"Converted {file} to YAML") + except OSError as e: + qWarning(f"Error accessing file {converted_path}: {e}") diff --git a/libs/basic_games/games/baldursgate3/plugins/icons.py b/libs/basic_games/games/baldursgate3/plugins/icons.py new file mode 100644 index 0000000..adc1023 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/plugins/icons.py @@ -0,0 +1,42 @@ +refresh = b""" + + + + + + + +""" + +exchange = b""" + + + + + + + + + + +""" + +download = b""" + + + + + + + + + + +""" diff --git a/libs/basic_games/games/baldursgate3/plugins/reparse_pak_metadata_plugin.py b/libs/basic_games/games/baldursgate3/plugins/reparse_pak_metadata_plugin.py new file mode 100644 index 0000000..f32a8a0 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/plugins/reparse_pak_metadata_plugin.py @@ -0,0 +1,17 @@ +from .bg3_tool_plugin import BG3ToolPlugin +from .icons import refresh + + +class BG3ToolReparsePakMetadata(BG3ToolPlugin): + icon_bytes = refresh + sub_name = "Reparse Pak Metadata" + desc = "Force reparsing mod metadata immediately." + + def display(self): + from ...game_baldursgate3 import BG3Game + + game_plugin = self._organizer.managedGame() + if isinstance(game_plugin, BG3Game): + game_plugin.utils.construct_modsettings_xml( + exec_path="bin/bg3", force_reparse_metadata=True + ) diff --git a/libs/basic_games/games/game_arkhamcity.py b/libs/basic_games/games/game_arkhamcity.py new file mode 100644 index 0000000..0b8269f --- /dev/null +++ b/libs/basic_games/games/game_arkhamcity.py @@ -0,0 +1,87 @@ +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_features import BasicLocalSavegames +from ..basic_game import BasicGame +from ..steam_utils import find_steam_path + + +# Lifted from https://github.com/ModOrganizer2/modorganizer-basic_games/blob/71dbb8c557d43cba9d290674a332e7ecd1650261/games/game_darkestdungeon.py +class ArkhamCityModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + self.validDirNames = [ + "config", + "cookedpcconsole", + "localization", + "movies", + "moviesstereo", + "splash", + ] + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if not entry.isDir(): + continue + if entry.name().casefold() in self.validDirNames: + return mobase.ModDataChecker.VALID + return mobase.ModDataChecker.INVALID + + +class ArkhamCityGame(BasicGame): + Name = "Batman: Arkham City Plugin" + Author = "Paynamia" + Version = "0.5.3" + + GameName = "Batman: Arkham City" + GameShortName = "batmanarkhamcity" + GameNexusId = 372 + GameSteamId = 200260 + GameGogId = 1260066469 + GameEpicId = "Egret" + GameBinary = "Binaries/Win32/BatmanAC.exe" + GameLauncher = "Binaries/Win32/BmLauncher.exe" + GameDataPath = "BmGame" + GameDocumentsDirectory = ( + "%DOCUMENTS%/WB Games/Batman Arkham City GOTY/BmGame/Config" + ) + GameIniFiles = ["UserEngine.ini", "UserGame.ini", "UserInput.ini"] + GameSaveExtension = "sgd" + + # This will only detect saves from the earliest-created Steam profile on the user's PC. + def savesDirectory(self) -> QDir: + docSaves = QDir(self.documentsDirectory().cleanPath("../../SaveData")) + if self.is_steam(): + if (steamDir := find_steam_path()) is None: + return docSaves + for child in steamDir.joinpath("userdata").iterdir(): + if not child.is_dir() or child.name == "0": + continue + steamSaves = child.joinpath("200260", "remote") + if steamSaves.is_dir(): + return QDir(str(steamSaves)) + else: + return docSaves + else: + return docSaves + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(ArkhamCityModDataChecker()) + self._register_feature(BasicLocalSavegames(self)) + return True + + def executables(self): + return [ + mobase.ExecutableInfo( + "Batman: Arkham City", + QFileInfo(self.gameDirectory(), "Binaries/Win32/BatmanAC.exe"), + ), + mobase.ExecutableInfo( + "Arkham City Launcher", + QFileInfo(self.gameDirectory(), "Binaries/Win32/BmLauncher.exe"), + ), + ] diff --git a/libs/basic_games/games/game_assettocorsa.py b/libs/basic_games/games/game_assettocorsa.py new file mode 100644 index 0000000..da70669 --- /dev/null +++ b/libs/basic_games/games/game_assettocorsa.py @@ -0,0 +1,18 @@ +from ..basic_game import BasicGame + + +class AssettoCorsaGame(BasicGame): + Name = "Assetto Corsa Support Plugin" + Author = "Deorder" + Version = "0.0.1" + + GameName = "Assetto Corsa" + GameShortName = "ac" + GameBinary = r"AssettoCorsa.exe" + GameDataPath = r"" + GameSteamId = 244210 + GameDocumentsDirectory = "%DOCUMENTS%/Assetto Corsa" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Assetto-Corsa" + ) diff --git a/libs/basic_games/games/game_baldursgate3.py b/libs/basic_games/games/game_baldursgate3.py new file mode 100644 index 0000000..4898696 --- /dev/null +++ b/libs/basic_games/games/game_baldursgate3.py @@ -0,0 +1,239 @@ +import datetime +import difflib +import os +import shutil +from functools import cached_property +from pathlib import Path +from typing import Any + +from PyQt6.QtCore import QLoggingCategory, qDebug, qInfo + +import mobase + +from ..basic_features import BasicGameSaveGameInfo, BasicLocalSavegames +from ..basic_game import BasicGame +from .baldursgate3 import bg3_file_mapper + + +class BG3Game(BasicGame, bg3_file_mapper.BG3FileMapper): + Name = "Baldur's Gate 3 Plugin" + Author = "daescha" + Version = "0.1.0" + GameName = "Baldur's Gate 3" + GameShortName = "baldursgate3" + GameNexusName = "baldursgate3" + GameValidShortNames = ["bg3"] + GameLauncher = "Launcher/LariLauncher.exe" + GameBinary = "bin/bg3.exe" + GameDataPath = "" + GameDocumentsDirectory = ( + "%USERPROFILE%/AppData/Local/Larian Studios/Baldur's Gate 3" + ) + GameSavesDirectory = "%GAME_DOCUMENTS%/PlayerProfiles/Public/Savegames/Story" + GameSaveExtension = "lsv" + GameNexusId = 3474 + GameSteamId = 1086940 + GameGogId = 1456460669 + + def __init__(self): + BasicGame.__init__(self) + from .baldursgate3 import bg3_utils + + self.utils = bg3_utils.BG3Utils(self.name()) + bg3_file_mapper.BG3FileMapper.__init__( + self, self.utils, self.documentsDirectory + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self.utils.init(organizer) + from .baldursgate3 import bg3_data_checker, bg3_data_content + + self._register_feature(bg3_data_checker.BG3ModDataChecker()) + self._register_feature(bg3_data_content.BG3DataContent()) + self._register_feature(BasicGameSaveGameInfo(lambda s: s.with_suffix(".webp"))) + self._register_feature(BasicLocalSavegames(self)) + organizer.onAboutToRun(self.utils.construct_modsettings_xml) + organizer.onFinishedRun(self._on_finished_run) + organizer.onUserInterfaceInitialized(self.utils.on_user_interface_initialized) + organizer.modList().onModInstalled(self.utils.on_mod_installed) + organizer.onPluginSettingChanged(self.utils.on_settings_changed) + return True + + def settings(self): + base_settings = super().settings() + custom_settings = [ + mobase.PluginSetting( + "force_load_dlls", + "Force load all dlls detected in active mods. Removes the need for 'Native Mod Loader' and similar mods.", + True, + ), + mobase.PluginSetting( + "log_diff", + "Log a diff of the modsettings.xml file before and after the game runs to check for differences.", + False, + ), + mobase.PluginSetting( + "delete_levelcache_folders_older_than_x_days", + "Maximum number of days a file in overwrite/LevelCache is allowed to exist before being deleted " + "after the executable finishes. Set to negative to disable.", + 3, + ), + mobase.PluginSetting( + "autobuild_paks", + "Autobuild folders likely to be PAK folders with every run of an executable.", + True, + ), + mobase.PluginSetting( + "remove_extracted_metadata", + "Remove extracted meta.lsx files when they are no longer needed.", + True, + ), + mobase.PluginSetting( + "extract_full_package", + "Extract the full pak when parsing metadata, instead of just meta.lsx.", + False, + ), + mobase.PluginSetting( + "convert_yamls_to_json", + "Convert YAMLs to JSONs when executable runs. Allows one to configure ScriptExtender and related mods with YAML files.", + False, + ), + ] + for setting in custom_settings: + setting.description = self.utils.tr(setting.description) + base_settings.append(setting) + return base_settings + + def executables(self) -> list[mobase.ExecutableInfo]: + return [ + mobase.ExecutableInfo( + f"{self.gameName()}: DX11", + self.gameDirectory().absoluteFilePath("bin/bg3_dx11.exe"), + ), + mobase.ExecutableInfo( + f"{self.gameName()}: Vulkan", + self.gameDirectory().absoluteFilePath(self.binaryName()), + ), + mobase.ExecutableInfo( + "Larian Launcher", + self.gameDirectory().absoluteFilePath(self.getLauncherName()), + ), + ] + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + try: + efls = super().executableForcedLoads() + except AttributeError: + efls = [] + if self.utils.force_load_dlls: + qInfo("detecting dlls in enabled mods") + libs: set[str] = set() + tree: mobase.IFileTree | mobase.FileTreeEntry | None = ( + self._organizer.virtualFileTree().find("bin") + ) + if type(tree) is not mobase.IFileTree: + return efls + + def find_dlls( + _: Any, entry: mobase.FileTreeEntry + ) -> mobase.IFileTree.WalkReturn: + relpath = entry.pathFrom(tree) + if ( + relpath + and entry.hasSuffix("dll") + and relpath not in self._base_dlls + ): + libs.add(relpath) + return mobase.IFileTree.WalkReturn.CONTINUE + + tree.walk(find_dlls) + exes = self.executables() + qDebug(f"dlls to force load: {libs}") + efls = efls + [ + mobase.ExecutableForcedLoadSetting( + exe.binary().fileName(), lib + ).withEnabled(True) + for lib in libs + for exe in exes + ] + return efls + + def loadOrderMechanism(self) -> mobase.LoadOrderMechanism: + return mobase.LoadOrderMechanism.PLUGINS_TXT + + @cached_property + def _base_dlls(self) -> set[str]: + base_bin = Path(self.gameDirectory().absoluteFilePath("bin")) + return {str(f.relative_to(base_bin)) for f in base_bin.glob("*.dll")} + + def _on_finished_run(self, exec_path: str, exit_code: int): + if "bin/bg3" not in exec_path: + return + cat = QLoggingCategory.defaultCategory() + self.utils.log_dir.mkdir(parents=True, exist_ok=True) + if ( + cat is not None + and cat.isDebugEnabled() + and self.utils.log_diff + and self.utils.modsettings_backup.exists() + and self.utils.modsettings_path.exists() + ): + for x in difflib.unified_diff( + self.utils.modsettings_backup.open().readlines(), + self.utils.modsettings_path.open().readlines(), + fromfile=str(self.utils.modsettings_backup), + tofile=str(self.utils.modsettings_path), + lineterm="", + ): + qDebug(x) + moved: dict[str, str] = {} + for path in self.utils.overwrite_path.rglob("*.log"): + try: + moved[str(path.relative_to(Path.home()))] = str( + (self.utils.log_dir / path.name).relative_to(Path.home()) + ) + path.replace(self.utils.log_dir / path.name) + except PermissionError as e: + qDebug(str(e)) + for path in self.utils.overwrite_path.rglob("*log.txt"): + dest = self.utils.log_dir / path.name + if path.name == "log.txt": + dest = self.utils.log_dir / f"{path.parent.name}-{path.name}" + try: + moved[str(path.relative_to(Path.home()))] = str( + dest.relative_to(Path.home()) + ) + path.replace(dest) + except PermissionError as e: + qDebug(str(e)) + if cat is not None and cat.isDebugEnabled() and len(moved) > 0: + qDebug(f"moved log files to logs dir: {moved}") + days = self.utils.get_setting("delete_levelcache_folders_older_than_x_days") + if type(days) is int and days >= 0: + cutoff_time = datetime.datetime.now() - datetime.timedelta(days=days) + qDebug(f"cleaning folders in overwrite/LevelCache older than {cutoff_time}") + removed: set[Path] = set() + for path in self.utils.overwrite_path.glob("LevelCache/*"): + if ( + datetime.datetime.fromtimestamp(os.path.getmtime(path)) + < cutoff_time + ): + shutil.rmtree(path, ignore_errors=True) + removed.add(path) + if cat is not None and cat.isDebugEnabled() and len(removed) > 0: + qDebug( + f"cleaned the following folders due to them being older than {cutoff_time}: {removed}" + ) + for fdir in {self.utils.overwrite_path, self.doc_path}: + removed: set[Path] = set() + for folder in sorted(list(fdir.walk(top_down=False)))[:-1]: + try: + folder[0].rmdir() + removed.add(folder[0].relative_to(Path.home())) + except OSError: + pass + if cat is not None and cat.isDebugEnabled() and len(removed) > 0: + qDebug( + f"cleaned empty dirs from {fdir.relative_to(Path.home())} {removed}" + ) diff --git a/libs/basic_games/games/game_blackandwhite2.py b/libs/basic_games/games/game_blackandwhite2.py new file mode 100644 index 0000000..5d9f60b --- /dev/null +++ b/libs/basic_games/games/game_blackandwhite2.py @@ -0,0 +1,341 @@ +import datetime +import os +import struct +import time +from collections.abc import Mapping +from pathlib import Path +from typing import BinaryIO + +from PyQt6.QtCore import QDateTime, QDir, QFile, QFileInfo + +import mobase + +from ..basic_features import BasicLocalSavegames +from ..basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, + format_date, +) +from ..basic_game import BasicGame + + +class BlackAndWhite2ModDataChecker(mobase.ModDataChecker): + _validFolderTree = { + "": ["audio", "data", "plugins", "scripts"], + "audio": ["dialogue", "music", "sfx"], + "music": [ + "buildingmusic", + "chant", + "cutscene", + "dynamic music", + "epicspell", + "townalignment", + ], + "sfx": ["atmos", "creature", "game", "script", "spells", "video", "grass"], + "data": [ + "art", + "balance", + "ctr", + "effects", + "encryptedshaders", + "font", + "handdemo", + "interface", + "landscape", + "light particle effects", + "lipsync", + "physics", + "sfx", + "shaders", + "symbols", + "text", + "textures", + "tutorial avi", + "visualeffects", + "weathersystem", + "zones", + ], + "art": [ + "binary_anim_libs", + "binary_animations", + "features", + "models", + "skins", + "textures", + "water", + ], + "ctr": [ + "badvisor_evil", + "badvisor_good", + "bape", + "bgorilla", + "bhand", + "blion", + "btiger", + "bwolf", + "damage", + "siren", + ], + "font": ["asian"], + "asian": ["korean", "traditional chinese"], + "landscape": [ + "aztec", + "bw2", + "egyptian", + "generic", + "greek", + "japanese", + "norse", + "skysettings", + ], + "tutorial avi": ["placeholder", "stills"], + "visualeffects": ["textures"], + "scripts": ["bw2"], + } + _validFileLocation = { + "": ["exe", "dll", "ico", "png", "jpeg", "jpg"] + } + _mapFile = ["chl", "bmp", "bwe", "ter", "pat", "xml", "wal", "txt"] + _fileIgnore = ["readme", "read me", "meta.ini", "thumbs.db", "backup", ".png"] + + def fix(self, filetree: mobase.IFileTree): + toMove: list[tuple[mobase.FileTreeEntry, str]] = [] + for entry in filetree: + if any([sub in entry.name().casefold() for sub in self._fileIgnore]): + continue + elif entry.suffix() == "chl": + toMove.append((entry, "/Scripts/BW2/")) + elif entry.suffix() == "bmp": + toMove.append((entry, "/Data/")) + elif entry.suffix() == "txt": + toMove.append((entry, "/Scripts/")) + else: + toMove.append((entry, "/Data/landscape/BW2/")) + + for entry, path in toMove: + filetree.move(entry, path, policy=mobase.IFileTree.MERGE) + + return filetree + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + # qInfo("Data validation start") + root = filetree + unpackagedMap = False + + for entry in filetree: + entryName = entry.name().casefold() + canIgnore = any([sub in entryName for sub in self._fileIgnore]) + if not canIgnore: + parent = entry.parent() + if parent is not None: + if parent != root: + parentName = parent.name().casefold() + else: + # qInfo(str(entryName)) + parentName = "" + + if not entry.isDir(): + if parentName in self._validFileLocation.keys(): + if ( + entry.suffix() + not in self._validFileLocation[parentName] + ): + if ( + entry.suffix() in self._mapFile + or entryName == "map.txt" + ): + unpackagedMap = True + else: + return mobase.ModDataChecker.INVALID + else: + unpackagedMap = False + if parentName in self._validFolderTree.keys(): + if entryName not in self._validFolderTree[parentName]: + return mobase.ModDataChecker.INVALID + + # qInfo(str(unpackagedMap)) + if unpackagedMap: + return mobase.ModDataChecker.FIXABLE + else: + return mobase.ModDataChecker.VALID + + +class BlackAndWhite2SaveGame(BasicGameSaveGame): + _saveInfLayout = { + "start": [0x00000000, 0x00000004], + "name": [0x00000004, 0x0000002C], + "empty": [0x0000002C, 0x00000104], + "land": [0x00000104, 0x00000108], + "date": [0x00000108, 0x00000110], + "empty1": [0x00000110, 0x00000114], + "elapsed": [0x00000114, 0x00000118], + "empty2": [0x00000108, 0x0000011C], + } + + def __init__(self, filepath: Path): + super().__init__(filepath) + self._filepath = Path(filepath) + self.name: str = "" + self.land: int = -1 + self.elapsed: int = 0 + self.lastsave: int = 0 + with open(self._filepath.joinpath("SaveGame.inf"), "rb") as info: + # Name embedded in "SaveGame.inf" with UTF-16 encoding + self.name = self.readInf(info, "name").decode("utf-16") + # Land number embedded in "SaveGame.inf" as an int written in binary + self.land = int.from_bytes(self.readInf(info, "land"), "little") + # Getting elapsed time in second + self.elapsed = int.from_bytes(self.readInf(info, "elapsed"), "little") + # Getting date in 100th of nanosecond need to convert NT time + # to UNIX time and offset localtime + self.lastsave = int( + ( + struct.unpack("q", self.readInf(info, "date"))[0] / 10000 + - 11644473600000 + ) + - (time.localtime().tm_gmtoff * 1000) + ) + info.close() + + def readInf(self, inf: BinaryIO, key: str): + inf.seek(self._saveInfLayout[key][0]) + return inf.read(self._saveInfLayout[key][1] - self._saveInfLayout[key][0]) + + def allFiles(self) -> list[str]: + files = [str(file) for file in self._filepath.glob("./*")] + files.append(str(self._filepath)) + return files + + def getCreationTime(self) -> QDateTime: + return QDateTime.fromMSecsSinceEpoch(self.lastsave) + + def getElapsed(self) -> str: + return str(datetime.timedelta(seconds=self.elapsed)) + + def getName(self) -> str: + return self.name + + def getLand(self) -> str: + return str(self.land) + + def getSaveGroupIdentifier(self): + return self._filepath.parent.parent.name + + +def getMetadata(savepath: Path, save: mobase.ISaveGame) -> Mapping[str, str]: + assert isinstance(save, BlackAndWhite2SaveGame) + return { + "Name": save.getName(), + "Profile": save.getSaveGroupIdentifier(), + "Land": save.getLand(), + "Saved at": format_date(save.getCreationTime()), + "Elapsed time": save.getElapsed(), + } + + +PSTART_MENU = ( + str(os.getenv("ProgramData")) + "\\Microsoft\\Windows\\Start Menu\\Programs" +) + + +class BlackAndWhite2Game(BasicGame): + Name = "Black & White 2 Support Plugin" + Author = "Ilyu" + Version = "1.0.1" + + GameName = "Black & White 2" + GameShortName = "BW2" + GameNexusName = "blackandwhite2" + GameDataPath = "%GAME_PATH%" + GameBinary = "white.exe" + GameDocumentsDirectory = "%DOCUMENTS%/Black & White 2" + GameSavesDirectory = "%GAME_DOCUMENTS%/Profiles" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Black-&-White-2" + ) + + _program_link = PSTART_MENU + "\\Black & White 2\\Black & White® 2.lnk" + + def init(self, organizer: mobase.IOrganizer) -> bool: + BasicGame.init(self, organizer) + + self._register_feature(BlackAndWhite2ModDataChecker()) + self._register_feature(BasicLocalSavegames(self)) + self._register_feature( + BasicGameSaveGameInfo(get_metadata=getMetadata, max_width=400) + ) + return True + + def detectGame(self): + super().detectGame() + + program_path = Path(self._program_link) + if program_path.exists(): + installation_path = Path(QFileInfo(self._program_link).symLinkTarget()) + if installation_path.exists(): + self.setGamePath(installation_path.parent) + + return + + def executables(self) -> list[mobase.ExecutableInfo]: + execs = super().executables() + + """ + A bat file to load modded executables from VFS. + """ + workaroundPath = self._gamePath + "/" + self.GameBinary[:-4] + ".bat" + + try: + workaround = open(workaroundPath, "rt") + except FileNotFoundError: + with open(workaroundPath, "wt") as workaround: + workaround.write('start "" "' + self.GameBinary + '"') + workaround.close() + + execs.append( + mobase.ExecutableInfo( + self.GameShortName + " Modded Exec", QFileInfo(workaroundPath) + ) + ) + + return execs + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + profiles: list[Path] = [] + for path in Path(folder.absolutePath()).glob("*/Saved Games/*"): + if ( + path.name == "Autosave" + or path.name == "Pictures" + or "_invalid" in path.name + ): + continue + if path.is_dir(): + saveFolder = QDir(str(path)) + if not saveFolder.exists("SaveGame.inf"): + savePath = saveFolder.absolutePath() + QFile.rename(savePath, savePath + "_invalid") + continue + else: + continue + + profiles.append(path) + + return [BlackAndWhite2SaveGame(path) for path in profiles] + + +class BOTGGame(BlackAndWhite2Game): + Name = "Black & White 2 Battle of the Gods Support Plugin" + + GameName = "Black & White 2 Battle of the Gods" + GameShortName = "BOTG" + GameBinary = "BattleOfTheGods.exe" + GameDocumentsDirectory = "%DOCUMENTS%/Black & White 2 - Battle of the Gods" + GameSavesDirectory = "%GAME_DOCUMENTS%/Profiles" + + _program_link = ( + PSTART_MENU + "\\Black & White 2 Battle of the Gods" + "\\Black & White® 2 Battle of the Gods.lnk" + ) diff --git a/libs/basic_games/games/game_bladeandsorcery.py b/libs/basic_games/games/game_bladeandsorcery.py new file mode 100644 index 0000000..bc689eb --- /dev/null +++ b/libs/basic_games/games/game_bladeandsorcery.py @@ -0,0 +1,98 @@ +import json +from collections.abc import Mapping +from pathlib import Path + +from PyQt6.QtCore import QDateTime, QDir + +import mobase + +from ..basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, + format_date, +) +from ..basic_game import BasicGame + + +class BaSSaveGame(BasicGameSaveGame): + def __init__(self, filepath: Path): + super().__init__(filepath) + with open(self._filepath, "rb") as save: + save_data = json.load(save) + self._gameMode = save_data["mode"]["saveData"]["gameModeId"] + self._gender = ( + "Male" + if save_data["customization"]["creatureId"] == "PlayerDefaultMale" + else "Female" + ) + self._ethnicity = save_data["customization"]["ethnicGroupId"] + h, m, s = save_data["playTime"].split(":") + self._elapsed = (float(h), int(m), float(s)) + f_stat = self._filepath.stat() + self._created = f_stat.st_birthtime + self._modified = f_stat.st_mtime + + def getName(self) -> str: + return f"{self.getPlayerSlug()} - {self._gameMode}" + + def getCreationTime(self) -> QDateTime: + return QDateTime.fromSecsSinceEpoch(int(self._created)) + + def getModifiedTime(self) -> QDateTime: + return QDateTime.fromSecsSinceEpoch(int(self._modified)) + + def getPlayerSlug(self) -> str: + return f"{self._gender} {self._ethnicity}" + + def getElapsed(self) -> str: + return ( + f"{self._elapsed[0]} hours, " + f"{self._elapsed[1]} minutes, " + f"{int(self._elapsed[2])} seconds" + ) + + def getGameMode(self) -> str: + return self._gameMode + + +def bas_parse_metadata(p: Path, save: mobase.ISaveGame) -> Mapping[str, str]: + assert isinstance(save, BaSSaveGame) + return { + "Character": save.getPlayerSlug(), + "Game Mode": save.getGameMode(), + "Created At": format_date(save.getCreationTime()), + "Last Saved": format_date(save.getModifiedTime()), + "Session Duration": save.getElapsed(), + } + + +class BaSGame(BasicGame): + Name = "Blade & Sorcery Plugin" + Author = "R3z Shark & Silarn & Jonny_Bro" + Version = "0.5.1" + + GameName = "Blade & Sorcery" + GameShortName = "bladeandsorcery" + GameBinary = "BladeAndSorcery.exe" + GameDataPath = r"BladeAndSorcery_Data\\StreamingAssets\\Mods" + GameDocumentsDirectory = "%DOCUMENTS%/My Games/BladeAndSorcery" + GameSavesDirectory = "%GAME_DOCUMENTS%/Saves/Default" + GameSaveExtension = "chr" + GameSteamId = 629730 + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Blade-&-Sorcery" + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + BasicGame.init(self, organizer) + self._register_feature( + BasicGameSaveGameInfo(get_metadata=bas_parse_metadata, max_width=400) + ) + return True + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + BaSSaveGame(path) for path in Path(folder.absolutePath()).glob(f"*.{ext}") + ] diff --git a/libs/basic_games/games/game_borderlands1.py b/libs/basic_games/games/game_borderlands1.py new file mode 100644 index 0000000..4b90b51 --- /dev/null +++ b/libs/basic_games/games/game_borderlands1.py @@ -0,0 +1,153 @@ +import re + +import mobase +from mobase import FileTreeEntry, IFileTree, ModDataChecker + +from ..basic_features import BasicModDataChecker +from ..basic_game import BasicGame + +_extention_pattern = re.compile("\\.(upk|umap|u|int|dll|exe)$", re.I) +_mapslot_pattern = re.compile("^Mapslot\\d\\d?\\.umap$", re.I) + +_mod_dirs = { + "Binaries".casefold(): "/", + "WillowGame".casefold(): "/", + "CookedPC".casefold(): "WillowGame/", + "Localization".casefold(): "WillowGame/", + "Maps".casefold(): "WillowGame/CookedPC/", +} + +_slots_path = "WillowGame/CookedPC/Maps/MapSlots" + + +def _check_filetree( + filetree: IFileTree | FileTreeEntry, recurse: bool +) -> ModDataChecker.CheckReturn: + if filetree.isFile(): + if _extention_pattern.search(filetree.name()): + if _mapslot_pattern.search(filetree.name()): + return ModDataChecker.FIXABLE + else: + return ModDataChecker.INVALID + elif recurse and isinstance(filetree, IFileTree): + status = ModDataChecker.VALID + for child in filetree: + child_status = _check_filetree(child, True) + if child_status is ModDataChecker.INVALID: + return ModDataChecker.INVALID + elif child_status is ModDataChecker.FIXABLE: + status = ModDataChecker.FIXABLE + return status + + return ModDataChecker.VALID + + +def _get_nest(filetree: IFileTree) -> IFileTree | None: + children = tuple(filetree) + if ( + len(children) == 1 + and children[0].isDir() + and isinstance(children[0], IFileTree) + and _mod_dirs.get(children[0].name().casefold()) is None + ): + return children[0] + return None + + +def _get_slotstree(roottree: IFileTree) -> IFileTree: + slotstree: IFileTree | None = roottree.find(_slots_path) # type: ignore + if slotstree is None: + slotstree = roottree.addDirectory(_slots_path) + return slotstree + + +def _fix_mapslots(filetree: IFileTree, roottree: IFileTree) -> None: + for child in filetree: + if child.isDir() and isinstance(child, IFileTree): + _fix_mapslots(child, roottree) + elif _mapslot_pattern.search(child.name()): + child.moveTo(_get_slotstree(roottree)) + + +class Borderlands1ModDataChecker(BasicModDataChecker): + def dataLooksValid(self, filetree: IFileTree) -> ModDataChecker.CheckReturn: + parent = filetree.parent() + if parent is not None: + return self.dataLooksValid(parent) + + status = ModDataChecker.VALID + + nest = _get_nest(filetree) + if nest is not None: + status = ModDataChecker.FIXABLE + filetree = nest + + if _check_filetree(filetree, False) is ModDataChecker.INVALID: + return ModDataChecker.INVALID + + slotstree = filetree.find(_slots_path) + if slotstree is not None and not slotstree.isDir(): + return ModDataChecker.INVALID + + for child in filetree: + if child.isDir(): + destination = _mod_dirs.get(child.name().casefold()) + if destination is None: + child_status = _check_filetree(child, True) + if child_status is ModDataChecker.INVALID: + return ModDataChecker.INVALID + elif child_status is ModDataChecker.FIXABLE: + status = ModDataChecker.FIXABLE + elif destination != "/": + status = ModDataChecker.FIXABLE + elif _mapslot_pattern.search(child.name()): + status = ModDataChecker.FIXABLE + elif _extention_pattern.search(child.name()): + return ModDataChecker.INVALID + return status + + def fix(self, filetree: IFileTree) -> IFileTree: + nest = _get_nest(filetree) + if nest is not None: + conflict: FileTreeEntry | None = None + for child in tuple(nest): + if not child.moveTo(filetree): + conflict = child + conflict.detach() + filetree.remove(nest) + if conflict is not None: + conflict.moveTo(filetree) + + for child in tuple(filetree): + if child.isDir() and isinstance(child, IFileTree): + destination = _mod_dirs.get(child.name().casefold()) + if destination is None: + _fix_mapslots(child, filetree) + elif destination != "/": + filetree.move(child, destination) + elif _mapslot_pattern.search(child.name()): + child.moveTo(_get_slotstree(filetree)) + + return filetree + + +class Borderlands1Game(BasicGame): + Name = "Borderlands 1 Support Plugin" + Author = "Miner Of Worlds, RedxYeti, mopioid" + Version = "1.0.0" + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(Borderlands1ModDataChecker()) + return True + + GameName = "Borderlands" + GameShortName = "Borderlands" + GameNexusName = "Borderlands GOTY" + GameSteamId = 8980 + GameBinary = "Binaries/Borderlands.exe" + GameDataPath = "." + GameSaveExtension = "sav" + GameDocumentsDirectory = "%DOCUMENTS%/My Games/Borderlands/" + GameSavesDirectory = "%GAME_DOCUMENTS%/savedata" + GameIniFiles = "%GAME_DOCUMENTS%/WillowGame/Config" diff --git a/libs/basic_games/games/game_control.py b/libs/basic_games/games/game_control.py new file mode 100644 index 0000000..2662410 --- /dev/null +++ b/libs/basic_games/games/game_control.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class ControlGame(BasicGame): + Name = "Control Support Plugin" + Author = "Zash" + Version = "1.0.0" + + GameName = "Control" + GameShortName = "control" + GameNexusId = 2936 + GameSteamId = 870780 + GameGogId = 2049187585 + GameEpicId = "calluna" + GameBinary = "Control.exe" + GameDataPath = "" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Control (Launcher)", + QFileInfo( + self.gameDirectory(), + self.binaryName(), + ), + ), + mobase.ExecutableInfo( + "Control DX11", + QFileInfo( + self.gameDirectory(), + "Control_DX11.exe", + ), + ), + mobase.ExecutableInfo( + "Control DX12", + QFileInfo( + self.gameDirectory(), + "Control_DX12.exe", + ), + ), + ] + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + try: + efls = super().executableForcedLoads() + except AttributeError: + efls = [] + + libraries = ["iphlpapi.dll", "xinput1_4.dll"] + efls.extend( + mobase.ExecutableForcedLoadSetting( + exe.binary().fileName(), lib + ).withEnabled(True) + for lib in libraries + for exe in self.executables() + ) + return efls diff --git a/libs/basic_games/games/game_cyberpunk2077.py b/libs/basic_games/games/game_cyberpunk2077.py new file mode 100644 index 0000000..ab44b8b --- /dev/null +++ b/libs/basic_games/games/game_cyberpunk2077.py @@ -0,0 +1,798 @@ +import filecmp +import json +import re +import shutil +import tempfile +import textwrap +from collections import Counter +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal, TypeVar + +from PyQt6.QtCore import QDateTime, QDir, Qt, qCritical, qInfo, qWarning +from PyQt6.QtWidgets import ( + QCheckBox, + QMainWindow, + QMessageBox, + QProgressDialog, + QWidget, +) + +import mobase + +from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns +from ..basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, + format_date, +) +from ..basic_game import BasicGame + + +class CyberpunkModDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + delete=[ + "*.gif", + "*.jpg", + "*.jpeg", + "*.jxl", + "*.md", + "*.png", + "*.txt", + "*.webp", + ], + move={ + # archive and ArchiveXL + "*.archive": "archive/pc/mod/", + "*.xl": "archive/pc/mod/", + }, + valid=[ + "archive", + # redscript + "engine", + "r6", + "mods", # RedMod + "red4ext", + "bin", # CET etc. gets handled below + ], + ) + ) + + +def time_from_seconds(s: int | float) -> str: + m, s = divmod(int(s), 60) + h, m = divmod(int(m), 60) + return f"{h:02}:{m:02}:{s:02}" + + +def parse_cyberpunk_save_metadata(save_path: Path, save: mobase.ISaveGame): + metadata_file = save_path / "metadata.9.json" + try: + with open(metadata_file) as file: + meta_data = json.load(file)["Data"]["metadata"] + name = meta_data["name"] + if name != (save_name := save.getName()): + name = f"{save_name} ({name})" + return { + "Name": name, + "Date": format_date(meta_data["timestampString"], "hh:mm:ss, d.M.yyyy"), + "Play Time": time_from_seconds(meta_data["playthroughTime"]), + "Quest": meta_data["trackedQuestEntry"], + "Level": int(meta_data["level"]), + "Street Cred": int(meta_data["streetCred"]), + "Life Path": meta_data["lifePath"], + "Difficulty": meta_data["difficulty"], + "Gender": f"{meta_data['bodyGender']} / {meta_data['brainGender']}", + "Game version": meta_data["buildPatch"], + } + except (FileNotFoundError, json.JSONDecodeError): + return None + + +class CyberpunkSaveGame(BasicGameSaveGame): + _name_file = "NamedSave.txt" # from mod: Named Saves + + def __init__(self, filepath: Path): + super().__init__(filepath) + try: # Custom name from Named Saves + with open(filepath / self._name_file) as file: + self._name = file.readline() + except FileNotFoundError: + self._name = "" + + def getName(self) -> str: + return self._name or super().getName() + + def getCreationTime(self) -> QDateTime: + return QDateTime.fromSecsSinceEpoch( + int((self._filepath / "sav.dat").stat().st_mtime) + ) + + +@dataclass +class ModListFile: + list_path: Path + mod_search_pattern: str + reversed_priority: bool = False + """True: load order priority is reversed compared to MO (first mod has priority).""" + + +_MOD_TYPE = TypeVar("_MOD_TYPE") + + +class ModListFileManager(dict[_MOD_TYPE, ModListFile]): + """Manages modlist files for specific mod types.""" + + def __init__(self, organizer: mobase.IOrganizer, **kwargs: ModListFile) -> None: + self._organizer = organizer + super().__init__(**kwargs) + + def update_modlist( + self, mod_type: _MOD_TYPE, mod_files: list[str] | None = None + ) -> tuple[Path, list[str], list[str]]: + """ + Updates the mod list file for `mod_type` with the current load order. + Removes the file if it is not needed. + + Args: + mod_type: Which modlist to update. + mod_files (optional): By default mod files in order of `mod_type` priority + (respecting `self[mod_type].reversed_priority`). + + Returns: + `(modlist_path, new_mod_list, old_mod_list)` + """ + if mod_files is None: + mod_files = list(self.modfile_names(mod_type)) + modlist_path = self.absolute_modlist_path(mod_type) + old_modlist = ( + modlist_path.read_text().splitlines() if modlist_path.exists() else [] + ) + if not mod_files or len(mod_files) == 1: + # No load order required + if old_modlist: + qInfo(f"Removing {mod_type} load order {modlist_path}") + modlist_path.unlink() + return modlist_path, [], old_modlist + else: + qInfo(f'Updating {mod_type} load order "{modlist_path}" with: {mod_files}') + modlist_path.parent.mkdir(parents=True, exist_ok=True) + modlist_path.write_text("\n".join(mod_files)) + return modlist_path, mod_files, old_modlist + + def absolute_modlist_path(self, mod_type: _MOD_TYPE) -> Path: + modlist_path = self[mod_type].list_path + if not modlist_path.is_absolute(): + existing = self._organizer.findFiles(modlist_path.parent, modlist_path.name) + overwrite = self._organizer.overwritePath() + modlist_path = ( + Path(existing[0]) if (existing) else Path(overwrite, modlist_path) + ) + return modlist_path + + def modfile_names(self, mod_type: _MOD_TYPE) -> Iterable[str]: + """Get all file names from the `mod_type` in MOs load order + (reversed with `self[mod_type].reversed_priority = True`). + """ + yield from (file.name for file in self.modfiles(mod_type)) + + def modfiles(self, mod_type: _MOD_TYPE) -> Iterable[Path]: + """Get all files from the `mod_type` in MOs load order + (reversed with `self[mod_type].reversed_priority = True`). + """ + mod_search_pattern = self[mod_type].mod_search_pattern + for mod_path in self.active_mod_paths(self[mod_type].reversed_priority): + yield from mod_path.glob(mod_search_pattern) + + def active_mod_paths(self, reverse: bool = False) -> Iterable[Path]: + """Yield the path to active mods in MOs load order.""" + mods_path = Path(self._organizer.modsPath()) + modlist = self._organizer.modList() + mods_load_order = modlist.allModsByProfilePriority() + for mod in reversed(mods_load_order) if reverse else mods_load_order: + if modlist.state(mod) & mobase.ModState.ACTIVE: + yield mods_path / mod + + +class Cyberpunk2077Game(BasicGame): + Name = "Cyberpunk 2077 Support Plugin" + Author = "6788, Zash" + Version = "3.0.1" + + GameName = "Cyberpunk 2077" + GameShortName = "cyberpunk2077" + GameBinary = "bin/x64/Cyberpunk2077.exe" + GameLauncher = "REDprelauncher.exe" + GameDataPath = "%GAME_PATH%" + GameDocumentsDirectory = "%USERPROFILE%/AppData/Local/CD Projekt Red/Cyberpunk 2077" + GameSavesDirectory = "%USERPROFILE%/Saved Games/CD Projekt Red/Cyberpunk 2077" + GameSaveExtension = "dat" + GameSteamId = 1091500 + GameGogId = 1423049311 + GameEpicId = "77f2b98e2cef40c8a7437518bf420e47" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Cyberpunk-2077" + ) + + # CET and RED4ext, relative to Cyberpunk2077.exe + _forced_libraries = ["version.dll", "winmm.dll"] + _crashreporter_path = "bin/x64/CrashReporter/CrashReporter.exe" + + _redmod_binary = Path("tools/redmod/bin/redMod.exe") + _redmod_log = Path("tools/redmod/bin/REDmodLog.txt") + _redmod_deploy_path = Path("r6/cache/modded/") + _redmod_deploy_args = "deploy -reportProgress" + """Deploy arguments for `redmod.exe`, -modlist=... is added.""" + + _parentWidget: QWidget + """Set with `_organizer.onUserInterfaceInitialized()`""" + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(BasicLocalSavegames(self)) + self._register_feature( + BasicGameSaveGameInfo( + lambda p: Path(p or "", "screenshot.png"), + parse_cyberpunk_save_metadata, + ) + ) + self._register_feature(CyberpunkModDataChecker()) + + self._modlist_files = ModListFileManager[Literal["archive", "redmod"]]( + organizer, + archive=ModListFile( + Path("archive/pc/mod/modlist.txt"), + "archive/pc/mod/*.archive", + reversed_priority=bool(self._get_setting("reverse_archive_load_order")), + ), + redmod=ModListFile( + Path(self._redmod_deploy_path, "MO_REDmod_load_order.txt"), + "mods/*/", + reversed_priority=bool(self._get_setting("reverse_redmod_load_order")), + ), + ) + organizer.onAboutToRun(self._onAboutToRun) + organizer.onFinishedRun(self._onFinishedRun) + organizer.onPluginSettingChanged(self._on_settings_changed) + organizer.modList().onModInstalled(self._check_disable_crashreporter) + organizer.onUserInterfaceInitialized(self._on_user_interface_initialized) + return True + + def _on_settings_changed( + self, + plugin_name: str, + setting: str, + old: mobase.MoVariant, + new: mobase.MoVariant, + ): + if self.name() != plugin_name: + return + match setting: + case "reverse_archive_load_order": + self._modlist_files["archive"].reversed_priority = bool(new) + case "reverse_remod_load_order": + self._modlist_files["redmod"].reversed_priority = bool(new) + case "show_rootbuilder_conversion": + if new and (dialog := self._get_rootbuilder_conversion_dialog()): + dialog.open() # type: ignore + case _: + return + + def _on_user_interface_initialized(self, window: QMainWindow): + self._parentWidget = window + if not self.isActive(): + return + if dialog := self._get_rootbuilder_conversion_dialog(window): + dialog.open() # type: ignore + else: + self._check_disable_crashreporter() + + def _check_disable_crashreporter( + self, mod: mobase.IModInterface | None | Any = None + ): + """ + Disable Crashreporter with CET installed in VFS, since it crashes + when trying to resolve `version.dll`. + """ + if not self.isActive() or not self._get_setting("disable_crashreporter"): + return + cet_mod_name = self._find_cet_mod_name( + mod if isinstance(mod, mobase.IModInterface) else None + ) + if ( + cet_mod_name + and (cr_origin := self._organizer.getFileOrigins(self._crashreporter_path)) + and cr_origin[0] == "data" + ): + self._create_dummy_crashreporter_mod( + self._organizer.modList().priority(cet_mod_name) + 1 + ) + + def _find_cet_mod_name(self, mod: mobase.IModInterface | None = None) -> str: + """ + Find the mod containing `version.dll`. + + Args: + mod (optional): check the mods filetree instead. Defaults to None. + + Returns: + The mods name if `version.dll` is found, else ''. + """ + cet_mod_name = "" + if mod: + if mod.fileTree().find("bin/x64/version.dll"): + cet_mod_name = mod.name() + elif dll_origins := self._organizer.getFileOrigins("bin/x64/version.dll"): + cet_mod_name = dll_origins[0] + return cet_mod_name if cet_mod_name != "data" else "" + + def _create_dummy_crashreporter_mod(self, priority: int = 0): + """ + Disables CrashReporter by creating an empty dummy file to replace + `CrashReporter.exe`. + """ + mod_name = "disable CrashReporter (MO CET fix)" + modlist = self._organizer.modList() + if modlist.getMod(mod_name): + return + qInfo(f"CET VFS fix: creating mod {mod_name}") + new_mod = self._organizer.createMod(mobase.GuessedString(mod_name)) + if not new_mod: + return + mod_name = new_mod.name() + new_mod.setGameName(self.gameShortName()) + new_mod.setUrl(f"{self.getSupportURL()}#crashreporter") + crashReporter = Path(new_mod.absolutePath(), self._crashreporter_path) + crashReporter.parent.mkdir(parents=True) + crashReporter.touch() + + def callback(): + modlist.setActive(mod_name, True) + modlist.setPriority(mod_name, priority) + + self._organizer.onNextRefresh(callback, False) + self._organizer.refresh() + self._set_setting("disable_crashreporter", False) + + def iniFiles(self): + return ["UserSettings.json"] + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + CyberpunkSaveGame(path.parent) + for path in Path(folder.absolutePath()).glob(f"**/*.{ext}") + ] + + def settings(self) -> list[mobase.PluginSetting]: + return [ + mobase.PluginSetting( + "skipStartScreen", + ( + 'Skips the "Breaching..." start screen on game launch' + " (can also skip loading of GOG rewards)" + ), + False, + ), + mobase.PluginSetting( + "enforce_archive_load_order", + ( + "Enforce the current load order via" + " archive/pc/mod/modlist.txt" + ), + False, + ), + mobase.PluginSetting( + "reverse_archive_load_order", + ( + "Reverse MOs load order in" + " archive/pc/mod/modlist.txt" + " (first loaded mod wins = last one / highest prio in MO)" + ), + False, + ), + mobase.PluginSetting( + "enforce_redmod_load_order", + "Enforce the current load order on redmod deployment", + True, + ), + mobase.PluginSetting( + "reverse_redmod_load_order", + ( + "Reverse MOs load order on redmod deployment" + " (first loaded mod wins = last one / highest prio in MO)" + ), + False, + ), + mobase.PluginSetting( + "auto_deploy_redmod", + "Deploy redmod before game launch if necessary", + True, + ), + mobase.PluginSetting( + "clear_cache_after_game_update", + ( + 'Clears "overwrite/r6/cache/*" if the original game files changed' + " (after update)" + ), + True, + ), + mobase.PluginSetting( + "disable_crashreporter", + ( + "Creates a dummy mod/file to disable CrashReporter.exe, " + "which is not compatible with VFS version.dll, see wiki" + ), + True, + ), + mobase.PluginSetting( + "crash_message", + ("Show a crash message as replacement of disabled CrashReporter"), + True, + ), + mobase.PluginSetting( + "show_rootbuilder_conversion", + ( + "Shows a dialog to convert legacy RootBuilder mods to native MO mods," + " using force load libraries" + ), + True, + ), + ] + + def _get_setting(self, key: str) -> mobase.MoVariant: + return self._organizer.pluginSetting(self.name(), key) + + def _set_setting(self, key: str, value: mobase.MoVariant): + self._organizer.setPluginSetting(self.name(), key, value) + + def executables(self) -> list[mobase.ExecutableInfo]: + game_name = self.gameName() + game_dir = self.gameDirectory() + bin_path = game_dir.absoluteFilePath(self.binaryName()) + skip_start_screen = ( + "-skipStartScreen" if self._get_setting("skipStartScreen") else "" + ) + return [ + # Default, runs REDmod deploy if necessary + mobase.ExecutableInfo( + f"{game_name} (REDmod)", + bin_path, + ).withArgument(f"--launcher-skip -modded {skip_start_screen}"), + # Start game without REDmod + mobase.ExecutableInfo( + f"{game_name}", + bin_path, + ).withArgument(f"--launcher-skip {skip_start_screen}"), + # Deploy REDmods only + mobase.ExecutableInfo( + "REDmod", + self._get_redmod_binary(), + ).withArgument("deploy -reportProgress -force %modlist%"), + # Launcher + mobase.ExecutableInfo( + "REDprelauncher", + game_dir.absoluteFilePath(self.getLauncherName()), + ).withArgument(f"{skip_start_screen}"), + ] + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + exe = Path(self.binaryName()).name + return [ + mobase.ExecutableForcedLoadSetting(exe, lib).withEnabled(True) + for lib in self._forced_libraries + ] + + def _get_redmod_binary(self) -> Path: + """Absolute path to redmod binary""" + return Path(self.gameDirectory().absolutePath(), self._redmod_binary) + + def _onAboutToRun(self, app_path_str: str, wd: QDir, args: str) -> bool: + if not self.isActive(): + return True + app_path = Path(app_path_str) + if app_path == self._get_redmod_binary(): + if m := re.search(r"%modlist%", args, re.I): + # Manual deployment: replace %modlist% variable + ( + modlist_path, + modlist, + _, + ) = self._modlist_files.update_modlist("redmod") + modlist_param = f'-modlist="{modlist_path}"' if modlist else "" + args = f"{args[: m.start()]}{modlist_param}{args[m.end() :]}" + qInfo(f"Manual modlist deployment: replacing {m[0]}, new args = {args}") + self._check_redmod_result( + self._organizer.waitForApplication( + self._organizer.startApplication(app_path_str, [args], wd), + False, + ) + ) + return False # redmod with new args started + return True # No recursive redmod call + if ( + self._get_setting("auto_deploy_redmod") + and app_path == Path(self.gameDirectory().absolutePath(), self.binaryName()) + and "-modded" in args + and not self._check_redmod_result(self._deploy_redmod()) + ): + qWarning("Aborting game launch.") + return False # Auto deploy failed + self._map_cache_files() + if self._get_setting("enforce_archive_load_order"): + self._modlist_files.update_modlist("archive") + return True + + def _onFinishedRun(self, path: str, exit_code: int) -> None: + if not self._get_setting("crash_message"): + return + if path.endswith(self.binaryName()) and exit_code > 0: + crash_message = QMessageBox( + QMessageBox.Icon.Critical, + "Cyberpunk Crashed", + textwrap.dedent( + f""" + Cyberpunk crashed. Tips: + - disable mods (create backup of modlist or use new profile) + - clear overwrite or delete at least overwrite/r6/cache (to keep mod settings) + - check log files of CET/redscript/RED4ext (in overwrite) + - read [FAQ & Troubleshooting]({self.GameSupportURL}#faq--troubleshooting) + """ + ), + QMessageBox.StandardButton.Ok, + self._parentWidget, + ) + crash_message.setTextFormat(Qt.TextFormat.MarkdownText) + hide_cb = QCheckBox("&Do not show again*", crash_message) + hide_cb.setToolTip(f"Settings/Plugins/{self.name()}/crash_message") + crash_message.setCheckBox(hide_cb) + crash_message.open( # type: ignore + lambda: hide_cb.isChecked() + and self._set_setting("crash_message", False) + ) + + def _check_redmod_result(self, result: tuple[bool, int]) -> bool: + if result == (True, 0): + return True + if result[1] < 0: + qWarning(f"REDmod deployment aborted (exit code {result[1]}).") + else: + qCritical( + f"REDmod deployment failed with exit code {result[1]} !" + f" Check {Path('GAME_FOLDER/', self._redmod_log)}" + ) + return False + + def _deploy_redmod(self) -> tuple[bool, int]: + """Deploys redmod. Clears deployed files if no redmods are active. + Recreates deployed files to force load order when necessary. + + Returns: + (success?, exit code) + """ + # Add REDmod load order if none is specified + redmod_list = list(self._modlist_files.modfile_names("redmod")) + if not redmod_list: + qInfo("Cleaning up redmod deployed files") + self._clean_deployed_redmod() + return True, 0 + args = self._redmod_deploy_args + if self._get_setting("enforce_redmod_load_order"): + modlist_path, _, old_redmods = self._modlist_files.update_modlist( + "redmod", redmod_list + ) + if ( + Counter(redmod_list) == Counter(old_redmods) + and not redmod_list == old_redmods + ): + # Only load order changed: recreate redmod deploys + # Fix for redmod not detecting change of load order. + # Faster than -force https://github.com/E1337Kat/cyberpunk2077_ext_redux/issues/297 # noqa: E501 + qInfo("Redmod order changed, recreate deployed files") + self._clean_deployed_redmod(modlist_path) + qInfo(f"Deploying redmod with modlist: {modlist_path}") + args += f' -modlist="{modlist_path}"' + else: + qInfo("Deploying redmod") + redmod_binary = self._get_redmod_binary() + return self._organizer.waitForApplication( + self._organizer.startApplication( + redmod_binary, [args], redmod_binary.parent + ), + False, + ) + + def _clean_deployed_redmod(self, modlist_path: Path | None = None): + """Delete all files from `_redmod_deploy_path` except for `modlist_path`.""" + for file in self._organizer.findFiles(self._redmod_deploy_path, "*"): + file_path = Path(file) + if modlist_path is None or file_path != modlist_path: + file_path.unlink() + + def _map_cache_files(self): + """ + Copy cache files (`final.redscript` etc.) to overwrite to catch + overwritten game files. + """ + data_path = Path(self.dataDirectory().absolutePath()) + overwrite_path = Path(self._organizer.overwritePath()) + data_cache_path = data_path / "r6/cache" + overwrite_cache_path = overwrite_path / "r6/cache" + cache_files = ( + file.relative_to(data_path) for file in data_cache_path.glob("*") + ) + if self._get_setting("clear_cache_after_game_update") and any( + self._is_cache_file_updated(file, data_path) for file in cache_files + ): + qInfo('Updated game files detected, clearing "overwrite/r6/cache/*"') + try: + shutil.rmtree(overwrite_cache_path) + except FileNotFoundError: + pass + qInfo('Updating "r6/cache" in overwrite') + shutil.copytree(data_cache_path, overwrite_cache_path, dirs_exist_ok=True) + else: + for file in self._unmapped_cache_files(data_path): + qInfo(f'Copying "{file}" to overwrite (to catch file overwrites)') + dst = overwrite_path / file + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(data_path / file, dst) + + def _is_cache_file_updated(self, file: Path, data_path: Path) -> bool: + """Checks if a cache file is updated (in game dir). + + Args: + file: Relative to data dir. + """ + game_file = data_path.absolute() / file + mapped_files = self._organizer.findFiles(file.parent, file.name) + return bool( + mapped_files + and (mapped_file := mapped_files[0]) + and not ( + game_file.samefile(mapped_file) + or filecmp.cmp(game_file, mapped_file) + or ( # different backup file + ( + backup_files := self._organizer.findFiles( + file.parent, f"{file.name}.bk" + ) + ) + and filecmp.cmp(game_file, backup_files[0]) + ) + ) + ) + + def _unmapped_cache_files(self, data_path: Path) -> Iterable[Path]: + """Yields unmapped cache files relative to `data_path`.""" + for file in self._organizer.findFiles("r6/cache", "*"): + try: + yield Path(file).absolute().relative_to(data_path) + except ValueError: + continue + + def _get_rootbuilder_conversion_dialog( + self, parent_widget: QWidget | None = None + ) -> QMessageBox | None: + """ + Dialog to convert any mods with `root` folder, if applicable. + CET and RED4ext work with forced load libraries since ~ Cyberpunk v2.12, + making RootBuilder unnecessary. + """ + setting = "show_rootbuilder_conversion" + if not self.isActive() or not self._get_setting(setting): + return None + if not ( + (root_folder := self._organizer.virtualFileTree().find("root")) + and root_folder.isDir() + ): + return None + parent_widget = parent_widget or self._parentWidget + message_box = QMessageBox( + QMessageBox.Icon.Question, + "RootBuilder obsolete", + textwrap.dedent( + """ + Mod Organizer now supports Cyberpunk Engine Tweaks (CET) and + RED4ext native via forced load libraries, making RootBuilder + unnecessary. + + Do you want to convert all mods with a `root` folder now? +
This usually only affects CET, RED4ext and overwrite. + + You can disable RootBuilder afterwards. + """ + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + parent_widget, + ) + message_box.setTextFormat(Qt.TextFormat.MarkdownText) + checkbox = QCheckBox("&Do not show again*", parent_widget) + checkbox.setChecked(True) + checkbox.setToolTip(f"Settings/Plugins/{self.name()}/{setting}") + message_box.setCheckBox(checkbox) + + def accept_callback(): + if unfolded_mods := unfold_root_folders(self._organizer, parent_widget): + n_mods = len(unfolded_mods) + info = QMessageBox( + QMessageBox.Icon.Information, + "Root mods converted", + ( + f"{n_mods} mod{'s' if n_mods > 1 else ''} converted." + "You can disable RootBuilder in the settings now." + ), + parent=parent_widget, + ) + info.setDetailedText(f"Converted mods:\n{'\n'.join(unfolded_mods)}") + info.open() # type: ignore + + message_box.accepted.connect(accept_callback) # type: ignore + + def finished_callback(): + if checkbox.isChecked(): + self._set_setting(setting, False) + self._check_disable_crashreporter() + + message_box.finished.connect(finished_callback) # type: ignore + return message_box + + +def unfold_root_folders( + organizer: mobase.IOrganizer, parent_widget: QWidget | None = None +) -> list[str]: + """Unfolds (RootBuilders) root folders of all mods (excluding backups).""" + mods = organizer.modList().allMods() + progress = None + unfolded_mods: list[str] = [] + if parent_widget: + progress = QProgressDialog( + "Merging/unfolding root folders...", + "Abort", + 0, + len(mods), + parent_widget, + ) + progress.setWindowModality(Qt.WindowModality.WindowModal) + for i, mod_name in enumerate(mods): + if progress: + if progress.wasCanceled(): + break + progress.setValue(i) + if mod_name == "data": + continue + mod = organizer.modList().getMod(mod_name) + if mod.isBackup() or mod.isSeparator() or mod.isForeign(): + continue + root_folder = mod.fileTree().find("root") + if root_folder is None or not root_folder.isDir(): + continue + qInfo(f"Merging root folder of {mod_name}") + mod_path = Path(mod.absolutePath()) + root_folder_path = mod_path / "root" + unfold_folder(root_folder_path) + unfolded_mods.append(mod_name) + if progress: + progress.setValue(len(mods)) + organizer.refresh() + return unfolded_mods + + +def unfold_folder(src_path: Path): + """ + Unfolds a folder (`parent/src/* -> parent/*`), overwriting existing files/folders. + Preserves a subfolder with same name (`parent/src/src -> parent/src`). + """ + parent = src_path.parent + if (src_path / src_path.name).exists(): + # Contains a file/folder with same name + with tempfile.TemporaryDirectory(dir=parent) as temp_folder: + src_path = src_path.rename(parent / temp_folder / src_path.name) + shutil.copytree(src_path, parent, symlinks=True, dirs_exist_ok=True) + else: + shutil.copytree(src_path, parent, symlinks=True, dirs_exist_ok=True) + shutil.rmtree(src_path) diff --git a/libs/basic_games/games/game_da2.py b/libs/basic_games/games/game_da2.py new file mode 100644 index 0000000..9d481e7 --- /dev/null +++ b/libs/basic_games/games/game_da2.py @@ -0,0 +1,35 @@ +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_game import BasicGame + + +class DA2Game(BasicGame): + Name = "Dragon Age 2 Support Plugin" + Author = "Patchier" + + GameName = "Dragon Age 2" + GameShortName = "dragonage2" + GameBinary = r"bin_ship\DragonAge2.exe" + GameDataPath = r"%DOCUMENTS%\BioWare\Dragon Age 2\packages\core\override" + GameSavesDirectory = r"%DOCUMENTS%\BioWare\Dragon Age 2\Characters" + GameSaveExtension = "das" + GameSteamId = 1238040 + GameOriginManifestIds = ["OFB-EAST:59474", "DR:201797000"] + GameOriginWatcherExecutables = ("DragonAge2.exe",) + GameEaDesktopId = [70784, 1002980] + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dragon-Age-II" + ) + + def version(self): + # Don't forget to import mobase! + return mobase.VersionInfo(1, 0, 1, mobase.ReleaseType.FINAL) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature( + BasicGameSaveGameInfo(lambda s: s.parent.joinpath("screen.dds")) + ) + return True diff --git a/libs/basic_games/games/game_daggerfallunity.py b/libs/basic_games/games/game_daggerfallunity.py new file mode 100644 index 0000000..6c170f1 --- /dev/null +++ b/libs/basic_games/games/game_daggerfallunity.py @@ -0,0 +1,56 @@ +import mobase + +from ..basic_game import BasicGame + + +class DaggerfallUnityModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + self.validDirNames = [ + "biogs", + "docs", + "factions", + "fonts", + "mods", + "questpacks", + "quests", + "sound", + "soundfonts", + "spellicons", + "tables", + "text", + "textures", + "worlddata", + "aa", + ] + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if not entry.isDir(): + continue + if entry.name().casefold() in self.validDirNames: + return mobase.ModDataChecker.VALID + return mobase.ModDataChecker.INVALID + + +class DaggerfallUnityGame(BasicGame): + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(DaggerfallUnityModDataChecker()) + return True + + Name = "Daggerfall Unity Support Plugin" + Author = "HomerSimpleton" + Version = "1.0.0" + + GameName = "Daggerfall Unity" + GameShortName = "daggerfallunity" + GameBinary = "DaggerfallUnity.exe" + GameLauncher = "DaggerfallUnity.exe" + GameDataPath = "%GAME_PATH%/DaggerfallUnity_Data/StreamingAssets" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Daggerfall-Unity" + ) diff --git a/libs/basic_games/games/game_dao.py b/libs/basic_games/games/game_dao.py new file mode 100644 index 0000000..bc3f87e --- /dev/null +++ b/libs/basic_games/games/game_dao.py @@ -0,0 +1,31 @@ +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_game import BasicGame + + +class DAOriginsGame(BasicGame): + Name = "Dragon Age Origins Support Plugin" + Author = "Patchier" + Version = "1.1.1" + + GameName = "Dragon Age: Origins" + GameShortName = "dragonage" + GameBinary = r"bin_ship\DAOrigins.exe" + GameDataPath = r"%DOCUMENTS%\BioWare\Dragon Age\packages\core\override" + GameSavesDirectory = r"%DOCUMENTS%\BioWare\Dragon Age\Characters" + GameSaveExtension = "das" + GameSteamId = [17450, 47810] + GameGogId = 1949616134 + GameEaDesktopId = [70377, 70843] + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dragon-Age:-Origins" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature( + BasicGameSaveGameInfo(lambda s: s.parent.joinpath("screen.dds")) + ) + return True diff --git a/libs/basic_games/games/game_darkestdungeon.py b/libs/basic_games/games/game_darkestdungeon.py new file mode 100644 index 0000000..5c3ae4e --- /dev/null +++ b/libs/basic_games/games/game_darkestdungeon.py @@ -0,0 +1,245 @@ +import json +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame, BasicGameSaveGame +from ..steam_utils import find_steam_path + + +class DarkestDungeonModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + self.validDirNames = [ + "activity_log", + "audio", + "campaign", + "colours", + "curios", + "cursors", + "dlc", + "dungeons", + "effects", + "fe_flow", + "fonts", + "fx", + "game_over", + "heroes", + "inventory", + "loading_screen", + "localization", + "loot", + "maps", + "modes", + "monsters", + "overlays", + "panels", + "props", + "raid", + "raid_result", + "scripts", + "scrolls", + "shaders", + "shared", + "trinkets", + "upgrades", + "video", + ] + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if not entry.isDir(): + continue + if entry.name().casefold() in self.validDirNames: + return mobase.ModDataChecker.VALID + return mobase.ModDataChecker.INVALID + + +class DarkestDungeonSaveGame(BasicGameSaveGame): + def __init__(self, filepath: Path): + super().__init__(filepath) + dataPath = filepath.joinpath("persist.game.json") + self.name: str = "" + if self.isBinary(dataPath): + self.loadBinarySaveFile(dataPath) + else: + self.loadJSONSaveFile(dataPath) + + @staticmethod + def isBinary(dataPath: Path) -> bool: + with dataPath.open(mode="rb") as fp: + magic = fp.read(4) + # magic number in binary save files + return magic == b"\x01\xb1\x00\x00" + + def loadJSONSaveFile(self, dataPath: Path): + text = dataPath.read_text() + content = json.loads(text) + data = content["data"] + self.name = str(data["estatename"]) + + def loadBinarySaveFile(self, dataPath: Path): + # see https://github.com/robojumper/DarkestDungeonSaveEditor + with dataPath.open(mode="rb") as fp: + # read Header + + # skip to headerLength + fp.seek(8, 0) + headerLength = int.from_bytes(fp.read(4), "little") + if headerLength != 64: + raise ValueError("Header Length is not 64: " + str(headerLength)) + fp.seek(4, 1) + + # meta1Size = int.from_bytes(fp.read(4), "little") + fp.seek(4, 1) + # numMeta1Entries = int.from_bytes(fp.read(4), "little") + fp.seek(4, 1) + + meta1Offset = int.from_bytes(fp.read(4), "little") + fp.seek(16, 1) + numMeta2Entries = int.from_bytes(fp.read(4), "little") + meta2Offset = int.from_bytes(fp.read(4), "little") + fp.seek(4, 1) + + # dataLength = int.from_bytes(fp.read(4), "little") + fp.seek(4, 1) + + dataOffset = int.from_bytes(fp.read(4), "little") + + # read Meta1 Block + fp.seek(meta1Offset, 0) + meta1DataLength = meta2Offset - meta1Offset + if meta1DataLength % 16 != 0: + raise ValueError( + "Meta1 has wrong number of bytes: " + str(meta1DataLength) + ) + + # read Meta2 Block + fp.seek(meta2Offset, 0) + meta2DataLength = dataOffset - meta2Offset + if meta2DataLength % 12 != 0: + raise ValueError( + "Meta2 has wrong number of bytes: " + str(meta2DataLength) + ) + meta2List: list[tuple[int, int, int]] = [] + for _ in range(numMeta2Entries): + entryHash = int.from_bytes(fp.read(4), "little") + offset = int.from_bytes(fp.read(4), "little") + fieldInfo = int.from_bytes(fp.read(4), "little") + meta2List.append((entryHash, offset, fieldInfo)) + + # read Data + fp.seek(dataOffset, 0) + for x in range(numMeta2Entries): + meta2Entry = meta2List[x] + fp.seek(dataOffset + meta2Entry[1], 0) + nameLength = (meta2Entry[2] & 0b11111111100) >> 2 + # null terminated string + nameBytes = fp.read(nameLength - 1) + fp.seek(1, 1) + name = bytes.decode(nameBytes, "utf-8") + if name != "estatename": + continue + valueLength = int.from_bytes(fp.read(4), "little") + valueBytes = fp.read(valueLength - 1) + value = bytes.decode(valueBytes, "utf-8") + self.name = value + break + + def getName(self) -> str: + if self.name == "": + return super().getName() + return self.name + + +class DarkestDungeonGame(BasicGame): + Name = "DarkestDungeon" + Author = "erri120" + Version = "0.2.0" + + GameName = "Darkest Dungeon" + GameShortName = "darkestdungeon" + GameNexusName = "darkestdungeon" + GameNexusId = 804 + GameSteamId = 262060 + GameGogId = 1719198803 + GameBinary = "_windows/win64/Darkest.exe" + GameDataPath = "" + GameDocumentsDirectory = "%DOCUMENTS%/Darkest" + GameSavesDirectory = "%GAME_DOCUMENTS%" + GameSupportURL = "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Darkest-Dungeon" + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(DarkestDungeonModDataChecker()) + return True + + def executables(self) -> list[mobase.ExecutableInfo]: + game_name = self.gameName() + game_dir = self.gameDirectory() + + return [ + mobase.ExecutableInfo( + f"{game_name} Steam x64", + QFileInfo(game_dir, "_windows/win64/Darkest.exe"), + ).withWorkingDirectory(game_dir), + mobase.ExecutableInfo( + f"{game_name} x64", + QFileInfo(game_dir, "_windowsnosteam/win64/Darkest.exe"), + ).withWorkingDirectory(game_dir), + mobase.ExecutableInfo( + f"{game_name} Steam x32", + QFileInfo(game_dir, "_windows/win32/Darkest.exe"), + ).withWorkingDirectory(game_dir), + mobase.ExecutableInfo( + f"{game_name} x32", + QFileInfo(game_dir, "_windowsnosteam/win32/Darkest.exe"), + ).withWorkingDirectory(game_dir), + ] + + @staticmethod + def getCloudSaveDirectory() -> str | None: + steamPath = find_steam_path() + if steamPath is None: + return None + + userData = steamPath.joinpath("userdata") + for child in userData.iterdir(): + name = child.name + try: + userID = int(name) + except ValueError: + userID = -1 + if userID == -1: + continue + cloudSaves = child.joinpath("262060", "remote") + if cloudSaves.exists() and cloudSaves.is_dir(): + return str(cloudSaves) + return None + + def savesDirectory(self) -> QDir: + documentsSaves = self.documentsDirectory() + + if not self.is_steam(): + return documentsSaves + + cloudSaves = self.getCloudSaveDirectory() + if cloudSaves is None: + return documentsSaves + + return QDir(cloudSaves) + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + profiles: list[Path] = [] + for path in Path(folder.absolutePath()).glob("profile_*"): + # profile_9 is only for the Multiplayer DLC "The Butcher's Circus" + # and contains different files than other profiles + if path.name == "profile_9": + continue + profiles.append(path) + + return [DarkestDungeonSaveGame(path) for path in profiles] diff --git a/libs/basic_games/games/game_darkmessiahofmightandmagic.py b/libs/basic_games/games/game_darkmessiahofmightandmagic.py new file mode 100644 index 0000000..ed3fbcd --- /dev/null +++ b/libs/basic_games/games/game_darkmessiahofmightandmagic.py @@ -0,0 +1,48 @@ +import struct +from pathlib import Path + +from PyQt6.QtGui import QImage + +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_game import BasicGame + + +class DarkMessiahOfMightAndMagicGame(BasicGame): + Name = "Dark Messiah of Might and Magic Support Plugin" + Author = "Holt59" + Version = "0.1.0" + + GameName = "Dark Messiah of Might & Magic" + GameShortName = "darkmessiahofmightandmagic" + GameNexusName = "darkmessiahofmightandmagic" + GameNexusId = 628 + GameSteamId = 2100 + GameBinary = "mm.exe" + GameDataPath = "mm" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dark-Messiah-of-Might-&-Magic" + ) + + GameDocumentsDirectory = "%GAME_PATH%/mm" + GameSavesDirectory = "%GAME_PATH%/mm/SAVE" + GameSaveExtension = "sav" + + def _read_save_tga(self, filepath: Path) -> QImage | None: + # Qt TGA reader does not work for TGA, I hope that all files + # have the same format: + with open( + filepath.parent.joinpath(filepath.name.replace(".sav", ".tga")), "rb" + ) as fp: + data = fp.read() + _, _, w, h, bpp, _ = struct.unpack(" bool: + super().init(organizer) + self._register_feature(DispatchModDataChecker()) + self._register_feature(BasicLocalSavegames(self)) + return True + + def executables(self): + return [ + mobase.ExecutableInfo("Dispatch", self.GameBinary), + ] + + ## SAVE + # credit to game.darkestdungeon.py + @staticmethod + def getCloudSaveDirectory() -> str | None: + steamPath = find_steam_path() + if steamPath is None: + return None + + userData = steamPath.joinpath("userdata") + for child in userData.iterdir(): + name = child.name + try: + int(name) + except ValueError: + continue + + cloudSaves = child.joinpath("2592160/remote") + if cloudSaves.exists() and cloudSaves.is_dir(): + return str(cloudSaves) + return None + + def savesDirectory(self) -> QDir: + return QDir(self.getCloudSaveDirectory()) + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + saves: list[Path] = [] + for path in Path(folder.absolutePath()).glob("*.bin"): + saves.append(path) + + ##TODO: need a proper implementation + return [BasicGameSaveGame(path) for path in saves] + + ## MAPPING + + def exeDirectory(self) -> QDir: + return QDir(QDir(self.gameDirectory()).filePath("Dispatch/Binaries/Win64")) + + def mappings(self) -> list[mobase.Mapping]: + return [ + mobase.Mapping("*.dll", self.exeDirectory().absolutePath(), False, True), + ] diff --git a/libs/basic_games/games/game_divinityoriginalsin.py b/libs/basic_games/games/game_divinityoriginalsin.py new file mode 100644 index 0000000..435846e --- /dev/null +++ b/libs/basic_games/games/game_divinityoriginalsin.py @@ -0,0 +1,39 @@ +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_game import BasicGame + + +class DivinityOriginalSinGame(BasicGame): + Name = "Divinity: Original Sin (Classic) Support Plugin" + Author = "LostDragonist" + Version = "1.0.0" + + GameName = "Divinity: Original Sin (Classic)" + GameShortName = "divinityoriginalsin" + GameNexusName = "divinityoriginalsin" + GameValidShortNames = ["divinityoriginalsin"] + GameNexusId = 573 + GameSteamId = [230230] + GameBinary = "Shipping/EoCApp.exe" + GameDataPath = "Data" + GameSaveExtension = "lsv" # Not confirmed + GameDocumentsDirectory = ( + "%USERPROFILE%/Documents/Larian Studios/Divinity Original Sin" + ) + GameSavesDirectory = ( + "%USERPROFILE%/Documents/Larian Studios/Divinity Original Sin/PlayerProfiles" + ) + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Divinity:-Original-Sin" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature( + BasicGameSaveGameInfo( + lambda s: s.with_suffix(".png") # Not confirmed + ) + ) + return True diff --git a/libs/basic_games/games/game_divinityoriginalsinee.py b/libs/basic_games/games/game_divinityoriginalsinee.py new file mode 100644 index 0000000..fd3836f --- /dev/null +++ b/libs/basic_games/games/game_divinityoriginalsinee.py @@ -0,0 +1,119 @@ +# -*- encoding: utf-8 -*- + +import os +from pathlib import Path + +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_game import BasicGame + + +class DivinityOriginalSinEnhancedEditionModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + folders: list[mobase.IFileTree] = [] + files: list[mobase.FileTreeEntry] = [] + + for entry in filetree: + if isinstance(entry, mobase.IFileTree): + folders.append(entry) + else: + files.append(entry) + + VALID_FOLDERS = [ + "Cursors", + "DLC", + "Engine", + "Fonts", + "Localization", + "PakInfo", + "PlayerProfiles", + "Public", + "Shaders", + DivinityOriginalSinEnhancedEditionGame.DOCS_MOD_SPECIAL_NAME, + ] + for src_folder in folders: + for dst_folder in VALID_FOLDERS: + if src_folder.name().lower() == dst_folder.lower(): + return mobase.ModDataChecker.VALID + + VALID_FILE_EXTENSIONS = [ + ".pak", + ] + for src_file in files: + for extension in VALID_FILE_EXTENSIONS: + if src_file.name().lower().endswith(extension.lower()): + return mobase.ModDataChecker.VALID + + return mobase.ModDataChecker.INVALID + + +class DivinityOriginalSinEnhancedEditionGame(BasicGame, mobase.IPluginFileMapper): + Name = "Divinity: Original Sin (Enhanced Edition) Support Plugin" + Author = "LostDragonist" + Version = "1.0.0" + + GameName = "Divinity: Original Sin (Enhanced Edition)" + GameShortName = "divinityoriginalsinenhancededition" + GameNexusName = "divinityoriginalsinenhancededition" + GameValidShortNames = ["divinityoriginalsin"] + GameNexusId = 1995 + GameSteamId = [373420] + GameGogId = [1445516929, 1445524575] + GameBinary = "Shipping/EoCApp.exe" + GameDataPath = "Data" + GameSaveExtension = "lsv" + GameDocumentsDirectory = ( + "%USERPROFILE%/Documents/Larian Studios/Divinity Original Sin Enhanced Edition" + ) + GameSavesDirectory = ( + "%USERPROFILE%/Documents/Larian Studios/" + "Divinity Original Sin Enhanced Edition/PlayerProfiles" + ) + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Divinity:-Original-Sin" + ) + + DOCS_MOD_SPECIAL_NAME = "DOCS_MOD" + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(BasicGameSaveGameInfo(lambda s: s.with_suffix(".png"))) + self._register_feature(DivinityOriginalSinEnhancedEditionModDataChecker()) + return True + + def mappings(self) -> list[mobase.Mapping]: + map: list[mobase.Mapping] = [] + modDirs = self._listDirsRecursive(Path(self.DOCS_MOD_SPECIAL_NAME)) + for dir_ in modDirs: + for file_ in self._organizer.findFiles(path=dir_, filter=lambda x: True): + m = mobase.Mapping() + m.createTarget = True + m.isDirectory = False + m.source = file_ + m.destination = os.path.join( + self.documentsDirectory().absoluteFilePath("Mods"), + file_.split(self.DOCS_MOD_SPECIAL_NAME)[1].strip("\\").strip("/"), + ) + map.append(m) + return map + + def primarySources(self): + return self.GameValidShortNames + + def _listDirsRecursive(self, prefix: Path) -> list[str]: + res = [str(prefix)] + dirs = self._organizer.listDirectories(str(prefix)) + for dir_ in dirs: + res.extend(self._listDirsRecursive(prefix.joinpath(dir_))) + return res diff --git a/libs/basic_games/games/game_dragonsdogmadarkarisen.py b/libs/basic_games/games/game_dragonsdogmadarkarisen.py new file mode 100644 index 0000000..e26b00e --- /dev/null +++ b/libs/basic_games/games/game_dragonsdogmadarkarisen.py @@ -0,0 +1,19 @@ +from ..basic_game import BasicGame + + +class NoMansSkyGame(BasicGame): + Name = "Dragon's Dogma: Dark Arisen Support Plugin" + Author = "Luca/EzioTheDeadPoet" + Version = "1.0.0" + + GameName = "Dragon's Dogma: Dark Arisen" + GameShortName = "dragonsdogma" + GaneNexusHame = "dragonsdogma" + GameSteamId = 367500 + GameGogId = 1242384383 + GameBinary = "DDDA.exe" + GameDataPath = "nativePC" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dragon's-Dogma:-Dark-Arisen" + ) diff --git a/libs/basic_games/games/game_dungeonsiege1.py b/libs/basic_games/games/game_dungeonsiege1.py new file mode 100644 index 0000000..4eb983d --- /dev/null +++ b/libs/basic_games/games/game_dungeonsiege1.py @@ -0,0 +1,92 @@ +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class DungeonSiegeIModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def get_resources_and_maps( + self, tree: mobase.IFileTree + ) -> tuple[list[mobase.FileTreeEntry], list[mobase.FileTreeEntry]]: + ress: list[mobase.FileTreeEntry] = [] + maps: list[mobase.FileTreeEntry] = [] + + for e in tree: + if e.isFile(): + if e.suffix().lower() == "dsres": + ress.append(e) + elif e.suffix().lower() == "dsmap": + maps.append(e) + + return ress, maps + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + # Check if we have a Resources / Maps folder or .dsres/.dsmap + ress, maps = self.get_resources_and_maps(filetree) + + if not ress and not maps: + if filetree.exists("Resources") or filetree.exists("Maps"): + return mobase.ModDataChecker.VALID + else: + return mobase.ModDataChecker.INVALID + + return mobase.ModDataChecker.FIXABLE + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + ress, maps = self.get_resources_and_maps(filetree) + + if ress: + rfolder = filetree.addDirectory("Resources") + for r in ress: + rfolder.insert(r, mobase.IFileTree.REPLACE) + if maps: + rfolder = filetree.addDirectory("Maps") + for r in maps: + rfolder.insert(r, mobase.IFileTree.REPLACE) + return filetree + + +class DungeonSiegeIGame(BasicGame): + Name = "Dungeon Siege I" + Author = "mrudat" + Version = "0.0.1" + + GameName = "Dungeon Siege I" + GameShortName = "dungeonsiege1" + GameNexusName = "dungeonsiege1" + GameNexusId = 541 + GameSteamId = [39190] + GameGogId = [1142020247] + GameBinary = "DungeonSiege.exe" + GameDataPath = "" + GameSavesDirectory = "%GAME_DOCUMENTS%/Save" + GameDocumentsDirectory = "%DOCUMENTS%/Dungeon Siege" + GameSaveExtension = "dssave" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dungeon-Siege-I" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(DungeonSiegeIModDataChecker()) + return True + + def executables(self): + execs = super().executables() + execs.append( + mobase.ExecutableInfo( + "Dungeon Siege Video Configuration", + QFileInfo(self.gameDirectory().absoluteFilePath("DSVideoConfig.exe")), + ) + ) + return execs + + def iniFiles(self): + return ["DungeonSiege.ini"] diff --git a/libs/basic_games/games/game_dungeonsiege2.py b/libs/basic_games/games/game_dungeonsiege2.py new file mode 100644 index 0000000..4a8f1de --- /dev/null +++ b/libs/basic_games/games/game_dungeonsiege2.py @@ -0,0 +1,92 @@ +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class DungeonSiegeIIModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def get_resources_and_maps( + self, tree: mobase.IFileTree + ) -> tuple[list[mobase.FileTreeEntry], list[mobase.FileTreeEntry]]: + ress: list[mobase.FileTreeEntry] = [] + maps: list[mobase.FileTreeEntry] = [] + + for e in tree: + if e.isFile(): + if e.suffix().lower() == "ds2res": + ress.append(e) + elif e.suffix().lower() == "ds2map": + maps.append(e) + + return ress, maps + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + # Check if we have a Resources / Maps folder or .ds2res/.ds2map + ress, maps = self.get_resources_and_maps(filetree) + + if not ress and not maps: + if filetree.exists("Resources") or filetree.exists("Maps"): + return mobase.ModDataChecker.VALID + else: + return mobase.ModDataChecker.INVALID + + return mobase.ModDataChecker.FIXABLE + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + ress, maps = self.get_resources_and_maps(filetree) + + if ress: + rfolder = filetree.addDirectory("Resources") + for r in ress: + rfolder.insert(r, mobase.IFileTree.REPLACE) + if maps: + rfolder = filetree.addDirectory("Maps") + for r in maps: + rfolder.insert(r, mobase.IFileTree.REPLACE) + return filetree + + +class DungeonSiegeIIGame(BasicGame): + Name = "Dungeon Siege II" + Author = "Holt59" + Version = "0.1.1" + + GameName = "Dungeon Siege II" + GameShortName = "dungeonsiegeii" + GameNexusName = "dungeonsiegeii" + GameNexusId = 2078 + GameSteamId = [39200] + GameGogId = [1142020247] + GameBinary = "DungeonSiege2.exe" + GameDataPath = "" + GameSavesDirectory = "%GAME_DOCUMENTS%/Save" + GameDocumentsDirectory = "%DOCUMENTS%/My Games/Dungeon Siege 2" + GameSaveExtension = "ds2party" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dungeon-Siege-II" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(DungeonSiegeIIModDataChecker()) + return True + + def executables(self): + execs = super().executables() + execs.append( + mobase.ExecutableInfo( + "Dungeon Siege Video Configuration", + QFileInfo(self.gameDirectory().absoluteFilePath("DS2VideoConfig.exe")), + ) + ) + return execs + + def iniFiles(self): + return ["DungeonSiege2.ini"] diff --git a/libs/basic_games/games/game_f123.py b/libs/basic_games/games/game_f123.py new file mode 100644 index 0000000..e740a40 --- /dev/null +++ b/libs/basic_games/games/game_f123.py @@ -0,0 +1,13 @@ +from ..basic_game import BasicGame + + +class F123Game(BasicGame): + Name = "F1 23 Support Plugin" + Author = "ju5tA1ex" + Version = "1.0.0" + + GameName = "F1 23" + GameShortName = "F1 23" + GameSteamId = 2108330 + GameBinary = "F1_23.exe" + GameDataPath = "" diff --git a/libs/basic_games/games/game_fantasylifei.py b/libs/basic_games/games/game_fantasylifei.py new file mode 100644 index 0000000..ae3cab1 --- /dev/null +++ b/libs/basic_games/games/game_fantasylifei.py @@ -0,0 +1,102 @@ +from pathlib import Path + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns +from ..basic_features.basic_save_game_info import BasicGameSaveGame +from ..basic_game import BasicGame +from ..steam_utils import find_steam_path + + +class FantasyLifeIModDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + move={ + "*.fliarchive": "Mods/", + "*.pak": "Paks/~mods/", + "*.ucas": "Paks/~mods/", + "*.utoc": "Paks/~mods/", + }, + valid=["L10N", "sound", "Mods", "Paks"], + ) + ) + + +class FantasyLifeI(BasicGame, mobase.IPluginFileMapper): + Name = "Fantasy Life I Support Plugin" + Author = "AmeliaCute" + Version = "0.2.2" + + GameName = "FANTASY LIFE i" + GameShortName = "fantasylifei" + GameNexusName = "fantasylifeithegirlwhostealstime" + GameValidShortNames = ["fli"] + + GameDataPath = "Game/Content/" + GameBinary = "Game/Binaries/Win64/NFL1-Win64-Shipping.exe" + GameSteamId = 2993780 + + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Fantasy-Life-I:-The-Girl-Who-Steals-Time" + ) + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(FantasyLifeIModDataChecker()) + self._register_feature(BasicLocalSavegames(self)) + return True + + def executables(self): + return [ + mobase.ExecutableInfo("Fantasy Life I", self.GameBinary), + ] + + ## SAVE + # credit to game.darkestdungeon.py + @staticmethod + def getCloudSaveDirectory() -> str | None: + steamPath = find_steam_path() + if steamPath is None: + return None + + userData = steamPath.joinpath("userdata") + for child in userData.iterdir(): + name = child.name + try: + int(name) + except ValueError: + continue + + cloudSaves = child.joinpath("2993780/remote") + if cloudSaves.exists() and cloudSaves.is_dir(): + return str(cloudSaves) + return None + + def savesDirectory(self) -> QDir: + return QDir(self.getCloudSaveDirectory()) + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + saves: list[Path] = [] + for path in Path(folder.absolutePath()).glob("*.bin"): + saves.append(path) + + ##TODO: need a proper implementation + return [BasicGameSaveGame(path) for path in saves] + + ## MAPPING + + def exeDirectory(self) -> QDir: + return QDir(QDir(self.gameDirectory()).filePath("Game/Binaries/Win64")) + + def mappings(self) -> list[mobase.Mapping]: + return [ + mobase.Mapping("*.dll", self.exeDirectory().absolutePath(), False, True), + ] diff --git a/libs/basic_games/games/game_finalfantasy7rebirth.py b/libs/basic_games/games/game_finalfantasy7rebirth.py new file mode 100644 index 0000000..c592824 --- /dev/null +++ b/libs/basic_games/games/game_finalfantasy7rebirth.py @@ -0,0 +1,157 @@ +import math +import random +from pathlib import Path +from typing import Iterable, List + +import mobase + +from ..basic_features.basic_mod_data_checker import BasicModDataChecker, GlobPatterns +from ..basic_game import BasicGame + + +class FinalFantasy7RebirthDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + move={ + "*.pak": "End/Content/Paks/~Mods/", + "*.ucas": "End/Content/Paks/~Mods/", + "*.utoc": "End/Content/Paks/~Mods/", + "*.dll": "End/Binaries/Win64/", + }, + valid=[ + "End/Content/Paks/~Mods", + "End/Binaries/Win64", + "End", + "/", + "_ROOT", + "_ROOT/End/Binaries/Win64", + "_ROOT/End/Content/Paks/~Mods", + ], + ) + ) + + +class FinalFantasy7RebirthFileManager: + _mods_extensions = [".pak", ".ucas", ".utoc"] + _file_extensions = [".dll"] + + def __init__( + self, + mod_path: Path, + file_path: Path, + organizer: mobase.IOrganizer, + load_enabled: mobase.MoVariant, + ) -> None: + self._mod_path = mod_path + self._file_path = file_path + self._organizer = organizer + self._load_enabled = load_enabled + + def _active_mods(self) -> Iterable[Path]: + mods_path = Path(self._organizer.modsPath()) + mod_list = self._organizer.modList() + mod_load_order = mod_list.allModsByProfilePriority() + for mod in mod_load_order: + if mod_list.state(mod) & mobase.ModState.ACTIVE: + yield mods_path / mod + + def _recursive(self, child: Path, mod_prefix: str) -> Iterable[mobase.Mapping]: + if child.suffix.lower() in self._file_extensions: + dest_mod_path = self._file_path / child.name + yield mobase.Mapping(str(child), str(dest_mod_path), child.is_dir(), True) + + if child.suffix.lower() in self._mods_extensions: + child_name = ( + child.with_stem(mod_prefix + child.stem).name + if self._load_enabled + else child.name + ) + dest_mod_path = self._mod_path / child_name + yield mobase.Mapping(str(child), str(dest_mod_path), child.is_dir(), True) + + if child.is_dir(): + for inner in child.iterdir(): + yield from self._recursive(inner, mod_prefix) + + def mod_mapping(self) -> Iterable[mobase.Mapping]: + priority_digits = math.floor(math.log10(random.randrange(1, 999))) + 1 + for priority, mod_path in enumerate(self._active_mods()): + mod_prefix = str(priority).zfill(priority_digits) + "_" + for child in mod_path.iterdir(): + yield from self._recursive(child, mod_prefix) + + +class FinalFantasy7RebirthGame(BasicGame, mobase.IPluginFileMapper): + Name = "Final Fasntasy 7 Rebirth Support Plugin" + Author = "diegofesanto, TheUnlocked" + Version = "0.0.1" + + GameName = "Final Fantasy VII Rebirth" + GameShortName = "finalfantasy7rebirth" + GameNexuName = "finalfantasy7rebirth" + GameSteamId = 2909400 + GameBinary = "End/Binaries/Win64/ff7rebirth_.exe" + GameDataPath = "_ROOT" + GameSaveExtension = "sav" + + _forced_libraries = ["xinput1_3.dll"] + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + + self._register_feature(FinalFantasy7RebirthDataChecker()) + + return True + + def settings(self) -> list[mobase.PluginSetting]: + return [ + mobase.PluginSetting( + "enforce_mod_load_order", + ("Force mod load order by list priority"), + True, + ) + ] + + def executables(self) -> list[mobase.ExecutableInfo]: + game_name = self.gameName() + game_dir = self.gameDirectory() + bin_path = game_dir.absoluteFilePath(self.binaryName()) + return [ + # Default + mobase.ExecutableInfo( + f"{game_name}", + bin_path, + ) + ] + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + exe = Path(self.binaryName()).name + return [ + mobase.ExecutableForcedLoadSetting(exe, lib).withEnabled(True) + for lib in self._forced_libraries + ] + + def _get_settings(self, key: str) -> mobase.MoVariant: + return self._organizer.pluginSetting(self.name(), key) + + def mappings(self) -> List[mobase.Mapping]: + mod_path = Path(self.gameDirectory().absolutePath()) / "End/Content/Paks/~Mods" + file_path = Path(self.gameDirectory().absolutePath()) / "End/Binaries/Win64" + load_enabled = self._get_settings("enforce_mod_load_order") + file_manager = FinalFantasy7RebirthFileManager( + mod_path, file_path, self._organizer, load_enabled + ) + + return [ + mobase.Mapping( + self._organizer.overwritePath(), + str(mod_path), + True, + True, + ) + ] + list(file_manager.mod_mapping()) diff --git a/libs/basic_games/games/game_finalfantasy7remake.py b/libs/basic_games/games/game_finalfantasy7remake.py new file mode 100644 index 0000000..44dd74f --- /dev/null +++ b/libs/basic_games/games/game_finalfantasy7remake.py @@ -0,0 +1,78 @@ +import math +from pathlib import Path +from typing import Iterable, List + +import mobase + +from ..basic_game import BasicGame + + +class FinalFantasy7RemakeGame(BasicGame, mobase.IPluginFileMapper): + Name = "Final Fantasy VII Remake Support Plugin" + Author = "TheUnlocked" + Version = "1.0.0" + + GameName = "Final Fantasy VII Remake" + GameShortName = "finalfantasy7remake" + GameNexusName = "finalfantasy7remake" + GameSteamId = 1462040 + GameBinary = "ff7remake.exe" + GameSaveExtension = "sav" + # _ROOT is a placeholder value. + # In order to properly apply load order to mods, custom mapping is used below. + GameDataPath = "_ROOT" + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def init(self, organizer: mobase.IOrganizer): + return BasicGame.init(self, organizer) + + def _get_mods_path(self): + """The directory path of where .pak files from mods should go""" + return ( + Path(self.gameDirectory().absolutePath()) + / "End" + / "Content" + / "Paks" + / "~mods" + ) + + 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 _active_mod_mappings(self, mod_paths: List[Path]) -> Iterable[mobase.Mapping]: + if not mod_paths: + return + pak_priority_digits = math.floor(math.log10(len(mod_paths))) + 1 + + for priority, mod_path in enumerate(mod_paths): + pak_prefix = str(priority).zfill(pak_priority_digits) + "_" + for child in mod_path.iterdir(): + dest_path = ( + self._get_mods_path() + / child.with_stem(pak_prefix + child.stem).name + ) + if child.is_dir() or child.suffix.lower() == ".pak": + yield mobase.Mapping( + str(child), + str(dest_path), + child.is_dir(), + ) + + def mappings(self) -> List[mobase.Mapping]: + return [ + # Not applying load order modifications to overwrites is OK + # since the UX is better this way and it will generally work out anyways + mobase.Mapping( + self._organizer.overwritePath(), + str(self._get_mods_path()), + True, + True, + ) + ] + list(self._active_mod_mappings(list(self._active_mod_paths()))) diff --git a/libs/basic_games/games/game_gta-3-de.py b/libs/basic_games/games/game_gta-3-de.py new file mode 100644 index 0000000..c9fc338 --- /dev/null +++ b/libs/basic_games/games/game_gta-3-de.py @@ -0,0 +1,72 @@ +import os +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class GTA3DefinitiveEditionModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if Path(entry.name().casefold()).suffix == ".pak": + return mobase.ModDataChecker.VALID + + return mobase.ModDataChecker.INVALID + + +class GTA3DefinitiveEditionGame(BasicGame): + Name = "Grand Theft Auto III - Definitive Edition Support Plugin" + Author = "dekart811" + Version = "1.0" + + GameName = "GTA III - Definitive Edition" + GameShortName = "grandtheftautothetrilogy" + GameNexusName = "grandtheftautothetrilogy" + GameBinary = "Gameface/Binaries/Win64/LibertyCity.exe" + GameDataPath = "Gameface/Content/Paks/~mods" + GameDocumentsDirectory = ( + "%USERPROFILE%/Documents/Rockstar Games/" + "GTA III Definitive Edition/Config/WindowsNoEditor" + ) + GameSavesDirectory = "%GAME_DOCUMENTS%/../../SaveGames" + GameSaveExtension = "sav" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Grand-Theft-Auto:-The-Trilogy-%E2%80%90-The-Definitive-Edition" + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(GTA3DefinitiveEditionModDataChecker()) + return True + + def executables(self): + return [ + mobase.ExecutableInfo( + "GTA III - Definitive Edition", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "GTA III - Definitive Edition (DirectX 12)", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ).withArgument("-dx12"), + ] + + def iniFiles(self): + return ["GameUserSettings.ini", "CustomSettings.ini"] + + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): + # Create the mods directory if it doesn't exist + modsPath = self.dataDirectory().absolutePath() + if not os.path.exists(modsPath): + os.mkdir(modsPath) + + super().initializeProfile(directory, settings) diff --git a/libs/basic_games/games/game_gta-san-andreas-de.py b/libs/basic_games/games/game_gta-san-andreas-de.py new file mode 100644 index 0000000..200a562 --- /dev/null +++ b/libs/basic_games/games/game_gta-san-andreas-de.py @@ -0,0 +1,72 @@ +import os +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class GTASanAndreasDefinitiveEditionModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if Path(entry.name().casefold()).suffix == ".pak": + return mobase.ModDataChecker.VALID + + return mobase.ModDataChecker.INVALID + + +class GTASanAndreasDefinitiveEditionGame(BasicGame): + Name = "Grand Theft Auto: San Andreas - Definitive Edition Support Plugin" + Author = "dekart811" + Version = "1.0" + + GameName = "GTA: San Andreas - Definitive Edition" + GameShortName = "grandtheftautothetrilogy" + GameNexusName = "grandtheftautothetrilogy" + GameBinary = "Gameface/Binaries/Win64/SanAndreas.exe" + GameDataPath = "Gameface/Content/Paks/~mods" + GameDocumentsDirectory = ( + "%USERPROFILE%/Documents/Rockstar Games/" + "GTA San Andreas Definitive Edition/Config/WindowsNoEditor" + ) + GameSavesDirectory = "%GAME_DOCUMENTS%/../../SaveGames" + GameSaveExtension = "sav" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Grand-Theft-Auto:-The-Trilogy-%E2%80%90-The-Definitive-Edition" + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(GTASanAndreasDefinitiveEditionModDataChecker()) + return True + + def executables(self): + return [ + mobase.ExecutableInfo( + "GTA: San Andreas - Definitive Edition", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "GTA: San Andreas - Definitive Edition (DirectX 12)", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ).withArgument("-dx12"), + ] + + def iniFiles(self): + return ["GameUserSettings.ini", "CustomSettings.ini"] + + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): + # Create the mods directory if it doesn't exist + modsPath = self.dataDirectory().absolutePath() + if not os.path.exists(modsPath): + os.mkdir(modsPath) + + super().initializeProfile(directory, settings) diff --git a/libs/basic_games/games/game_gta-vice-city-de.py b/libs/basic_games/games/game_gta-vice-city-de.py new file mode 100644 index 0000000..fa068e6 --- /dev/null +++ b/libs/basic_games/games/game_gta-vice-city-de.py @@ -0,0 +1,72 @@ +import os +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class GTAViceCitysDefinitiveEditionModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if Path(entry.name().casefold()).suffix == ".pak": + return mobase.ModDataChecker.VALID + + return mobase.ModDataChecker.INVALID + + +class GTAViceCityDefinitiveEditionGame(BasicGame): + Name = "Grand Theft Auto: Vice City - Definitive Edition Support Plugin" + Author = "dekart811" + Version = "1.0" + + GameName = "GTA: Vice City - Definitive Edition" + GameShortName = "grandtheftautothetrilogy" + GameNexusName = "grandtheftautothetrilogy" + GameBinary = "Gameface/Binaries/Win64/ViceCity.exe" + GameDataPath = "Gameface/Content/Paks/~mods" + GameDocumentsDirectory = ( + "%USERPROFILE%/Documents/Rockstar Games/" + "GTA Vice City Definitive Edition/Config/WindowsNoEditor" + ) + GameSavesDirectory = "%GAME_DOCUMENTS%/../../SaveGames" + GameSaveExtension = "sav" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Grand-Theft-Auto:-The-Trilogy-%E2%80%90-The-Definitive-Edition" + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(GTAViceCitysDefinitiveEditionModDataChecker()) + return True + + def executables(self): + return [ + mobase.ExecutableInfo( + "GTA: Vice City - Definitive Edition", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "GTA: Vice City - Definitive Edition (DirectX 12)", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ).withArgument("-dx12"), + ] + + def iniFiles(self): + return ["GameUserSettings.ini", "CustomSettings.ini"] + + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): + # Create the mods directory if it doesn't exist + modsPath = self.dataDirectory().absolutePath() + if not os.path.exists(modsPath): + os.mkdir(modsPath) + + super().initializeProfile(directory, settings) diff --git a/libs/basic_games/games/game_kerbalspaceprogram.py b/libs/basic_games/games/game_kerbalspaceprogram.py new file mode 100644 index 0000000..fec834f --- /dev/null +++ b/libs/basic_games/games/game_kerbalspaceprogram.py @@ -0,0 +1,61 @@ +from pathlib import Path + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_features.basic_save_game_info import BasicGameSaveGame +from ..basic_game import BasicGame + + +class KerbalSpaceProgramSaveGame(BasicGameSaveGame): + def allFiles(self): + files = [super().getFilepath()] + banner = self._filepath.joinpath("banners").joinpath(f"${self.getName()}.png") + if banner.exists(): + files.append(banner.as_posix()) + return files + + def getName(self): + return self._filepath.stem + + def getSaveGroupIdentifier(self): + return self._filepath.parent.name + + +class KerbalSpaceProgramGame(BasicGame): + Name = "Kerbal Space Program Support Plugin" + Author = "LaughingHyena" + Version = "1.0.0" + + GameName = "Kerbal Space Program" + GameShortName = "kerbalspaceprogram" + GameNexusName = "kerbalspaceprogram" + GameSteamId = [220200, 283740, 982970] + GameBinary = "KSP_x64.exe" + GameDataPath = "GameData" + GameSavesDirectory = "%GAME_PATH%/saves" + GameSaveExtension = "sfs" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Kerbal-Space-Program" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature( + BasicGameSaveGameInfo( + lambda s: str( + Path(s).parent.joinpath("banners").joinpath(f"{Path(s).stem}.png") + ) + ) + ) + return True + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + KerbalSpaceProgramSaveGame(path) + for path in Path(folder.absolutePath()).glob(f"*/*.{ext}") + ] diff --git a/libs/basic_games/games/game_kingdomcomedeliverance.py b/libs/basic_games/games/game_kingdomcomedeliverance.py new file mode 100644 index 0000000..0bc5fe1 --- /dev/null +++ b/libs/basic_games/games/game_kingdomcomedeliverance.py @@ -0,0 +1,48 @@ +import os + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_game import BasicGame + + +class KingdomComeDeliveranceGame(BasicGame): + Name = "Kingdom Come Deliverance Support Plugin" + Author = "Silencer711" + Version = "1.0.0" + + GameName = "Kingdom Come: Deliverance" + GameShortName = "kingdomcomedeliverance" + GameNexusName = "kingdomcomedeliverance" + GameNexusId = 2298 + GameSteamId = [379430] + GameGogId = [1719198803] + GameEpicId = "Eel" + GameBinary = "bin/Win64/KingdomCome.exe" + GameDataPath = "mods" + GameSaveExtension = "whs" + GameDocumentsDirectory = "%GAME_PATH%" + GameSavesDirectory = "%USERPROFILE%/Saved Games/kingdomcome/saves" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Kingdom-Come:-Deliverance" + ) + + def iniFiles(self): + return ["custom.cfg", "system.cfg", "user.cfg"] + + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): + # Create .cfg files if they don't exist + for iniFile in self.iniFiles(): + iniPath = self.documentsDirectory().absoluteFilePath(iniFile) + if not os.path.exists(iniPath): + with open(iniPath, "w") as _: + pass + + # Create the mods directory if it doesn't exist + modsPath = self.dataDirectory().absolutePath() + if not os.path.exists(modsPath): + os.mkdir(modsPath) + + super().initializeProfile(directory, settings) diff --git a/libs/basic_games/games/game_masterduel.py b/libs/basic_games/games/game_masterduel.py new file mode 100644 index 0000000..8c23512 --- /dev/null +++ b/libs/basic_games/games/game_masterduel.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import List, Optional + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class MasterDuelGame(BasicGame, mobase.IPluginFileMapper): + Name = "Yu-Gi-Oh! Master Duel Support Plugin" + Author = "The Conceptionist & uwx" + Version = "1.0.2" + Description = ( + "Adds support for basic Yu-Gi-Oh! Master Duel mods.\n" + 'Eligible folders for mods are "0000" and "AssetBundle".' + ) + + GameName = "Yu-Gi-Oh! Master Duel" + GameShortName = "masterduel" + GameNexusName = "yugiohmasterduel" + GameNexusId = 4272 + GameSteamId = 1449850 + GameBinary = "masterduel.exe" + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def executables(self): + return [ + mobase.ExecutableInfo( + "Yu-Gi-Oh! Master Duel", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ).withArgument("-popupwindow"), + ] + + # dataDirectory returns the specific LocalData folder because + # this is where most mods will go to + def dataDirectory(self) -> QDir: + return QDir(self.userDataDir()) + + _userDataDirCached: Optional[str] = None + + # Gets the LocalData/xxxxxxxxx directory. This directory has a different, + # unique, 8-character hex name for each user. + def userDataDir(self) -> str: + if self._userDataDirCached is not None: + return self._userDataDirCached + + dir = self.gameDirectory() + dir.cd("LocalData") + + subdirs = dir.entryList(filters=QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot) + dir.cd(subdirs[0]) + + self._userDataDirCached = dir.absolutePath() + return self._userDataDirCached + + def mappings(self) -> List[mobase.Mapping]: + modsPath = Path(self._organizer.modsPath()) + unityMods = self.getUnityDataMods() + + mappings: List[mobase.Mapping] = [] + + for modName in unityMods: + m = mobase.Mapping() + m.createTarget = False + m.isDirectory = True + m.source = modsPath.joinpath(modName, "AssetBundle").as_posix() + m.destination = self.gameDirectory().filePath( + Path("masterduel_Data", "StreamingAssets", "AssetBundle").as_posix() + ) + mappings.append(m) + + return mappings + + def getUnityDataMods(self) -> list[str]: + modsPath = Path(self._organizer.modsPath()) + allMods = self._organizer.modList().allModsByProfilePriority() + + unityMods: list[str] = [] + for modName in allMods: + if self._organizer.modList().state(modName) & mobase.ModState.ACTIVE != 0: + if modsPath.joinpath(modName, "AssetBundle").exists(): + unityMods.append(modName) + + return unityMods diff --git a/libs/basic_games/games/game_metalgearsolid2mc.py b/libs/basic_games/games/game_metalgearsolid2mc.py new file mode 100644 index 0000000..30804b8 --- /dev/null +++ b/libs/basic_games/games/game_metalgearsolid2mc.py @@ -0,0 +1,17 @@ +from ..basic_game import BasicGame + + +class MetalGearSolid2MCGame(BasicGame): + Name = ( + "METAL GEAR SOLID 2: Sons of Liberty - Master Collection Version Support Plugin" + ) + Author = "AkiraJkr" + Version = "1.0.0" + + GameName = "METAL GEAR SOLID 2: Sons of Liberty - Master Collection Version" + GameShortName = "metalgearsolid2mc" + GameNexusName = "metalgearsolid2mc" + GameSteamId = 2131640 + GameBinary = "METAL GEAR SOLID2.exe" + GameDataPath = "" + GameLauncher = "launcher.exe" diff --git a/libs/basic_games/games/game_metalgearsolid3mc.py b/libs/basic_games/games/game_metalgearsolid3mc.py new file mode 100644 index 0000000..10f669c --- /dev/null +++ b/libs/basic_games/games/game_metalgearsolid3mc.py @@ -0,0 +1,15 @@ +from ..basic_game import BasicGame + + +class MetalGearSolid3MCGame(BasicGame): + Name = "METAL GEAR SOLID 3: Snake Eater - Master Collection Version Support Plugin" + Author = "AkiraJkr" + Version = "1.0.0" + + GameName = "METAL GEAR SOLID 3: Snake Eater - Master Collection Version" + GameShortName = "metalgearsolid3mc" + GameNexusName = "metalgearsolid3mc" + GameSteamId = 2131650 + GameBinary = "METAL GEAR SOLID3.exe" + GameDataPath = "" + GameLauncher = "launcher.exe" diff --git a/libs/basic_games/games/game_mirrorsedge.py b/libs/basic_games/games/game_mirrorsedge.py new file mode 100644 index 0000000..db81908 --- /dev/null +++ b/libs/basic_games/games/game_mirrorsedge.py @@ -0,0 +1,19 @@ +from ..basic_game import BasicGame + + +class MirrorsEdgeGame(BasicGame): + Name = "Mirror's Edge Support Plugin" + Author = "Luca/EzioTheDeadPoet" + Version = "1.0.0" + + GameName = "Mirror's Edge" + GameShortName = "mirrorsedge" + GaneNexusHame = "" + GameSteamId = 17410 + GameGogId = 1893001152 + GameBinary = "Binaries/MirrorsEdge.exe" + GameDataPath = "TdGame" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Mirror's-Edge" + ) diff --git a/libs/basic_games/games/game_monsterhunterrise.py b/libs/basic_games/games/game_monsterhunterrise.py new file mode 100644 index 0000000..e63ed10 --- /dev/null +++ b/libs/basic_games/games/game_monsterhunterrise.py @@ -0,0 +1,15 @@ +from ..basic_game import BasicGame + + +class MonsterHunterRiseGame(BasicGame): + Name = "Monster Hunter: Rise Support Plugin" + Author = "RodolfoFigueroa" + Version = "1.0.0" + + GameName = "Monster Hunter: Rise" + GameShortName = "monsterhunterrise" + GameBinary = "MonsterHunterRise.exe" + GameDataPath = "%GAME_PATH%" + GameSaveExtension = "bin" + GameNexusId = 4095 + GameSteamId = 1446780 diff --git a/libs/basic_games/games/game_monsterhunterworld.py b/libs/basic_games/games/game_monsterhunterworld.py new file mode 100644 index 0000000..290048c --- /dev/null +++ b/libs/basic_games/games/game_monsterhunterworld.py @@ -0,0 +1,21 @@ +from ..basic_game import BasicGame + + +class MonsterHunterWorldGame(BasicGame): + Name = "Monster Hunter: World Support Plugin" + Author = "prz" + Version = "1.0.0" + + GameName = "Monster Hunter: World" + GameShortName = "monsterhunterworld" + GameNexusName = "monsterhunterworld" + GameNexusId = 2531 + GameBinary = "MonsterHunterWorld.exe" + GameLauncher = "MonsterHunterWorld.exe" + GameDataPath = "%GAME_PATH%" + GameSaveExtension = "dat" + GameSteamId = 582010 + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Monster-Hunter:-World" + ) diff --git a/libs/basic_games/games/game_mountandblade2.py b/libs/basic_games/games/game_mountandblade2.py new file mode 100644 index 0000000..df2c157 --- /dev/null +++ b/libs/basic_games/games/game_mountandblade2.py @@ -0,0 +1,100 @@ +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_features import BasicLocalSavegames +from ..basic_game import BasicGame, BasicGameSaveGame + + +class MountAndBladeIIModDataChecker(mobase.ModDataChecker): + _valid_folders: list[str] = [ + "native", + "sandbox", + "sandboxcore", + "storymode", + "custombattle", + ] + + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for e in filetree: + if e.isDir(): + if e.name().lower() in self._valid_folders: + return mobase.ModDataChecker.VALID + if e.exists("SubModule.xml", mobase.IFileTree.FILE): # type: ignore + return mobase.ModDataChecker.VALID + + return mobase.ModDataChecker.INVALID + + +class MountAndBladeIIGame(BasicGame): + Name = "Mount & Blade II: Bannerlord" + Author = "Holt59" + Version = "0.1.1" + Description = "Adds support for Mount & Blade II: Bannerlord" + + GameName = "Mount & Blade II: Bannerlord" + GameShortName = "mountandblade2bannerlord" + GameDataPath = "Modules" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Mount-&-Blade-II:-Bannerlord" + ) + + GameBinary = "bin/Win64_Shipping_Client/TaleWorlds.MountAndBlade.Launcher.exe" + + GameDocumentsDirectory = "%DOCUMENTS%/Mount and Blade II Bannerlord/Configs" + GameSavesDirectory = "%DOCUMENTS%/Mount and Blade II Bannerlord/Game Saves" + + GameNexusId = 3174 + GameSteamId = 261550 + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(MountAndBladeIIModDataChecker()) + self._register_feature(BasicLocalSavegames(self)) + return True + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + save_paths = list(Path(folder.absolutePath()).glob("*.sav")) + list( + Path(folder.absolutePath()).glob("*.sav.cleaner_backup_*") + ) + return [BasicGameSaveGame(path) for path in save_paths] + + def executables(self): + return [ + mobase.ExecutableInfo( + "Mount & Blade II: Bannerlord (Launcher)", + QFileInfo( + self.gameDirectory(), + "bin/Win64_Shipping_Client/TaleWorlds.MountAndBlade.Launcher.exe", + ), + ), + mobase.ExecutableInfo( + "Mount & Blade II: Bannerlord", + QFileInfo( + self.gameDirectory(), + "bin/Win64_Shipping_Client/Bannerlord.exe", + ), + ), + mobase.ExecutableInfo( + "Mount & Blade II: Bannerlord (Native)", + QFileInfo( + self.gameDirectory(), + "bin/Win64_Shipping_Client/Bannerlord.Native.exe", + ), + ), + mobase.ExecutableInfo( + "Mount & Blade II: Bannerlord (BE)", + QFileInfo( + self.gameDirectory(), + "bin/Win64_Shipping_Client/Bannerlord_BE.exe", + ), + ), + ] diff --git a/libs/basic_games/games/game_msfs2020.py b/libs/basic_games/games/game_msfs2020.py new file mode 100644 index 0000000..d0c91ba --- /dev/null +++ b/libs/basic_games/games/game_msfs2020.py @@ -0,0 +1,34 @@ +import os +import re + +from PyQt6.QtCore import QDir + +from ..basic_game import BasicGame + + +class MSFS2020Game(BasicGame): + Name = "Microsoft Flight Simulator 2020 Support Plugin" + Author = "Deorder" + Version = "0.0.1" + + GameName = "Microsoft Flight Simulator 2020" + GameShortName = "msfs2020" + GameBinary = r"FlightSimulator.exe" + GameSteamId = [1250410] + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Microsoft-Flight-Simulator-(2020)" + ) + + def dataDirectory(self) -> QDir: + # Find and use package path specified in Asobo engine options + AppDataPath = os.path.expandvars(r"%APPDATA%\Microsoft Flight Simulator") + UserCfgPath = os.path.join(AppDataPath, "UserCfg.opt") + InstalledPackagesPathPattern = re.compile( + r'InstalledPackagesPath\s*=\s*"(.*)"', re.IGNORECASE + ) + with open(UserCfgPath, newline="") as f: + for _, line in enumerate(f): + for match in re.finditer(InstalledPackagesPathPattern, line): + return QDir(os.path.join(match.group(), "Community")) + return QDir(os.path.join(AppDataPath, "Packages", "Community")) diff --git a/libs/basic_games/games/game_nfshs.py b/libs/basic_games/games/game_nfshs.py new file mode 100644 index 0000000..ef24234 --- /dev/null +++ b/libs/basic_games/games/game_nfshs.py @@ -0,0 +1,28 @@ +# -*- encoding: utf-8 -*- + +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class NFSHSGame(BasicGame): + Name = "Need for Speed: High Stakes Support Plugin" + Author = "uwx" + Version = "1.0.0" + + GameName = "Need for Speed: High Stakes" + GameShortName = "nfshs" + GameNexusName = "needforspeedhighstakes" + GameNexusId = 6032 + GameBinary = "nfshs.exe" + GameDataPath = "" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Need for Speed: High Stakes", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ) + ] diff --git a/libs/basic_games/games/game_nierautomata.py b/libs/basic_games/games/game_nierautomata.py new file mode 100644 index 0000000..3026fb1 --- /dev/null +++ b/libs/basic_games/games/game_nierautomata.py @@ -0,0 +1,14 @@ +from ..basic_game import BasicGame + + +class NierAutomataGame(BasicGame): + Name = "NieR:Automata Support Plugin" + Author = "Luca/EzioTheDeadPoet" + Version = "1.0.0" + + GameName = "NieR:Automata" + GameShortName = "nierautomata" + GameNexusName = "nierautomata" + GameSteamId = 524220 + GameBinary = "NieRAutomata.exe" + GameDataPath = "" diff --git a/libs/basic_games/games/game_nomanssky.py b/libs/basic_games/games/game_nomanssky.py new file mode 100644 index 0000000..651a326 --- /dev/null +++ b/libs/basic_games/games/game_nomanssky.py @@ -0,0 +1,38 @@ +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class NoMansSkyGame(BasicGame): + Name = "No Man's Sky Support Plugin" + Author = "Luca/EzioTheDeadPoet" + Version = "1.0.0" + + GameName = "No Man's Sky" + GameShortName = "nomanssky" + GameNexusName = "nomanssky" + GameNexusId = 1634 + GameSteamId = 275850 + GameGogId = 1446213994 + GameBinary = "Binaries/NMS.exe" + GameDataPath = "GAMEDATA/MODS" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-No-Man's-Sky" + ) + GameSavesDirectory = "%USERPROFILE%/AppData/Roaming/HelloGames/NMS" + GameSaveExtension = "hg" + + def executables(self): + return [ + mobase.ExecutableInfo( + "No Man's Sky", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "No Man's Sky VR", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ).withArgument("-HmdEnable 1"), + ] diff --git a/libs/basic_games/games/game_oblivion_remaster.py b/libs/basic_games/games/game_oblivion_remaster.py new file mode 100644 index 0000000..218bb04 --- /dev/null +++ b/libs/basic_games/games/game_oblivion_remaster.py @@ -0,0 +1,441 @@ +import json +import os.path +import shutil +import winreg +from enum import IntEnum, auto +from pathlib import Path +from typing import cast + +from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths +from PyQt6.QtWidgets import QMainWindow, QTabWidget, QWidget + +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_game import BasicGame +from .oblivion_remaster.constants import DEFAULT_UE4SS_MODS, PLUGIN_NAME, UE4SSModInfo +from .oblivion_remaster.paks.widget import PaksTabWidget +from .oblivion_remaster.ue4ss.widget import UE4SSTabWidget + + +def getLootPath() -> Path | None: + """ + Parse the LOOT path using either the modern InnoSetup registry entries (local vs. global installs) or the + old registry path. + """ + paths = [ + ( + winreg.HKEY_CURRENT_USER, + "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{BF634210-A0D4-443F-A657-0DCE38040374}_is1", + "InstallLocation", + ), + ( + winreg.HKEY_LOCAL_MACHINE, + "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{BF634210-A0D4-443F-A657-0DCE38040374}_is1", + "InstallLocation", + ), + (winreg.HKEY_LOCAL_MACHINE, "Software\\LOOT", "Installed Path"), + ] + + for path in paths: + try: + with winreg.OpenKeyEx( + path[0], + path[1], + ) as key: + value = winreg.QueryValueEx(key, path[2]) + return Path((value[0] + "/LOOT.exe").replace("/", "\\")) + except FileNotFoundError: + pass + return None + + +class Problems(IntEnum): + """ + Enums for IPluginDiagnose. + """ + + # The 'dwmapi.dll' is present in the game EXE directory. + UE4SS_LOADER = auto() + # A UE4SS mod has a space in the directory name. + INVALID_UE4SS_MOD_NAME = auto() + + +class OblivionRemasteredGame( + BasicGame, mobase.IPluginFileMapper, mobase.IPluginDiagnose +): + Name = PLUGIN_NAME + Author = "Silarn" + Version = "0.1.0-b.1" + Description = "TES IV: Oblivion Remastered; an unholy hybrid of Bethesda and Unreal" + + GameName = "Oblivion Remastered" + GameShortName = "oblivionremastered" + GameNexusId = 7587 + GameSteamId = 2623190 + GameBinary = "OblivionRemastered.exe" + GameDataPath = r"%GAME_PATH%\OblivionRemastered\Content\Dev\ObvData\Data" + GameDocumentsDirectory = r"%GAME_PATH%\OblivionRemastered\Content\Dev\ObvData" + UserHome = QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.HomeLocation + ) + # Oblivion Remastered does not use the expanded Documents path but instead always uses the + # base user directory path, even when this disagrees with Windows. + MyDocumentsDirectory = rf"{UserHome}\Documents\My Games\{GameName}" + GameSavesDirectory = rf"{MyDocumentsDirectory}\Saved\SaveGames" + GameSaveExtension = "sav" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Elder-Scrolls-IV:-Oblivion-Remastered" + ) + + _main_window: QMainWindow + _ue4ss_tab: UE4SSTabWidget + _paks_tab: PaksTabWidget + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + mobase.IPluginDiagnose.__init__(self) + + def init(self, organizer: mobase.IOrganizer) -> bool: + from .oblivion_remaster.game_plugins import OblivionRemasteredGamePlugins + from .oblivion_remaster.mod_data_checker import OblivionRemasteredModDataChecker + from .oblivion_remaster.mod_data_content import OblivionRemasteredDataContent + from .oblivion_remaster.script_extender import OblivionRemasteredScriptExtender + + super().init(organizer) + self._register_feature(BasicGameSaveGameInfo()) + self._register_feature(OblivionRemasteredGamePlugins(self._organizer)) + self._register_feature(OblivionRemasteredModDataChecker(self._organizer)) + self._register_feature(OblivionRemasteredScriptExtender(self)) + self._register_feature(OblivionRemasteredDataContent()) + + organizer.onUserInterfaceInitialized(self.init_tab) + return True + + def init_tab(self, main_window: QMainWindow): + """ + Initializes tabs unique to Oblivion Remastered. + The "UE4SS Mods" tab and "Paks" tab. + """ + if self._organizer.managedGame() != self: + return + + self._main_window = main_window + tab_widget: QTabWidget = main_window.findChild(QTabWidget, "tabWidget") + if not tab_widget or not tab_widget.findChild(QWidget, "espTab"): + return + + self._ue4ss_tab = UE4SSTabWidget(main_window, self._organizer) + + # Find the default "Plugins" tab + plugin_tab = tab_widget.findChild(QWidget, "espTab") + tab_index = tab_widget.indexOf(plugin_tab) + 1 + # The "Bethesda Plugins Manager" plugin hides the default Plugins tab and inserts itself after. + # If the default tab is hidden, increment our position by one to account for it. + if not tab_widget.isTabVisible(tab_widget.indexOf(plugin_tab)): + tab_index += 1 + tab_widget.insertTab(tab_index, self._ue4ss_tab, "UE4SS Mods") + + self._paks_tab = PaksTabWidget(main_window, self._organizer) + + tab_index += 1 + tab_widget.insertTab(tab_index, self._paks_tab, "Paks") + + def settings(self) -> list[mobase.PluginSetting]: + return [ + mobase.PluginSetting( + "ue4ss_use_root_builder", "Use Root Builder paths for UE4SS mods", False + ) + ] + + def executables(self): + execs = [ + mobase.ExecutableInfo( + "Oblivion Remastered", + QFileInfo( + QDir( + self.gameDirectory().absoluteFilePath( + "OblivionRemastered/Binaries/Win64" + ) + ), + "OblivionRemastered-Win64-Shipping.exe", + ), + ) + ] + if extender := self._organizer.gameFeatures().gameFeature( + mobase.ScriptExtender + ): + execs.append( + mobase.ExecutableInfo("OBSE64", QFileInfo(extender.loaderPath())) # type: ignore + ) + if lootPath := getLootPath(): + execs.append( + mobase.ExecutableInfo("LOOT", QFileInfo(str(lootPath))).withArgument( + '--game="Oblivion Remastered"' + ) + ) + if magicLoaderPath := self.gameDirectory().absoluteFilePath( + "MagicLoader/MagicLoader.exe" + ): + execs.append( + mobase.ExecutableInfo("MagicLoader", QFileInfo(magicLoaderPath)) + ) + + return execs + + def primaryPlugins(self) -> list[str]: + return [ + "Oblivion.esm", + "DLCBattlehornCastle.esp", + "DLCFrostcrag.esp", + "DLCHorseArmor.esp", + "DLCMehrunesRazor.esp", + "DLCOrrery.esp", + "DLCShiveringIsles.esp", + "DLCSpellTomes.esp", + "DLCThievesDen.esp", + "DLCVileLair.esp", + "Knights.esp", + "AltarESPMain.esp", + "AltarDeluxe.esp", + "AltarESPLocal.esp", # Not actually shipped with the game files but present in plugins.txt. + ] + + def modDataDirectory(self) -> str: + return "Data" + + def moviesDirectory(self) -> QDir: + return QDir( + self.gameDirectory().absolutePath() + "/OblivionRemastered/Content/Movies" + ) + + def paksDirectory(self) -> QDir: + return QDir( + self.gameDirectory().absolutePath() + "/OblivionRemastered/Content/Paks" + ) + + def exeDirectory(self) -> QDir: + return QDir( + self.gameDirectory().absolutePath() + "/OblivionRemastered/Binaries/Win64" + ) + + def obseDirectory(self) -> QDir: + return QDir(self.exeDirectory().absolutePath() + "/OBSE") + + def ue4ssDirectory(self) -> QDir: + return QDir(self.exeDirectory().absolutePath() + "/ue4ss/Mods") + + def loadOrderMechanism(self) -> mobase.LoadOrderMechanism: + return mobase.LoadOrderMechanism.PLUGINS_TXT + + def sortMechanism(self) -> mobase.SortMechanism: + return mobase.SortMechanism.LOOT + + def initializeProfile( + self, directory: QDir, settings: mobase.ProfileSetting + ) -> None: + if settings & mobase.ProfileSetting.CONFIGURATION: + game_ini_file = self.gameDirectory().absoluteFilePath( + r"OblivionRemastered\Content\Dev\ObvData\Oblivion.ini" + ) + game_default_ini = self.gameDirectory().absoluteFilePath( + r"OblivionRemastered\Content\Dev\ObvData\Oblivion_default.ini" + ) + profile_ini = directory.absoluteFilePath( + QFileInfo("Oblivion.ini").fileName() + ) + if not os.path.exists(profile_ini): + try: + shutil.copyfile( + game_ini_file, + profile_ini, + ) + except FileNotFoundError: + if os.path.exists(game_ini_file): + shutil.copyfile( + game_default_ini, + profile_ini, + ) + else: + Path(profile_ini).touch() + # Initialize a default UE4SS mods.ini and mods.json with the core mods included + self.write_default_mods(directory) + # Bootstrap common mod directories used by the USVFS map + if ( + self._organizer.managedGame() + and self._organizer.managedGame().gameName() == self.gameName() + ): + if not self.paksDirectory().exists(): + os.makedirs(self.paksDirectory().absolutePath()) + if not self.obseDirectory().exists(): + os.makedirs(self.obseDirectory().absolutePath()) + if not self.ue4ssDirectory().exists(): + os.makedirs(self.ue4ssDirectory().absolutePath()) + + def write_default_mods(self, profile: QDir): + """ + Writer for the default UE4SS 'mods.txt' and 'mods.json' profile files. + """ + + ue4ss_mods_txt = QFileInfo(profile.absoluteFilePath("mods.txt")) + ue4ss_mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + if not ue4ss_mods_txt.exists(): + with open(ue4ss_mods_txt.absoluteFilePath(), "w") as mods_txt: + for mod in DEFAULT_UE4SS_MODS: + mods_txt.write(f"{mod['mod_name']} : 1\n") + if not ue4ss_mods_json.exists(): + mods_data: list[UE4SSModInfo] = [] + for mod in DEFAULT_UE4SS_MODS: + mods_data.append({"mod_name": mod["mod_name"], "mod_enabled": True}) + with open(ue4ss_mods_json.absoluteFilePath(), "w") as mods_json: + mods_json.write(json.dumps(mods_data, indent=4)) + + def iniFiles(self) -> list[str]: + return ["Oblivion.ini"] + + def mappings(self) -> list[mobase.Mapping]: + mappings: list[mobase.Mapping] = [] + for profile_file in ["plugins.txt", "loadorder.txt"]: + mappings.append( + mobase.Mapping( + self._organizer.profilePath() + "/" + profile_file, + self.dataDirectory().absolutePath() + "/" + profile_file, + False, + ) + ) + for profile_file in ["mods.txt", "mods.json"]: + mappings.append( + mobase.Mapping( + self._organizer.profilePath() + "/" + profile_file, + self.ue4ssDirectory().absolutePath() + "/" + profile_file, + False, + ) + ) + return mappings + + def getModMappings(self) -> dict[str, list[str]]: + return { + "Data": [self.dataDirectory().absolutePath()], + "Paks": [self.paksDirectory().absolutePath()], + "OBSE": [self.obseDirectory().absolutePath()], + "Movies": [self.moviesDirectory().absolutePath()], + "UE4SS": [self.ue4ssDirectory().absolutePath()], + "GameSettings": [self.exeDirectory().absoluteFilePath("GameSettings")], + } + + def activeProblems(self) -> list[int]: + if self._organizer.managedGame() == self: + problems: set[Problems] = set() + # The dwmapi.dll loader should not be used with USVFS + ue4ss_loader = QFileInfo(self.exeDirectory().absoluteFilePath("dwmapi.dll")) + if ue4ss_loader.exists(): + problems.add(Problems.UE4SS_LOADER) + # Leverage UE4SS mod tab to find mod names with spaces + for mod in self._ue4ss_tab.get_mod_list(): + if " " in mod: + problems.add(Problems.INVALID_UE4SS_MOD_NAME) + break + return list(problems) + return [] + + def fullDescription(self, key: int) -> str: + match key: + case Problems.UE4SS_LOADER: + return ( + "The UE4SS loader DLL is present (dwmapi.dll). This will not function out-of-the box with MO2's virtual filesystem.\n\n" + + "In order to resolve this, either delete the DLL and use the OBSE UE4SS Loader plugin, or rename " + + "the DLL (ex. 'ue4ss_loader.dll') and set it to force load with the game exe.\n\n" + + "Do this for any executable which runs the game, such as the OBSE64 loader." + ) + case Problems.INVALID_UE4SS_MOD_NAME: + return ( + "UE4SS mods do not load properly with spaces in the mod name. These are stripped when parsing mods.txt and then" + "fail to match up when parsing the mods.json. Simply remove the spaces and they should load correctly." + ) + case _: + return "" + + def hasGuidedFix(self, key: int) -> bool: + match key: + case Problems.UE4SS_LOADER: + return True + case Problems.INVALID_UE4SS_MOD_NAME: + return True + case _: + return False + + def shortDescription(self, key: int) -> str: + match key: + case Problems.UE4SS_LOADER: + return "The UE4SS loader DLL is present (dwmapi.dll)." + case Problems.INVALID_UE4SS_MOD_NAME: + return "A UE4SS mod name contains a space." + case _: + return "" + + def startGuidedFix(self, key: int) -> None: + match key: + case Problems.UE4SS_LOADER: + os.rename( + self.exeDirectory().absoluteFilePath("dwmapi.dll"), + self.exeDirectory().absoluteFilePath("ue4ss_loader.dll"), + ) + case Problems.INVALID_UE4SS_MOD_NAME: + for mod in self._organizer.modList().allMods(): + filetree = self._organizer.modList().getMod(mod).fileTree() + ue4ss_mod = filetree.find("UE4SS") + if not ue4ss_mod: + filetree.find( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + if isinstance(ue4ss_mod, mobase.IFileTree): + for entry in ue4ss_mod: + if isinstance(entry, mobase.IFileTree) and entry.find( + "scripts/main.lua" + ): + if " " in entry.name(): + mod_dir = Path( + self._organizer.modList() + .getMod(mod) + .absolutePath() + ) + mod_path = mod_dir.joinpath(entry.path("/")) + fixed_path = mod_dir.joinpath( + cast(mobase.IFileTree, entry.parent()).path( + "/" + ), + entry.name().replace(" ", ""), + ) + try: + mod_path.rename(fixed_path) + self._organizer.modDataChanged( + self._organizer.modList().getMod(mod) + ) + self._ue4ss_tab.update_mod_files(mod) + except FileExistsError: + pass + except FileNotFoundError: + pass + for entry in self.ue4ssDirectory().entryInfoList( + QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot + ): + entry_dir = QDir(entry.absoluteFilePath()) + if QFileInfo( + entry_dir.absoluteFilePath("scripts/main.lua") + ).exists(): + if " " in entry_dir.dirName(): + dest = ( + entry_dir.absoluteFilePath("..") + + "/" + + entry_dir.dirName().replace(" ", "") + ) + try: + os.rename(entry_dir.absolutePath(), dest) + except FileExistsError: + pass + except FileNotFoundError: + pass + case _: + pass diff --git a/libs/basic_games/games/game_readyornot.py b/libs/basic_games/games/game_readyornot.py new file mode 100644 index 0000000..d436e38 --- /dev/null +++ b/libs/basic_games/games/game_readyornot.py @@ -0,0 +1,24 @@ +from ..basic_game import BasicGame + + +class ReadyOrNotGame(BasicGame): + Name = "Ready or Not Support Plugin" + Author = "Ra2-IFV" + Version = "0.0.0.1" + + GameName = "Ready or Not" + GameShortName = "readyornot" + GameNexusName = "readyornot" + GameValidShortNames = ["ron"] + # GameNexusId = "readyornot" + GameBinary = "ReadyOrNot/Binaries/Win64/ReadyOrNot-Win64-Shipping.exe" + GameLauncher = "ReadyOrNot.exe" + GameDataPath = "ReadyOrNot/Content/Paks" + GameDocumentsDirectory = "%USERPROFILE%/AppData/Local/ReadyOrNot" + GameIniFiles = [ + "%GAME_DOCUMENTS%/Saved/Config/Windows/Game.ini", + "%GAME_DOCUMENTS%/Saved/Config/Windows/GameUserSettings.ini", + ] + GameSavesDirectory = "%USERPROFILE%/AppData/Local/ReadyOrNot/Saved/SaveGames" + GameSaveExtension = "sav" + GameSteamId = 1144200 diff --git a/libs/basic_games/games/game_schedule1.py b/libs/basic_games/games/game_schedule1.py new file mode 100644 index 0000000..8ed98d6 --- /dev/null +++ b/libs/basic_games/games/game_schedule1.py @@ -0,0 +1,112 @@ +import json +from pathlib import Path + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns +from ..basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, +) +from ..basic_game import BasicGame + + +def parse_schedule1_save_metadata(save_path: Path, save: mobase.ISaveGame): + metadata_file = save_path / "Game.json" + try: + with open(metadata_file) as file: + meta_data = json.load(file) + name = meta_data["OrganisationName"] + if name != (save_name := save.getName()): + name = f"{save_name} ({name})" + return { + "Name": name, + "Game version": meta_data["GameVersion"], + } + except (FileNotFoundError, json.JSONDecodeError): + return None + + +class Schedule1SaveGame(BasicGameSaveGame): + def getName(self) -> str: + metadata_file = self._filepath / "Game.json" + try: + with open(metadata_file) as file: + meta_data = json.load(file) + return meta_data["OrganisationName"] + except (FileNotFoundError, json.JSONDecodeError): + return ( + f"[{self.getSaveGroupIdentifier().rstrip('s')}] {self._filepath.stem}" + ) + + def getSaveGroupIdentifier(self) -> str: + return self._filepath.parent.name + + +class Schedule1Game(BasicGame): + Name = "Schedule I Support Plugin" + Author = "shellbj" + Version = "1.0.0" + + GameName = "Schedule I" + GameShortName = "scheduleI" + GameNexusName = "schedule1" + GameNexusId = 7381 + GameSteamId = 3164500 + + GameBinary = "Schedule I.exe" + GameValidShortNames = ["schedule1", "scheduleI"] + GameDataPath = "" + GameSavesDirectory = r"%USERPROFILE%/AppData/LocalLow/TVGS/Schedule I/Saves" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Schedule-I" + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature( + BasicModDataChecker( + GlobPatterns( + unfold=[ + # Fixes bad packaging for select mods + "ModManager&PhoneApp", + "AutoClearCompletedDeals", + ], + valid=[ + "meta.ini", # Included in installed mod folder. + "Mods", + "Plugins", + "UserData", # might not be needed + ], + delete=[ + "*.md", + "icon.png", + "fomod", # not sure this is needed either + ], + move={ + "*.dll": "Mods/", + # If these even exisit outside of mod packs + "*.ini": "UserData/", + "*.cfg": "UserData/", + "*.json": "UserData/", + }, + ) + ) + ) + self._register_feature(BasicLocalSavegames(self)) + self._register_feature( + BasicGameSaveGameInfo( + None, # no snapshot to add to the widget + parse_schedule1_save_metadata, + ) + ) + return True + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + return [ + Schedule1SaveGame(path) + for path in Path(folder.absolutePath()).glob("*/SaveGame_[1-5]") + ] diff --git a/libs/basic_games/games/game_sekiroshadowsdietwice.py b/libs/basic_games/games/game_sekiroshadowsdietwice.py new file mode 100644 index 0000000..5e7aaf0 --- /dev/null +++ b/libs/basic_games/games/game_sekiroshadowsdietwice.py @@ -0,0 +1,14 @@ +from ..basic_game import BasicGame + + +class SekiroShadowsDieTwiceGame(BasicGame): + Name = "Sekiro: Shadows Die Twice Support Plugin" + Author = "Kane Dou" + Version = "1.0.0" + + GameName = "Sekiro: Shadows Die Twice" + GameShortName = "sekiro" + GameBinary = "sekiro.exe" + GameDataPath = "mods" + GameSaveExtension = "sl2" + GameSteamId = 814380 diff --git a/libs/basic_games/games/game_silenthill2remake.py b/libs/basic_games/games/game_silenthill2remake.py new file mode 100644 index 0000000..4683a5d --- /dev/null +++ b/libs/basic_games/games/game_silenthill2remake.py @@ -0,0 +1,113 @@ +from typing import Tuple + +import mobase + +from ..basic_features import BasicModDataChecker, GlobPatterns +from ..basic_game import BasicGame + + +class SilentHill2RemakeModDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + delete=[ + "*.txt", + "*.md", + "manifest.json", + "icon.png", + ], + ) + ) + self.mod_path = ["SHProto", "Content", "Paks", "~mod"] + self.mod_path_lower = [name.lower() for name in self.mod_path] + + def _find_tree( + self, filetree: mobase.IFileTree + ) -> Tuple[str | None, mobase.FileTreeEntry | None]: + """ + Search the given filetree for a directory name that matches any component + of self.mod_path (case-insensitive). + + Returns: + (prefix, entry) + prefix: The missing part before the match (e.g. 'SHProto/Content/') + entry: The IFileTree entry that matched (e.g. the 'Paks' directory) + (None, None) if nothing matches. + """ + for entry in filetree: + if not entry.isDir(): + continue + + name_lower = entry.name().lower() + for i, component in enumerate(self.mod_path_lower): + if name_lower == component: + # Build the prefix string for everything *before* this match + prefix_parts = self.mod_path[:i] + prefix = "/".join(prefix_parts) + ("/" if prefix_parts else "") + return (prefix, entry) + # No matches found + return (None, None) + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + # Check for fully valid layout + has_entry, _ = self._find_tree(filetree) + if has_entry is None: + # in this case we check to make sure there's a .pak file + for entry in filetree: + if entry.name().lower().endswith(".pak") and entry.isFile(): + return mobase.ModDataChecker.FIXABLE + elif has_entry == "": + return mobase.ModDataChecker.VALID + else: + return mobase.ModDataChecker.FIXABLE + + # Otherwise, not recognizable + return mobase.ModDataChecker.INVALID + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + filetree = super().fix(filetree) + prefix, item = self._find_tree(filetree) + if prefix is None: + foundAPak = False + # Move all top-level items to BepInEx/plugins/ + items_to_move = list(filetree) + for cur_item in items_to_move: + if cur_item.name().lower().endswith(".pak"): + foundAPak = True + filetree.move(cur_item, f"SHProto/Content/Paks/~mod/{cur_item.name()}") + # foundAPack MUST be true because if 'prefix' returned None then + # there must be a .pak file or dataLooksValid wouldn't have returned + # a FIXABLE. This is therefore just a sanity check + assert foundAPak + return filetree + elif prefix == "": + return filetree + else: + # if prefix is not None then item cannot be None + assert item is not None + filetree.move(item, f"{prefix}{item.name()}") + return filetree + + +class SilentHill2RemakeGame(BasicGame): + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(SilentHill2RemakeModDataChecker()) + return True + + Name = "Silent Hill 2 Remake Support Plugin" + Author = "HomerSimpleton Returns" + Version = "1.0" + + GameName = "Silent Hill 2 Remake" + GameShortName = "silenthill2" + GameNexusName = "silenthill2" + + GameBinary = "SHProto/Binaries/Win64/SHProto-Win64-Shipping.exe" + GameLauncher = "SHProto.exe" + GameDataPath = "%GAME_PATH%" + GameSupportURL = "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Silent-Hill-2-Remake" + + GameGogId = [1225972913, 2051029707] diff --git a/libs/basic_games/games/game_silksong.py b/libs/basic_games/games/game_silksong.py new file mode 100644 index 0000000..12aab55 --- /dev/null +++ b/libs/basic_games/games/game_silksong.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from pathlib import Path + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicModDataChecker, GlobPatterns +from ..basic_features.basic_save_game_info import BasicGameSaveGame +from ..basic_game import BasicGame + + +class SilksongModDataChecker(BasicModDataChecker): + def __init__(self, patterns: GlobPatterns | None = None): + super().__init__( + GlobPatterns( + valid=[ + # BepInEx files go to root + "BepInEx", + "doorstop_config.ini", + "winhttp.dll", + ".doorstop_version", + ], + delete=[ + "*.txt", + "*.md", + "manifest.json", + "icon.png", + ], + ).merge(patterns or GlobPatterns()), + ) + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + # Check if it contains BepInEx folders/files - if so, keep structure + if self._has_bepinex_structure(filetree): + return self.VALID + + # Everything else needs to go to BepInEx/plugins/ + return self.FIXABLE + + def _has_bepinex_structure(self, filetree: mobase.IFileTree) -> bool: + """Check if the mod has BepInEx folder structure""" + for entry in filetree: + name = entry.name().lower() + if name in [ + "bepinex", + "doorstop_config.ini", + "winhttp.dll", + ".doorstop_version", + ]: + return True + return False + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + # First apply the basic fix (handles delete patterns) + filetree = super().fix(filetree) + + # If no BepInEx structure, move everything to BepInEx/plugins/ + if not self._has_bepinex_structure(filetree): + # Move all top-level items to BepInEx/plugins/ + items_to_move = list(filetree) + for item in items_to_move: + filetree.move(item, f"BepInEx/plugins/{item.name()}") + + return filetree + + +class SilksongGame(BasicGame): + Name = "Hollow Knight: Silksong Support Plugin" + Author = "Nikirack" + Version = "1.0.0" + + GameName = "Hollow Knight: Silksong" + GameShortName = "hollowknightsilksong" # Match the error message + GameNexusName = "hollowknightsilksong" + GameSteamId = 1030300 + GameBinary = r"Hollow Knight Silksong.exe" + GameDataPath = "" + GameSavesDirectory = ( + r"%USERPROFILE%\AppData\LocalLow\Team Cherry\Hollow Knight Silksong" + ) + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Hollow-Knight-Silksong" + ) + + _forced_libraries = ["winhttp.dll"] + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(SilksongModDataChecker()) + return True + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + saves: list[mobase.ISaveGame] = [] + save_dir = Path(folder.absolutePath()) + + if save_dir.exists(): + # Look for common save file patterns + for pattern in ["*.save", "user*.dat"]: + for save_file in save_dir.rglob(pattern): + saves.append(BasicGameSaveGame(save_file)) + + return saves + + def executables(self) -> list[mobase.ExecutableInfo]: + return [ + mobase.ExecutableInfo( + self.gameName(), + 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 + ] diff --git a/libs/basic_games/games/game_sims4.py b/libs/basic_games/games/game_sims4.py new file mode 100644 index 0000000..e5e7fa2 --- /dev/null +++ b/libs/basic_games/games/game_sims4.py @@ -0,0 +1,153 @@ +from collections.abc import Callable +from enum import IntEnum +from re import match +from typing import Any, List, Set, cast + +from mobase import ( + FileTreeEntry, + IFileTree, + IOrganizer, + ModDataChecker, + ModDataContent, + ReleaseType, + VersionInfo, +) + +from ..basic_game import BasicGame + + +class Content(IntEnum): + PACKAGE = 0 + SCRIPT = 1 + + +class ValidationResult(IntEnum): + INVALID = 0 + VALID = 1 + FIXABLE = 2 + + +class TS4Game(BasicGame): + Name = "The Sims 4 Support Plugin" + Author = "R3z Shark, xieve" + GameName = "The Sims 4" + GameShortName = "thesims4" + GameBinary = "Game/Bin/TS4_x64.exe" + GameDataPath = "%DOCUMENTS%/Electronic Arts/The Sims 4/Mods" + GameSteamId = 1222670 + GameOriginManifestIds = ["OFB-EAST:109552677"] + GameOriginWatcherExecutables = ("TS4_x64.exe",) + + def version(self): + return VersionInfo(1, 0, 0, ReleaseType.FINAL) + + def documentsDirectory(self): + return self.dataDirectory() + + def init(self, organizer: IOrganizer): + if super().init(organizer): + self._register_feature(TS4ModDataChecker()) + self._register_feature(TS4ModDataContent()) + return True + return False + + +class TS4ModDataChecker(ModDataChecker): + # .package files are allowed at a maximum depth of 5 subfolders, script files can be at most one level deep + # The first capturing group lazily captures any parent folders exceeding that depth, see below + _fixableOrValid = r"(?i)^(.*?)((?:[^\\]+\\){0,5}[^\\]*\.package|(?:[^\\]+\\)?[^\\]*\.ts4script|(?:[^\\]+\\)scripts\\.*\.py)$" + + def dataLooksValid(self, filetree: IFileTree) -> ModDataChecker.CheckReturn: + return cast( + ModDataChecker.CheckReturn, + self._fixOrValidateTree(filetree, validateMode=True), + ) + + def fix(self, filetree: IFileTree) -> IFileTree | None: + return cast(IFileTree, self._fixOrValidateTree(filetree)) + + def _fixOrValidateTree( + self, + tree: IFileTree, + validateMode: bool = False, + ) -> IFileTree | ModDataChecker.CheckReturn: + validationResult = ValidationResult.INVALID + checkReturn = ModDataChecker.INVALID + walkReturn = IFileTree.CONTINUE + + def setValidationResult( + newResult: ValidationResult = ValidationResult.INVALID, + actionCallback: Callable[[], Any] = lambda: None, + ): + nonlocal validationResult + nonlocal checkReturn + nonlocal walkReturn + if validateMode: + validationResult = max(validationResult, newResult) + match validationResult: + case ValidationResult.INVALID: + pass + case ValidationResult.VALID: + checkReturn = ModDataChecker.VALID + case ValidationResult.FIXABLE: + checkReturn = ModDataChecker.FIXABLE + walkReturn = IFileTree.STOP + else: + actionCallback() + + def fixOrValidateEntry( + parentPath: str, entry: FileTreeEntry + ) -> IFileTree.WalkReturn: + path = f"{parentPath}{entry.name()}" + fixableOrValid = match(self._fixableOrValid, path) + if fixableOrValid: + match fixableOrValid.groups(): + case ["", _]: + setValidationResult(ValidationResult.VALID) + case [_, innerPath]: + # Move files that are nested too deeply up, preserving the inner folder structure + # E.g. a/b/c.ts4script will be moved to b/c.ts4script + setValidationResult( + ValidationResult.FIXABLE, + lambda: tree.move(entry, innerPath), + ) + case _: + pass + return walkReturn + + tree.walk(fixOrValidateEntry) + + if validateMode: + return checkReturn + else: + return tree + + +class TS4ModDataContent(ModDataContent): + def getAllContents(self: ModDataContent) -> List[ModDataContent.Content]: + return [ + ModDataContent.Content( + Content.PACKAGE, "Package", ":/MO/gui/content/plugin" + ), + ModDataContent.Content(Content.SCRIPT, "Script", ":/MO/gui/content/script"), + ] + + def getContentsFor(self: ModDataContent, filetree: IFileTree) -> List[int]: + contents: Set[int] = set() + + def getContentForEntry(path: str, entry: FileTreeEntry): + nonlocal contents + match entry.suffix(): + case "package": + contents.add(Content.PACKAGE) + case "ts4script" | "py": + contents.add(Content.SCRIPT) + case _: + pass + if len(contents) == 2: + return IFileTree.STOP + else: + return IFileTree.CONTINUE + + filetree.walk(getContentForEntry) + return list(contents) diff --git a/libs/basic_games/games/game_stalkeranomaly.py b/libs/basic_games/games/game_stalkeranomaly.py new file mode 100644 index 0000000..bb40575 --- /dev/null +++ b/libs/basic_games/games/game_stalkeranomaly.py @@ -0,0 +1,293 @@ +from enum import IntEnum +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo, Qt +from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget + +import mobase + +from ..basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, +) +from ..basic_game import BasicGame +from .stalkeranomaly import XRSave + + +class StalkerAnomalyModDataChecker(mobase.ModDataChecker): + _valid_folders: list[str] = [ + "appdata", + "bin", + "db", + "gamedata", + ] + + def hasValidFolders(self, tree: mobase.IFileTree) -> bool: + for e in tree: + if e.isDir(): + if e.name().lower() in self._valid_folders: + return True + + return False + + def findLostData(self, tree: mobase.IFileTree) -> list[mobase.FileTreeEntry]: + lost_db: list[mobase.FileTreeEntry] = [] + + for e in tree: + if e.isFile(): + if e.suffix().lower().startswith("db"): + lost_db.append(e) + + return lost_db + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + if self.hasValidFolders(filetree): + return mobase.ModDataChecker.VALID + + if self.findLostData(filetree): + return mobase.ModDataChecker.FIXABLE + + return mobase.ModDataChecker.INVALID + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + lost_db = self.findLostData(filetree) + if lost_db: + rfolder = filetree.addDirectory("db").addDirectory("mods") + for r in lost_db: + rfolder.insert(r, mobase.IFileTree.REPLACE) + + return filetree + + +class Content(IntEnum): + INTERFACE = 0 + TEXTURE = 1 + MESH = 2 + SCRIPT = 3 + SOUND = 4 + MCM = 5 + CONFIG = 6 + + +class StalkerAnomalyModDataContent(mobase.ModDataContent): + content: list[int] = [] + + def getAllContents(self) -> list[mobase.ModDataContent.Content]: + return [ + mobase.ModDataContent.Content( + Content.INTERFACE, "Interface", ":/MO/gui/content/interface" + ), + mobase.ModDataContent.Content( + Content.TEXTURE, "Textures", ":/MO/gui/content/texture" + ), + mobase.ModDataContent.Content( + Content.MESH, "Meshes", ":/MO/gui/content/mesh" + ), + mobase.ModDataContent.Content( + Content.SCRIPT, "Scripts", ":/MO/gui/content/script" + ), + mobase.ModDataContent.Content( + Content.SOUND, "Sounds", ":/MO/gui/content/sound" + ), + mobase.ModDataContent.Content(Content.MCM, "MCM", ":/MO/gui/content/menu"), + mobase.ModDataContent.Content( + Content.CONFIG, "Configs", ":/MO/gui/content/inifile" + ), + ] + + def walkContent( + self, path: str, entry: mobase.FileTreeEntry + ) -> mobase.IFileTree.WalkReturn: + name = entry.name().lower() + if entry.isFile(): + ext = entry.suffix().lower() + if ext in ["dds", "thm"]: + self.content.append(Content.TEXTURE) + if path.startswith("gamedata/textures/ui"): + self.content.append(Content.INTERFACE) + elif ext in ["omf", "ogf"]: + self.content.append(Content.MESH) + elif ext in ["script"]: + self.content.append(Content.SCRIPT) + if "_mcm" in name: + self.content.append(Content.MCM) + elif ext in ["ogg"]: + self.content.append(Content.SOUND) + elif ext in ["ltx", "xml"]: + self.content.append(Content.CONFIG) + if path.startswith("gamedata/configs/ui"): + self.content.append(Content.INTERFACE) + + return mobase.IFileTree.WalkReturn.CONTINUE + + def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]: + self.content = [] + filetree.walk(self.walkContent, "/") + return self.content + + +class StalkerAnomalySaveGame(BasicGameSaveGame): + _filepath: Path + + xr_save: XRSave + + def __init__(self, filepath: Path): + super().__init__(filepath) + self._filepath = filepath + self.xr_save = XRSave(self._filepath) + + def getName(self) -> str: + xr_save = self.xr_save + player = xr_save.player + if player: + name = player.character_name_str + time = xr_save.time_fmt + return f"{name}, {xr_save.save_fmt} [{time}]" + return "" + + def allFiles(self) -> list[str]: + filepath = str(self._filepath) + paths = [filepath] + scoc = filepath.replace(".scop", ".scoc") + if Path(scoc).exists(): + paths.append(scoc) + dds = filepath.replace(".scop", ".dds") + if Path(dds).exists(): + paths.append(dds) + return paths + + +class StalkerAnomalySaveGameInfoWidget(mobase.ISaveGameInfoWidget): + def __init__(self, parent: QWidget | None): + super().__init__(parent) + layout = QVBoxLayout() + self._labelSave = self.newLabel(layout) + self._labelName = self.newLabel(layout) + self._labelFaction = self.newLabel(layout) + self._labelHealth = self.newLabel(layout) + self._labelMoney = self.newLabel(layout) + self._labelRank = self.newLabel(layout) + self._labelRep = self.newLabel(layout) + self.setLayout(layout) + palette = self.palette() + palette.setColor(self.backgroundRole(), Qt.GlobalColor.black) + self.setAutoFillBackground(True) + self.setPalette(palette) + self.setWindowFlags( + Qt.WindowType.ToolTip | Qt.WindowType.BypassGraphicsProxyWidget + ) + + def newLabel(self, layout: QVBoxLayout) -> QLabel: + label = QLabel() + label.setAlignment(Qt.AlignmentFlag.AlignLeft) + palette = label.palette() + palette.setColor(label.foregroundRole(), Qt.GlobalColor.white) + label.setPalette(palette) + layout.addWidget(label) + layout.addStretch() + return label + + def setSave(self, save: mobase.ISaveGame): + self.resize(240, 32) + if not isinstance(save, StalkerAnomalySaveGame): + return + xr_save = save.xr_save + player = xr_save.player + if player: + self._labelSave.setText(f"Save: {xr_save.save_fmt}") + self._labelName.setText(f"Name: {player.character_name_str}") + self._labelFaction.setText(f"Faction: {xr_save.getFaction()}") + self._labelHealth.setText(f"Health: {player.health:.2f}%") + self._labelMoney.setText(f"Money: {player.money} RU") + self._labelRank.setText(f"Rank: {xr_save.getRank()} ({player.rank})") + self._labelRep.setText( + f"Reputation: {xr_save.getReputation()} ({player.reputation})" + ) + + +class StalkerAnomalySaveGameInfo(BasicGameSaveGameInfo): + def getSaveGameWidget(self, parent: QWidget | None = None): + return StalkerAnomalySaveGameInfoWidget(parent) + + +class StalkerAnomalyGame(BasicGame, mobase.IPluginFileMapper): + Name = "STALKER Anomaly" + Author = "Qudix" + Version = "0.5.0" + Description = "Adds support for STALKER Anomaly" + + GameName = "STALKER Anomaly" + GameShortName = "stalkeranomaly" + GameNexusName = "stalkeranomaly" + GameNexusId = 3743 + GameBinary = "AnomalyLauncher.exe" + GameDataPath = "" + GameDocumentsDirectory = "%GAME_PATH%/appdata" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-S.T.A.L.K.E.R.-Anomaly" + ) + + GameSaveExtension = "scop" + GameSavesDirectory = "%GAME_DOCUMENTS%/savedgames" + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def init(self, organizer: mobase.IOrganizer): + BasicGame.init(self, organizer) + self._register_feature(StalkerAnomalyModDataChecker()) + self._register_feature(StalkerAnomalyModDataContent()) + self._register_feature(StalkerAnomalySaveGameInfo()) + organizer.onAboutToRun(lambda _str: self.aboutToRun(_str)) + return True + + def aboutToRun(self, _str: str) -> bool: + gamedir = self.gameDirectory() + if gamedir.exists(): + # For mappings + gamedir.mkdir("appdata") + # The game will crash if this file exists in the + # virtual tree rather than the game dir + dbg_path = Path(self._gamePath, "gamedata/configs/cache_dbg.ltx") + if not dbg_path.exists(): + dbg_path.parent.mkdir(parents=True, exist_ok=True) + with open(dbg_path, "w", encoding="utf-8"): + pass + return True + + def executables(self) -> list[mobase.ExecutableInfo]: + info = [ + ["Anomaly Launcher", "AnomalyLauncher.exe"], + ["Anomaly (DX11-AVX)", "bin/AnomalyDX11AVX.exe"], + ["Anomaly (DX11)", "bin/AnomalyDX11.exe"], + ["Anomaly (DX10-AVX)", "bin/AnomalyDX10AVX.exe"], + ["Anomaly (DX10)", "bin/AnomalyDX10.exe"], + ["Anomaly (DX9-AVX)", "bin/AnomalyDX9AVX.exe"], + ["Anomaly (DX9)", "bin/AnomalyDX9.exe"], + ["Anomaly (DX8-AVX)", "bin/AnomalyDX8AVX.exe"], + ["Anomaly (DX8)", "bin/AnomalyDX8.exe"], + ] + gamedir = self.gameDirectory() + return [ + mobase.ExecutableInfo(inf[0], QFileInfo(gamedir, inf[1])) for inf in info + ] + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + StalkerAnomalySaveGame(path) + for path in Path(folder.absolutePath()).glob(f"*.{ext}") + ] + + def mappings(self) -> list[mobase.Mapping]: + appdata = self.gameDirectory().filePath("appdata") + m = mobase.Mapping() + m.createTarget = True + m.isDirectory = True + m.source = appdata + m.destination = appdata + return [m] diff --git a/libs/basic_games/games/game_stardewvalley.py b/libs/basic_games/games/game_stardewvalley.py new file mode 100644 index 0000000..6397b40 --- /dev/null +++ b/libs/basic_games/games/game_stardewvalley.py @@ -0,0 +1,57 @@ +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class StardewValleyModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for e in filetree: + if isinstance(e, mobase.IFileTree) and e.exists( + "manifest.json", mobase.IFileTree.FILE + ): + return mobase.ModDataChecker.VALID + + return mobase.ModDataChecker.INVALID + + +class StardewValleyGame(BasicGame): + Name = "Stardew Valley Support Plugin" + Author = "Syer10" + Version = "0.1.0a" + + GameName = "Stardew Valley" + GameShortName = "stardewvalley" + GameNexusName = "stardewvalley" + GameNexusId = 1303 + GameSteamId = 413150 + GameGogId = 1453375253 + GameBinary = "Stardew Valley.exe" + GameDataPath = "mods" + GameDocumentsDirectory = "%DOCUMENTS%/StardewValley" + GameSavesDirectory = "%GAME_DOCUMENTS%/Saves" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Stardew-Valley" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(StardewValleyModDataChecker()) + return True + + def executables(self): + return [ + mobase.ExecutableInfo( + "SMAPI", QFileInfo(self.gameDirectory(), "StardewModdingAPI.exe") + ), + mobase.ExecutableInfo( + "Stardew Valley", QFileInfo(self.gameDirectory(), "Stardew Valley.exe") + ), + ] diff --git a/libs/basic_games/games/game_starsector.py b/libs/basic_games/games/game_starsector.py new file mode 100644 index 0000000..2dc27d9 --- /dev/null +++ b/libs/basic_games/games/game_starsector.py @@ -0,0 +1,30 @@ +from pathlib import Path + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_game import BasicGame, BasicGameSaveGame + + +class Starsector(BasicGame): + Name = "Starsector Support Plugin" + Author = "ddbb07" + Version = "1.0.1" + + GameName = "Starsector" + GameShortName = "starsector" + GameNexusName = "starsector" + GameBinary = "starsector.exe" + GameDataPath = "mods" + GameSavesDirectory = "%GAME_PATH%/saves" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Starsector" + ) + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + return [ + BasicGameSaveGame(path) + for path in Path(folder.absolutePath()).glob("save_*") + ] diff --git a/libs/basic_games/games/game_starwars-empire-at-war-foc.py b/libs/basic_games/games/game_starwars-empire-at-war-foc.py new file mode 100644 index 0000000..188c288 --- /dev/null +++ b/libs/basic_games/games/game_starwars-empire-at-war-foc.py @@ -0,0 +1,33 @@ +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class StarWarsEmpireAtWarGame(BasicGame): + Name = "STAR WARS Empire at War - Force of Corruption" + Author = "erri120" + Version = "1.0.0" + + GameName = "STAR WARSâ„¢ Empire at War: Forces of Corruption" + GameShortName = "starwarsempireatwar" + GameNexusName = "starwarsempireatwar" + GameNexusId = 453 + GameSteamId = 32470 + GameGogId = 1421404887 + # using StarWarsG.exe instead of swfoc.exe because it has an icon + GameBinary = "corruption/StarWarsG.exe" + GameDataPath = "corruption/Data" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Star-Wars:-Empire-At-War" + ) + + def executables(self) -> list[mobase.ExecutableInfo]: + return [ + mobase.ExecutableInfo( + "STAR WARS Empire at War: Forces of Corruption", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ) + ] diff --git a/libs/basic_games/games/game_starwars-empire-at-war.py b/libs/basic_games/games/game_starwars-empire-at-war.py new file mode 100644 index 0000000..171ecfc --- /dev/null +++ b/libs/basic_games/games/game_starwars-empire-at-war.py @@ -0,0 +1,33 @@ +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class StarWarsEmpireAtWarGame(BasicGame): + Name = "STAR WARS Empire at War" + Author = "erri120" + Version = "1.0.0" + + GameName = "STAR WARSâ„¢ Empire at War" + GameShortName = "starwarsempireatwar" + GameNexusName = "starwarsempireatwar" + GameNexusId = 453 + GameSteamId = 32470 + GameGogId = 1421404887 + # using StarWarsG.exe instead of sweaw.exe because it has an icon + GameBinary = "GameData/StarWarsG.exe" + GameDataPath = "GameData/Data" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Star-Wars:-Empire-At-War" + ) + + def executables(self) -> list[mobase.ExecutableInfo]: + return [ + mobase.ExecutableInfo( + "STAR WARS Empire at War", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ) + ] diff --git a/libs/basic_games/games/game_subnautica-below-zero.py b/libs/basic_games/games/game_subnautica-below-zero.py new file mode 100644 index 0000000..297049f --- /dev/null +++ b/libs/basic_games/games/game_subnautica-below-zero.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from ..basic_features import GlobPatterns +from . import game_subnautica # namespace to not load SubnauticaGame here, too! + + +class SubnauticaBelowZeroGame(game_subnautica.SubnauticaGame): + Name = "Subnautica Below Zero Support Plugin" + + GameName = "Subnautica: Below Zero" + GameShortName = "subnauticabelowzero" + GameNexusName = "subnauticabelowzero" + GameThunderstoreName = "subnautica-below-zero" + GameSteamId = 848450 + GameEpicId = "foxglove" + GameBinary = "SubnauticaZero.exe" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Subnautica:-Below-Zero" + ) + + _game_extra_save_paths = [ + r"%USERPROFILE%\Appdata\LocalLow\Unknown Worlds" + r"\Subnautica Below Zero\SubnauticaZero\SavedGames" + ] + + def _set_mod_data_checker( + self, extra_patterns: GlobPatterns | None = None, use_qmod: bool | None = None + ): + super()._set_mod_data_checker( + GlobPatterns(unfold=["BepInExPack_BelowZero"]), use_qmod + ) diff --git a/libs/basic_games/games/game_subnautica.py b/libs/basic_games/games/game_subnautica.py new file mode 100644 index 0000000..f9e5128 --- /dev/null +++ b/libs/basic_games/games/game_subnautica.py @@ -0,0 +1,279 @@ +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", + ], + ignore=["*.mohidden"], + 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.3" + + GameName = "Subnautica" + GameShortName = "subnautica" + GameNexusName = "subnautica" + GameThunderstoreName = "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, + ) diff --git a/libs/basic_games/games/game_tdu.py b/libs/basic_games/games/game_tdu.py new file mode 100644 index 0000000..5624ff8 --- /dev/null +++ b/libs/basic_games/games/game_tdu.py @@ -0,0 +1,36 @@ +# -*- encoding: utf-8 -*- + +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class TDUGame(BasicGame): + Name = "Test Drive Unlimited Support Plugin" + Author = "uwx" + Version = "1.0.0" + + GameName = "Test Drive Unlimited" + GameShortName = "tdu" + GameNexusName = "testdriveunlimited" + GameNexusId = 4615 + GameBinary = "TestDriveUnlimited.exe" + GameDataPath = "" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Test Drive Unlimited", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ).withArgument("-w -wx -vsync -bigbnks -offline"), + mobase.ExecutableInfo( + "Project Paradise Launcher", + QFileInfo( + self.gameDirectory().absoluteFilePath( + "TDU - Project Paradise Launcher.exe" + ) + ), + ), + ] diff --git a/libs/basic_games/games/game_tdu2.py b/libs/basic_games/games/game_tdu2.py new file mode 100644 index 0000000..f7fd94f --- /dev/null +++ b/libs/basic_games/games/game_tdu2.py @@ -0,0 +1,35 @@ +# -*- encoding: utf-8 -*- + +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class TDU2Game(BasicGame): + Name = "Test Drive Unlimited 2 Support Plugin" + Author = "uwx" + Version = "1.0.0" + + GameName = "Test Drive Unlimited 2" + GameShortName = "tdu2" + GameNexusName = "testdriveunlimited2" + GameNexusId = 2353 + GameSteamId = 9930 + GameBinary = "UpLauncher.exe" + GameDataPath = "" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Test Drive Unlimited 2", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "TDU2 Universal Launcher", + QFileInfo( + self.gameDirectory().absoluteFilePath("TDU2_Universal_Launcher.exe") + ), + ), + ] diff --git a/libs/basic_games/games/game_thebindingofisaacrebirth.py b/libs/basic_games/games/game_thebindingofisaacrebirth.py new file mode 100644 index 0000000..a21f11d --- /dev/null +++ b/libs/basic_games/games/game_thebindingofisaacrebirth.py @@ -0,0 +1,23 @@ +from ..basic_game import BasicGame + + +class TheBindingOfIsaacRebirthGame(BasicGame): + Name = "The Binding of Isaac: Rebirth - Support Plugin" + Author = "Luca/EzioTheDeadPoet" + Version = "0.1.0" + + GameName = "The Binding of Isaac: Rebirth" + GameShortName = "thebindingofisaacrebirth" + GameNexusName = "thebindingofisaacrebirth" + GameNexusId = 1293 + GameSteamId = 250900 + GameBinary = "isaac-ng.exe" + GameDocumentsDirectory = "%DOCUMENTS%/My Games/Binding of Isaac Afterbirth+" + GameDataPath = "%DOCUMENTS%/My Games/Binding of Isaac Afterbirth+ Mods" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-The-Binding-of-Isaac:-Rebirth" + ) + + def iniFiles(self): + return ["options.ini"] diff --git a/libs/basic_games/games/game_thps3.py b/libs/basic_games/games/game_thps3.py new file mode 100644 index 0000000..52ea07c --- /dev/null +++ b/libs/basic_games/games/game_thps3.py @@ -0,0 +1,38 @@ +# -*- encoding: utf-8 -*- + +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class THPS3Game(BasicGame): + Name = "Tony Hawk's Pro Skater 3 Support Plugin" + Author = "uwx" + Version = "1.0.0" + + GameName = "Tony Hawk's Pro Skater 3" + GameShortName = "thps3" + GameBinary = "Skate3.exe" + GameDataPath = "Data" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Tony Hawk's Pro Skater 3", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "Tony Hawk's Pro Skater 3 Setup", + QFileInfo(self.gameDirectory().absoluteFilePath("THPS3Setup.exe")), + ), + mobase.ExecutableInfo( + "Tony Hawk's Pro Skater 3 (PARTYMOD)", + QFileInfo(self.gameDirectory().absoluteFilePath("THPS3.exe")), + ), + mobase.ExecutableInfo( + "PARTYMOD Configurator", + QFileInfo(self.gameDirectory().absoluteFilePath("partyconfig.exe")), + ), + ] diff --git a/libs/basic_games/games/game_thps4.py b/libs/basic_games/games/game_thps4.py new file mode 100644 index 0000000..7a7ca0b --- /dev/null +++ b/libs/basic_games/games/game_thps4.py @@ -0,0 +1,40 @@ +# -*- encoding: utf-8 -*- + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class THPS4Game(BasicGame): + Name = "Tony Hawk's Pro Skater 4 Support Plugin" + Author = "uwx" + Version = "1.0.0" + + GameName = "Tony Hawk's Pro Skater 4" + GameShortName = "thps4" + GameBinary = "Skate4.exe" + GameDataPath = "Data" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Tony Hawk's Pro Skater 4", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "Tony Hawk's Pro Skater 4 Setup", + QFileInfo(self.gameDirectory().absoluteFilePath("../Start.exe")), + ).withWorkingDirectory( + QDir(QDir.cleanPath(self.gameDirectory().absoluteFilePath(".."))) + ), + mobase.ExecutableInfo( + "Tony Hawk's Pro Skater 4 (PARTYMOD)", + QFileInfo(self.gameDirectory().absoluteFilePath("THPS4.exe")), + ), + mobase.ExecutableInfo( + "PARTYMOD Configurator", + QFileInfo(self.gameDirectory().absoluteFilePath("partyconfig.exe")), + ), + ] diff --git a/libs/basic_games/games/game_thug.py b/libs/basic_games/games/game_thug.py new file mode 100644 index 0000000..6a0976f --- /dev/null +++ b/libs/basic_games/games/game_thug.py @@ -0,0 +1,36 @@ +# -*- encoding: utf-8 -*- + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class THPS4Game(BasicGame): + Name = "Tony Hawk's Underground Support Plugin" + Author = "uwx" + Version = "1.0.0" + + GameName = "Tony Hawk's Underground" + GameShortName = "thug" + GameBinary = "THUG.exe" + GameDataPath = "Data" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Tony Hawk's Underground", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "Tony Hawk's Underground Launcher", + QFileInfo(self.gameDirectory().absoluteFilePath("../Launcher.exe")), + ).withWorkingDirectory( + QDir(QDir.cleanPath(self.gameDirectory().absoluteFilePath(".."))) + ), + mobase.ExecutableInfo( + "Tony Hawk's Underground (ClownJob'd)", + QFileInfo(self.gameDirectory().absoluteFilePath("THUGONE.exe")), + ), + ] diff --git a/libs/basic_games/games/game_thug2.py b/libs/basic_games/games/game_thug2.py new file mode 100644 index 0000000..389e204 --- /dev/null +++ b/libs/basic_games/games/game_thug2.py @@ -0,0 +1,54 @@ +# -*- encoding: utf-8 -*- + +import os + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class THPS4Game(BasicGame): + Name = "Tony Hawk's Underground 2 Support Plugin" + Author = "uwx" + Version = "1.0.0" + + GameName = "Tony Hawk's Underground 2" + GameShortName = "thug2" + GameBinary = "THUG2.exe" + GameDataPath = "Data" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Tony Hawk's Underground 2", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "Tony Hawk's Underground 2 Launcher", + QFileInfo(self.gameDirectory().absoluteFilePath("../Launcher.exe")), + ).withWorkingDirectory( + QDir(QDir.cleanPath(self.gameDirectory().absoluteFilePath(".."))) + ), + mobase.ExecutableInfo( + "Tony Hawk's Underground 2 (ClownJob'd)", + QFileInfo(self.gameDirectory().absoluteFilePath("THUGTWO.exe")), + ), + mobase.ExecutableInfo( + "THUG Pro Launcher", + QFileInfo( + QDir(os.getenv("LOCALAPPDATA")).absoluteFilePath( + "THUG Pro/THUGProLauncher.exe" + ) + ), + ), + mobase.ExecutableInfo( + "THUG Pro", + QFileInfo( + QDir(os.getenv("LOCALAPPDATA")).absoluteFilePath( + "THUG Pro/THUGPro.exe" + ) + ), + ), + ] diff --git a/libs/basic_games/games/game_tmuf.py b/libs/basic_games/games/game_tmuf.py new file mode 100644 index 0000000..648da6d --- /dev/null +++ b/libs/basic_games/games/game_tmuf.py @@ -0,0 +1,35 @@ +# -*- encoding: utf-8 -*- + +from __future__ import annotations + +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class TmufGame(BasicGame): + Name = "Trackmania United Forever Support Plugin" + Author = "uwx" + Version = "1.0.0" + Description = "Adds support for Trackmania United Forever game folder mods." + + GameName = "Trackmania United Forever" + GameShortName = "tmuf" + GameNexusName = "trackmaniaunited" + GameNexusId = 1500 + GameSteamId = 7200 + GameBinary = "TmForeverLauncher.exe" + GameDataPath = "GameData" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Trackmania United Forever", + QFileInfo( + self.gameDirectory(), + self.binaryName(), + ), + ), + ] diff --git a/libs/basic_games/games/game_trainsimulator.py b/libs/basic_games/games/game_trainsimulator.py new file mode 100644 index 0000000..dc1d44e --- /dev/null +++ b/libs/basic_games/games/game_trainsimulator.py @@ -0,0 +1,36 @@ +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class RailworksGame(BasicGame): + Name = "Train Simulator Classic Support Plugin" + Author = "Ryan Young" + Version = "1.1.0" + + GameName = "Train Simulator" + GameShortName = "railworks" + GameBinary = "RailWorks.exe" + GameDataPath = "" + GameSteamId = "24010" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Train-Simulator-Classic" + ) + + def executables(self): + game_directory = self.gameDirectory() + executables: list[tuple[str, str]] = [ + ("32-bit", "RailWorks.exe"), + ("64-bit", "RailWorks64.exe"), + ("64-bit, DirectX 12", "RailWorksDX12_64.exe"), + ] + return [ + mobase.ExecutableInfo( + f"Train Simulator ({name})", + QFileInfo(game_directory.absoluteFilePath(path)), + ) + for name, path in executables + ] diff --git a/libs/basic_games/games/game_valheim.py b/libs/basic_games/games/game_valheim.py new file mode 100644 index 0000000..c402323 --- /dev/null +++ b/libs/basic_games/games/game_valheim.py @@ -0,0 +1,427 @@ +from __future__ import annotations + +import itertools +import re +import shutil +from collections.abc import Collection, Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional, TextIO + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns +from ..basic_features.basic_save_game_info import BasicGameSaveGame +from ..basic_game import BasicGame + + +def move_file(source: Path, target: Path): + """Move `source` to `target`. Creates missing (parent) directories and + overwrites existing `target`.""" + if not target.parent.exists(): + target.parent.mkdir(parents=True) + shutil.move(str(source.resolve()), str(target.resolve())) + + +@dataclass +class PartialMatch: + partial_match_regex: re.Pattern[str] = re.compile(r"[A-Z]?[a-z]+") + """Matches words, for e.g. 'Camel' and 'Case' in 'CamelCase'.""" + + exclude: set[str] = field(default_factory=set[str]) + min_length: int = 3 + + def partial_match(self, str_with_parts: str, search_string: str) -> Collection[str]: + """Returns partial matches of the first string (`str_with_parts`) + in the `search_string`. + + See: + `partial_match_regex` + """ + parts = self.partial_match_regex.finditer(str_with_parts) + search_string_lower = search_string.casefold() + return set( + p_lower + for p in parts + if len(p_lower := p[0].casefold()) >= self.min_length + and p_lower not in self.exclude + and p_lower in search_string_lower + ) + + +@dataclass +class ContentMatch: + file_glob_patterns: Iterable[str] + """File patterns (glob) for content files and `content_regex`.""" + content_regex: re.Pattern[str] + """Regex to get mod name from content. with (?P<`match_group`>) group.""" + match_group: str + + def match_content(self, file_path: Path) -> str: + if ( + self.content_regex + and self.file_glob_patterns + and any(file_path.match(p) for p in self.file_glob_patterns) + ): + with open(file_path) as file: + if match := self.content_regex.search(file.read()): + return match.group(self.match_group) + return "" + + +class DebugTable: + """Debug in markdown table.""" + + def __init__(self, column_keys: Collection[str]) -> None: + """Init the table, adds the header. + + Args: + column_keys: The column keys for the table. + """ + self.new_table(column_keys) + + def __call__(self, **kwargs: Any) -> None: + self.add(**kwargs) + + def new_table(self, column_keys: Collection[str] | None = None) -> None: + if column_keys: + self._column_keys = column_keys + self._table: list[dict[str, str]] = [ + {c: c for c in self._column_keys}, + {c: "-" * len(c) for c in self._column_keys}, + ] + + def add( + self, + **kwargs: Any, + ) -> None: + """Add data to the table. Adds a new line if the last row has already data in + for the column key. + + Args: + **kwargs: the values per column key for a row. + """ + for k, v in kwargs.items(): + if not self._table or self._table[-1].get(k, ""): + # Append line if element in last list is set. + self._table.append(dict.fromkeys(self._column_keys, "")) + self._table[-1][k] = str(v) + + def print(self, output_file: Optional[TextIO] = None): + if self._table: + for line in self._table: + print("|", " | ".join(line.values()), "|", file=output_file) + if output_file: + output_file.flush() + self._table = [] + + +class OverwriteSync: + organizer: mobase.IOrganizer + game: mobase.IPluginGame + search_file_contents: bool = True + + overwrite_file_pattern: Iterable[str] = ["BepInEx/config/*"] + """File pattern (glob) in overwrite folder.""" + partial_match: PartialMatch = PartialMatch(exclude={"valheim", "mod"}) + content_match: ContentMatch = ContentMatch( + file_glob_patterns=["*.cfg"], + content_regex=re.compile(r"\A.*plugin (?P.+) v[\d.]+?$", re.I | re.M), + match_group="mod", + ) + + _debug = DebugTable("overwrite_file | mod | target_path | matches".split(" | ")) + + def __init__(self, organizer: mobase.IOrganizer, game: mobase.IPluginGame) -> None: + self.organizer = organizer + self.game = game + + def sync(self) -> None: + """Sync the Overwrite folder (back) to the mods.""" + print("Syncing Overwrite with mods") + modlist = self.organizer.modList() + mod_map = self._get_active_mods(modlist) + mod_dll_map = self._get_mod_dll_map(mod_map) + overwrite_path = Path(self.organizer.overwritePath()) + self._debug.new_table() + for pattern in self.overwrite_file_pattern: + for file_path in overwrite_path.glob(pattern): + self._debug(overwrite_file=file_path.name) + if mod := self._find_mod_for_overwrite_file(file_path, mod_dll_map): + # Move cfg to mod folder + mod_path = Path(modlist.getMod(mod).absolutePath()) + target_path = mod_path / file_path.relative_to(overwrite_path) + self._debug(mod=mod, target_path=target_path) + move_file(file_path, target_path) + self._debug.print() + + def _get_active_mods( + self, modlist: mobase.IModList | None = None + ) -> dict[str, mobase.IModInterface]: + """Get all active mods. + + Args: modlist (optional): the `mobase.IModList`. Defaults to None (get it from + `self._organizer`). + + Returns: `{mod_name: mobase.IModInterface}` + """ + modlist = modlist or self.organizer.modList() + + return { + name: mod + for name in modlist.allMods() # allModsByProfilePriority ? + if (mod := modlist.getMod(name)).gameName() == self.game.gameShortName() + and not mod.isForeign() + and not mod.isBackup() + and not mod.isSeparator() + and (modlist.state(name) & mobase.ModState.ACTIVE) + } + + def _get_mod_dll_map(self, mod_map: Mapping[str, str | mobase.IModInterface]): + return {name: self._get_mod_dlls(mod) for name, mod in mod_map.items()} + + def _get_mod_dlls(self, mod: str | mobase.IModInterface) -> Sequence[str]: + """Get all BepInEx/plugins/*.dll files of a mod.""" + if isinstance(mod, str): + mod = self.organizer.modList().getMod(mod) + plugins = mod.fileTree().find("BepInEx/plugins/", mobase.IFileTree.DIRECTORY) + if isinstance(plugins, mobase.IFileTree): + return [name for p in plugins if (name := p.name()).endswith(".dll")] + else: + return [] + + def _find_mod_for_overwrite_file( + self, + file_path: Path, + mod_dll_map: Mapping[str, Collection[str]], + ) -> str: + """Find the mod (name) matching a file in Overwrite (using the mods dll name). + + Args: + file_path: The name of the file. + mod_dll_map: Mods names and their dll files `{mod_name: ["ModName.dll"]}`. + + Returns: + The name of the mod matching the given file_name best. + If there is no clear mod match for the file, an empty string "" is returned. + """ + if file_path.is_dir(): + return "" + file_name = file_path.stem + # matching metric: combined length of partial matches per mod. + matching_mods = self._get_matching_mods(file_name, mod_dll_map) + if len(matching_mods) == 0: + if self.search_file_contents and self.content_match: + # Get mod name from file content. + long_mod_name = self.content_match.match_content(file_path) + matching_mods = self._get_matching_mods(long_mod_name, mod_dll_map) + if len(matching_mods) == 1: + # Only a single mod match found. + return matching_mods[0][0] + elif len(matching_mods) > 1: + # Find mod with longest (combined) partial_match. + self._debug.add(matches=matching_mods) + # Do not return a mod for multiple "equal" matches. + if matching_mods[0][1] > matching_mods[1][1]: + return matching_mods[0][0] + return "" + + def _get_matching_mods( + self, search_str: str, mod_dll_map: Mapping[str, Collection[str]] + ) -> Sequence[tuple[str, int, set[str]]]: + """Find matching mods for the given `search_str`. + + Args: + search_str: A string to find a mod match for. + mod_dll_map: Mods names and their dll files `{mod_name: ["ModName.dll"]}`. + + Returns: + Mods with partial matches, sorted descending by their metric + (length of combined partial matches): + {mod_name: (len_of_combined_partial_matches, {partial_matches, ...}), ...} + """ + return sorted( + ( + (name, sum(len(s) for s in partial_matches), partial_matches) + for name, dlls in mod_dll_map.items() + if len( + partial_matches := set( + itertools.chain.from_iterable( + self.partial_match.partial_match(search_str, dll) + for dll in dlls + ) + ) + ) + > 0 + ), + key=lambda x: x[1], + reverse=True, + ) + + +class ValheimSaveGame(BasicGameSaveGame): + def getName(self) -> str: + return f"[{self.getSaveGroupIdentifier().rstrip('s')}] {self._filepath.stem}" + + def getSaveGroupIdentifier(self) -> str: + return self._filepath.parent.name + + def allFiles(self) -> list[str]: + files = super().allFiles() + files.extend( + self._filepath.with_suffix(suffix).as_posix() + for suffix in [self._filepath.suffix + ".old"] + ) + return files + + +class ValheimWorldSaveGame(ValheimSaveGame): + def allFiles(self) -> list[str]: + files = super().allFiles() + files.extend( + self._filepath.with_suffix(suffix).as_posix() + for suffix in [".db", ".db.old"] + ) + return files + + +class ValheimGame(BasicGame): + Name = "Valheim Support Plugin" + Author = "Zash" + Version = "1.3" + + GameName = "Valheim" + GameShortName = "valheim" + GameThunderstoreName = "valheim" + GameNexusId = 3667 + GameSteamId = [892970, 896660, 1223920] + GameBinary = "valheim.exe" + GameDataPath = "" + GameSavesDirectory = r"%USERPROFILE%/AppData/LocalLow/IronGate/Valheim" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Valheim" + ) + + _forced_libraries = ["winhttp.dll"] + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature( + BasicModDataChecker( + GlobPatterns( + unfold=[ + "BepInExPack_Valheim", + ], + valid=[ + "meta.ini", # Included in installed mod folder. + "BepInEx", + "doorstop_libs", + "unstripped_corlib", + ".doorstop_version", + "doorstop_config.ini", + "start_game_bepinex.sh", + "start_server_bepinex.sh", + "winhttp.dll", + "changelog.txt", + # + "InSlimVML", + "valheim_Data", + "inslimvml.ini", + # + "unstripped_managed", + # + "AdvancedBuilder", + ], + ignore=[ + "*.mohidden", + ], + delete=[ + "*.txt", + "*.md", + "README", + "icon.png", + "license", + "manifest.json", + "*.dll.mdb", + "*.pdb", + ], + move={ + "*_VML.dll": "InSlimVML/Mods/", + # + "plugins": "BepInEx/", + "Jotunn": "BepInEx/plugins/", + "*.dll": "BepInEx/plugins/", + "*.xml": "BepInEx/plugins/", + "Translations": "BepInEx/plugins/", + "config": "BepInEx/", + "*.cfg": "BepInEx/config/", + # + "CustomTextures": "BepInEx/plugins/", + "*.png": "BepInEx/plugins/CustomTextures/", + # + "Builds": "AdvancedBuilder/", + "*.vbuild": "AdvancedBuilder/Builds/", + # + "*.assets": "valheim_Data/", + }, + ) + ) + ) + self._register_feature(BasicLocalSavegames(self)) + self._overwrite_sync = OverwriteSync(organizer=self._organizer, game=self) + self._register_event_handler() + return True + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + return [ + mobase.ExecutableForcedLoadSetting(self.binaryName(), lib).withEnabled(True) + for lib in self._forced_libraries + ] + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + save_games = super().listSaves(folder) + path = Path(folder.absolutePath()) + save_games.extend(ValheimSaveGame(f) for f in path.glob("characters/*.fch")) + save_games.extend(ValheimWorldSaveGame(f) for f in path.glob("worlds/*.fwl")) + return save_games + + def settings(self) -> list[mobase.PluginSetting]: + settings = super().settings() + settings.extend( + [ + mobase.PluginSetting( + "sync_overwrite", "Sync overwrite with mods", True + ), + mobase.PluginSetting( + "search_overwrite_file_content", + "Search content of files in overwrite for matching mod", + True, + ), + ] + ) + return settings + + def _register_event_handler(self): + self._organizer.onUserInterfaceInitialized(lambda win: self._sync_overwrite()) + self._organizer.onFinishedRun(self._game_finished_event_handler) + + def _game_finished_event_handler(self, app_path: str, exit_code: int) -> None: + """Sync overwrite folder with mods after game was closed.""" + if Path(app_path) == Path( + self.gameDirectory().absolutePath(), self.binaryName() + ): + self._sync_overwrite() + + def _sync_overwrite(self) -> None: + if self._organizer.managedGame() is not self: + return + if self._organizer.pluginSetting(self.name(), "sync_overwrite") is not False: + self._overwrite_sync.search_file_contents = ( + self._organizer.pluginSetting( + self.name(), "search_overwrite_file_content" + ) + is not False + ) + self._overwrite_sync.sync() diff --git a/libs/basic_games/games/game_valkyriachronicles.py b/libs/basic_games/games/game_valkyriachronicles.py new file mode 100644 index 0000000..d208de6 --- /dev/null +++ b/libs/basic_games/games/game_valkyriachronicles.py @@ -0,0 +1,15 @@ +from ..basic_game import BasicGame + + +class ValkyriaChroniclesGame(BasicGame): + Name = "Valkyria Chronicles Support Plugin" + Author = "Ketsuban" + Version = "1.0.0" + + GameName = "Valkyria Chronicles" + GameShortName = "vc1" + GameBinary = "Valkyria.exe" + GameLauncher = "Launcher.exe" + GameDataPath = "%GAME_PATH%" + GameSavesDirectory = "%GAME_PATH%/savedata" + GameSteamId = 294860 diff --git a/libs/basic_games/games/game_vampirebloodlines.py b/libs/basic_games/games/game_vampirebloodlines.py new file mode 100644 index 0000000..a63b308 --- /dev/null +++ b/libs/basic_games/games/game_vampirebloodlines.py @@ -0,0 +1,103 @@ +from pathlib import Path +from typing import List + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicLocalSavegames +from ..basic_game import BasicGame, BasicGameSaveGame + + +class VampireModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + self.validDirNames = [ + "cfg", + "cl_dlls", + "dlg", + "dlls", + "maps", + "materials", + "media", + "models", + "particles", + "python", + "resource", + "scripts", + "sound", + "vdata", + ] + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if not entry.isDir(): + continue + if entry.name().casefold() in self.validDirNames: + return mobase.ModDataChecker.VALID + return mobase.ModDataChecker.INVALID + + +class VampireSaveGame(BasicGameSaveGame): + _filepath: Path + + def __init__(self, filepath: Path): + super().__init__(filepath) + self._filepath = filepath + self.name = None + self.elapsedTime = None + + +class VampireTheMasqueradeBloodlinesGame(BasicGame): + Name = "Vampire - The Masquerade: Bloodlines Support Plugin" + Author = "John" + Version = "1.0.0" + Description = "Adds support for Vampires: The Masquerade - Bloodlines" + + GameName = "Vampire - The Masquerade: Bloodlines" + GameShortName = "vampirebloodlines" + GameNexusName = "vampirebloodlines" + GameNexusId = 437 + GameSteamId = [2600] + GameGogId = [1207659240] + GameBinary = "vampire.exe" + GameDataPath = "vampire" + GameDocumentsDirectory = "%GAME_PATH%/vampire/cfg" + GameSavesDirectory = "%GAME_PATH%/vampire/SAVE" + GameSaveExtension = "sav" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Vampire:-The-Masquerade-%E2%80%90-Bloodlines" + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(VampireModDataChecker()) + self._register_feature(BasicLocalSavegames(self)) + return True + + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): + # Create .cfg files if they don't exist + for iniFile in self.iniFiles(): + iniPath = Path(self.documentsDirectory().absoluteFilePath(iniFile)) + if not iniPath.exists(): + with open(iniPath, "w") as _: + pass + + super().initializeProfile(directory, settings) + + def version(self): + # Don't forget to import mobase! + return mobase.VersionInfo(1, 0, 0, mobase.ReleaseType.FINAL) + + def iniFiles(self): + return ["autoexec.cfg", "user.cfg"] + + def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + VampireSaveGame(path) + for path in Path(folder.absolutePath()).glob(f"*.{ext}") + ] diff --git a/libs/basic_games/games/game_witcher1.py b/libs/basic_games/games/game_witcher1.py new file mode 100644 index 0000000..4d76b58 --- /dev/null +++ b/libs/basic_games/games/game_witcher1.py @@ -0,0 +1,93 @@ +from pathlib import Path +from typing import BinaryIO, List + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame, BasicGameSaveGame + + +class Witcher1SaveGame(BasicGameSaveGame): + def __init__(self, filepath: Path): + super().__init__(filepath) + self.areaName: str = "" + self.parseSaveFile(filepath) + + @staticmethod + def readInt(fp: BinaryIO, length: int = 4) -> int: + return int.from_bytes(fp.read(length), "little") + + @staticmethod + def readFixedString(fp: BinaryIO, length: int) -> str: + b: bytes = fp.read(length) + res = b.decode("utf-16") + return res.rstrip("\0") + + def parseSaveFile(self, filepath: Path): + # https://github.com/xoreos/xoreos/blob/82bd991052732ab1f8f75f512b3dfabfcc92ae8f/src/aurora/thewitchersavefile.cpp#L60 + with filepath.open(mode="rb") as fp: + magic = fp.read(4) + if magic != b"RGMH": + raise ValueError("Invalid TheWitcherSave file!") + + version = self.readInt(fp) + if version != 1: + raise ValueError("Invalid TheWitcherSave file!") + + # TODO: get the preview image + # dataOffset = self.readInt(fp, 8) + fp.seek(8, 1) + fp.seek(8, 1) + fp.seek(4 * 4, 1) + + lightningStorm = self.readFixedString(fp, 2048) + if lightningStorm != "Lightning Storm": + raise ValueError('Missing "Lightning Storm"') + + areaName1 = self.readFixedString(fp, 2048) + areaName2 = self.readFixedString(fp, 2048) + + if areaName1 != areaName2: + raise ValueError("Invalid Area Name!") + + self.areaName = areaName1 + + def getName(self) -> str: + return self.areaName + + +class Witcher1Game(BasicGame): + Name = "Witcher 1 Support Plugin" + Author = "erri120" + Version = "1.0.0" + + GameName = "The Witcher: Enhanced Edition" + GameShortName = "witcher" + GameNexusName = "witcher" + GameNexusId = 150 + GameSteamId = 20900 + GameGogId = 1207658924 + GameBinary = "System/witcher.exe" + GameDataPath = "Data" + GameSaveExtension = "TheWitcherSave" + GameDocumentsDirectory = "%DOCUMENTS%/The Witcher" + GameSavesDirectory = "%GAME_DOCUMENTS%/saves" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-The-Witcher" + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + return True + + def executables(self) -> List[mobase.ExecutableInfo]: + path = QFileInfo(self.gameDirectory(), "System/witcher.exe") + return [mobase.ExecutableInfo("The Witcher", path)] + + def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]: + return [ + Witcher1SaveGame(path) + for path in Path(folder.absolutePath()).glob("*.TheWitcherSave") + ] diff --git a/libs/basic_games/games/game_witcher2.py b/libs/basic_games/games/game_witcher2.py new file mode 100644 index 0000000..d38bb6b --- /dev/null +++ b/libs/basic_games/games/game_witcher2.py @@ -0,0 +1,67 @@ +from pathlib import Path +from typing import List + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_features.basic_save_game_info import BasicGameSaveGame +from ..basic_game import BasicGame + + +class Witcher2SaveGame(BasicGameSaveGame): + def allFiles(self): + return [ + self._filepath.name, + self._filepath.name.replace(".sav", "_640x360.bmp"), + ] + + +class Witcher2Game(BasicGame): + Name = "Witcher 2 Support Plugin" + Author = "DefinitelyNotSade" + Version = "1.0.0" + + GameName = "The Witcher 2: Assassins of Kings" + GameShortName = "witcher2" + GaneNexusHame = "witcher2" + # GameNexusId = 952 + GameSteamId = 20920 + GameGogId = 1207658930 + GameLauncher = "Launcher.exe" + GameBinary = "bin/witcher2.exe" + GameDataPath = "CookedPC" + GameSaveExtension = "sav" + GameDocumentsDirectory = "%DOCUMENTS%/witcher 2/Config" + GameSavesDirectory = "%GAME_DOCUMENTS%/../gamesaves" + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature( + BasicGameSaveGameInfo( + lambda s: Path(str(s.parent) + "\\" + s.stem + "_640x360").with_suffix( + ".bmp" + ) + ) + ) + return True + + def iniFiles(self): + return [ + "User.ini", + "Rendering.ini", + "Community.ini", + "UserContent.ini", + "DIMapping.ini", + "Input_QWERTY.ini", + "Input_AZERTY.ini", + "Input_QWERTZ.ini", + ] + + def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + Witcher2SaveGame(path) + for path in Path(folder.absolutePath()).glob(f"*.{ext}") + ] diff --git a/libs/basic_games/games/game_witcher3.py b/libs/basic_games/games/game_witcher3.py new file mode 100644 index 0000000..ff64df2 --- /dev/null +++ b/libs/basic_games/games/game_witcher3.py @@ -0,0 +1,52 @@ +from pathlib import Path +from typing import List + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_features.basic_save_game_info import BasicGameSaveGame +from ..basic_game import BasicGame + + +class Witcher3SaveGame(BasicGameSaveGame): + def allFiles(self): + return [self._filepath.name, self._filepath.name.replace(".sav", ".png")] + + +class Witcher3Game(BasicGame): + Name = "Witcher 3 Support Plugin" + Author = "Holt59" + Version = "1.0.0a" + + GameName = "The Witcher 3: Wild Hunt" + GameShortName = "witcher3" + GaneNexusHame = "witcher3" + GameNexusId = 952 + GameSteamId = [499450, 292030] + GameGogId = [1640424747, 1495134320, 1207664663, 1207664643] + GameBinary = "bin/x64/witcher3.exe" + GameDataPath = "Mods" + GameSaveExtension = "sav" + GameDocumentsDirectory = "%DOCUMENTS%/The Witcher 3" + GameSavesDirectory = "%GAME_DOCUMENTS%/gamesaves" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-The-Witcher-3" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(BasicGameSaveGameInfo(lambda s: s.with_suffix(".png"))) + return True + + def iniFiles(self): + return ["user.settings", "input.settings"] + + def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + Witcher3SaveGame(path) + for path in Path(folder.absolutePath()).glob(f"*.{ext}") + ] diff --git a/libs/basic_games/games/game_x4.py b/libs/basic_games/games/game_x4.py new file mode 100644 index 0000000..a1a7041 --- /dev/null +++ b/libs/basic_games/games/game_x4.py @@ -0,0 +1,17 @@ +from ..basic_game import BasicGame + + +class X4FoundationsGame(BasicGame): + Name = "X4 Foundations Support Plugin" + Author = "Twinki,BrandonM4" + Version = "0.1.0" + + GameName = "X4: Foundations" + GameShortName = "x4foundations" + + GameDocumentsDirectory = "%DOCUMENTS%/Egosoft/X4" + GameBinary = "x4.exe" + GameDataPath = "extensions" + + GameNexusId = 2659 + GameSteamId = 392160 diff --git a/libs/basic_games/games/game_xplane11.py b/libs/basic_games/games/game_xplane11.py new file mode 100644 index 0000000..ecbe89a --- /dev/null +++ b/libs/basic_games/games/game_xplane11.py @@ -0,0 +1,16 @@ +from ..basic_game import BasicGame + + +class XP11Game(BasicGame): + Name = "X-Plane 11 Support Plugin" + Author = "Deorder" + Version = "0.0.1" + + GameName = "X-Plane 11" + GameShortName = "xp11" + GameBinary = r"X-Plane.exe" + GameDataPath = r"" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-X%E2%80%90Plane-11" + ) diff --git a/libs/basic_games/games/game_zeusandposeidon.py b/libs/basic_games/games/game_zeusandposeidon.py new file mode 100644 index 0000000..33a8c5a --- /dev/null +++ b/libs/basic_games/games/game_zeusandposeidon.py @@ -0,0 +1,70 @@ +from typing import List, Optional + +import mobase + +from ..basic_game import BasicGame + + +class ZeusAndPoseidonModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + folders: List[mobase.IFileTree] = [] + files: List[mobase.FileTreeEntry] = [] + + for entry in filetree: + if isinstance(entry, mobase.IFileTree): + folders.append(entry) + else: + files.append(entry) + + if len(folders) != 1: + return mobase.ModDataChecker.INVALID + + folder = folders[0] + pakfile = folder.name() + ".pak" + if folder.exists(pakfile): + if filetree.exists(pakfile): + return mobase.ModDataChecker.VALID + else: + return mobase.ModDataChecker.FIXABLE + + return mobase.ModDataChecker.INVALID + + def fix(self, filetree: mobase.IFileTree) -> Optional[mobase.IFileTree]: + first_entry = filetree[0] + if not isinstance(first_entry, mobase.IFileTree): + return None + entry = first_entry.find(filetree[0].name() + ".pak") + if entry is None: + return None + filetree.copy(entry, "", mobase.IFileTree.InsertPolicy.FAIL_IF_EXISTS) + return filetree + + +class ZeusAndPoseidonGame(BasicGame): + Name = "Zeus and Poseidon Support Plugin" + Author = "Holt59" + Version = "1.0.0a" + + GameName = "Zeus and Poseidon" + GameShortName = "zeusandposeidon" # No Nexus support + GameSteamId = 566050 + GameGogId = 1207659039 + GameBinary = "Zeus.exe" + GameDataPath = "Adventures" + GameDocumentsDirectory = "%GAME_PATH%" + GameSavesDirectory = "%GAME_PATH%/Save" + GameSaveExtension = "sav" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Zeus%EF%BC%8BPoseidon" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(ZeusAndPoseidonModDataChecker()) + return True diff --git a/libs/basic_games/games/oblivion_remaster/__init__.py b/libs/basic_games/games/oblivion_remaster/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/basic_games/games/oblivion_remaster/constants.py b/libs/basic_games/games/oblivion_remaster/constants.py new file mode 100644 index 0000000..656e296 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/constants.py @@ -0,0 +1,14 @@ +from typing import TypedDict + +PLUGIN_NAME = "Oblivion Remastered Support Plugin" + + +class UE4SSModInfo(TypedDict): + mod_name: str + mod_enabled: bool + + +DEFAULT_UE4SS_MODS: list[UE4SSModInfo] = [ + {"mod_name": "BPML_GenericFunctions", "mod_enabled": True}, + {"mod_name": "BPModLoaderMod", "mod_enabled": True}, +] diff --git a/libs/basic_games/games/oblivion_remaster/game_plugins.py b/libs/basic_games/games/oblivion_remaster/game_plugins.py new file mode 100644 index 0000000..6777da6 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/game_plugins.py @@ -0,0 +1,207 @@ +from functools import cmp_to_key +from typing import Sequence + +from PyQt6.QtCore import ( + QByteArray, + QCoreApplication, + QDateTime, + QFile, + QFileInfo, + QStringConverter, + QStringEncoder, + qCritical, + qWarning, +) + +import mobase + + +class OblivionRemasteredGamePlugins(mobase.GamePlugins): + """ + Reimplementation of GameGamebryo "GamePlugins" code, in the Skyrim style. + + Should properly account for disabled plugins and the loadorder.txt profile file. + """ + + def __init__(self, organizer: mobase.IOrganizer): + super().__init__() + self._last_read = QDateTime().currentDateTime() + self._organizer = organizer + # Not currently used. These plugins exist in the base game but are not enabled by default. + self._plugin_blacklist = ["TamrielLevelledRegion.esp", "AltarGymNavigation.esp"] + + def writePluginLists(self, plugin_list: mobase.IPluginList) -> None: + if not self._last_read.isValid(): + return + self.writePluginList( + plugin_list, self._organizer.profile().absolutePath() + "/plugins.txt" + ) + self.writeLoadOrderList( + plugin_list, self._organizer.profile().absolutePath() + "/loadorder.txt" + ) + self._last_read = QDateTime.currentDateTime() + + def readPluginLists(self, plugin_list: mobase.IPluginList) -> None: + load_order_path = self._organizer.profile().absolutePath() + "/loadorder.txt" + load_order = self.readLoadOrderList(plugin_list, load_order_path) + plugin_list.setLoadOrder(load_order) + self.readPluginList(plugin_list) + self._last_read = QDateTime.currentDateTime() + + def getLoadOrder(self) -> Sequence[str]: + load_order_path = self._organizer.profile().absolutePath() + "/loadorder.txt" + plugins_path = self._organizer.profile().absolutePath() + "/plugins.txt" + + load_order_is_new = ( + not self._last_read.isValid() + or not QFileInfo(load_order_path).exists() + or QFileInfo(load_order_path).lastModified() > self._last_read + ) + plugins_is_new = ( + not self._last_read.isValid() + or QFileInfo(plugins_path).lastModified() > self._last_read + ) + + if load_order_is_new or not plugins_is_new: + return self.readLoadOrderList(self._organizer.pluginList(), load_order_path) + else: + return self.readPluginList(self._organizer.pluginList()) + + def writePluginList(self, plugin_list: mobase.IPluginList, filePath: str): + self.writeList(plugin_list, filePath, False) + + def writeLoadOrderList(self, plugin_list: mobase.IPluginList, filePath: str): + self.writeList(plugin_list, filePath, True) + + def writeList( + self, plugin_list: mobase.IPluginList, filePath: str, load_order: bool + ): + plugins_file = open(filePath, "w") + encoder = ( + QStringEncoder(QStringConverter.Encoding.Utf8) + if load_order + else QStringEncoder(QStringConverter.Encoding.System) + ) + plugins_text = "# This file was automatically generated by Mod Organizer.\n" + invalid_filenames = False + written_count = 0 + plugins = plugin_list.pluginNames() + plugins_sorted = sorted( + plugins, + key=cmp_to_key( + lambda lhs, rhs: plugin_list.priority(lhs) - plugin_list.priority(rhs) + ), + ) + for plugin_name in plugins_sorted: + if ( + load_order + or plugin_list.state(plugin_name) == mobase.PluginState.ACTIVE + ): + result = encoder.encode(plugin_name) + if encoder.hasError(): + invalid_filenames = True + qCritical("invalid plugin name %s" % plugin_name) + plugins_text += result.data().decode() + "\n" + written_count += 1 + + if invalid_filenames: + qCritical( + QCoreApplication.translate( + "MainWindow", + "Some of your plugins have invalid names! These " + + "plugins can not be loaded by the game. Please see " + + "mo_interface.log for a list of affected plugins " + + "and rename them.", + ) + ) + + if written_count == 0: + qWarning( + "plugin list would be empty, this is almost certainly wrong. Not saving." + ) + else: + plugins_file.write(plugins_text) + plugins_file.close() + + def readLoadOrderList( + self, plugin_list: mobase.IPluginList, file_path: str + ) -> list[str]: + plugin_names = [ + plugin for plugin in self._organizer.managedGame().primaryPlugins() + ] + plugin_lookup: set[str] = set() + for name in plugin_names: + if name.lower() not in plugin_lookup: + plugin_lookup.add(name.lower()) + + try: + with open(file_path) as file: + for line in file: + if line.startswith("#"): + continue + plugin_file = line.rstrip("\n") + if plugin_file.lower() not in plugin_lookup: + plugin_lookup.add(plugin_file.lower()) + plugin_names.append(plugin_file) + except FileNotFoundError: + return self.readPluginList(plugin_list) + + return plugin_names + + def readPluginList(self, plugin_list: mobase.IPluginList) -> list[str]: + plugins = [plugin for plugin in plugin_list.pluginNames()] + sorted_plugins: list[str] = [] + primary = [plugin for plugin in self._organizer.managedGame().primaryPlugins()] + primary_lower = [plugin.lower() for plugin in primary] + for plugin_name in primary: + if plugin_list.state(plugin_name) != mobase.PluginState.MISSING: + plugin_list.setState(plugin_name, mobase.PluginState.ACTIVE) + sorted_plugins.append(plugin_name) + plugin_remove = [ + plugin for plugin in plugins if plugin.lower() in primary_lower + ] + for plugin in plugin_remove: + plugins.remove(plugin) + + plugins_txt_exists = True + file_path = self._organizer.profile().absolutePath() + "/plugins.txt" + file = QFile(file_path) + if not file.open(QFile.OpenModeFlag.ReadOnly): + plugins_txt_exists = False + if file.size() == 0: + plugins_txt_exists = False + if plugins_txt_exists: + while not file.atEnd(): + line = file.readLine() + file_plugin_name = QByteArray() + if line.size() > 0 and line.at(0).decode() != "#": + encoder = QStringEncoder(QStringEncoder.Encoding.System) + file_plugin_name = encoder.encode(line.trimmed().data().decode()) + if file_plugin_name.size() > 0: + if file_plugin_name.data().decode().lower() in [ + plugin.lower() for plugin in plugins + ]: + plugin_list.setState( + file_plugin_name.data().decode(), mobase.PluginState.ACTIVE + ) + sorted_plugins.append(file_plugin_name.data().decode()) + plugins.remove(file_plugin_name.data().decode()) + + file.close() + + for plugin_name in plugins: + plugin_list.setState(plugin_name, mobase.PluginState.INACTIVE) + else: + for plugin_name in plugins: + plugin_list.setState(plugin_name, mobase.PluginState.INACTIVE) + + return sorted_plugins + plugins + + def lightPluginsAreSupported(self) -> bool: + return False + + def mediumPluginsAreSupported(self) -> bool: + return False + + def blueprintPluginsAreSupported(self) -> bool: + return False diff --git a/libs/basic_games/games/oblivion_remaster/mod_data_checker.py b/libs/basic_games/games/oblivion_remaster/mod_data_checker.py new file mode 100644 index 0000000..51875b2 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/mod_data_checker.py @@ -0,0 +1,451 @@ +from typing import cast + +import mobase + +from .constants import PLUGIN_NAME + + +def _parent(entry: mobase.FileTreeEntry): + """ + Same as entry.parent() but always returns a mobase.IFileTree and never None. + """ + return cast(mobase.IFileTree, entry.parent()) + + +class OblivionRemasteredModDataChecker(mobase.ModDataChecker): + # These directories are generally considered valid, but may require additional checks. + # These represent top level directories in the mod. + _dirs = ["Data", "Paks", "OBSE", "Movies", "UE4SS", "GameSettings", "Root"] + # Directories considered valid for 'Data' though many of these may be obsolete. + _data_dirs = [ + "meshes", + "textures", + "music", + "fonts", + "interface", + "shaders", + "strings", + "materials", + ] + # Data file extensions considered valid. Unclear if BSAs are actually used. + _data_extensions = [".esm", ".esp", ".bsa"] + + def __init__(self, organizer: mobase.IOrganizer): + super().__init__() + self._organizer = organizer + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + status = mobase.ModDataChecker.INVALID + # These represent common mod structures that include UE4SS base files. + # These should generally be pruned or moved into a Root Builder path. + if filetree.find("ue4ss/UE4SS.dll") is not None: + return mobase.ModDataChecker.FIXABLE + elif ( + filetree.find("OblivionRemastered/Binaries/Win64/ue4ss/UE4SS.dll") + is not None + ): + return mobase.ModDataChecker.FIXABLE + + # Crawl the directory tree to check mod structure. + for entry in filetree: + name = entry.name().casefold() + parent = entry.parent() + assert parent is not None + # These are top-level file entries. + if parent.parent() is None: + if isinstance(entry, mobase.IFileTree): + # Look for valid top level directories. + if name in [dirname.lower() for dirname in self._dirs]: + if name == "ue4ss": + """ + The UE4SS mod directory should contain either mod directories with + a 'scripts/main.lua' file, or 'shared' library files. Certain common + 'preset settings' files are also acceptable. + """ + mods = entry.find("Mods") + if isinstance(mods, mobase.IFileTree): + """ + UE4SS intrinsically maps to the 'Mods' directory, so if this directory + is present, it should be relocated. + """ + for sub_entry in mods: + if isinstance(sub_entry, mobase.IFileTree): + if sub_entry.find("scripts/main.lua"): + status = mobase.ModDataChecker.FIXABLE + break + if sub_entry.name().casefold() in [ + "shared", + "npcappearancemanager", + "naturalbodymorph", + ]: + status = mobase.ModDataChecker.FIXABLE + break + else: + for sub_entry in entry: + if isinstance(sub_entry, mobase.IFileTree): + # Files are present in the correct directory. Mark valid. + if sub_entry.find("scripts/main.lua"): + status = mobase.ModDataChecker.VALID + break + if sub_entry.name().casefold() in [ + "shared", + "npcappearancemanager", + "naturalbodymorph", + ]: + status = mobase.ModDataChecker.VALID + break + else: + # All other base directories are considered valid + status = mobase.ModDataChecker.VALID + # No need to continue checks if the directory looks valid + if status == mobase.ModDataChecker.VALID: + break + elif name in [dirname.lower() for dirname in self._data_dirs]: + # Found a 'Data' subdirectory. Should be moved into 'Data'. + status = mobase.ModDataChecker.FIXABLE + else: + # Parse other directories for potential mod files. + for sub_entry in entry: + if sub_entry.isFile(): + sub_name = sub_entry.name().casefold() + if sub_name.endswith(".exe"): + # Trying to handle EXE files is problematic, let the user figure it out + return mobase.ModDataChecker.INVALID + if sub_name.endswith((".pak", ".bk2")): + # Found Pak files or movie files, should be fixable + status = mobase.ModDataChecker.FIXABLE + elif sub_name.endswith(tuple(self._data_extensions)): + # Found plugins or BSA files, should be fixable + status = mobase.ModDataChecker.FIXABLE + else: + if name == "Paks": + # Found Paks directory as subdirectory, should be fixable + status = mobase.ModDataChecker.FIXABLE + # Iterate into subdirectories so we can check the entire archive + new_status = self.dataLooksValid(entry) + if new_status != mobase.ModDataChecker.INVALID: + status = new_status + if status == mobase.ModDataChecker.VALID: + break + else: + if name.endswith(".exe"): + return mobase.ModDataChecker.INVALID + if name.endswith(tuple(self._data_extensions + [".pak", ".bk2"])): + status = mobase.ModDataChecker.FIXABLE + else: + # Section is for parsing subdirectories + if isinstance(entry, mobase.IFileTree): + if name in [dir_name.lower() for dir_name in self._dirs]: + status = mobase.ModDataChecker.FIXABLE + if name in [dir_name.lower() for dir_name in self._data_dirs]: + status = mobase.ModDataChecker.FIXABLE + else: + new_status = self.dataLooksValid(entry) + if new_status != mobase.ModDataChecker.INVALID: + status = new_status + else: + if name.endswith(".exe"): + return mobase.ModDataChecker.INVALID + if name.endswith( + tuple(self._data_extensions + [".pak", ".lua", ".bk2"]) + ): + status = mobase.ModDataChecker.FIXABLE + if status == mobase.ModDataChecker.VALID: + break + return status + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + """ + Main fixer function. Iterates files, using 'parse_directory' for subdirectory search and fixing. + """ + + # UE4SS is packaged with many mods (or standalone) and should be processed into Root if found. + # This can avoid a lot of unnecessary iterations. + ue4ss_dll = filetree.find("ue4ss/UE4SS.dll") + if ue4ss_dll is None: + ue4ss_dll = filetree.find( + "OblivionRemastered/Binaries/Win64/ue4ss/UE4SS.dll" + ) + if ue4ss_dll is not None and (ue4ss_folder := ue4ss_dll.parent()) is not None: + entries: list[mobase.FileTreeEntry] = [] + for entry in _parent(ue4ss_folder): + entries.append(entry) + for entry in entries: + filetree.move( + entry, + "Root/OblivionRemastered/Binaries/Win64/", + mobase.IFileTree.MERGE, + ) + + # Similar to the above, many mods pack files relative to the root game directory. Some common paths can be + # automatically moved into the appropriate directory structures to avoid needless iteration. + exe_dir = filetree.find(r"OblivionRemastered\Binaries\Win64") + if isinstance(exe_dir, mobase.IFileTree): + gamesettings_dir = exe_dir.find("GameSettings") + if isinstance(gamesettings_dir, mobase.IFileTree): + gamesettings_main = self.get_dir(filetree, "GameSettings") + gamesettings_main.merge(gamesettings_dir, True) + self.detach_parents(gamesettings_dir) + obse_dir = exe_dir.find("OBSE") + if isinstance(obse_dir, mobase.IFileTree): + obse_main = self.get_dir(filetree, "OBSE") + obse_main.merge(obse_dir, True) + self.detach_parents(obse_dir) + ue4ss_mod_dir = exe_dir.find("ue4ss/Mods") + if isinstance(ue4ss_mod_dir, mobase.IFileTree): + if self._organizer.pluginSetting(PLUGIN_NAME, "ue4ss_use_root_builder"): + ue4ss_main = self.get_dir( + filetree, "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + else: + ue4ss_main = self.get_dir(filetree, "UE4SS") + ue4ss_main.merge(ue4ss_mod_dir, True) + self.detach_parents(ue4ss_mod_dir) + if len(exe_dir): + root_exe_dir = self.get_dir( + filetree, "Root/OblivionRemastered/Binaries" + ) + parent = exe_dir.parent() + exe_dir.moveTo(root_exe_dir) + if parent: + self.detach_parents(parent) + else: + self.detach_parents(exe_dir) + + # Start the main directory iteration code + directories: list[mobase.IFileTree] = [] + for entry in filetree: + if isinstance(entry, mobase.IFileTree): + directories.append(entry) + for directory in directories: + if directory.name().casefold() in [ + dirname.lower() for dirname in self._data_dirs + ]: + # Move detected 'Data' directories into 'Data' + data_dir = self.get_dir(filetree, "Data") + directory.moveTo(data_dir) + elif directory.name().casefold() == "ue4ss": + # Validate and correct UE4SS mod files into 'UE4SS' + # Alternately, can use Root Builder path per user request + mods = directory.find("Mods") + if isinstance(mods, mobase.IFileTree): + for sub_entry in mods: + if isinstance(sub_entry, mobase.IFileTree): + if ( + sub_entry.find("scripts/main.lua") + or sub_entry.name().casefold() == "shared" + ): + if self._organizer.pluginSetting( + PLUGIN_NAME, "ue4ss_use_root_builder" + ): + ue4ss_main = self.get_dir( + filetree, + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods", + ) + sub_entry.moveTo(ue4ss_main) + self.detach_parents(directory) + else: + parent = _parent(sub_entry) + sub_entry.moveTo(directory) + self.detach_parents(parent) + elif directory.name().casefold() not in [ + dirname.lower() for dirname in self._dirs + ]: + # For non-valid directories, iterate into the directory + filetree = self.parse_directory(filetree, directory) + # Parsing top-level files + entries: list[mobase.FileTreeEntry] = [] + # As we are changing the filetree, cache the current directory list and iterate on that + for entry in filetree: + entries.append(entry) + for entry in entries: + if entry.parent() == filetree and entry.isFile(): + name = entry.name().casefold() + if name.endswith(".pak"): + # Move all pak|ucas|utoc files into "Paks\~mods" + paks_dir = self.get_dir(filetree, "Paks/~mods") + pak_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + if file.isFile(): + if ( + file.name() + .casefold() + .endswith((".pak", ".ucas", ".utoc")) + ): + pak_files.append(file) + for pak_file in pak_files: + pak_file.moveTo(paks_dir) + elif name.endswith(".bk2"): + # Top-level bk2 files should be moved to "Movies\Modern" + movies_dir = self.get_dir(filetree, "Movies/Modern") + movie_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + if file.isFile(): + if file.name().casefold().endswith(".bk2"): + movie_files.append(file) + for movie_file in movie_files: + movie_file.moveTo(movies_dir) + elif name.endswith(tuple(self._data_extensions)): + # Files matching Data file extensions should be moved to "Data" + data_dir = self.get_dir(filetree, "Data") + data_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + data_files.append(file) + for data_file in data_files: + data_file.moveTo(data_dir) + return filetree + + def parse_directory( + self, main_filetree: mobase.IFileTree, next_dir: mobase.IFileTree + ) -> mobase.IFileTree: + """ + Subdirectory iterator for fix(). Allows parsing all subdirectory files. + + :param main_filetree: The main mod filetree that should be returned. + :param next_dir: The currently processed subdirectory of main_filetree. + :returns: The updated main_filetree. + """ + directories: list[mobase.IFileTree] = [] + for entry in next_dir: + if isinstance(entry, mobase.IFileTree): + directories.append(entry) + for directory in directories: + name = directory.name().casefold() + stop = False + for dir_name in self._dirs: + if name == dir_name.lower(): + main_dir = self.get_dir(main_filetree, dir_name) + if name == "ue4ss": + # UE4SS directories should presumably map to 'UE4SS' but check for a 'Mods' directory and move that instead. + if self._organizer.pluginSetting( + PLUGIN_NAME, "ue4ss_use_root_builder" + ): + ue4ss_dir = self.get_dir( + main_filetree, + "Root/OblivionRemastered/Binaries/Win64/ue4ss", + ) + ue4ss_dir.merge(directory) + else: + mod_dir = directory.find("Mods") + if isinstance(mod_dir, mobase.IFileTree): + main_dir.merge(mod_dir) + else: + main_dir.merge(directory) + else: + main_dir.merge(directory) + self.detach_parents(directory) + stop = True + break + if stop: + continue + if name in ["~mods", "logicmods"]: + # These directories should represent Paks mods and should be moved into that directory. + paks_dir = self.get_dir(main_filetree, "Paks") + directory.moveTo(paks_dir) + continue + elif name in [dirname.lower() for dirname in self._data_dirs]: + # These directories are typically associated with Data and should be moved into that directory. + data_dir = self.get_dir(main_filetree, "Data") + data_dir.merge(directory) + self.detach_parents(directory) + continue + main_filetree = self.parse_directory(main_filetree, directory) + for entry in next_dir: + if entry.isFile(): + name = entry.name().casefold() + if name.endswith(tuple(self._data_extensions)): + # Files matching Data extensions should be moved into 'Data' + data_dir = self.get_dir(main_filetree, "Data") + data_dir.merge(next_dir) + self.detach_parents(next_dir) + elif name.endswith(".pak"): + # Loose pak files most likely should be installed to 'Paks\~mods' but check the parent directory + paks_dir = self.get_dir(main_filetree, "Paks") + if next_dir.name().casefold() == "paks": + paks_dir.merge(next_dir) + self.detach_parents(next_dir) + return main_filetree + elif next_dir.name().casefold() in ["~mods", "logicmods"]: + next_dir.moveTo(paks_dir) + return main_filetree + else: + parent = _parent(next_dir) + main_filetree.move( + next_dir, "Paks/~mods/", mobase.IFileTree.MERGE + ) + + self.detach_parents(parent) + return main_filetree + elif name.endswith(".lua"): + # LUA files are generally associated with UE4SS mods. Most will probably be found before this point. + if next_dir.parent() and next_dir.parent() != main_filetree: + parent = _parent(next_dir).parent() + if self._organizer.pluginSetting( + PLUGIN_NAME, "ue4ss_use_root_builder" + ): + ue4ss_main = self.get_dir( + main_filetree, + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods", + ) + _parent(next_dir).moveTo(ue4ss_main) + else: + if main_filetree.find("UE4SS") is None: + main_filetree.addDirectory("UE4SS") + main_filetree.move( + _parent(next_dir), + "UE4SS/", + mobase.IFileTree.MERGE, + ) + if parent is not None: + self.detach_parents(parent) + return main_filetree + elif name.endswith(".bk2"): + movies_dir = self.get_dir(main_filetree, "Movies/Modern") + movies_dir.merge(next_dir) + self.detach_parents(next_dir) + + return main_filetree + + def detach_parents(self, directory: mobase.IFileTree) -> None: + """ + Attempts to clean up empty archive directories after files have been moved. + Find the top-most directory of an empty file tree where only the child directory is contained. + Remove that filetree. + + :param directory: The directory tree to be pruned (starting with the lowest element). + """ + + if ( + directory.parent() is not None + and (parent := directory.parent()) is not None + and len(parent) == 1 + ): + parent = parent if parent.parent() is not None else directory + while ( + parent + and (p_parent := parent.parent()) is not None + and (pp_parent := p_parent.parent()) is not None + and len(pp_parent) == 1 + ): + parent = parent.parent() + + assert parent is not None + parent.detach() + else: + directory.detach() + + def get_dir(self, filetree: mobase.IFileTree, directory: str) -> mobase.IFileTree: + """ + Simple helper function that finds or creates a directory within a given filetree. + + :param filetree: The filetree in which to file or create the given directory + :param directory: The directory name to return + :return: The filetree of the created or found directory. + """ + + tree_dir = filetree.find(directory) + if not isinstance(tree_dir, mobase.IFileTree): + tree_dir = filetree.addDirectory(directory) + return tree_dir diff --git a/libs/basic_games/games/oblivion_remaster/mod_data_content.py b/libs/basic_games/games/oblivion_remaster/mod_data_content.py new file mode 100644 index 0000000..2ee7e5f --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/mod_data_content.py @@ -0,0 +1,109 @@ +from enum import IntEnum, auto + +import mobase + + +class Content(IntEnum): + PLUGIN = auto() + BSA = auto() + PAK = auto() + OBSE = auto() + OBSE_FILES = auto() + MOVIE = auto() + UE4SS = auto() + MAGIC_LOADER = auto() + GAME_SETTINGS = auto() + + +class OblivionRemasteredDataContent(mobase.ModDataContent): + OR_CONTENTS: list[tuple[Content, str, str, bool] | tuple[Content, str, str]] = [ + (Content.PLUGIN, "Plugins (ESM/ESP)", ":/MO/gui/content/plugin"), + (Content.BSA, "Bethesda Archive", ":/MO/gui/content/bsa"), + (Content.PAK, "Paks", ":/MO/gui/content/geometries"), + (Content.OBSE, "Script Extender Plugin", ":/MO/gui/content/skse"), + (Content.OBSE_FILES, "Script Extender Files", "", True), + (Content.MOVIE, "Movies", ":/MO/gui/content/media"), + (Content.UE4SS, "UE4SS Mods", ":/MO/gui/content/script"), + (Content.MAGIC_LOADER, "Magic Loader Mod", ":/MO/gui/content/inifile"), + (Content.GAME_SETTINGS, "Game Settings", ":/MO/gui/content/menu"), + ] + + def getAllContents(self) -> list[mobase.ModDataContent.Content]: + return [ + mobase.ModDataContent.Content(id, name, icon, *filter_only) + for id, name, icon, *filter_only in self.OR_CONTENTS + ] + + def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]: + contents: set[int] = set() + + for entry in filetree: + if isinstance(entry, mobase.IFileTree): + match entry.name().casefold(): + case "data": + for data_entry in entry: + if data_entry.isFile(): + match data_entry.suffix().casefold(): + case "esm" | "esp": + contents.add(Content.PLUGIN) + case "bsa": + contents.add(Content.BSA) + case _: + pass + else: + match data_entry.name().casefold(): + case "magicloader": + contents.add(Content.MAGIC_LOADER) + case _: + pass + case "obse": + contents.add(Content.OBSE_FILES) + plugins_dir = entry.find("Plugins") + if isinstance(plugins_dir, mobase.IFileTree): + for plugin_entry in plugins_dir: + if ( + plugin_entry.isFile() + and plugin_entry.suffix().casefold() == "dll" + ): + contents.add(Content.OBSE) + if ( + isinstance(plugin_entry, mobase.IFileTree) + and plugins_dir.name().casefold() == "gamesettings" + ): + for settings_file in plugin_entry: + if ( + settings_file.isFile() + and settings_file.suffix().casefold() + == "ini" + ): + contents.add(Content.GAME_SETTINGS) + case "paks": + contents.add(Content.PAK) + for paks_entry in entry: + if isinstance(paks_entry, mobase.IFileTree): + if paks_entry.name().casefold() == "~mods": + for mods_entry in paks_entry: + if isinstance(mods_entry, mobase.IFileTree): + if ( + "magicloader" + in mods_entry.name().casefold() + ): + contents.add(Content.MAGIC_LOADER) + break + if paks_entry.name().casefold() == "logicmods": + contents.add(Content.UE4SS) + case "movies": + contents.add(Content.MOVIE) + case "ue4ss": + contents.add(Content.UE4SS) + case "gamesettings": + for settings_file in entry: + if ( + settings_file.isFile() + and settings_file.suffix().casefold() == "ini" + ): + contents.add(Content.GAME_SETTINGS) + case _: + pass + + return list(contents) diff --git a/libs/basic_games/games/oblivion_remaster/paks/__init__.py b/libs/basic_games/games/oblivion_remaster/paks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/basic_games/games/oblivion_remaster/paks/model.py b/libs/basic_games/games/oblivion_remaster/paks/model.py new file mode 100644 index 0000000..c43cec0 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/paks/model.py @@ -0,0 +1,253 @@ +import itertools +import typing +from enum import IntEnum, auto +from typing import Any, TypeAlias, overload + +from PyQt6.QtCore import ( + QAbstractItemModel, + QByteArray, + QDataStream, + QDir, + QFileInfo, + QMimeData, + QModelIndex, + QObject, + Qt, + QVariant, +) +from PyQt6.QtWidgets import QWidget + +import mobase + +_PakInfo: TypeAlias = tuple[str, str, str, str] + + +class PaksColumns(IntEnum): + PRIORITY = auto() + PAK_NAME = auto() + SOURCE = auto() + + +class PaksModel(QAbstractItemModel): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self.paks: dict[int, _PakInfo] = {} + self._organizer = organizer + self._init_mod_states() + + def _init_mod_states(self): + profile = QDir(self._organizer.profilePath()) + paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) + if paks_txt.exists(): + with open(paks_txt.absoluteFilePath(), "r") as paks_file: + index = 0 + for line in paks_file: + self.paks[index] = (line, "", "", "") + index += 1 + + def set_paks(self, paks: dict[int, _PakInfo]): + self.layoutAboutToBeChanged.emit() + self.paks = paks + self.layoutChanged.emit() + self.dataChanged.emit( + self.index(0, 0), + self.index(self.rowCount(), self.columnCount()), + [Qt.ItemDataRole.DisplayRole], + ) + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + if not index.isValid(): + return ( + Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled + | Qt.ItemFlag.ItemIsEnabled + ) + return ( + super().flags(index) + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled & Qt.ItemFlag.ItemIsEditable + ) + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + return len(PaksColumns) + + def index( + self, row: int, column: int, parent: QModelIndex = QModelIndex() + ) -> QModelIndex: + if ( + row < 0 + or row >= self.rowCount() + or column < 0 + or column >= self.columnCount() + ): + return QModelIndex() + return self.createIndex(row, column, row) + + @overload + def parent(self, child: QModelIndex) -> QModelIndex: ... + @overload + def parent(self) -> QObject | None: ... + + def parent(self, child: QModelIndex | None = None) -> QModelIndex | QObject | None: + if child is None: + return super().parent() + return QModelIndex() + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + return len(self.paks) + + def setData( + self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole + ) -> bool: + return False + + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> typing.Any: + if ( + orientation != Qt.Orientation.Horizontal + or role != Qt.ItemDataRole.DisplayRole + ): + return QVariant() + + column = PaksColumns(section + 1) + match column: + case PaksColumns.PAK_NAME: + return "Pak Group" + case PaksColumns.PRIORITY: + return "Priority" + case PaksColumns.SOURCE: + return "Source" + + return QVariant() + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid(): + return None + if index.column() + 1 == PaksColumns.PAK_NAME: + if role == Qt.ItemDataRole.DisplayRole: + return self.paks[index.row()][0] + elif index.column() + 1 == PaksColumns.PRIORITY: + if role == Qt.ItemDataRole.DisplayRole: + return index.row() + elif index.column() + 1 == PaksColumns.SOURCE: + if role == Qt.ItemDataRole.DisplayRole: + return self.paks[index.row()][1] + return QVariant() + + def canDropMimeData( + self, + data: QMimeData | None, + action: Qt.DropAction, + row: int, + column: int, + parent: QModelIndex, + ) -> bool: + if action == Qt.DropAction.MoveAction and (row != -1 or column != -1): + return True + return False + + def supportedDropActions(self) -> Qt.DropAction: + return Qt.DropAction.MoveAction + + def dropMimeData( + self, + data: QMimeData | None, + action: Qt.DropAction, + row: int, + column: int, + parent: QModelIndex, + ) -> bool: + if action == Qt.DropAction.IgnoreAction: + return True + + if data is None: + return False + + encoded: QByteArray = data.data("application/x-qabstractitemmodeldatalist") + stream: QDataStream = QDataStream(encoded, QDataStream.OpenModeFlag.ReadOnly) + source_rows: list[int] = [] + + while not stream.atEnd(): + source_row = stream.readInt() + col = stream.readInt() + size = stream.readInt() + item_data = {} + for _ in range(size): + role = stream.readInt() + value = stream.readQVariant() + item_data[role] = value + if col == 0: + source_rows.append(source_row) + + if row == -1: + row = parent.row() + + if row < 0 or row >= len(self.paks): + new_priority = len(self.paks) + else: + new_priority = row + + before_paks: list[_PakInfo] = [] + moved_paks: list[_PakInfo] = [] + after_paks: list[_PakInfo] = [] + before_paks_p: list[_PakInfo] = [] + moved_paks_p: list[_PakInfo] = [] + after_paks_p: list[_PakInfo] = [] + for row, paks in sorted(self.paks.items()): + if row < new_priority: + if row in source_rows: + if paks[0].casefold()[-2:] == "_p": + moved_paks_p.append(paks) + else: + moved_paks.append(paks) + else: + if paks[0].casefold()[-2:] == "_p": + before_paks_p.append(paks) + else: + before_paks.append(paks) + if row >= new_priority: + if row in source_rows: + if paks[0].casefold()[-2:] == "_p": + moved_paks_p.append(paks) + else: + moved_paks.append(paks) + else: + if paks[0].casefold()[-2:] == "_p": + after_paks_p.append(paks) + else: + after_paks.append(paks) + + new_paks = dict( + enumerate( + itertools.chain( + before_paks, + moved_paks, + after_paks, + before_paks_p, + moved_paks_p, + after_paks_p, + ) + ) + ) + + index = 8999 + for row, pak in new_paks.items(): + current_dir = QDir(pak[2]) + parent_dir = QDir(pak[2]) + parent_dir.cdUp() + if current_dir.exists() and parent_dir.dirName().casefold() == "~mods": + new_paks[row] = ( + pak[0], + pak[1], + pak[2], + parent_dir.absoluteFilePath(str(index).zfill(4)), + ) + index -= 1 + + self.set_paks(new_paks) + return False diff --git a/libs/basic_games/games/oblivion_remaster/paks/view.py b/libs/basic_games/games/oblivion_remaster/paks/view.py new file mode 100644 index 0000000..a56b0cd --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/paks/view.py @@ -0,0 +1,33 @@ +from typing import Iterable + +from PyQt6.QtCore import QModelIndex, Qt, pyqtSignal +from PyQt6.QtGui import QDropEvent +from PyQt6.QtWidgets import QAbstractItemView, QTreeView, QWidget + + +class PaksView(QTreeView): + data_dropped = pyqtSignal() + + def __init__(self, parent: QWidget | None): + super().__init__(parent) + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + if (viewport := self.viewport()) is not None: + viewport.setAcceptDrops(True) + self.setItemsExpandable(False) + self.setRootIsDecorated(False) + + def dropEvent(self, e: QDropEvent | None): + super().dropEvent(e) + self.clearSelection() + self.data_dropped.emit() + + def dataChanged( + self, topLeft: QModelIndex, bottomRight: QModelIndex, roles: Iterable[int] = () + ): + super().dataChanged(topLeft, bottomRight, roles) + self.repaint() diff --git a/libs/basic_games/games/oblivion_remaster/paks/widget.py b/libs/basic_games/games/oblivion_remaster/paks/widget.py new file mode 100644 index 0000000..efc3038 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/paks/widget.py @@ -0,0 +1,213 @@ +from functools import cmp_to_key +from pathlib import Path +from typing import cast + +from PyQt6.QtCore import QDir, QFileInfo +from PyQt6.QtWidgets import QGridLayout, QWidget + +import mobase + +from ....basic_features.utils import is_directory +from .model import PaksModel +from .view import PaksView + + +def pak_sort(a: tuple[str, str], b: tuple[str, str]) -> int: + a_pak, a_str = a[0], a[1] or a[0] + b_pak, b_str = b[0], b[1] or b[0] + + a_pak_ends_p = a_pak.casefold().endswith("_p") + b_pak_ends_p = b_pak.casefold().endswith("_p") + + if a_pak_ends_p == b_pak_ends_p: + if a_str.casefold() <= b_str.casefold(): + return 1 + return -1 + elif a_pak_ends_p: + return 1 + elif b_pak_ends_p: + return -1 + return 0 + + +class PaksTabWidget(QWidget): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self._organizer = organizer + self._view = PaksView(self) + self._layout = QGridLayout(self) + self._layout.addWidget(self._view) + self._model = PaksModel(self._view, organizer) + self._view.setModel(self._model) + self._model.dataChanged.connect(self.write_paks_list) # type: ignore + self._view.data_dropped.connect(self.write_paks_list) # type: ignore + organizer.onProfileChanged(lambda profile_a, profile_b: self._parse_pak_files()) + organizer.modList().onModInstalled(lambda mod: self._parse_pak_files()) + organizer.modList().onModRemoved(lambda mod: self._parse_pak_files()) + organizer.modList().onModStateChanged(lambda mods: self._parse_pak_files()) + self._parse_pak_files() + + def load_paks_list(self) -> list[str]: + profile = QDir(self._organizer.profilePath()) + paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) + paks_list: list[str] = [] + if paks_txt.exists(): + with open(paks_txt.absoluteFilePath(), "r") as paks_file: + for line in paks_file: + paks_list.append(line.strip()) + return paks_list + + def write_paks_list(self): + profile = QDir(self._organizer.profilePath()) + paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) + with open(paks_txt.absoluteFilePath(), "w") as paks_file: + for _, pak in sorted(self._model.paks.items()): + name, _, _, _ = pak + paks_file.write(f"{name}\n") + self.write_pak_files() + + def write_pak_files(self): + for index, pak in sorted(self._model.paks.items()): + _, _, current_path, target_path = pak + if current_path and current_path != target_path: + path_dir = Path(current_path) + target_dir = Path(target_path) + if not target_dir.exists(): + target_dir.mkdir(parents=True, exist_ok=True) + if path_dir.exists(): + for pak_file in path_dir.glob("*.pak"): + ucas_file = pak_file.with_suffix(".ucas") + utoc_file = pak_file.with_suffix(".utoc") + for file in (pak_file, ucas_file, utoc_file): + if not file.exists(): + continue + try: + file.rename(target_dir.joinpath(file.name)) + except FileExistsError: + pass + data = self._model.paks[index] + self._model.paks[index] = ( + data[0], + data[1], + data[3], + data[3], + ) + break + if not list(path_dir.iterdir()): + path_dir.rmdir() + + def _shake_paks(self, sorted_paks: dict[str, str]) -> list[str]: + shaken_paks: list[str] = [] + shaken_paks_p: list[str] = [] + paks_list = self.load_paks_list() + for pak in paks_list: + if pak in sorted_paks.keys(): + if pak.casefold().endswith("_p"): + shaken_paks_p.append(pak) + else: + shaken_paks.append(pak) + sorted_paks.pop(pak) + for pak in sorted_paks.keys(): + if pak.casefold().endswith("_p"): + shaken_paks_p.append(pak) + else: + shaken_paks.append(pak) + return shaken_paks + shaken_paks_p + + def _parse_pak_files(self): + from ...game_oblivion_remaster import OblivionRemasteredGame + + mods = self._organizer.modList().allMods() + paks: dict[str, str] = {} + pak_paths: dict[str, tuple[str, str]] = {} + pak_source: dict[str, str] = {} + for mod in mods: + mod_item = self._organizer.modList().getMod(mod) + if not self._organizer.modList().state(mod) & mobase.ModState.ACTIVE: + continue + filetree = mod_item.fileTree() + pak_mods = filetree.find("Paks/~mods") + if not pak_mods: + pak_mods = filetree.find("Root/OblivionRemastered/Content/Paks/~mods") + if isinstance(pak_mods, mobase.IFileTree): + for entry in pak_mods: + if is_directory(entry): + if "magicloader" in entry.name().casefold(): + continue + for sub_entry in entry: + if ( + sub_entry.isFile() + and sub_entry.suffix().casefold() == "pak" + ): + pak_name = sub_entry.name()[ + : -1 - len(sub_entry.suffix()) + ] + paks[pak_name] = entry.name() + pak_paths[pak_name] = ( + mod_item.absolutePath() + + "/" + + cast(mobase.IFileTree, sub_entry.parent()).path( + "/" + ), + mod_item.absolutePath() + "/" + pak_mods.path("/"), + ) + pak_source[pak_name] = mod_item.name() + else: + if entry.suffix().casefold() == "pak": + pak_name = entry.name()[: -1 - len(entry.suffix())] + paks[pak_name] = "" + pak_paths[pak_name] = ( + mod_item.absolutePath() + + "/" + + cast(mobase.IFileTree, entry.parent()).path("/"), + mod_item.absolutePath() + "/" + pak_mods.path("/"), + ) + pak_source[pak_name] = mod_item.name() + game = self._organizer.managedGame() + if isinstance(game, OblivionRemasteredGame): + pak_mods = QFileInfo(game.paksDirectory().absoluteFilePath("~mods")) + if pak_mods.exists() and pak_mods.isDir(): + for entry in QDir(pak_mods.absoluteFilePath()).entryInfoList( + QDir.Filter.Dirs | QDir.Filter.Files | QDir.Filter.NoDotAndDotDot + ): + if entry.isDir(): + if "magicloader" in entry.completeBaseName().casefold(): + continue + for sub_entry in QDir(entry.absoluteFilePath()).entryInfoList( + QDir.Filter.Files + ): + if ( + sub_entry.isFile() + and sub_entry.suffix().casefold() == "pak" + ): + pak_name = sub_entry.completeBaseName() + paks[pak_name] = entry.completeBaseName() + pak_paths[pak_name] = ( + sub_entry.absolutePath(), + pak_mods.absolutePath(), + ) + pak_source[pak_name] = "Game Directory" + else: + if entry.suffix().casefold() == "pak": + pak_name = entry.completeBaseName() + paks[pak_name] = "" + pak_paths[pak_name] = ( + entry.absolutePath(), + pak_mods.absolutePath(), + ) + pak_source[pak_name] = "Game Directory" + sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) + shaken_paks: list[str] = self._shake_paks(sorted_paks) + final_paks: dict[str, tuple[str, str, str]] = {} + pak_index = 8999 + for pak in shaken_paks: + target_dir = pak_paths[pak][1] + "/" + str(pak_index).zfill(4) + final_paks[pak] = (pak_source[pak], pak_paths[pak][0], target_dir) + pak_index -= 1 + new_data_paks: dict[int, tuple[str, str, str, str]] = {} + i = 0 + for pak, data in final_paks.items(): + source, current_path, target_path = data + new_data_paks[i] = (pak, source, current_path, target_path) + i += 1 + self._model.set_paks(new_data_paks) diff --git a/libs/basic_games/games/oblivion_remaster/script_extender.py b/libs/basic_games/games/oblivion_remaster/script_extender.py new file mode 100644 index 0000000..571e675 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/script_extender.py @@ -0,0 +1,37 @@ +from pathlib import Path + +import mobase + + +class OblivionRemasteredScriptExtender(mobase.ScriptExtender): + def __init__(self, game: mobase.IPluginGame): + super().__init__() + self._game = game + + def binaryName(self): + return "obse64_loader.exe" + + def loaderName(self) -> str: + return self.binaryName() + + def loaderPath(self) -> str: + return ( + self._game.gameDirectory().absolutePath() + + "\\OblivionRemastered\\Binaries\\Win64\\" + + self.loaderName() + ) + + def pluginPath(self) -> str: + return "OBSE/Plugins" + + def savegameExtension(self) -> str: + return "" + + def isInstalled(self) -> bool: + return Path(self.loaderPath()).exists() + + def getExtenderVersion(self) -> str: + return mobase.getFileVersion(self.loaderPath()) + + def getArch(self) -> int: + return 0x8664 if self.isInstalled() else 0x0 diff --git a/libs/basic_games/games/oblivion_remaster/ue4ss/__init__.py b/libs/basic_games/games/oblivion_remaster/ue4ss/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/basic_games/games/oblivion_remaster/ue4ss/model.py b/libs/basic_games/games/oblivion_remaster/ue4ss/model.py new file mode 100644 index 0000000..a3804fe --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/ue4ss/model.py @@ -0,0 +1,125 @@ +import json +from json import JSONDecodeError +from typing import Any, Iterable + +from PyQt6.QtCore import ( + QDir, + QFileInfo, + QMimeData, + QModelIndex, + QStringListModel, + Qt, +) +from PyQt6.QtWidgets import QWidget + +import mobase + +from ..constants import DEFAULT_UE4SS_MODS + + +class UE4SSListModel(QStringListModel): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self._checked_items: set[str] = set() + self._organizer = organizer + self._init_mod_states() + + def _init_mod_states(self): + profile = QDir(self._organizer.profilePath()) + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + if mods_json.exists(): + with open(mods_json.absoluteFilePath(), "r") as json_file: + try: + mod_data = json.load(json_file) + except JSONDecodeError: + mod_data = DEFAULT_UE4SS_MODS + for mod in mod_data: + if mod["mod_enabled"]: + self._checked_items.add(mod["mod_name"]) + + def _set_mod_states(self): + profile = QDir(self._organizer.profilePath()) + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + mod_list: dict[str, bool] = {} + if mods_json.exists(): + with open(mods_json.absoluteFilePath(), "r") as json_file: + try: + mod_data = json.load(json_file) + except JSONDecodeError: + mod_data = DEFAULT_UE4SS_MODS + for mod in mod_data: + mod_list[mod["mod_name"]] = mod["mod_enabled"] + for i in range(self.rowCount()): + item = self.index(i, 0) + name = self.data(item, Qt.ItemDataRole.DisplayRole) + if name in mod_list: + self.setData( + item, + True if mod_list[name] else False, + Qt.ItemDataRole.CheckStateRole, + ) + else: + self.setData(item, True, Qt.ItemDataRole.CheckStateRole) + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + flags = super().flags(index) + if not index.isValid(): + return ( + Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled + | Qt.ItemFlag.ItemIsEnabled + ) + return ( + flags + | Qt.ItemFlag.ItemIsUserCheckable + | Qt.ItemFlag.ItemIsDragEnabled & Qt.ItemFlag.ItemIsEditable + ) + + def setData( + self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole + ) -> bool: + if not index.isValid() or role != Qt.ItemDataRole.CheckStateRole: + return False + + if ( + bool(value) + and self.data(index, Qt.ItemDataRole.DisplayRole) not in self._checked_items + ): + self._checked_items.add(self.data(index, Qt.ItemDataRole.DisplayRole)) + elif ( + not bool(value) + and self.data(index, Qt.ItemDataRole.DisplayRole) in self._checked_items + ): + self._checked_items.remove(self.data(index, Qt.ItemDataRole.DisplayRole)) + self.dataChanged.emit(index, index, [role]) + return True + + def setStringList(self, strings: Iterable[str | None]): + super().setStringList(strings) + self._set_mod_states() + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid(): + return None + + if role == Qt.ItemDataRole.CheckStateRole: + return ( + Qt.CheckState.Checked + if self.data(index, Qt.ItemDataRole.DisplayRole) in self._checked_items + else Qt.CheckState.Unchecked + ) + + return super().data(index, role) + + def canDropMimeData( + self, + data: QMimeData | None, + action: Qt.DropAction, + row: int, + column: int, + parent: QModelIndex, + ) -> bool: + if action == Qt.DropAction.MoveAction and (row != -1 or column != -1): + return True + return False diff --git a/libs/basic_games/games/oblivion_remaster/ue4ss/view.py b/libs/basic_games/games/oblivion_remaster/ue4ss/view.py new file mode 100644 index 0000000..bb994ca --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/ue4ss/view.py @@ -0,0 +1,31 @@ +from typing import Iterable + +from PyQt6.QtCore import QModelIndex, Qt, pyqtSignal +from PyQt6.QtGui import QDropEvent +from PyQt6.QtWidgets import QAbstractItemView, QListView, QWidget + + +class UE4SSView(QListView): + data_dropped = pyqtSignal() + + def __init__(self, parent: QWidget | None): + super().__init__(parent) + self.setAcceptDrops(True) + self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + if (viewport := self.viewport()) is not None: + viewport.setAcceptDrops(True) + + def dropEvent(self, e: QDropEvent | None): + super().dropEvent(e) + self.data_dropped.emit() + + def dataChanged( + self, topLeft: QModelIndex, bottomRight: QModelIndex, roles: Iterable[int] = () + ): + super().dataChanged(topLeft, bottomRight, roles) + self.repaint() diff --git a/libs/basic_games/games/oblivion_remaster/ue4ss/widget.py b/libs/basic_games/games/oblivion_remaster/ue4ss/widget.py new file mode 100644 index 0000000..246b7fa --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/ue4ss/widget.py @@ -0,0 +1,175 @@ +import json +from functools import cmp_to_key +from json import JSONDecodeError +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo, Qt +from PyQt6.QtWidgets import QGridLayout, QWidget + +import mobase + +from ..constants import DEFAULT_UE4SS_MODS, UE4SSModInfo +from .model import UE4SSListModel +from .view import UE4SSView + + +class UE4SSTabWidget(QWidget): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self._organizer = organizer + self._view = UE4SSView(self) + self._layout = QGridLayout(self) + self._layout.addWidget(self._view) + self._model = UE4SSListModel(self._view, organizer) + self._view.setModel(self._model) + self._model.dataChanged.connect(self.write_mod_list) # type: ignore + self._view.data_dropped.connect(self.write_mod_list) # type: ignore + organizer.onProfileChanged(lambda profile_a, profile_b: self._parse_mod_files()) + organizer.modList().onModInstalled(self.update_mod_files) + organizer.modList().onModRemoved(lambda mod: self._parse_mod_files()) + organizer.modList().onModStateChanged(self.update_mod_files) + self._parse_mod_files() + + def get_mod_list(self) -> list[str]: + mod_list: list[str] = [] + for index in range(self._model.rowCount()): + mod_list.append( + self._model.data( + self._model.index(index, 0), Qt.ItemDataRole.DisplayRole + ) + ) + return mod_list + + def update_mod_files( + self, mods: dict[str, mobase.ModState] | mobase.IModInterface | str + ): + mod_list: list[mobase.IModInterface] = [] + if isinstance(mods, dict): + for mod in mods.keys(): + mod_list.append(self._organizer.modList().getMod(mod)) + elif isinstance(mods, mobase.IModInterface): + mod_list.append(mods) + else: + mod_list.append(self._organizer.modList().getMod(mods)) + + for mod in mod_list: + tree = mod.fileTree() + ue4ss_files = tree.find("UE4SS") + if not ue4ss_files: + ue4ss_files = tree.find( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + if isinstance(ue4ss_files, mobase.IFileTree): + for entry in ue4ss_files: + if isinstance(entry, mobase.IFileTree): + if enabled_txt := entry.find("enabled.txt"): + try: + Path(mod.absolutePath(), enabled_txt.path("/")).unlink() + self._organizer.modDataChanged(mod) + except FileNotFoundError: + pass + + self._parse_mod_files() + + def _parse_mod_files(self): + from ...game_oblivion_remaster import OblivionRemasteredGame + + mod_list: set[str] = set() + for mod in self._organizer.modList().allMods(): + if ( + mobase.ModState(self._organizer.modList().state(mod)) + & mobase.ModState.ACTIVE + ): + tree = self._organizer.modList().getMod(mod).fileTree() + ue4ss_files = tree.find("UE4SS") + if not ue4ss_files: + ue4ss_files = tree.find( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + if isinstance(ue4ss_files, mobase.IFileTree): + for entry in ue4ss_files: + if isinstance(entry, mobase.IFileTree): + if entry.find("scripts/main.lua"): + mod_list.add(entry.name()) + if enabled_txt := entry.find("enabled.txt"): + try: + Path( + self._organizer.modList() + .getMod(mod) + .absolutePath(), + enabled_txt.path("/"), + ).unlink() + self._organizer.modDataChanged( + self._organizer.modList().getMod(mod) + ) + except FileNotFoundError: + pass + + game = self._organizer.managedGame() + if isinstance(game, OblivionRemasteredGame): + if game.ue4ssDirectory().exists(): + for dir_info in game.ue4ssDirectory().entryInfoList( + QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot + ): + if QFileInfo( + QDir(dir_info.absoluteFilePath()).absoluteFilePath( + "scripts/main.lua" + ) + ).exists(): + mod_list.add(dir_info.fileName()) + if QFileInfo( + QDir(dir_info.absoluteFilePath()).absoluteFilePath( + "enabled.txt" + ) + ).exists(): + Path(dir_info.absoluteFilePath(), "enabled.txt").unlink() + + final_list = sorted(mod_list, key=cmp_to_key(self.sort_mods)) + self._model.setStringList(final_list) + + def write_mod_list(self): + mod_list: list[UE4SSModInfo] = [] + profile = QDir(self._organizer.profilePath()) + mods_txt = QFileInfo(profile.absoluteFilePath("mods.txt")) + with open(mods_txt.absoluteFilePath(), "w") as txt_file: + for i in range(self._model.rowCount()): + item = self._model.index(i, 0) + name = self._model.data(item, Qt.ItemDataRole.DisplayRole) + active = ( + self._model.data(item, Qt.ItemDataRole.CheckStateRole) + == Qt.CheckState.Checked + ) + mod_list.append({"mod_name": name, "mod_enabled": active}) + txt_file.write(f"{name} : {1 if active else 0}\n") + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + with open(mods_json.absoluteFilePath(), "w") as json_file: + json_file.write(json.dumps(mod_list, indent=4)) + + def sort_mods(self, mod_a: str, mod_b: str) -> int: + profile = QDir(self._organizer.profilePath()) + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + mods_list: list[str] = [] + if mods_json.exists() and mods_json.isFile(): + with open(mods_json.absoluteFilePath(), "r") as json_file: + try: + mods = json.load(json_file) + except JSONDecodeError: + mods = DEFAULT_UE4SS_MODS + for mod in mods: + if mod["mod_enabled"]: + mods_list.append(mod["mod_name"]) + index_a = -1 + if mod_a in mods_list: + index_a = mods_list.index(mod_a) + index_b = -1 + if mod_b in mods_list: + index_b = mods_list.index(mod_b) + if index_a != -1 and index_b != -1: + return index_a - index_b + if index_a != -1: + return -1 + if index_b != -1: + return 1 + if mod_a < mod_b: + return -1 + return 1 diff --git a/libs/basic_games/games/quarantine/game_masseffectlegendary.py b/libs/basic_games/games/quarantine/game_masseffectlegendary.py new file mode 100644 index 0000000..fadc250 --- /dev/null +++ b/libs/basic_games/games/quarantine/game_masseffectlegendary.py @@ -0,0 +1,24 @@ +from ...basic_game import BasicGame + + +class MassEffectLegendaryGame(BasicGame): + Name = "Mass Effect Legendary Edition Support Plugin" + Author = "LostDragonist" + Version = "1.0.0" + + GameName = "Mass Effect: Legendary Edition" + GameShortName = "masseffectlegendaryedition" + GameBinary = "Game/Launcher/MassEffectLauncher.exe" + GameLauncher = "MassEffectLauncher.exe" + GameDataPath = "%GAME_PATH%" + GameDocumentsDirectory = ( + "%USERPROFILE%/Documents/BioWare/Mass Effect Legendary Edition/Save" + ) + GameSaveExtension = "pcsav" + GameSteamId = 1328670 + GameOriginWatcherExecutables = ( + "masseffectlauncher.exe", + "masseffect1.exe", + "masseffect2.exe", + "masseffect3.exe", + ) diff --git a/libs/basic_games/games/quarantine/readme.txt b/libs/basic_games/games/quarantine/readme.txt new file mode 100644 index 0000000..7b7cd32 --- /dev/null +++ b/libs/basic_games/games/quarantine/readme.txt @@ -0,0 +1,3 @@ +These are plugins that we want to keep around for various reasons but don't want enabled in a default install. + +Mass Effect: Removed because it only supports like 1% of mods and is meant purely for possible WJ support. diff --git a/libs/basic_games/games/stalkeranomaly/XRIO.py b/libs/basic_games/games/stalkeranomaly/XRIO.py new file mode 100644 index 0000000..870f28d --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRIO.py @@ -0,0 +1,148 @@ +# -*- encoding: utf-8 -*- +from __future__ import annotations + +import io +import struct +from typing import Optional, Tuple + +from .XRMath import IVec3 + + +class XRReader: + def __init__(self, buffer: bytes): + self._buffer = buffer + self._pos = 0 + + def __len__(self) -> int: + return len(self._buffer) + + def _read(self, size: int) -> Tuple[bytes, int]: + pos = min(len(self._buffer), self._pos + size) + buffer = self._buffer[self._pos : pos] + return (buffer, pos) + + def read(self, size: int = -1) -> bytes: + if size < 0: + size = len(self._buffer) + if len(self._buffer) <= self._pos: + return b"" + (buffer, pos) = self._read(size) + self._pos = pos + return buffer + + def peek(self, size: int = -1) -> bytes: + if size < 0: + size = len(self._buffer) + if len(self._buffer) <= self._pos: + return b"" + (buffer, _pos) = self._read(size) + return buffer + + def seek(self, pos: int, whence: int = io.SEEK_SET) -> int: + if whence == 0: + if pos < 0: + raise ValueError(f"negative seek position {pos}") + self._pos = pos + elif whence == 1: + self._pos = max(0, self._pos + pos) + elif whence == 2: + self._pos = max(0, len(self._buffer) + pos) + else: + raise ValueError("unsupported whence value") + return self._pos + + def elapsed(self) -> int: + return len(self._buffer) - self._pos + + def eof(self) -> bool: + return self.elapsed() <= 0 + + def u8(self) -> int: + return int(struct.unpack(" int: + return int(struct.unpack(" int: + return int(struct.unpack(" int: + return int(struct.unpack(" int: + return int(struct.unpack(" int: + return int(struct.unpack(" int: + return int(struct.unpack(" int: + return int(struct.unpack(" bool: + return bool(struct.unpack(" float: + return float(struct.unpack(" str: + chars = bytearray() + while not self.eof(): + c = self.read(1) + if c == b"\x00": + return str(chars, "utf-8") + else: + chars += c + return "" + + def fvec3(self) -> IVec3: + (f1, f2, f3) = struct.unpack(" Optional[int]: + dw_type = 0 + dw_size = 0 + success = False + if self.last_pos != 0: + self.seek(self.last_pos) + dw_type = self.u32() + dw_size = self.u32() + if (dw_type & (~(1 << 31))) == id: + success = True + + if not success: + self.seek(0) + while not self.eof(): + dw_type = self.u32() + dw_size = self.u32() + if (dw_type & (~(1 << 31))) == id: + success = True + break + else: + self.seek(dw_size, io.SEEK_CUR) + + if not success: + self.last_pos = 0 + return None + + if (self._pos + dw_size) < len(self._buffer): + self.last_pos = self._pos + dw_size + else: + self.last_pos = 0 + + return dw_size + + def open_chunk(self, id: int) -> Optional[XRStream]: + size = self.find_chunk(id) + if size and size != 0: + data = self.read(size) + return XRStream(data) + return None diff --git a/libs/basic_games/games/stalkeranomaly/XRMath.py b/libs/basic_games/games/stalkeranomaly/XRMath.py new file mode 100644 index 0000000..355fcaf --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRMath.py @@ -0,0 +1,37 @@ +class IVec3: + def __init__(self, x: float, y: float, z: float): + self.x = x + self.y = y + self.z = z + + def __str__(self) -> str: + return f"{self.x}, {self.y}, {self.z}" + + +class IVec4(IVec3): + def __init__(self, x: float, y: float, z: float, w: float): + super().__init__(x, y, z) + self.w = w + + def __str__(self) -> str: + return f"{self.x}, {self.y}, {self.z}, f{self.w}" + + +class IFlag: + def __init__(self, flag: int): + self._flag = flag + + def __str__(self) -> str: + return str(self._flag) + + def assign(self, mask: int): + self._flag = mask + + def has(self, mask: int) -> bool: + return bool((self._flag & mask) == mask) + + def set(self, mask: int) -> None: + self._flag |= mask + + def remove(self, mask: int) -> None: + self._flag &= ~mask diff --git a/libs/basic_games/games/stalkeranomaly/XRNET.py b/libs/basic_games/games/stalkeranomaly/XRNET.py new file mode 100644 index 0000000..2df87a9 --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRNET.py @@ -0,0 +1,45 @@ +from .XRIO import XRReader +from .XRMath import IVec3, IVec4 + + +class XRNETState: + def __init__(self): + self.position = IVec3(0.0, 0.0, 0.0) + self.quaternion = IVec4(0.0, 0.0, 0.0, 0.0) + self.enabled = False + + def read(self, reader: XRReader, fmin: IVec3, fmax: IVec3): + self.position = self.fvec_q8(reader, fmin, fmax) + self.quaternion = self.fqt_q8(reader) + self.enabled = bool(reader.u8()) + + def clamp(self, val: float, low: float, high: float) -> float: + if val < low: + return low + elif val > high: + return high + else: + return val + + def fvec_q8(self, reader: XRReader, fmin: IVec3, fmax: IVec3): + vec = IVec3(0.0, 0.0, 0.0) + vec.x = self.f_q8(reader, fmin.x, fmax.x) + vec.y = self.f_q8(reader, fmin.y, fmax.y) + vec.z = self.f_q8(reader, fmin.z, fmax.z) + vec.x = self.clamp(vec.x, fmin.x, fmax.x) + vec.y = self.clamp(vec.x, fmin.y, fmax.y) + vec.z = self.clamp(vec.z, fmin.z, fmax.z) + + def fqt_q8(self, reader: XRReader): + vec = IVec4(0.0, 0.0, 0.0, 0.0) + vec.x = self.f_q8(reader, -1.0, 1.0) + vec.y = self.f_q8(reader, -1.0, 1.0) + vec.z = self.f_q8(reader, -1.0, 1.0) + vec.w = self.f_q8(reader, -1.0, 1.0) + vec.x = self.clamp(vec.x, -1.0, 1.0) + vec.y = self.clamp(vec.y, -1.0, 1.0) + vec.z = self.clamp(vec.z, -1.0, 1.0) + vec.w = self.clamp(vec.w, -1.0, 1.0) + + def f_q8(self, reader: XRReader, fmin: float, fmax: float): + return (float(reader.u8()) / 255.0) * (fmax - fmin) + fmin diff --git a/libs/basic_games/games/stalkeranomaly/XRObject.py b/libs/basic_games/games/stalkeranomaly/XRObject.py new file mode 100644 index 0000000..d1eccdb --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRObject.py @@ -0,0 +1,277 @@ +# -*- encoding: utf-8 -*- + +import io +from enum import IntFlag +from typing import List + +from .XRIO import XRReader +from .XRMath import IFlag, IVec3 +from .XRNET import XRNETState + + +class XRFlag(IntFlag): + CHUNK_ALIFE = 0x0 + CHUNK_SPAWN = 0x1 + CHUNK_OBJECT = 0x2 + CHUNK_GAME_TIME = 0x5 + CHUNK_REGISTRY = 0x9 + + SPAWN_VERSION = 1 << 5 + + MSG_UPDATE = 0x0 + MSG_SPAWN = 0x1 + + TRADER_INFINITE_AMMO = 0x1 + + +class XRAbstract: + def __init__(self, name: str = ""): + self._valid = False + self.name = name + self.name_replace = "" + self.rp = 0xFE + self.position = IVec3(0.0, 0.0, 0.0) + self.angle = IVec3(0.0, 0.0, 0.0) + self.respawn_time = 0 + self.id = 0xFFFF + self.id_parent = 0xFFFF + self.id_phantom = 0xFFFF + self.flags = IFlag(0) + self.version = 0 + self.game_type = IFlag(0) + self.script_version = 0 + self.client_data: List[int] = [] + self.spawn_id = 0 + self.ini_str = "" + + def read_spawn(self, reader: XRReader): + spawn = reader.u16() + if spawn != XRFlag.MSG_SPAWN: + self._valid = False + return + self.name = reader.str() + self.name2 = reader.str() + reader.seek(1, io.SEEK_CUR) # temp_gt + self.rp = reader.u8() + self.position = reader.fvec3() + self.angle = reader.fvec3() + self.respawn_time = reader.u16() + self.id = reader.u16() + self.id_parent = reader.u16() + self.id_phantom = reader.u16() + self.flags = IFlag(reader.u16()) + if self.flags.has(XRFlag.SPAWN_VERSION): + self.version = reader.u16() + if self.version == 0: + reader._pos -= 2 # pyright: ignore[reportPrivateUsage] + return + if self.version > 120: + self.game_type.set(reader.u16()) + if self.version > 69: + self.script_version = reader.u16() + if self.version > 70: + cl_size = 0 + if self.version > 93: + cl_size = reader.u16() + else: + cl_size = reader.u8() + if cl_size > 0: + for _ in range(cl_size): + data = reader.u8() + self.client_data.append(data) + if self.version > 79: + self.spawn_id = reader.u16() + self._valid = True + + def __bool__(self): + return self._valid + + +class XRVisual: + def __init__(self): + self.visual_name = "" + self.startup_animation = "" + self.flags = IFlag(0) + + def read_visual(self, reader: XRReader, version: int): + self.visual_name = reader.str() + self.flags = IFlag(reader.u8()) + + +class XRBoneData: + def __init__(self): + self.bones_mask = -1 + self.root_bone = 0 + self.min = IVec3(0.0, 0.0, 0.0) + self.max = IVec3(0.0, 0.0, 0.0) + self.bones: list[XRNETState] = [] + + def load(self, reader: XRReader): + self.bones_mask = reader.u64() + self.root_bone = reader.u16() + self.min = reader.fvec3() + self.max = reader.fvec3() + bones_count = reader.u16() + for _ in range(bones_count): + bone = XRNETState() + bone.read(reader, self.min, self.max) + self.bones.append(bone) + + +class XRSkeleton: + def __init__(self): + self.source_id = -1 + self.saved_bones = XRBoneData() + + def read_state(self, reader: XRReader): + self.visual_animation = reader.str() + flags = IFlag(reader.u8()) + self.source_id = reader.u16() + if flags.has(4): + self.saved_bones.load(reader) + + +class XRObject(XRAbstract): + def __init__(self): + super().__init__() + self.graph_id = 0 + self.distance = 0.0 + self.direct_control = True + self.node_id = 0 + self.story_id = -1 + self.spawn_story_id = -1 + + def read_spawn(self, reader: XRReader): + super().read_spawn(reader) + size = reader.u16() + if size <= 2: + self._valid = False + return + self.read_state(reader) + + def read_state(self, reader: XRReader): + self.graph_id = reader.u16() + self.distance = reader.float() + self.direct_control = bool(reader.u32()) + self.node_id = reader.u32() + self.flags.set(reader.u32()) + self.ini_str = reader.str() + self.story_id = reader.u32() + self.spawn_story_id = reader.u32() + + def read_update(self, reader: XRReader): + update = reader.u16() + if update != XRFlag.MSG_UPDATE: + self._valid = False + return + + +class XRDynamicObject(XRObject): + def __init__(self): + super().__init__() + self.time_id = -1 + self.switch_counter = 0 + + +class XRDynamicObjectVisual(XRDynamicObject, XRVisual): + def read_state(self, reader: XRReader): + XRDynamicObject.read_state(self, reader) + if self.version > 31: + XRVisual.read_visual(self, reader, self.version) + + +class XRCreatureAbstract(XRDynamicObjectVisual): + def __init__(self): + super().__init__() + self.team = 0 + self.squad = 0 + self.group = 0 + self.health = 1.0 + self.dynamic_out: list[int] = [] + self.dynamic_in: list[int] = [] + self.killer_id = -1 + self.death_time = 0 + + def read_state(self, reader: XRReader): + super().read_state(reader) + self.team = reader.u8() + self.squad = reader.u8() + self.group = reader.u8() + self.health = reader.float() * 100 + + for _ in range(reader.u32()): + _id = reader.u16() + self.dynamic_out.append(_id) + + for _ in range(reader.u32()): + _id = reader.u16() + self.dynamic_in.append(_id) + + self.killer_id = reader.u16() + self.death_time = reader.u64() + + +class XRTraderAbstract: + def __init__(self): + self.money = 0 + self.max_item_mass = 0 + self.character_name = "" + self.character_name_str = "" + self.character_profile = "" + self.specific_character = "" + self.community_index = -1 + self.rank = -1 # -MAX_INT? + self.reputation = -1 # -MAX_INT? + self.dead_body_can_take = True + self.dead_body_closed = False + self.trader_flags = IFlag(0) + self.trader_flags.remove(XRFlag.TRADER_INFINITE_AMMO) + + def read_state(self, reader: XRReader): + self.money = reader.u32() + self.specific_character = reader.str() + self.trader_flags.assign(reader.u32()) + self.character_profile = reader.str() + self.community_index = reader.s32() + self.rank = reader.s32() + self.reputation = reader.s32() + self.character_name_str = reader.str() + self.dead_body_can_take = reader.u8() == 1 + self.dead_body_closed = reader.u8() == 1 + + +class XRCreatureActor(XRCreatureAbstract, XRTraderAbstract, XRSkeleton): + def __init__(self): + XRCreatureAbstract.__init__(self) + XRTraderAbstract.__init__(self) + XRSkeleton.__init__(self) + self.state = 0 + self.acceleration = IVec3(0.0, 0.0, 0.0) + self.velocity = IVec3(0.0, 0.0, 0.0) + self.radiation = 0.0 + self.weapon = 0 + self.num_items = 0 + self.holder_id = -1 + + def read_spawn(self, reader: XRReader): + XRCreatureAbstract.read_spawn(self, reader) + + def read_state(self, reader: XRReader): + XRCreatureAbstract.read_state(self, reader) + XRTraderAbstract.read_state(self, reader) + XRSkeleton.read_state(self, reader) + self.holder_id = reader.u16() + + def read_update(self, reader: XRReader): + XRCreatureAbstract.read_update(self, reader) + # XRTraderAbstract.read_update(self, reader) # Future? + self.state = reader.u16() + # acceleration (r_sdir) + reader.seek(2, io.SEEK_CUR) # u16 + reader.seek(4, io.SEEK_CUR) # float + # velocity (r_sdir) + reader.seek(2, io.SEEK_CUR) # u16 + reader.seek(4, io.SEEK_CUR) # float + self.radiation = reader.float() + self.weapon = reader.u8() + self.num_items = reader.u16() diff --git a/libs/basic_games/games/stalkeranomaly/XRSave.py b/libs/basic_games/games/stalkeranomaly/XRSave.py new file mode 100644 index 0000000..b73a988 --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRSave.py @@ -0,0 +1,164 @@ +import io +import struct +from datetime import datetime +from pathlib import Path +from typing import BinaryIO, Optional, cast + +import lzokay # pyright: ignore[reportMissingTypeStubs] + +from .XRIO import XRReader, XRStream +from .XRObject import XRCreatureActor, XRFlag + + +class XRSave: + filepath: Path + player: XRCreatureActor + + _factions = { + 0: "Loner", + 1: "Monster", + 2: "Trader", + 3: "Army", + 4: "Sin", + 5: "Bandit", + 6: "Duty", + 7: "Ecologist", + 8: "Freedom", + 9: "Mercenary", + 10: "Army", + 11: "Monolith", + 12: "Sin", + 13: "Loner", + 14: "Zombified", + 15: "Clear Sky", + 16: "UNISG", + 17: "Renegade", + 18: "Loner", + 19: "Bandit", + 20: "Duty", + 21: "Freedom", + 22: "Clear Sky", + 23: "Ecologist", + 24: "Mercenary", + 25: "Military", + 26: "Monolith", + 27: "Zombified", + 28: "Sin", + 29: "UNISG", + 30: "Renegade", + 31: "Participant", + } + + _ranks = { + 1999: "Novice", + 3999: "Trainee", + 6999: "Experienced", + 9999: "Professional", + 14999: "Veteran", + 20999: "Expert", + 27999: "Master", + "max": "Legend", + } + + _reputation = { + -2000: "Terrible", + -1500: "Really Bad", + -1000: "Very Bad", + -500: "Bad", + 499: "Netural", + 999: "Good", + 1499: "Very Good", + 1999: "Really Good", + "max": "Excellent", + } + + def __init__(self, filepath: Path): + self.filepath = filepath + self.fetchInfo() + with open(filepath, "rb") as file: + stream = self.readFile(file) + if stream: + self.readObject(stream) + + def fetchInfo(self): + self.splitInfo() + self.time = self.filepath.stat().st_mtime + self.time_date = datetime.fromtimestamp(self.time) + self.time_fmt = self.time_date.strftime("%I:%M %m/%d/%Y") + + def splitInfo(self): + save_clean = self.filepath.name.split(".scop", 1)[0] + save_split = save_clean.split(" - ", 1) + self.user = save_split[0] + if len(save_split) > 1: + save_end = save_split[1].split("_", 1) + if len(save_end) > 1: + self.save_fmt = f"{save_end[0]} (#{save_end[1]})".title() + else: + self.save_fmt = f"{save_end[0]}".title() + else: + self.save_fmt = "Unknown" + + def readFile(self, file: BinaryIO) -> Optional[XRStream]: + size = self.filepath.stat().st_size + if size < 8: + return None + + (start, version, source) = struct.unpack("@iii", file.read(12)) + if (start == -1) and (version >= 6): + file.seek(12) + data = file.read(size - 12) + return XRStream( + cast( + bytes, + lzokay.decompress( # pyright: ignore[reportUnknownMemberType] + data, source + ), + ) + ) + + return None + + def readObject(self, stream: XRStream): + chunk = stream.open_chunk(XRFlag.CHUNK_OBJECT) + if chunk: + chunk.seek(4, io.SEEK_CUR) # obj_count + count_spawn = chunk.u16() + spawn = XRReader(chunk.read(count_spawn)) + actor = XRCreatureActor() + actor.read_spawn(spawn) + count_update = chunk.u16() + update = XRReader(chunk.read(count_update)) + actor.read_update(update) + if actor: + self.player = actor + return None + + def getFaction(self) -> str: + player = self.player + if player: + player_faction = player.community_index + for faction in self._factions: + if player_faction == faction: + return self._factions[faction] + return "Unknown" + + def getRank(self) -> str: + player = self.player + if player: + player_rank = player.rank + for rank in self._ranks: + if isinstance(rank, int): + if player_rank <= rank: + return self._ranks[rank] + return self._ranks["max"] + + def getReputation(self) -> str: + player = self.player + if player: + player_rep = player.reputation + for rep in self._reputation: + if isinstance(rep, int): + if player_rep <= rep: + return self._reputation[rep] + return self._reputation["max"] diff --git a/libs/basic_games/games/stalkeranomaly/__init__.py b/libs/basic_games/games/stalkeranomaly/__init__.py new file mode 100644 index 0000000..a8730a4 --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/__init__.py @@ -0,0 +1,38 @@ +from .XRIO import XRReader, XRStream +from .XRMath import IFlag, IVec3, IVec4 +from .XRNET import XRNETState +from .XRObject import ( + XRAbstract, + XRBoneData, + XRCreatureAbstract, + XRCreatureActor, + XRDynamicObject, + XRDynamicObjectVisual, + XRFlag, + XRObject, + XRSkeleton, + XRTraderAbstract, + XRVisual, +) +from .XRSave import XRSave + +__all__ = [ + "IFlag", + "IVec3", + "IVec4", + "XRAbstract", + "XRBoneData", + "XRCreatureAbstract", + "XRCreatureActor", + "XRDynamicObject", + "XRDynamicObjectVisual", + "XRFlag", + "XRNETState", + "XRObject", + "XRReader", + "XRSave", + "XRSkeleton", + "XRStream", + "XRTraderAbstract", + "XRVisual", +] -- cgit v1.3.1