Files

428 lines
15 KiB
Python
Raw Permalink Normal View History

2021-12-16 00:34:03 +01:00
from __future__ import annotations
2021-12-18 15:12:53 +01:00
import itertools
2021-12-16 00:34:03 +01:00
import re
2021-12-18 15:12:53 +01:00
import shutil
from collections.abc import Collection, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
2021-12-18 15:12:53 +01:00
from pathlib import Path
from typing import Any, Optional, TextIO
2021-12-23 17:58:15 +01:00
from PyQt6.QtCore import QDir
2021-12-16 00:34:03 +01:00
2025-04-25 08:23:10 +02:00
import mobase
from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns
2021-12-23 17:58:15 +01:00
from ..basic_features.basic_save_game_info import BasicGameSaveGame
2021-12-16 00:34:03 +01:00
from ..basic_game import BasicGame
2021-12-18 15:12:53 +01:00
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]+")
2021-12-18 15:12:53 +01:00
"""Matches words, for e.g. 'Camel' and 'Case' in 'CamelCase'."""
2025-04-25 08:23:10 +02:00
exclude: set[str] = field(default_factory=set[str])
2021-12-18 15:12:53 +01:00
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:
2021-12-18 15:12:53 +01:00
self.add(**kwargs)
def new_table(self, column_keys: Collection[str] | None = None) -> None:
2021-12-18 15:12:53 +01:00
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,
2021-12-18 15:12:53 +01:00
) -> 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()
2021-12-18 15:12:53 +01:00
self._table = []
class OverwriteSync:
organizer: mobase.IOrganizer
game: mobase.IPluginGame
2021-12-20 03:19:52 +01:00
search_file_contents: bool = True
2021-12-18 15:12:53 +01:00
overwrite_file_pattern: Iterable[str] = ["BepInEx/config/*"]
"""File pattern (glob) in overwrite folder."""
partial_match: PartialMatch = PartialMatch(exclude={"valheim", "mod"})
content_match: ContentMatch = ContentMatch(
2021-12-18 15:12:53 +01:00
file_glob_patterns=["*.cfg"],
content_regex=re.compile(r"\A.*plugin (?P<mod>.+) v[\d.]+?$", re.I | re.M),
2021-12-18 15:12:53 +01:00
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]:
2021-12-18 15:12:53 +01:00
"""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]):
2021-12-18 15:12:53 +01:00
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]:
2021-12-18 15:12:53 +01:00
"""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.
2021-12-18 15:12:53 +01:00
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)
2021-12-20 03:19:52 +01:00
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)
2021-12-18 15:12:53 +01:00
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"]}`.
2021-12-18 15:12:53 +01:00
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,
)
2021-12-23 17:58:15 +01:00
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
2021-12-16 00:34:03 +01:00
class ValheimGame(BasicGame):
Name = "Valheim Support Plugin"
Author = "Zash"
2026-01-11 17:44:06 +01:00
Version = "1.3"
2021-12-16 00:34:03 +01:00
GameName = "Valheim"
GameShortName = "valheim"
2026-01-11 17:44:06 +01:00
GameThunderstoreName = "valheim"
2021-12-16 00:34:03 +01:00
GameNexusId = 3667
GameSteamId = [892970, 896660, 1223920]
GameBinary = "valheim.exe"
GameDataPath = ""
2021-12-23 17:58:15 +01:00
GameSavesDirectory = r"%USERPROFILE%/AppData/LocalLow/IronGate/Valheim"
2021-12-27 00:57:18 -07:00
GameSupportURL = (
r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Valheim"
)
2021-12-16 00:34:03 +01:00
_forced_libraries = ["winhttp.dll"]
2021-12-16 00:34:03 +01:00
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",
],
2026-01-11 17:44:06 +01:00
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))
2021-12-20 03:19:52 +01:00
self._overwrite_sync = OverwriteSync(organizer=self._organizer, game=self)
self._register_event_handler()
2021-12-16 00:34:03 +01:00
return True
def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]:
return [
mobase.ExecutableForcedLoadSetting(self.binaryName(), lib).withEnabled(True)
for lib in self._forced_libraries
2021-12-16 00:34:03 +01:00
]
2021-12-20 03:19:52 +01:00
2021-12-23 17:58:15 +01:00
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
2021-12-20 03:19:52 +01:00
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:
2021-12-20 03:19:52 +01:00
"""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
2021-12-20 03:19:52 +01:00
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()