From a39201986f796a750e379e84360ec60ca67211fc Mon Sep 17 00:00:00 2001 From: Zash Date: Thu, 16 Dec 2021 00:34:03 +0100 Subject: [PATCH 1/7] Add support for Valheim --- games/game_valheim.py | 204 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 games/game_valheim.py diff --git a/games/game_valheim.py b/games/game_valheim.py new file mode 100644 index 0000000..d7d3ec3 --- /dev/null +++ b/games/game_valheim.py @@ -0,0 +1,204 @@ +# -*- encoding: utf-8 -*- + +from __future__ import annotations + +import fnmatch +import re +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field, fields +from typing import Optional + +import mobase + +from ..basic_game import BasicGame + + +def convert_entry_to_tree(entry: mobase.FileTreeEntry) -> Optional[mobase.IFileTree]: + if not entry.isDir(): + return None + if isinstance(entry, mobase.IFileTree): + return entry + if (parent := entry.parent()) is None: + return None + converted_entry = parent.find( + entry.name(), mobase.FileTreeEntry.FileTypes.DIRECTORY + ) + if isinstance(converted_entry, mobase.IFileTree): + return converted_entry + return None + + +@dataclass +class RegexFilesDefinition: + """Regex pattern for the file lists in `FilesDefinition` - for globbing support. + Fields should match `RegexFilesDefinition`. + """ + + set_as_root: re.Pattern + valid: re.Pattern + delete: re.Pattern + move: re.Pattern + + @classmethod + def from_filesmap(cls, filesdef: FilesDefinition) -> RegexFilesDefinition: + """Returns an instance of `RegexFilesDefinition`, + with the file list fields from `FilesDefinition` as regex patterns. + """ + return cls( + **{ + f.name: cls.file_list_regex(getattr(filesdef, f.name)) + for f in fields(cls) + } + ) + + @classmethod + def file_list_regex(cls, file_list: Iterable[str]) -> re.Pattern: + """Returns a regex pattern for a file list with glob patterns. + + Every pattern has a capturing group, + so that match.lastindex - 1 will give the file_list index. + """ + return re.compile( + f'(?:{"|".join(f"({fnmatch.translate(f)})" for f in file_list)})', re.I + ) + + +@dataclass +class FilesDefinition: + """File (pattern) definitions for the `mobase.ModDataChecker`. + Fields should match `RegexFilesDefinition`. + """ + + set_as_root: set[str] + """If a folder from this set is found, it will be set as new root dir (unfolded).""" + valid: set[str] + """Files and folders in the right path.""" + delete: set[str] + """Files/folders to delete.""" + move: dict[str, str] + """Files/folders to move, like `{"*.ext": "path/to/folder/"}`. + If the path ends with / or \\, the entry will be inserted + in the corresponding directory instead of replacing it. + """ + + regex: RegexFilesDefinition = field(init=False) + _move_targets: Sequence[str] = field(init=False) + + def __post_init__(self): + self.regex = RegexFilesDefinition.from_filesmap(self) + self._move_targets = list(self.move.values()) + + def get_move_target(self, index: int) -> str: + return self._move_targets[index] + + +class ValheimGameModDataChecker(mobase.ModDataChecker): + files_map = FilesDefinition( + set_as_root={ + "BepInExPack_Valheim", + }, + valid={ + "BepInEx", + "doorstop_libs", + "unstripped_corlib", + "doorstop_config.ini", + "start_game_bepinex.sh", + "start_server_bepinex.sh", + "winhttp.dll", + # + "InSlimVML", + "valheim_Data", + "inslimvml.ini", + # + "unstripped_managed", + # + "AdvancedBuilder", + }, + delete={ + "*.txt", + "*.md", + "icon.png", + "license", + "manifest.json", + }, + move={ + "*_VML.dll": "InSlimVML/Mods/", + # + "plugins": "BepInEx/", + "*.dll": "BepInEx/plugins/", + "config": "BepInEx/", + "*.cfg": "BepInEx/config/", + # + "CustomTextures": "BepInEx/plugins/", + "*.png": "BepInEx/plugins/CustomTextures/", + # + "Builds": "AdvancedBuilder/", + "*.vbuild": "AdvancedBuilder/Builds/", + # + "*.assets": "valheim_Data/", + }, + ) + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + status = mobase.ModDataChecker.INVALID + for entry in filetree: + name = entry.name().casefold() + if self.files_map.regex.set_as_root.match(name): + status = mobase.ModDataChecker.FIXABLE + elif self.files_map.regex.valid.match(name): + if status is not mobase.ModDataChecker.FIXABLE: + status = mobase.ModDataChecker.VALID + elif self.files_map.regex.move.match( + name + ) or self.files_map.regex.delete.match(name): + status = mobase.ModDataChecker.FIXABLE + else: + return mobase.ModDataChecker.INVALID + return status + + def fix(self, filetree: mobase.IFileTree) -> Optional[mobase.IFileTree]: + for entry in list(filetree): + name = entry.name().casefold() + if self.files_map.regex.set_as_root.match(name): + new_root = convert_entry_to_tree(entry) + if not new_root: + return None + return self.fix(new_root) + elif self.files_map.regex.delete.match(name): + entry.detach() + elif match := self.files_map.regex.move.match(name): + if match.lastindex is not None: + # Get index of matched group + map_index = match.lastindex - 1 + # Get the move target corresponding to the matched group + filetree.move(entry, self.files_map.get_move_target(map_index)) + return filetree + + +class ValheimGame(BasicGame): + + Name = "Valheim Support Plugin" + Author = "Zash" + Version = "1.0.0" + + GameName = "Valheim" + GameShortName = "valheim" + GameNexusId = 3667 + GameSteamId = [892970, 896660, 1223920] + GameBinary = "valheim.exe" + GameDataPath = "" + + forced_libraries = ["winhttp.dll"] + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._featureMap[mobase.ModDataChecker] = ValheimGameModDataChecker() + return True + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + return [ + mobase.ExecutableForcedLoadSetting(self.binaryName(), lib).withEnabled(True) + for lib in self.forced_libraries + ] From 4d9ec5335ad7af0dc8caec072a07db9136febffd Mon Sep 17 00:00:00 2001 From: Zash Date: Fri, 17 Dec 2021 18:46:41 +0100 Subject: [PATCH 2/7] Fix for "No valid game data": meta.ini = VALID in installed mods. --- games/game_valheim.py | 1 + 1 file changed, 1 insertion(+) diff --git a/games/game_valheim.py b/games/game_valheim.py index d7d3ec3..a08468d 100644 --- a/games/game_valheim.py +++ b/games/game_valheim.py @@ -98,6 +98,7 @@ class ValheimGameModDataChecker(mobase.ModDataChecker): "BepInExPack_Valheim", }, valid={ + "meta.ini", # Included in installed mod folder. "BepInEx", "doorstop_libs", "unstripped_corlib", From b28460c1549a8aeca57ef694d20e2e44e7a9238e Mon Sep 17 00:00:00 2001 From: Zash Date: Thu, 16 Dec 2021 23:41:04 +0100 Subject: [PATCH 3/7] Add Valheim to README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 666dd8b..073fee1 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ You can rename `modorganizer-basic_games-xxx` to whatever you want (e.g., `basic | S.T.A.L.K.E.R. Anomaly — [MOD](https://www.stalker-anomaly.com/) | [Qudix](https://github.com/Qudix) | [game_stalkeranomaly.py](games/game_stalkeranomaly.py) |
  • mod data checker
