Merge branch 'ZashIn-feat/BasicModDataChecker' into qt6

# Conflicts:
#	README.md
This commit is contained in:
Mikaël Capelle
2022-05-02 16:53:35 +02:00
4 changed files with 277 additions and 182 deletions
+5 -1
View File
@@ -64,7 +64,7 @@ You can rename `modorganizer-basic_games-xxx` to whatever you want (e.g., `basic
| 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) | <ul><li>mod data checker</li></ul> |
| 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) | <ul><li>Empire at War: [game_starwars-empire-at-war.py](games/game_starwars-empire-at-war.py)</li><li>Force of Corruption: [game_starwars-empire-at-war-foc.py](games/game_starwars-empire-at-war-foc.py)</li></ul> | |
| Subnautica — [STEAM](https://store.steampowered.com/app/264710/) / [Epic](https://store.epicgames.com/p/subnautica) | dekart811 | [game_subnautica.py](games/game_subnautica.py) | |
| Valheim — [STEAM](https://store.steampowered.com/app/892970/Valheim/) | [Zash](https://github.com/ZashIn) | [game_valheim.py](games/game_valheim.py) | <ul><li>mod data checker</li><li>overwrite config sync</li><li>save game support (no preview)</li></ul>
| Valheim — [STEAM](https://store.steampowered.com/app/892970/Valheim/) | [Zash](https://github.com/ZashIn) | [game_valheim.py](games/game_valheim.py) | <ul><li>mod data checker</li><li>overwrite config sync</li><li>save game support (no preview)</li></ul> |
| 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) | <ul><li>save game parsing (no preview)</li></ul> |
| 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) | <ul><li>save game preview</li></ul> |
| Yu-Gi-Oh! Master Duel — [STEAM](https://store.steampowered.com/app/1449850/) | [The Conceptionist](https://github.com/the-conceptionist) & [uwx](https://github.com/uwx) | [game_masterduel.py](games/game_masterduel.py) | |
@@ -178,6 +178,10 @@ The meta-plugin provides some useful extra feature:
2. **Basic save game preview:** If you use the Python version, and if you can easily obtain a picture (file)
for any saves, you can provide basic save-game preview by using the `BasicGameSaveGameInfo`.
See [games/game_witcher3.py](games/game_witcher3.py) for more details.
3. **Basic mod data checker** (Python):
Check and fix different mod archive layouts for an automatic installation with the proper
file structure, using simple (glob) patterns via `BasicModDataChecker`.
See [games/game_valheim.py](games/game_valheim.py) for an example.
Game IDs can be found here:
+1
View File
@@ -1,3 +1,4 @@
# -*- encoding: utf-8 -*-
from .basic_save_game_info import BasicGameSaveGameInfo # noqa
from .basic_mod_data_checker import BasicModDataChecker # noqa
+214
View File
@@ -0,0 +1,214 @@
# -*- encoding: utf-8 -*-
from __future__ import annotations
import fnmatch
import re
import sys
from collections.abc import Iterable, Sequence
from typing import ClassVar, MutableMapping, Optional, TypedDict
import mobase
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
class RegexPatternDict(dict, MutableMapping[str, re.Pattern]):
"""Regex patterns for validation in `BasicModDataChecker`."""
@classmethod
def from_glob_patterns(cls, glob_patterns: GlobPatternDict) -> RegexPatternDict:
"""Returns an instance of `RegexPatternDict`, with the `glob_patterns`
translated to regex.
"""
return cls(
(
key,
cls.regex_from_glob_list(value)
if isinstance(value, Iterable)
else None,
)
for key, value in glob_patterns.items()
)
def get_match_index(self, key: str, search_str: str) -> Optional[int]:
"""Get the index of the matched group if the `self[key]` matches `search_str`.
Returns:
The 0-based index of the matched group or None for no match.
"""
if pattern := self.get(key):
return self.match_index_of_pattern(search_str, pattern)
return None
@staticmethod
def match_index_of_pattern(search_str: str, pattern: re.Pattern) -> Optional[int]:
"""Get the index of the matched group if `search_str` matches `pattern`
(from this dict).
Returns:
The 0-based index of the matched group or None for no match.
"""
if (match := pattern.match(search_str)) and match.lastindex:
return match.lastindex - 1
return None
@staticmethod
def regex_from_glob_list(glob_list: Iterable[str]) -> re.Pattern:
"""Returns a regex pattern form a list of glob patterns.
Every pattern has a capturing group, so that `match.lastindex - 1` will
give the `glob_list` index.
"""
return re.compile(
f'(?:{"|".join(f"({fnmatch.translate(f)})" for f in glob_list)})', re.I
)
class GlobPatternDict(TypedDict, total=False):
"""See: `BasicModDataChecker`"""
unfold: Iterable[str] | None
valid: Iterable[str] | None
delete: Iterable[str] | None
move: MutableMapping[str, str] | None
class BasicModDataChecker(mobase.ModDataChecker):
"""Game feature that is used to check and fix the content of a data tree
via simple file definitions.
The file definitions support glob pattern (without subfolders) and are
checked and fixed in definition order of the `file_patterns` dict.
Args:
file_patterns (optional): A dict (GlobPatternDict) with the following keys::
{
"unfold": [ "list of folders to unfold" ],
# (remove and move contents to parent), after being checked and
# fixed recursively.
# Check result: `mobase.ModDataChecker.VALID`.
"valid": [ "list of files and folders in the right path." ],
# Check result: `mobase.ModDataChecker.VALID`.
"delete": [ "list of files/folders to delete." ],
# Check result: `mobase.ModDataChecker.FIXABLE`.
"move": {"Files/folders to move": "target path"}
# If the path ends with `/` or `\\`, the entry will be inserted
# in the corresponding directory instead of replacing it.
# Check result: `mobase.ModDataChecker.FIXABLE`.
}
Example::
BasicModDataChecker(
{
"valid": ["valid_folder", "*.ext1"]
"move": {"*.ext2": "path/to/target_folder/"}
}
)
See Also:
`mobase.IFileTree.move` for the `"move"` target path specs.
"""
default_file_patterns: ClassVar[GlobPatternDict] = {}
"""Default for `file_patterns` - for subclasses."""
_file_patterns: GlobPatternDict
"""Private `file_patterns`, updated together with `._regex` and `._move_targets`."""
_regex: RegexPatternDict
"""The regex patterns derived from the file (glob) patterns."""
_move_targets: Sequence[str]
"""Target paths from `file_patterns["move"]`."""
def __init__(self, file_patterns: Optional[GlobPatternDict] = None):
super().__init__()
# Init with copy from class var by default (for unique instance var).
self.set_patterns(file_patterns or (self.default_file_patterns.copy()))
def set_patterns(self, file_patterns: GlobPatternDict):
"""Sets the file patterns, replacing previous/default values and order."""
self._file_patterns = file_patterns
self.update_patterns()
def update_patterns(self, file_patterns: Optional[GlobPatternDict] = None):
"""Update file patterns. Preserves previous/default definition
(check/fix) order.
"""
if file_patterns:
self._file_patterns.update(file_patterns)
self._regex = RegexPatternDict.from_glob_patterns(self._file_patterns)
if move_map := self._file_patterns.get("move"):
self._move_targets = list(move_map.values())
def dataLooksValid(
self, filetree: mobase.IFileTree
) -> mobase.ModDataChecker.CheckReturn:
status = mobase.ModDataChecker.INVALID
for entry in filetree:
name = entry.name().casefold()
for key, regex in self._regex.items():
if not regex or not regex.match(name):
continue
if key == "unfold":
return mobase.ModDataChecker.FIXABLE
elif key == "valid":
if status is not mobase.ModDataChecker.FIXABLE:
status = mobase.ModDataChecker.VALID
elif key in ("move", "delete"):
status = mobase.ModDataChecker.FIXABLE
break
else:
return mobase.ModDataChecker.INVALID
return status
def fix(self, filetree: mobase.IFileTree) -> Optional[mobase.IFileTree]:
for entry in list(filetree):
name = entry.name()
# Fix entries in pattern definition order.
for key, regex in self._regex.items():
if not regex or not regex.match(name):
continue
if key == "valid":
break
elif key == "unfold":
if (folder_tree := convert_entry_to_tree(entry)) is not None:
if folder_tree: # Not empty
# Recursively fix subtree and unfold.
if not (fixed_folder_tree := self.fix(folder_tree)):
return None
filetree.merge(fixed_folder_tree)
folder_tree.detach()
else:
print(f"Cannot unfold {name}!", file=sys.stderr)
return None
elif key == "delete":
entry.detach()
elif key == "move":
if (move_target := self._get_move_target(name)) is not None:
filetree.move(entry, move_target)
break
return filetree
def _get_move_target(self, filename: str) -> Optional[str]:
if (i := self._regex.get_match_index("move", filename)) is None:
return None
return self._move_targets[i]
+57 -181
View File
@@ -2,12 +2,11 @@
from __future__ import annotations
import fnmatch
import itertools
import re
import shutil
from collections.abc import Collection, Container, Iterable, Mapping, Sequence
from dataclasses import dataclass, field, fields
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, TextIO, Union
@@ -15,25 +14,11 @@ from PyQt6.QtCore import QDir
import mobase
from ..basic_features import BasicModDataChecker
from ..basic_features.basic_save_game_info import BasicGameSaveGame
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
def move_file(source: Path, target: Path):
"""Move `source` to `target`. Creates missing (parent) directories and
overwrites existing `target`."""
@@ -130,7 +115,8 @@ class DebugTable:
if self._table:
for line in self._table:
print("|", " | ".join(line.values()), "|", file=output_file)
output_file and output_file.flush()
if output_file:
output_file.flush()
self._table = []
@@ -277,165 +263,6 @@ class OverwriteSync:
)
@dataclass
class RegexFilesDefinition:
"""Regex pattern for the file lists in `FilesDefinition` - for globbing support.
Fields should match `RegexFilesDefinition`.
"""
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:
"""Returns an instance of `RegexFilesDefinition`,
with the file list fields from `FilesDefinition` as regex patterns.
"""
return cls(
**{
f.name: (
cls.file_list_regex(value)
if (value := getattr(filesdef, f.name))
else None
)
for f in fields(cls)
}
)
@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,
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: Optional[set[str]]
"""If a folder from this set is found, it will be set as new root dir (unfolded)."""
valid: Optional[set[str]]
"""Files and folders in the right path."""
delete: Optional[set[str]]
"""Files/folders to delete."""
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, repr=False)
def __post_init__(self):
self.regex = RegexFilesDefinition.from_filesmap(self)
if self.move:
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={
"meta.ini", # Included in installed mod folder.
"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()
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 (regex.move and regex.move.match(name)) or (
regex.delete and 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()
regex = self.files_map.regex
if regex.set_as_root and regex.set_as_root.match(name):
new_root = convert_entry_to_tree(entry)
return self.fix(new_root) if new_root else None
elif regex.valid and regex.valid.match(name):
continue
elif regex.delete and regex.delete.match(name):
entry.detach()
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
filetree.move(entry, self.files_map.get_move_target(map_index))
return filetree
class ValheimSaveGame(BasicGameSaveGame):
def getName(self) -> str:
return f"[{self.getSaveGroupIdentifier().rstrip('s')}] {self._filepath.stem}"
@@ -485,7 +312,7 @@ class ValheimGame(BasicGame):
Name = "Valheim Support Plugin"
Author = "Zash"
Version = "1.1.1"
Version = "1.2"
GameName = "Valheim"
GameShortName = "valheim"
@@ -495,11 +322,58 @@ class ValheimGame(BasicGame):
GameDataPath = ""
GameSavesDirectory = r"%USERPROFILE%/AppData/LocalLow/IronGate/Valheim"
forced_libraries = ["winhttp.dll"]
_forced_libraries = ["winhttp.dll"]
def init(self, organizer: mobase.IOrganizer) -> bool:
super().init(organizer)
self._featureMap[mobase.ModDataChecker] = ValheimGameModDataChecker()
self._featureMap[mobase.ModDataChecker] = BasicModDataChecker(
{
"unfold": [
"BepInExPack_Valheim",
],
"valid": [
"meta.ini", # Included in installed mod folder.
"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/",
},
}
)
self._featureMap[mobase.LocalSavegames] = ValheimLocalSavegames(
self.savesDirectory()
)
@@ -510,7 +384,7 @@ class ValheimGame(BasicGame):
def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]:
return [
mobase.ExecutableForcedLoadSetting(self.binaryName(), lib).withEnabled(True)
for lib in self.forced_libraries
for lib in self._forced_libraries
]
def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]:
@@ -548,6 +422,8 @@ class ValheimGame(BasicGame):
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(