diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 02:37:39 -0600 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 02:37:39 -0600 |
| commit | 7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch) | |
| tree | 27fb39be241fdb5ac2734c574de678977d1856d0 /libs/basic_games/games/game_valheim.py | |
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 <noreply@anthropic.com>
Diffstat (limited to 'libs/basic_games/games/game_valheim.py')
| -rw-r--r-- | libs/basic_games/games/game_valheim.py | 427 |
1 files changed, 427 insertions, 0 deletions
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<mod>.+) 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() |