| | Stardew Valley — [GOG](https://www.gog.com/game/stardew_valley) / [STEAM](https://store.steampowered.com/app/413150/Stardew_Valley/) | [Syer10](https://github.com/Syer10), [Holt59](https://github.com/holt59/) | [game_stardewvalley.py](games/game_stardewvalley.py) |
  • mod data checker
| | STAR WARS™ Empire at War: Gold Pack - [GOG](https://www.gog.com/game/star_wars_empire_at_war_gold_pack) / [STEAM](https://store.steampowered.com/app/32470/) | [erri120](https://github.com/erri120) |
  • Empire at War: [game_starwars-empire-at-war.py](games/game_starwars-empire-at-war.py)
  • Force of Corruption: [game_starwars-empire-at-war-foc.py](games/game_starwars-empire-at-war-foc.py)
| | +| Valheim — [STEAM](https://store.steampowered.com/app/892970/Valheim/) | [Zash](https://github.com/ZashIn) | [game_valheim.py](games/game_valheim.py) |
  • mod data checker
| The Witcher: Enhanced Edition - [GOG](https://www.gog.com/game/the_witcher) / [STEAM](https://store.steampowered.com/app/20900/The_Witcher_Enhanced_Edition_Directors_Cut/) | [erri120](https://github.com/erri120) | [game_witcher1.py](games/game_witcher1.py) |
  • save game parsing (no preview)
| | The Witcher 3: Wild Hunt — [GOG](https://www.gog.com/game/the_witcher_3_wild_hunt) / [STEAM](https://store.steampowered.com/app/292030/The_Witcher_3_Wild_Hunt/) | [Holt59](https://github.com/holt59/) | [game_witcher3.py](games/game_witcher3.py) |
  • save game preview
| | Zeus and Poseidon — [GOG](https://www.gog.com/game/zeus_poseidon) / [STEAM](https://store.steampowered.com/app/566050/Zeus__Poseidon/) | [Holt59](https://github.com/holt59/) | [game_zeusandpoiseidon.py](games/game_zeusandpoiseidon.py) |
  • mod data checker
| From 02e283964ed6934d001c1318247c57b6217a75b9 Mon Sep 17 00:00:00 2001 From: Zash Date: Sat, 18 Dec 2021 15:12:53 +0100 Subject: [PATCH 4/7] + overwrite sync for config files --- README.md | 2 +- games/game_valheim.py | 322 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 294 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 073fee1..f86ff0a 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ You can rename `modorganizer-basic_games-xxx` to whatever you want (e.g., `basic | S.T.A.L.K.E.R. Anomaly — [MOD](https://www.stalker-anomaly.com/) | [Qudix](https://github.com/Qudix) | [game_stalkeranomaly.py](games/game_stalkeranomaly.py) |
  • mod data checker
| | Stardew Valley — [GOG](https://www.gog.com/game/stardew_valley) / [STEAM](https://store.steampowered.com/app/413150/Stardew_Valley/) | [Syer10](https://github.com/Syer10), [Holt59](https://github.com/holt59/) | [game_stardewvalley.py](games/game_stardewvalley.py) |
  • mod data checker
| | STAR WARS™ Empire at War: Gold Pack - [GOG](https://www.gog.com/game/star_wars_empire_at_war_gold_pack) / [STEAM](https://store.steampowered.com/app/32470/) | [erri120](https://github.com/erri120) |
  • Empire at War: [game_starwars-empire-at-war.py](games/game_starwars-empire-at-war.py)
  • Force of Corruption: [game_starwars-empire-at-war-foc.py](games/game_starwars-empire-at-war-foc.py)
| | -| Valheim — [STEAM](https://store.steampowered.com/app/892970/Valheim/) | [Zash](https://github.com/ZashIn) | [game_valheim.py](games/game_valheim.py) |
  • mod data checker
+| Valheim — [STEAM](https://store.steampowered.com/app/892970/Valheim/) | [Zash](https://github.com/ZashIn) | [game_valheim.py](games/game_valheim.py) |
  • mod data checker
  • overwrite config sync
| The Witcher: Enhanced Edition - [GOG](https://www.gog.com/game/the_witcher) / [STEAM](https://store.steampowered.com/app/20900/The_Witcher_Enhanced_Edition_Directors_Cut/) | [erri120](https://github.com/erri120) | [game_witcher1.py](games/game_witcher1.py) |
  • save game parsing (no preview)
| | The Witcher 3: Wild Hunt — [GOG](https://www.gog.com/game/the_witcher_3_wild_hunt) / [STEAM](https://store.steampowered.com/app/292030/The_Witcher_3_Wild_Hunt/) | [Holt59](https://github.com/holt59/) | [game_witcher3.py](games/game_witcher3.py) |
  • save game preview
| | Zeus and Poseidon — [GOG](https://www.gog.com/game/zeus_poseidon) / [STEAM](https://store.steampowered.com/app/566050/Zeus__Poseidon/) | [Holt59](https://github.com/holt59/) | [game_zeusandpoiseidon.py](games/game_zeusandpoiseidon.py) |
  • mod data checker
| diff --git a/games/game_valheim.py b/games/game_valheim.py index a08468d..a0ea1d5 100644 --- a/games/game_valheim.py +++ b/games/game_valheim.py @@ -3,10 +3,13 @@ from __future__ import annotations import fnmatch +import itertools import re -from collections.abc import Iterable, Sequence +import shutil +from collections.abc import Collection, Container, Iterable, Mapping, Sequence from dataclasses import dataclass, field, fields -from typing import Optional +from pathlib import Path +from typing import Optional, TextIO, Union import mobase @@ -28,16 +31,268 @@ def convert_entry_to_tree(entry: mobase.FileTreeEntry) -> Optional[mobase.IFileT return None +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 = re.compile(r"[A-Z]?[a-z]+") + """Matches words, for e.g. 'Camel' and 'Case' in 'CamelCase'.""" + + exclude: Container = field(default_factory=set) + 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) -> None: + self.add(**kwargs) + + def new_table(self, column_keys: Optional[Collection[str]] = 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, + ) -> 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) + output_file and output_file.flush() + self._table = [] + + +class OverwriteSync: + organizer: mobase.IOrganizer + game: mobase.IPluginGame + + overwrite_file_pattern: Iterable[str] = ["BepInEx/config/*"] + """File pattern (glob) in overwrite folder.""" + partial_match: PartialMatch = PartialMatch(exclude={"valheim", "mod"}) + content_match: Optional[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 register_event_handler(self): + self.organizer.onUserInterfaceInitialized(lambda win: self.sync()) + self.organizer.onFinishedRun(self.game_finished_event_handler) + + def game_finished_event_handler(self, app_path: str, *args) -> None: + """Sync overwrite folder with mods after game was closed.""" + if Path(app_path) == Path( + self.game.gameDirectory().absolutePath(), self.game.binaryName() + ): + self.sync() + + 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: Optional[mobase.IModList] = None + ) -> Mapping[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): + return {name: self._get_mod_dlls(mod) for name, mod in mod_map.items()} + + def _get_mod_dlls(self, mod: Union[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_name: 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 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_name: 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, + ) + + @dataclass class RegexFilesDefinition: """Regex pattern for the file lists in `FilesDefinition` - for globbing support. Fields should match `RegexFilesDefinition`. """ - set_as_root: re.Pattern - valid: re.Pattern - delete: re.Pattern - move: re.Pattern + set_as_root: Optional[re.Pattern] + valid: Optional[re.Pattern] + delete: Optional[re.Pattern] + move: Optional[re.Pattern] @classmethod def from_filesmap(cls, filesdef: FilesDefinition) -> RegexFilesDefinition: @@ -46,13 +301,17 @@ class RegexFilesDefinition: """ return cls( **{ - f.name: cls.file_list_regex(getattr(filesdef, f.name)) + f.name: ( + cls.file_list_regex(value) + if (value := getattr(filesdef, f.name)) + else None + ) for f in fields(cls) } ) - @classmethod - def file_list_regex(cls, file_list: Iterable[str]) -> re.Pattern: + @staticmethod + def file_list_regex(file_list: Iterable[str]) -> re.Pattern: """Returns a regex pattern for a file list with glob patterns. Every pattern has a capturing group, @@ -69,24 +328,25 @@ class FilesDefinition: Fields should match `RegexFilesDefinition`. """ - set_as_root: set[str] + set_as_root: Optional[set[str]] """If a folder from this set is found, it will be set as new root dir (unfolded).""" - valid: set[str] + valid: Optional[set[str]] """Files and folders in the right path.""" - delete: set[str] + delete: Optional[set[str]] """Files/folders to delete.""" - move: dict[str, str] + move: Optional[dict[str, str]] """Files/folders to move, like `{"*.ext": "path/to/folder/"}`. If the path ends with / or \\, the entry will be inserted in the corresponding directory instead of replacing it. """ regex: RegexFilesDefinition = field(init=False) - _move_targets: Sequence[str] = field(init=False) + _move_targets: Sequence[str] = field(init=False, repr=False) def __post_init__(self): self.regex = RegexFilesDefinition.from_filesmap(self) - self._move_targets = list(self.move.values()) + if self.move: + self._move_targets = list(self.move.values()) def get_move_target(self, index: int) -> str: return self._move_targets[index] @@ -146,14 +406,15 @@ class ValheimGameModDataChecker(mobase.ModDataChecker): status = mobase.ModDataChecker.INVALID for entry in filetree: name = entry.name().casefold() - if self.files_map.regex.set_as_root.match(name): - status = mobase.ModDataChecker.FIXABLE - elif self.files_map.regex.valid.match(name): + regex = self.files_map.regex + if regex.set_as_root and regex.set_as_root.match(name): + return mobase.ModDataChecker.FIXABLE + elif regex.valid and regex.valid.match(name): if status is not mobase.ModDataChecker.FIXABLE: status = mobase.ModDataChecker.VALID - elif self.files_map.regex.move.match( - name - ) or self.files_map.regex.delete.match(name): + elif (regex.move and regex.move.match(name)) or ( + regex.delete and regex.delete.match(name) + ): status = mobase.ModDataChecker.FIXABLE else: return mobase.ModDataChecker.INVALID @@ -162,15 +423,16 @@ class ValheimGameModDataChecker(mobase.ModDataChecker): def fix(self, filetree: mobase.IFileTree) -> Optional[mobase.IFileTree]: for entry in list(filetree): name = entry.name().casefold() - if self.files_map.regex.set_as_root.match(name): + regex = self.files_map.regex + if regex.set_as_root and regex.set_as_root.match(name): new_root = convert_entry_to_tree(entry) - if not new_root: - return None - return self.fix(new_root) - elif self.files_map.regex.delete.match(name): + return self.fix(new_root) if new_root else None + elif regex.delete and regex.delete.match(name): entry.detach() - elif match := self.files_map.regex.move.match(name): - if match.lastindex is not None: + elif regex.move and (match := regex.move.match(name)): + if match.lastindex is None: + return None + else: # Get index of matched group map_index = match.lastindex - 1 # Get the move target corresponding to the matched group @@ -182,7 +444,7 @@ class ValheimGame(BasicGame): Name = "Valheim Support Plugin" Author = "Zash" - Version = "1.0.0" + Version = "1.1.0" GameName = "Valheim" GameShortName = "valheim" @@ -196,6 +458,8 @@ class ValheimGame(BasicGame): def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) self._featureMap[mobase.ModDataChecker] = ValheimGameModDataChecker() + self.overwrite_sync = OverwriteSync(organizer=self._organizer, game=self) + self.overwrite_sync.register_event_handler() return True def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: From 9dc2835588fdfcd80d25f151a29c7c30980d1459 Mon Sep 17 00:00:00 2001 From: Zash Date: Mon, 20 Dec 2021 03:19:52 +0100 Subject: [PATCH 5/7] + settings for sync overwrite --- games/game_valheim.py | 62 +++++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/games/game_valheim.py b/games/game_valheim.py index a0ea1d5..927f21f 100644 --- a/games/game_valheim.py +++ b/games/game_valheim.py @@ -134,6 +134,7 @@ class DebugTable: 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.""" @@ -150,17 +151,6 @@ class OverwriteSync: self.organizer = organizer self.game = game - def register_event_handler(self): - self.organizer.onUserInterfaceInitialized(lambda win: self.sync()) - self.organizer.onFinishedRun(self.game_finished_event_handler) - - def game_finished_event_handler(self, app_path: str, *args) -> None: - """Sync overwrite folder with mods after game was closed.""" - if Path(app_path) == Path( - self.game.gameDirectory().absolutePath(), self.game.binaryName() - ): - self.sync() - def sync(self) -> None: """Sync the Overwrite folder (back) to the mods.""" print("Syncing Overwrite with mods") @@ -235,10 +225,11 @@ class OverwriteSync: 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 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) == 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] @@ -458,8 +449,8 @@ class ValheimGame(BasicGame): def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) self._featureMap[mobase.ModDataChecker] = ValheimGameModDataChecker() - self.overwrite_sync = OverwriteSync(organizer=self._organizer, game=self) - self.overwrite_sync.register_event_handler() + self._overwrite_sync = OverwriteSync(organizer=self._organizer, game=self) + self._register_event_handler() return True def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: @@ -467,3 +458,40 @@ class ValheimGame(BasicGame): mobase.ExecutableForcedLoadSetting(self.binaryName(), lib).withEnabled(True) for lib in self.forced_libraries ] + + 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, *args) -> 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.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() From 795e51379f23e27869cb2a40346eb1a854d0ba0b Mon Sep 17 00:00:00 2001 From: Zash Date: Thu, 23 Dec 2021 17:58:15 +0100 Subject: [PATCH 6/7] + savegame list & profile support --- README.md | 2 +- games/game_valheim.py | 59 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f86ff0a..185c1df 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ You can rename `modorganizer-basic_games-xxx` to whatever you want (e.g., `basic | S.T.A.L.K.E.R. Anomaly — [MOD](https://www.stalker-anomaly.com/) | [Qudix](https://github.com/Qudix) | [game_stalkeranomaly.py](games/game_stalkeranomaly.py) |
  • mod data checker
| | Stardew Valley — [GOG](https://www.gog.com/game/stardew_valley) / [STEAM](https://store.steampowered.com/app/413150/Stardew_Valley/) | [Syer10](https://github.com/Syer10), [Holt59](https://github.com/holt59/) | [game_stardewvalley.py](games/game_stardewvalley.py) |
  • mod data checker
| | STAR WARS™ Empire at War: Gold Pack - [GOG](https://www.gog.com/game/star_wars_empire_at_war_gold_pack) / [STEAM](https://store.steampowered.com/app/32470/) | [erri120](https://github.com/erri120) |
  • Empire at War: [game_starwars-empire-at-war.py](games/game_starwars-empire-at-war.py)
  • Force of Corruption: [game_starwars-empire-at-war-foc.py](games/game_starwars-empire-at-war-foc.py)
| | -| Valheim — [STEAM](https://store.steampowered.com/app/892970/Valheim/) | [Zash](https://github.com/ZashIn) | [game_valheim.py](games/game_valheim.py) |
  • mod data checker
  • overwrite config sync
+| Valheim — [STEAM](https://store.steampowered.com/app/892970/Valheim/) | [Zash](https://github.com/ZashIn) | [game_valheim.py](games/game_valheim.py) |
  • mod data checker
  • overwrite config sync
  • save game support (no preview)
| The Witcher: Enhanced Edition - [GOG](https://www.gog.com/game/the_witcher) / [STEAM](https://store.steampowered.com/app/20900/The_Witcher_Enhanced_Edition_Directors_Cut/) | [erri120](https://github.com/erri120) | [game_witcher1.py](games/game_witcher1.py) |
  • save game parsing (no preview)
| | The Witcher 3: Wild Hunt — [GOG](https://www.gog.com/game/the_witcher_3_wild_hunt) / [STEAM](https://store.steampowered.com/app/292030/The_Witcher_3_Wild_Hunt/) | [Holt59](https://github.com/holt59/) | [game_witcher3.py](games/game_witcher3.py) |
  • save game preview
| | Zeus and Poseidon — [GOG](https://www.gog.com/game/zeus_poseidon) / [STEAM](https://store.steampowered.com/app/566050/Zeus__Poseidon/) | [Holt59](https://github.com/holt59/) | [game_zeusandpoiseidon.py](games/game_zeusandpoiseidon.py) |
  • mod data checker
| diff --git a/games/game_valheim.py b/games/game_valheim.py index 927f21f..fbc05c4 100644 --- a/games/game_valheim.py +++ b/games/game_valheim.py @@ -11,8 +11,11 @@ from dataclasses import dataclass, field, fields from pathlib import Path from typing import Optional, TextIO, Union +from PyQt5.QtCore import QDir + import mobase +from ..basic_features.basic_save_game_info import BasicGameSaveGame from ..basic_game import BasicGame @@ -431,6 +434,51 @@ class ValheimGameModDataChecker(mobase.ModDataChecker): return filetree +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 ValheimLocalSavegames(mobase.LocalSavegames): + def __init__(self, myGameSaveDir): + super().__init__() + self._savesDir = myGameSaveDir.absolutePath() + + def mappings(self, profile_save_dir): + return [ + mobase.Mapping( + source=profile_save_dir.absolutePath(), + destination=self._savesDir, + is_directory=True, + create_target=True, + ) + ] + + def prepareProfile(self, profile): + return profile.localSavesEnabled() + + class ValheimGame(BasicGame): Name = "Valheim Support Plugin" @@ -443,12 +491,16 @@ class ValheimGame(BasicGame): GameSteamId = [892970, 896660, 1223920] GameBinary = "valheim.exe" GameDataPath = "" + GameSavesDirectory = r"%USERPROFILE%/AppData/LocalLow/IronGate/Valheim" forced_libraries = ["winhttp.dll"] def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) self._featureMap[mobase.ModDataChecker] = ValheimGameModDataChecker() + self._featureMap[mobase.LocalSavegames] = ValheimLocalSavegames( + self.savesDirectory() + ) self._overwrite_sync = OverwriteSync(organizer=self._organizer, game=self) self._register_event_handler() return True @@ -459,6 +511,13 @@ class ValheimGame(BasicGame): 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( From a2b55a743b53969c134fd71dd4a8a2585850573e Mon Sep 17 00:00:00 2001 From: Chris Bessent Date: Thu, 23 Dec 2021 12:15:23 -0700 Subject: [PATCH 7/7] Fix formatting of GTA and VtM:B plugins --- games/game_gta-san-andreas-de.py | 4 +- games/game_gta-vice-city-de.py | 7 +- ...loodlines.py => game_vampirebloodlines.py} | 245 +++++++++--------- 3 files changed, 131 insertions(+), 125 deletions(-) rename games/{game_vampriebloodlines.py => game_vampirebloodlines.py} (92%) diff --git a/games/game_gta-san-andreas-de.py b/games/game_gta-san-andreas-de.py index 7f85855..3816f52 100644 --- a/games/game_gta-san-andreas-de.py +++ b/games/game_gta-san-andreas-de.py @@ -42,7 +42,9 @@ class GTASanAndreasDefinitiveEditionGame(BasicGame): def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) - self._featureMap[mobase.ModDataChecker] = GTASanAndreasDefinitiveEditionModDataChecker() + self._featureMap[ + mobase.ModDataChecker + ] = GTASanAndreasDefinitiveEditionModDataChecker() return True def executables(self): diff --git a/games/game_gta-vice-city-de.py b/games/game_gta-vice-city-de.py index 61ddb54..797eaef 100644 --- a/games/game_gta-vice-city-de.py +++ b/games/game_gta-vice-city-de.py @@ -1,7 +1,7 @@ import os from pathlib import Path -from PyQt5.QtCore import QDir,QFileInfo +from PyQt5.QtCore import QDir, QFileInfo import mobase @@ -21,6 +21,7 @@ class GTAViceCitysDefinitiveEditionModDataChecker(mobase.ModDataChecker): return mobase.ModDataChecker.INVALID + class GTAViceCityDefinitiveEditionGame(BasicGame): Name = "Grand Theft Auto: Vice City - Definitive Edition Support Plugin" @@ -41,7 +42,9 @@ class GTAViceCityDefinitiveEditionGame(BasicGame): def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) - self._featureMap[mobase.ModDataChecker] = GTAViceCitysDefinitiveEditionModDataChecker() + self._featureMap[ + mobase.ModDataChecker + ] = GTAViceCitysDefinitiveEditionModDataChecker() return True def executables(self): diff --git a/games/game_vampriebloodlines.py b/games/game_vampirebloodlines.py similarity index 92% rename from games/game_vampriebloodlines.py rename to games/game_vampirebloodlines.py index 6e5b729..a05c195 100644 --- a/games/game_vampriebloodlines.py +++ b/games/game_vampirebloodlines.py @@ -1,122 +1,123 @@ -# -*- encoding: utf-8 -*- -import os -import mobase - -from PyQt5.QtCore import QDir - -from typing import List -from pathlib import Path - -from ..basic_game import BasicGame, BasicGameSaveGame -from ..basic_features import BasicGameSaveGameInfo - - -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, tree: mobase.IFileTree - ) -> mobase.ModDataChecker.CheckReturn: - for entry in tree: - 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 VampireLocalSavegames(mobase.LocalSavegames): - def __init__(self, myGameSaveDir): - super().__init__() - self._savesDir = myGameSaveDir.absolutePath() - - def mappings(self, profile_save_dir): - m = mobase.Mapping() - m.createTarget = True - m.isDirectory = True - m.source = profile_save_dir.absolutePath() - m.destination = self._savesDir - - return [m] - - def prepareProfile(self, profile): - return profile.localSavesEnabled() - - -class VampireTheMasqueradeBloodlinesGame(BasicGame): - Name = "Vampire - The Masquerade: Bloodlines Support Plugin" - Author = "John" - Version = "1.0.0" - Description = "Adds support for Vampires: The Masquerade - Bloodlines, with the only caveat that global save games end up in overwrite." - - 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" - - def init(self, organizer: mobase.IOrganizer) -> bool: - super().init(organizer) - self._featureMap[mobase.ModDataChecker] = VampireModDataChecker() - self._featureMap[mobase.SaveGameInfo] = VampireSaveGame(self.savesDirectory()) - self._featureMap[mobase.LocalSavegames] = VampireLocalSavegames( - self.savesDirectory() - ) - return True - - def initializeProfile(self, path: QDir, settings: int): - # 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 - - super().initializeProfile(path, 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}") - ] +# -*- encoding: utf-8 -*- +import os +import mobase + +from PyQt5.QtCore import QDir + +from typing import List +from pathlib import Path + +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, tree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in tree: + 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 VampireLocalSavegames(mobase.LocalSavegames): + def __init__(self, myGameSaveDir): + super().__init__() + self._savesDir = myGameSaveDir.absolutePath() + + def mappings(self, profile_save_dir): + m = mobase.Mapping() + m.createTarget = True + m.isDirectory = True + m.source = profile_save_dir.absolutePath() + m.destination = self._savesDir + + return [m] + + def prepareProfile(self, profile): + return profile.localSavesEnabled() + + +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" + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._featureMap[mobase.ModDataChecker] = VampireModDataChecker() + self._featureMap[mobase.SaveGameInfo] = VampireSaveGame( + Path(self.savesDirectory().absolutePath()) + ) + self._featureMap[mobase.LocalSavegames] = VampireLocalSavegames( + self.savesDirectory() + ) + return True + + def initializeProfile(self, path: QDir, settings: int): + # 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 + + super().initializeProfile(path, 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}") + ]