Files
Jackify/jackify/backend/services/automated_prefix_game_utils.py
2026-04-20 20:57:23 +01:00

286 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Game utilities mixin for AutomatedPrefixService.
Handles game-specific operations:
- Launch options generation
- Game detection
- User directory creation
- Proton version preferences
"""
import os
import logging
from pathlib import Path
from typing import Optional, List
logger = logging.getLogger(__name__)
class GameUtilsMixin:
"""Mixin for game-related utility operations"""
# TODO post-0.6: remove this method - dead code, never called.
# Superseded by registry injection (game paths written directly into the modlist prefix).
# def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
# """
# Generate launch options for FNV/Enderal games that require vanilla compatdata.
#
# Args:
# special_game_type: "fnv" or "enderal"
# modlist_install_dir: Directory where the modlist is installed
#
# Returns:
# Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
# """
# if not special_game_type or special_game_type not in ["fnv", "enderal"]:
# return None
#
# logger.info(f"Generating {special_game_type.upper()} launch options")
#
# # Map game types to AppIDs
# appid_map = {"fnv": "22380", "enderal": "976620"}
# appid = appid_map[special_game_type]
#
# # Find vanilla game compatdata
# from ..handlers.path_handler import PathHandler
# compatdata_path = PathHandler.find_compat_data(appid)
# if not compatdata_path:
# logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
# return None
#
# # Create STEAM_COMPAT_DATA_PATH string
# compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
#
# # Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
# compat_mounts_str = ""
# try:
# all_libs = PathHandler.get_all_steam_library_paths()
# main_steam_lib_path_obj = PathHandler.find_steam_library()
# if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
# main_steam_lib_path = main_steam_lib_path_obj.parent.parent
# else:
# main_steam_lib_path = main_steam_lib_path_obj
#
# mount_paths = []
# if main_steam_lib_path:
# main_resolved = main_steam_lib_path.resolve()
# for lib_path in all_libs:
# if lib_path.resolve() != main_resolved:
# mount_paths.append(str(lib_path.resolve()))
#
# if mount_paths:
# mount_paths_str = ':'.join(mount_paths)
# compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
# logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
# except Exception as e:
# logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
#
# # Combine all launch options
# launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
# launch_options = ' '.join(launch_options.split()) # Clean up spacing
#
# logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}")
# return launch_options
def _find_steam_game(self, app_id: str, common_names: list) -> Optional[str]:
"""Find a Steam game installation path by AppID and common names"""
import os
from pathlib import Path
# Get Steam libraries from libraryfolders.vdf - check multiple possible locations
possible_config_paths = [
Path.home() / ".steam/steam/config/libraryfolders.vdf",
Path.home() / ".local/share/Steam/config/libraryfolders.vdf",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf" # Flatpak
]
steam_config_path = None
for path in possible_config_paths:
if path.exists():
steam_config_path = path
break
if not steam_config_path:
return None
steam_libraries = []
try:
with open(steam_config_path, 'r') as f:
content = f.read()
# Parse library paths from VDF
import re
library_matches = re.findall(r'"path"\s+"([^"]+)"', content)
steam_libraries = [Path(path) / "steamapps" / "common" for path in library_matches]
except Exception as e:
logger.warning(f"Failed to parse Steam library folders: {e}")
return None
# Search for game in each library
for library_path in steam_libraries:
if not library_path.exists():
continue
# Check manifest file first (more reliable)
manifest_path = library_path.parent / "appmanifest_{}.acf".format(app_id)
if manifest_path.exists():
try:
with open(manifest_path, 'r') as f:
content = f.read()
install_dir_match = re.search(r'"installdir"\s+"([^"]+)"', content)
if install_dir_match:
game_path = library_path / install_dir_match.group(1)
if game_path.exists():
return str(game_path)
except Exception:
pass
# Fallback: check common folder names
for name in common_names:
game_path = library_path / name
if game_path.exists():
return str(game_path)
return None
def _detect_skyrim_se_modlist(self, modlist_dir: str) -> bool:
"""
Return True if modlist_dir is a Skyrim SE (non-VR) modlist.
Used only to trigger first-launch seeding when special_game_type is None.
Other games are not yet confirmed to need this treatment.
"""
if not modlist_dir:
return False
try:
mo2_ini = Path(modlist_dir) / "ModOrganizer.ini"
if not mo2_ini.exists():
mo2_ini = Path(modlist_dir) / "files" / "ModOrganizer.ini"
if not mo2_ini.exists():
return False
content = mo2_ini.read_text(errors='ignore').lower()
# Anchor VR check to gameName= to avoid false positives from plugin
# setting keys like enable_skyrimVR=false appearing in SE modlists.
for _line in content.splitlines():
if _line.strip().startswith("gamename="):
game_name_value = _line.strip()[len("gamename="):]
if 'skyrim vr' in game_name_value or 'skyrimvr' in game_name_value:
return False
break
return 'skyrim special edition' in content or 'skse64_loader' in content
except Exception as e:
logger.debug(f"Could not check Skyrim SE detection for {modlist_dir}: {e}")
return False
def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str,
modlist_dir: Optional[str] = None):
"""
Pre-create game-specific user directories to prevent first-launch issues.
Creates both My Documents/My Games and AppData/Local directories for the game.
special_game_type covers FNV/FO3/Enderal (vanilla-compatdata games). For standard
games like Skyrim SE that aren't "special" in that sense, modlist_dir is used to
detect what directories to seed.
"""
# Bethesda-pattern games: same name used for both My Games and AppData/Local
game_dir_names = {
"skyrim": "Skyrim Special Edition",
"skyrimvr": "Skyrim VR",
"fnv": "FalloutNV",
"fo3": "Fallout3",
"fo4": "Fallout4",
"fallout4vr": "Fallout4VR",
"oblivion": "Oblivion",
"oblivion_remastered": "Oblivion Remastered",
"enderal": "Enderal Special Edition",
"starfield": "Starfield",
}
# Non-Bethesda games: AppData/Local only, with a vendor-namespaced subdirectory
game_appdata_only = {
"cp2077": os.path.join("CD Projekt Red", "Cyberpunk 2077"),
"bg3": os.path.join("Larian Studios", "Baldur's Gate 3"),
}
# special_game_type covers FNV/FO3/Enderal (vanilla-compatdata games).
# Skyrim SE returns None from detect_special_game_type but still needs seeding.
game_type = special_game_type
if special_game_type is None and modlist_dir and self._detect_skyrim_se_modlist(modlist_dir):
game_type = "skyrim"
base_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser")
if game_type in game_appdata_only:
appdata_dir = os.path.join(base_path, "AppData", "Local", game_appdata_only[game_type])
try:
os.makedirs(appdata_dir, exist_ok=True)
logger.info(f"Created AppData/Local directory: {appdata_dir}")
except Exception as e:
logger.warning(f"Failed to create AppData/Local directory {appdata_dir}: {e}")
return
game_dir_name = game_dir_names.get(game_type)
if not game_dir_name:
logger.debug(f"No user directory mapping for game type: {game_type}")
return
directories_to_create = [
os.path.join(base_path, "Documents", "My Games", game_dir_name),
os.path.join(base_path, "AppData", "Local", game_dir_name),
]
created_count = 0
for directory in directories_to_create:
try:
os.makedirs(directory, exist_ok=True)
logger.info(f"Created user directory: {directory}")
created_count += 1
except Exception as e:
logger.warning(f"Failed to create directory {directory}: {e}")
if created_count > 0:
logger.info(f"Created {created_count} user directories for {game_dir_name}")
if game_type == "skyrim":
self._seed_skyrim_first_launch_files(base_path, game_dir_name)
elif game_type == "fo4":
self._seed_fo4_first_launch_files(base_path, game_dir_name)
elif game_type == "skyrimvr":
self._seed_skyrimvr_first_launch_files(base_path, game_dir_name)
elif game_type == "fallout4vr":
self._seed_fallout4vr_first_launch_files(base_path, game_dir_name)
def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""Delegate to FileSystemHandler to seed Skyrim first-launch fix files."""
try:
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
fsh = FileSystemHandler()
fsh._seed_skyrim_first_launch_files(prefix_user, docs_dir_name)
except Exception as e:
logger.warning(f"Could not seed Skyrim first-launch files: {e}")
def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""Delegate to FileSystemHandler to seed Fallout 4 first-launch fix files."""
try:
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
fsh = FileSystemHandler()
fsh._seed_fo4_first_launch_files(prefix_user, docs_dir_name)
except Exception as e:
logger.warning(f"Could not seed FO4 first-launch files: {e}")
def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""Delegate to FileSystemHandler to seed Skyrim VR first-launch fix files."""
try:
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
fsh = FileSystemHandler()
fsh._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
except Exception as e:
logger.warning(f"Could not seed SkyrimVR first-launch files: {e}")
def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""Delegate to FileSystemHandler to seed Fallout 4 VR first-launch fix files."""
try:
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
fsh = FileSystemHandler()
fsh._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name)
except Exception as e:
logger.warning(f"Could not seed FO4VR first-launch files: {e}")