mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 11:37:01 +01:00
1364 lines
69 KiB
Python
1364 lines
69 KiB
Python
from pathlib import Path
|
|
import json
|
|
import logging
|
|
from typing import Union, Dict, Optional, List, Tuple
|
|
import re
|
|
import time
|
|
import vdf
|
|
import os
|
|
import subprocess
|
|
import shutil
|
|
import requests
|
|
import atexit
|
|
import signal
|
|
import sys
|
|
|
|
# Import our modules
|
|
from .path_handler import PathHandler
|
|
# from .wine_utils import WineUtils # Removed unused import
|
|
from .filesystem_handler import FileSystemHandler
|
|
from .protontricks_handler import ProtontricksHandler
|
|
from .shortcut_handler import ShortcutHandler
|
|
from .resolution_handler import ResolutionHandler
|
|
|
|
# Import our safe VDF handler
|
|
from .vdf_handler import VDFHandler
|
|
|
|
# Import colors from the new central location
|
|
from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_SELECTION, COLOR_ERROR
|
|
|
|
# Standard logging (no file handler)
|
|
import logging
|
|
|
|
# Initialize logger
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Ensure terminal state is restored on exit, error, or interrupt
|
|
def _restore_terminal():
|
|
try:
|
|
# Skip stty in GUI mode to prevent "Inappropriate ioctl for device" error
|
|
if os.environ.get('JACKIFY_GUI_MODE') == '1':
|
|
return
|
|
os.system('stty sane')
|
|
except Exception:
|
|
pass
|
|
|
|
# Only register signal handlers if we're in the main thread
|
|
try:
|
|
import threading
|
|
if threading.current_thread() is threading.main_thread():
|
|
atexit.register(_restore_terminal)
|
|
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
signal.signal(sig, lambda signum, frame: (_restore_terminal(), sys.exit(1)))
|
|
except Exception:
|
|
# If signal handling fails, just continue without it
|
|
pass
|
|
|
|
class ModlistHandler:
|
|
"""
|
|
Handles operations related to modlist detection and configuration
|
|
"""
|
|
|
|
# Dictionary mapping modlist name patterns (lowercase, spaces optional)
|
|
# to lists of additional Wine components or special actions.
|
|
MODLIST_SPECIFIC_COMPONENTS = {
|
|
# Pattern: [component1, component2, ... or special_action_string]
|
|
"wildlander": ["dotnet48"], # Example from bash script
|
|
"licentia": ["dotnet8"], # Example from bash script (needs special handling)
|
|
"nolvus": ["dotnet6", "dotnet7"], # Example
|
|
# Add other modlists and their specific needs here
|
|
# e.g., "fallout4_anotherlife": ["some_component"]
|
|
}
|
|
|
|
# Canonical mapping of modlist-specific Wine components (from omni-guides.sh)
|
|
MODLIST_WINE_COMPONENTS = {
|
|
"wildlander": ["dotnet472"],
|
|
"librum": ["dotnet40", "dotnet8"],
|
|
"apostasy": ["dotnet40", "dotnet8"],
|
|
"nordicsouls": ["dotnet40"],
|
|
"livingskyrim": ["dotnet40"],
|
|
"lsiv": ["dotnet40"],
|
|
"ls4": ["dotnet40"],
|
|
"lorerim": ["dotnet40"],
|
|
"lostlegacy": ["dotnet48"],
|
|
}
|
|
|
|
def __init__(self, steam_path_or_config: Union[Dict, str, Path, None] = None,
|
|
mo2_path: Optional[Union[str, Path]] = None,
|
|
steamdeck: bool = False,
|
|
verbose: bool = False, # Add verbose flag
|
|
filesystem_handler: Optional['FileSystemHandler'] = None):
|
|
"""
|
|
Initialize the ModlistHandler.
|
|
Can be initialized with:
|
|
1. A config dictionary: ModlistHandler(config_dict, steamdeck=True)
|
|
2. Explicit paths: ModlistHandler(steam_path="/path/to/steam", mo2_path="/path/to/mo2", steamdeck=False)
|
|
3. Default (will try to find paths if needed later): ModlistHandler()
|
|
|
|
Args:
|
|
steam_path_or_config: Config dict or path to Steam installation.
|
|
mo2_path: Path to ModOrganizer installation (needed if steam_path_or_config is a path).
|
|
steamdeck: Boolean indicating if running on Steam Deck.
|
|
verbose: Boolean indicating if verbose output is desired.
|
|
filesystem_handler: Optional FileSystemHandler instance to use instead of creating a new one.
|
|
"""
|
|
# Use standard logging (no file handler)
|
|
self.logger = logging.getLogger(__name__)
|
|
self.logger.propagate = False
|
|
self.steamdeck = steamdeck
|
|
self.steam_path: Optional[Path] = None
|
|
self.verbose = verbose # Store verbose flag
|
|
self.mo2_path: Optional[Path] = None
|
|
|
|
if isinstance(steam_path_or_config, dict):
|
|
# Scenario 1: Init with config dict
|
|
self.logger.debug("Initializing ModlistHandler with config dict")
|
|
steam_path_str = steam_path_or_config.get('steam_path')
|
|
self.steam_path = Path(steam_path_str) if steam_path_str else None
|
|
mo2_path_str = steam_path_or_config.get('mo2_path')
|
|
self.mo2_path = Path(mo2_path_str) if mo2_path_str else None
|
|
elif steam_path_or_config:
|
|
# Scenario 2: Init with explicit paths
|
|
self.logger.debug("Initializing ModlistHandler with explicit paths")
|
|
self.steam_path = Path(steam_path_or_config)
|
|
if mo2_path:
|
|
self.mo2_path = Path(mo2_path)
|
|
else:
|
|
# Decide if mo2_path is strictly required here
|
|
self.logger.warning("MO2 path not provided during path-based initialization")
|
|
# If MO2 path is essential, raise ValueError
|
|
# raise ValueError("mo2_path is required when providing steam_path directly")
|
|
else:
|
|
# Scenario 3: Default init, paths might be found later if needed
|
|
self.logger.debug("Initializing ModlistHandler with default settings")
|
|
# Paths remain None for now
|
|
|
|
self.modlists: Dict[str, Dict] = {}
|
|
self.launch_options = [
|
|
"--no-sandbox",
|
|
"--disable-gpu-sandbox",
|
|
"--disable-software-rasterizer",
|
|
"--disable-dev-shm-usage"
|
|
]
|
|
# Initialize state reset variables first
|
|
self.modlist = None
|
|
self.appid = None
|
|
self.game_var = None
|
|
self.game_var_full = None
|
|
self.modlist_dir = None
|
|
self.modlist_ini = None
|
|
self.steam_library = None
|
|
self.basegame_sdcard = False
|
|
self.modlist_sdcard = False
|
|
self.compat_data_path = None
|
|
self.proton_ver = None
|
|
self.game_name = None
|
|
self.selected_resolution = None
|
|
self.which_protontricks = None
|
|
self.steamdeck = steamdeck
|
|
self.stock_game_path = None
|
|
|
|
# Initialize Handlers (should happen regardless of how paths were provided)
|
|
self.protontricks_handler = ProtontricksHandler(self.steamdeck, logger=self.logger)
|
|
# Initialize winetricks handler for wine component installation
|
|
from .winetricks_handler import WinetricksHandler
|
|
self.winetricks_handler = WinetricksHandler(logger=self.logger)
|
|
self.shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=self.verbose)
|
|
self.filesystem_handler = filesystem_handler if filesystem_handler else FileSystemHandler()
|
|
self.resolution_handler = ResolutionHandler()
|
|
self.path_handler = PathHandler() # Assuming PathHandler is needed
|
|
|
|
# Use shared timing for consistency across services
|
|
|
|
# Load modlists if steam_path is known
|
|
if self.steam_path:
|
|
self._load_modlists()
|
|
else:
|
|
self.logger.debug("Steam path not known during init, skipping initial modlist load.")
|
|
|
|
# Use static methods from VDFHandler
|
|
self.vdf_handler = VDFHandler
|
|
|
|
def _get_progress_timestamp(self):
|
|
"""Get consistent progress timestamp"""
|
|
from jackify.shared.timing import get_timestamp
|
|
return get_timestamp()
|
|
|
|
# --- Original methods continue below ---
|
|
def _load_modlists(self) -> None:
|
|
"""Load modlists from local configuration or detect from Steam shortcuts."""
|
|
try:
|
|
# Try to load from local config first
|
|
if not self.steam_path or not self.steam_path.exists():
|
|
self.logger.warning("Steam path not valid in __init__, cannot load modlists.json")
|
|
self._detect_modlists_from_shortcuts()
|
|
return
|
|
|
|
config_path = self.steam_path.parent / 'modlists.json'
|
|
if config_path.exists():
|
|
with open(config_path, 'r') as f:
|
|
self.modlists = json.load(f)
|
|
self.logger.info("Loaded modlists from local configuration")
|
|
return
|
|
|
|
self._detect_modlists_from_shortcuts()
|
|
except Exception as e:
|
|
self.logger.error(f"Error loading modlists: {e}")
|
|
|
|
def _detect_modlists_from_shortcuts(self) -> bool:
|
|
"""
|
|
Detect modlists from Steam shortcuts.vdf entries
|
|
"""
|
|
self.logger.info("Detecting modlists from Steam shortcuts")
|
|
return False # Placeholder return
|
|
|
|
def discover_executable_shortcuts(self, executable_name: str) -> List[Dict]:
|
|
"""Discovers non-Steam shortcuts pointing to a specific executable.
|
|
|
|
Args:
|
|
executable_name: The name of the executable (e.g., "ModOrganizer.exe")
|
|
to look for in the shortcut's 'Exe' path.
|
|
|
|
Returns:
|
|
A list of dictionaries, each containing validated shortcut info:
|
|
{'name': AppName, 'appid': AppID, 'path': StartDir}
|
|
Returns an empty list if none are found or an error occurs.
|
|
"""
|
|
self.logger.info(f"Discovering non-Steam shortcuts for executable: {executable_name}")
|
|
discovered_modlists_info = []
|
|
|
|
try:
|
|
# Get shortcuts pointing to the executable from shortcuts.vdf
|
|
matching_vdf_shortcuts = self.shortcut_handler.find_shortcuts_by_exe(executable_name)
|
|
if not matching_vdf_shortcuts:
|
|
self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in shortcuts.vdf.")
|
|
return []
|
|
self.logger.debug(f"Shortcuts matching executable '{executable_name}' in VDF: {matching_vdf_shortcuts}")
|
|
|
|
# Process each matching shortcut and convert signed AppID to unsigned
|
|
for vdf_shortcut in matching_vdf_shortcuts:
|
|
app_name = vdf_shortcut.get('AppName')
|
|
start_dir = vdf_shortcut.get('StartDir')
|
|
signed_appid = vdf_shortcut.get('appid')
|
|
|
|
if not app_name or not start_dir:
|
|
self.logger.warning(f"Skipping VDF shortcut due to missing AppName or StartDir: {vdf_shortcut}")
|
|
continue
|
|
|
|
if signed_appid is None:
|
|
self.logger.warning(f"Skipping VDF shortcut due to missing appid: {vdf_shortcut}")
|
|
continue
|
|
|
|
# Convert signed AppID to unsigned AppID (the format used by Steam prefixes)
|
|
if signed_appid < 0:
|
|
unsigned_appid = signed_appid + (2**32)
|
|
else:
|
|
unsigned_appid = signed_appid
|
|
|
|
# Append dictionary with all necessary info using unsigned AppID
|
|
modlist_info = {
|
|
'name': app_name,
|
|
'appid': unsigned_appid,
|
|
'path': start_dir
|
|
}
|
|
discovered_modlists_info.append(modlist_info)
|
|
self.logger.info(f"Discovered shortcut: '{app_name}' (Signed: {signed_appid} → Unsigned: {unsigned_appid}, Path: {start_dir})")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error discovering executable shortcuts: {e}", exc_info=True)
|
|
return []
|
|
|
|
if not discovered_modlists_info:
|
|
self.logger.warning("No validated shortcuts found after correlation.")
|
|
|
|
return discovered_modlists_info
|
|
|
|
def set_modlist(self, modlist_info: Dict) -> bool:
|
|
"""Sets the internal context based on the selected modlist dictionary.
|
|
|
|
Extracts AppName, AppID, and StartDir from the input dictionary
|
|
and sets internal state variables like self.game_name, self.appid,
|
|
self.modlist_dir, self.modlist_ini.
|
|
|
|
Args:
|
|
modlist_info: Dictionary containing {'name', 'appid', 'path'}.
|
|
|
|
Returns:
|
|
True if the context was successfully set, False otherwise.
|
|
"""
|
|
self.logger.info(f"Setting context for selected modlist: {modlist_info.get('name')}")
|
|
|
|
# 1. Extract info from dictionary
|
|
app_name = modlist_info.get('name')
|
|
app_id = modlist_info.get('appid')
|
|
modlist_dir_path_str = modlist_info.get('path')
|
|
|
|
if not all([app_name, app_id, modlist_dir_path_str]):
|
|
self.logger.error(f"Incomplete modlist info provided: {modlist_info}")
|
|
return False
|
|
|
|
self.logger.debug(f"Using AppName: {app_name}, AppID: {app_id}, Path: {modlist_dir_path_str}")
|
|
modlist_dir_path = Path(modlist_dir_path_str)
|
|
|
|
# 2. Validate paths and set internal state
|
|
if not modlist_dir_path.is_dir():
|
|
self.logger.error(f"Modlist directory does not exist: {modlist_dir_path}")
|
|
return False
|
|
|
|
modlist_ini_path = modlist_dir_path / "ModOrganizer.ini"
|
|
if not modlist_ini_path.is_file():
|
|
self.logger.error(f"ModOrganizer.ini not found in directory: {modlist_dir_path}")
|
|
return False
|
|
|
|
# Set state variables
|
|
self.game_name = app_name
|
|
self.appid = str(app_id) # Ensure AppID is always stored as string
|
|
self.modlist_dir = Path(modlist_dir_path_str)
|
|
self.modlist_ini = modlist_ini_path
|
|
|
|
# Determine if modlist is on SD card (Steam Deck only)
|
|
# On non-Steam Deck systems, /media mounts should use Z: drive, not D: drive
|
|
if (str(self.modlist_dir).startswith("/run/media") or str(self.modlist_dir).startswith("/media")) and self.steamdeck:
|
|
self.modlist_sdcard = True
|
|
self.logger.info("Modlist appears to be on an SD card (Steam Deck).")
|
|
else:
|
|
self.modlist_sdcard = False
|
|
if (str(self.modlist_dir).startswith("/run/media") or str(self.modlist_dir).startswith("/media")) and not self.steamdeck:
|
|
self.logger.info("Modlist on /media mount detected on non-Steam Deck system - using Z: drive mapping.")
|
|
|
|
# Find and set compatdata path now that we have appid
|
|
# Ensure PathHandler is available (should be initialized in __init__)
|
|
if hasattr(self, 'path_handler'):
|
|
# Convert appid to string since find_compat_data expects a string
|
|
appid_str = str(self.appid)
|
|
self.compat_data_path = self.path_handler.find_compat_data(appid_str)
|
|
if self.compat_data_path:
|
|
self.logger.debug(f"Found compatdata path: {self.compat_data_path}")
|
|
else:
|
|
self.logger.warning(f"Could not find compatdata path for AppID {self.appid}")
|
|
else:
|
|
self.logger.error("PathHandler not initialized, cannot find compatdata path.")
|
|
self.compat_data_path = None # Ensure it's None if handler missing
|
|
|
|
self.logger.info(f"Modlist context set successfully for '{self.game_name}' (AppID: {self.appid})")
|
|
self.logger.debug(f" Directory: {self.modlist_dir}")
|
|
self.logger.debug(f" INI Path: {self.modlist_ini}")
|
|
self.logger.debug(f" On SD Card: {self.modlist_sdcard}")
|
|
|
|
# Store engine_installed flag for conditional path manipulation
|
|
self.engine_installed = modlist_info.get('engine_installed', False)
|
|
self.logger.debug(f" Engine Installed: {self.engine_installed}")
|
|
|
|
|
|
# Call internal detection methods to populate more state
|
|
if not self._detect_game_variables():
|
|
self.logger.warning("Failed to auto-detect game type after setting context.")
|
|
# Decide if failure to detect game should make set_modlist return False
|
|
# return False
|
|
|
|
# TODO: Add calls here or later to detect_steam_library,
|
|
# detect_compatdata_path, detect_proton_version based on the now-known AppID/paths
|
|
# to fully populate the handler's state before configuration phase.
|
|
|
|
return True
|
|
|
|
def _detect_game_variables(self):
|
|
"""Detect game_var and game_var_full based on ModOrganizer.ini content."""
|
|
if not self.modlist_ini or not Path(self.modlist_ini).is_file():
|
|
self.logger.error("Cannot detect game variables: ModOrganizer.ini path not set or file not found.")
|
|
self.game_var = "Unknown"
|
|
self.game_var_full = "Unknown"
|
|
return False
|
|
|
|
# Define mapping from loader executable to full game name
|
|
loader_to_game = {
|
|
"skse64_loader.exe": "Skyrim Special Edition",
|
|
"f4se_loader.exe": "Fallout 4",
|
|
"nvse_loader.exe": "Fallout New Vegas",
|
|
"obse_loader.exe": "Oblivion"
|
|
# Add others if needed
|
|
}
|
|
|
|
# Short name lookup (can derive from full name later)
|
|
short_name_lookup = {
|
|
"Skyrim Special Edition": "Skyrim",
|
|
"Fallout 4": "Fallout",
|
|
"Fallout New Vegas": "FNV", # Or "Fallout"
|
|
"Oblivion": "Oblivion"
|
|
}
|
|
|
|
try:
|
|
with open(self.modlist_ini, 'r', encoding='utf-8', errors='ignore') as f:
|
|
ini_content = f.read().lower() # Read entire file, lowercase for easier matching
|
|
except Exception as e:
|
|
self.logger.error(f"Error reading ModOrganizer.ini ({self.modlist_ini}): {e}")
|
|
self.game_var = "Unknown"
|
|
self.game_var_full = "Unknown"
|
|
return False
|
|
|
|
found_game = None
|
|
for loader, game_name in loader_to_game.items():
|
|
# Look for the loader name within the INI content
|
|
# A simple check might be enough, or use regex for more specific context
|
|
# (e.g., in a binary= line)
|
|
if loader in ini_content:
|
|
found_game = game_name
|
|
self.logger.info(f"Detected game type '{found_game}' based on finding '{loader}' in ModOrganizer.ini")
|
|
break
|
|
|
|
if found_game:
|
|
self.game_var_full = found_game
|
|
self.game_var = short_name_lookup.get(found_game, found_game.split()[0]) # Fallback short name
|
|
return True
|
|
else:
|
|
# Fallback: Could try checking self.game_name keywords as a last resort?
|
|
self.logger.warning(f"Could not detect game type from ModOrganizer.ini content. Check INI for known loaders (skse64, f4se, nvse, obse).")
|
|
# Optionally, ask the user here?
|
|
self.game_var = "Unknown"
|
|
self.game_var_full = "Unknown"
|
|
return False # Indicate detection failed
|
|
|
|
def _detect_proton_version(self):
|
|
"""Detect the Proton version used for the modlist prefix."""
|
|
self.logger.info(f"Detecting Proton version for AppID {self.appid}...")
|
|
self.proton_ver = "Unknown"
|
|
|
|
if not self.appid:
|
|
self.logger.error("Cannot detect Proton version without a valid AppID.")
|
|
return False
|
|
|
|
# --- Check config.vdf first for user-selected tool name ---
|
|
try:
|
|
# Reuse PathHandler's method to find config.vdf
|
|
config_vdf_path = self.path_handler.find_steam_config_vdf()
|
|
if config_vdf_path and config_vdf_path.exists():
|
|
import vdf # Assuming vdf library is available
|
|
with open(config_vdf_path, 'r') as f:
|
|
data = vdf.load(f)
|
|
|
|
# Navigate the VDF structure (adjust path as needed based on vdf library usage)
|
|
mapping = data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {})
|
|
app_mapping = mapping.get(str(self.appid), {})
|
|
tool_name = app_mapping.get('name', '')
|
|
|
|
if tool_name and 'experimental' in tool_name.lower():
|
|
self.proton_ver = tool_name # Use the name from config.vdf (e.g., proton_experimental)
|
|
self.logger.info(f"Detected Proton tool from config.vdf: {self.proton_ver}")
|
|
return True
|
|
elif tool_name: # If found but not experimental, log it but proceed to reg check
|
|
self.logger.debug(f"Proton tool from config.vdf: {tool_name}. Checking registry for runtime version.")
|
|
else:
|
|
self.logger.debug(f"No specific Proton tool mapping found for AppID {self.appid} in config.vdf.")
|
|
else:
|
|
self.logger.debug("config.vdf not found, proceeding with registry check.")
|
|
|
|
except ImportError:
|
|
self.logger.warning("Python 'vdf' library not found. Cannot check config.vdf for Proton version. Skipping.")
|
|
except Exception as e:
|
|
self.logger.warning(f"Error reading config.vdf: {e}. Proceeding with registry check.")
|
|
# --- End config.vdf check ---
|
|
|
|
# --- If config.vdf didn't yield 'Experimental', check prefix files ---
|
|
if not self.compat_data_path or not self.compat_data_path.exists():
|
|
self.logger.warning(f"Compatdata path '{self.compat_data_path}' not found or invalid for AppID {self.appid}. Cannot detect Proton version via prefix files.")
|
|
# Keep self.proton_ver as "Unknown" if config.vdf also failed
|
|
return False
|
|
|
|
# Method 1: Check system.reg (Primary runtime check)
|
|
system_reg_path = self.compat_data_path / "pfx" / "system.reg"
|
|
if system_reg_path.exists():
|
|
try:
|
|
with open(system_reg_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
content = f.read()
|
|
# Use regex to find the version string
|
|
match = re.search(r'"SteamClientProtonVersion"="([^"]+)"\r?', content)
|
|
if match:
|
|
version_str = match.group(1).strip()
|
|
if version_str:
|
|
# Check if it's a GE version
|
|
if "GE" in version_str.upper():
|
|
self.proton_ver = version_str
|
|
else:
|
|
self.proton_ver = f"Proton {version_str}"
|
|
self.logger.info(f"Detected Proton runtime version from system.reg: {self.proton_ver}")
|
|
return True
|
|
else:
|
|
self.logger.debug("'SteamClientProtonVersion' not found in system.reg.")
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"Error reading system.reg: {e}")
|
|
else:
|
|
self.logger.debug("system.reg not found.")
|
|
|
|
# Method 2: Check config_info (Fallback runtime check)
|
|
config_info_path = self.compat_data_path / "config_info"
|
|
if config_info_path.exists():
|
|
try:
|
|
with open(config_info_path, 'r') as f:
|
|
version_str = f.readline().strip()
|
|
if version_str:
|
|
# Check if it's a GE version
|
|
if "GE" in version_str.upper():
|
|
self.proton_ver = version_str
|
|
else:
|
|
self.proton_ver = f"Proton {version_str}"
|
|
self.logger.info(f"Detected Proton runtime version from config_info: {self.proton_ver}")
|
|
return True
|
|
except Exception as e:
|
|
self.logger.warning(f"Error reading config_info: {e}")
|
|
else:
|
|
self.logger.debug("config_info file not found.")
|
|
|
|
# If neither method worked
|
|
self.logger.warning(f"Could not detect Proton version for AppID {self.appid} from prefix files.")
|
|
# self.proton_ver remains "Unknown" from initialization
|
|
return False
|
|
|
|
def display_modlist_summary(self, skip_confirmation: bool = False) -> bool:
|
|
"""Display the detected modlist summary and ask for confirmation."""
|
|
if not self.appid or not self.modlist_dir or not self.modlist_ini:
|
|
logger.error("Cannot display summary: Missing essential modlist context.")
|
|
return False
|
|
|
|
# Detect potentially missing info if not already set
|
|
if not self.game_name:
|
|
self._detect_game_variables()
|
|
if not self.proton_ver or self.proton_ver == "Unknown":
|
|
self._detect_proton_version()
|
|
|
|
# Don't reset timing - continue from Steam Integration timing
|
|
print("=== Configuration Summary ===")
|
|
print(f"{self._get_progress_timestamp()} Selected Modlist: {self.game_name}")
|
|
print(f"{self._get_progress_timestamp()} Game Type: {self.game_var_full if self.game_var_full else 'Unknown'}")
|
|
print(f"{self._get_progress_timestamp()} Steam App ID: {self.appid}")
|
|
print(f"{self._get_progress_timestamp()} Modlist Directory: {self.modlist_dir}")
|
|
print(f"{self._get_progress_timestamp()} ModOrganizer.ini: {self.modlist_dir}/ModOrganizer.ini")
|
|
print(f"{self._get_progress_timestamp()} Proton Version: {self.proton_ver if self.proton_ver else 'Unknown'}")
|
|
print(f"{self._get_progress_timestamp()} Resolution: {self.selected_resolution if self.selected_resolution else 'Default'}")
|
|
print(f"{self._get_progress_timestamp()} Modlist on SD Card: {self.modlist_sdcard}")
|
|
print("")
|
|
|
|
if skip_confirmation:
|
|
return True
|
|
# Ask for confirmation
|
|
proceed = input(f"{COLOR_PROMPT}Proceed with configuration? (Y/n): {COLOR_RESET}").lower()
|
|
if proceed == 'n': # Now defaults to Yes unless 'n' is entered
|
|
logger.info("Configuration cancelled by user after summary.")
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
def _execute_configuration_steps(self, status_callback=None, manual_steps_completed=False):
|
|
"""
|
|
Runs the actual configuration steps for the selected modlist.
|
|
Args:
|
|
status_callback (callable, optional): A function to call with status updates during configuration.
|
|
manual_steps_completed (bool): If True, skip the manual steps prompt (used for new modlist flow).
|
|
"""
|
|
# Store status_callback for Configuration Summary
|
|
self._current_status_callback = status_callback
|
|
|
|
self.logger.info("Executing configuration steps...")
|
|
|
|
# Ensure required context is set
|
|
if not all([self.modlist_dir, self.appid, self.game_var, self.steamdeck is not None]):
|
|
self.logger.error("Cannot execute configuration steps: Missing required context (modlist_dir, appid, game_var, steamdeck status).")
|
|
print("Error: Missing required information to start configuration.")
|
|
return False
|
|
|
|
# Step 1: Set protontricks permissions
|
|
if status_callback:
|
|
# Reset timing for Prefix Configuration section
|
|
from jackify.shared.timing import start_new_phase
|
|
start_new_phase()
|
|
|
|
status_callback("") # Blank line after Configuration Summary
|
|
status_callback("") # Extra blank line before Prefix Configuration
|
|
status_callback("=== Prefix Configuration ===")
|
|
status_callback(f"{self._get_progress_timestamp()} Setting Protontricks permissions")
|
|
self.logger.info("Step 1: Setting Protontricks permissions...")
|
|
if not self.protontricks_handler.set_protontricks_permissions(self.modlist_dir, self.steamdeck):
|
|
self.logger.error("Failed to set Protontricks permissions. Configuration aborted.")
|
|
print("Error: Could not set necessary Protontricks permissions.")
|
|
return False # Abort on failure
|
|
self.logger.info("Step 1: Setting Protontricks permissions... Done")
|
|
|
|
# Step 2: Prompt user for manual steps and wait for compatdata
|
|
skip_manual_prompt = False
|
|
if not manual_steps_completed:
|
|
# Check if Proton Experimental is already set and compatdata exists
|
|
proton_ok = False
|
|
compatdata_ok = False
|
|
|
|
# Check Proton version
|
|
self.logger.debug(f"[MANUAL STEPS DEBUG] Checking Proton version for AppID {self.appid}")
|
|
if self._detect_proton_version():
|
|
self.logger.debug(f"[MANUAL STEPS DEBUG] Detected Proton version: {self.proton_ver}")
|
|
if self.proton_ver and 'experimental' in self.proton_ver.lower():
|
|
proton_ok = True
|
|
self.logger.debug("[MANUAL STEPS DEBUG] Proton Experimental detected - proton_ok = True")
|
|
else:
|
|
self.logger.debug("[MANUAL STEPS DEBUG] Could not detect Proton version")
|
|
|
|
# Check compatdata/prefix
|
|
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
|
|
self.logger.debug(f"[MANUAL STEPS DEBUG] Compatdata path search result: {prefix_path_str}")
|
|
|
|
if prefix_path_str and os.path.isdir(prefix_path_str):
|
|
compatdata_ok = True
|
|
self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory exists - compatdata_ok = True")
|
|
else:
|
|
self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory does not exist")
|
|
|
|
self.logger.debug(f"[MANUAL STEPS DEBUG] proton_ok: {proton_ok}, compatdata_ok: {compatdata_ok}")
|
|
|
|
if proton_ok and compatdata_ok:
|
|
self.logger.info("Proton Experimental and compatdata already set for this AppID; skipping manual steps prompt.")
|
|
skip_manual_prompt = True
|
|
else:
|
|
self.logger.debug("[MANUAL STEPS DEBUG] Manual steps will be required")
|
|
|
|
self.logger.debug(f"[MANUAL STEPS DEBUG] manual_steps_completed: {manual_steps_completed}, skip_manual_prompt: {skip_manual_prompt}")
|
|
|
|
if not manual_steps_completed and not skip_manual_prompt:
|
|
# Check if we're in GUI mode - if so, don't show CLI prompts, just fail and let GUI callbacks handle it
|
|
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
|
|
|
|
if gui_mode:
|
|
# In GUI mode: don't show CLI prompts, just fail so GUI can show dialog and retry
|
|
self.logger.info("GUI mode detected: skipping CLI manual steps prompt, will fail configuration to trigger GUI callback")
|
|
if status_callback:
|
|
status_callback("Manual Steam/Proton setup required - this will be handled by GUI dialog")
|
|
# Return False to trigger manual steps callback in GUI
|
|
return False
|
|
else:
|
|
# CLI mode: show the traditional CLI prompt
|
|
if status_callback:
|
|
status_callback("Please perform the manual steps in Steam (set Proton, launch shortcut, then close MO2)...")
|
|
self.logger.info("Prompting user to perform manual Steam/Proton steps and launch shortcut.")
|
|
print("\n───────────────────────────────────────────────────────────────────")
|
|
print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} Please follow the on-screen instructions to set Proton Experimental and launch the shortcut from Steam.")
|
|
print("───────────────────────────────────────────────────────────────────")
|
|
input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}")
|
|
self.logger.info("User confirmed completion of manual steps.")
|
|
# Step 3: Download and apply curated user.reg.modlist and system.reg.modlist
|
|
if status_callback:
|
|
status_callback(f"{self._get_progress_timestamp()} Applying curated registry files for modlist configuration")
|
|
self.logger.info("Step 3: Downloading and applying curated user.reg.modlist and system.reg.modlist...")
|
|
try:
|
|
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
|
|
if not prefix_path_str or not os.path.isdir(prefix_path_str):
|
|
raise Exception("Could not determine Wine prefix path for this modlist. Please ensure you have launched the shortcut from Steam at least once.")
|
|
user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.modlist"
|
|
user_reg_dest = Path(prefix_path_str) / "user.reg"
|
|
response = requests.get(user_reg_url, verify=True)
|
|
response.raise_for_status()
|
|
with open(user_reg_dest, "wb") as f:
|
|
f.write(response.content)
|
|
self.logger.info(f"Curated user.reg.modlist downloaded and applied to {user_reg_dest}")
|
|
system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.modlist"
|
|
system_reg_dest = Path(prefix_path_str) / "system.reg"
|
|
response = requests.get(system_reg_url, verify=True)
|
|
response.raise_for_status()
|
|
with open(system_reg_dest, "wb") as f:
|
|
f.write(response.content)
|
|
self.logger.info(f"Curated system.reg.modlist downloaded and applied to {system_reg_dest}")
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist: {e}")
|
|
print(f"{COLOR_ERROR}Error: Failed to download or apply curated user.reg.modlist or system.reg.modlist. {e}{COLOR_RESET}")
|
|
return False
|
|
self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.")
|
|
|
|
# Step 4: Install Wine Components
|
|
if status_callback:
|
|
status_callback(f"{self._get_progress_timestamp()} Installing Wine components (this may take a while)")
|
|
self.logger.info("Step 4: Installing Wine components (this may take a while)...")
|
|
|
|
# Use canonical logic for all modlists/games
|
|
components = self.get_modlist_wine_components(self.game_name, self.game_var_full)
|
|
|
|
# DISABLED: Special game wine component routing - now using registry injection approach
|
|
# special_game_type = self.detect_special_game_type(self.modlist_dir)
|
|
# if special_game_type == "fnv":
|
|
# target_appid = "22380" # Vanilla Fallout New Vegas AppID
|
|
# elif special_game_type == "enderal":
|
|
# target_appid = "976620" # Enderal: Forgotten Stories Special Edition AppID
|
|
# else:
|
|
# target_appid = self.appid # Normal modlist AppID
|
|
|
|
# All modlists now use their own AppID for wine components
|
|
target_appid = self.appid
|
|
|
|
# Use user's preferred component installation method (respects settings toggle)
|
|
wineprefix = self.protontricks_handler.get_wine_prefix_path(target_appid)
|
|
if not wineprefix:
|
|
self.logger.error("Failed to get WINEPREFIX path for component installation.")
|
|
print("Error: Could not determine wine prefix location.")
|
|
return False
|
|
|
|
# Use the winetricks handler which respects the user's toggle setting
|
|
try:
|
|
self.logger.info("Installing Wine components using user's preferred method...")
|
|
success = self.winetricks_handler.install_wine_components(wineprefix, self.game_var_full, specific_components=components)
|
|
if success:
|
|
self.logger.info("Wine component installation completed successfully")
|
|
else:
|
|
self.logger.error("Wine component installation failed")
|
|
print("Error: Failed to install necessary Wine components.")
|
|
return False
|
|
except Exception as e:
|
|
self.logger.error(f"Wine component installation failed with exception: {e}")
|
|
print("Error: Failed to install necessary Wine components.")
|
|
return False
|
|
self.logger.info("Step 4: Installing Wine components... Done")
|
|
|
|
# Step 5: Ensure permissions of Modlist directory
|
|
if status_callback:
|
|
status_callback(f"{self._get_progress_timestamp()} Setting ownership and permissions for modlist directory")
|
|
self.logger.info("Step 5: Setting ownership and permissions for modlist directory...")
|
|
# Convert modlist_dir string to Path object for the method
|
|
modlist_path_obj = Path(self.modlist_dir)
|
|
if not self.filesystem_handler.set_ownership_and_permissions_sudo(modlist_path_obj):
|
|
self.logger.error("Failed to set ownership/permissions for modlist directory. Configuration aborted.")
|
|
print("Error: Failed to set permissions for the modlist directory.")
|
|
return False # Abort on failure
|
|
self.logger.info("Step 5: Setting ownership and permissions... Done")
|
|
|
|
# Step 6: Backup ModOrganizer.ini
|
|
if status_callback:
|
|
status_callback(f"{self._get_progress_timestamp()} Backing up ModOrganizer.ini")
|
|
self.logger.info(f"Step 6: Backing up {self.modlist_ini}...")
|
|
modlist_ini_path_obj = Path(self.modlist_ini)
|
|
backup_path = self.filesystem_handler.backup_file(modlist_ini_path_obj)
|
|
if not backup_path:
|
|
self.logger.error("Failed to back up ModOrganizer.ini. Configuration aborted.")
|
|
print("Error: Failed to back up ModOrganizer.ini.")
|
|
return False # Abort on failure
|
|
self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}")
|
|
self.logger.info("Step 6: Backing up ModOrganizer.ini... Done")
|
|
|
|
# Step 7a: Detect Stock Game/Game Root path
|
|
if status_callback:
|
|
status_callback(f"{self._get_progress_timestamp()} Detecting stock game path")
|
|
# This method sets self.stock_game_path if found
|
|
if not self._detect_stock_game_path():
|
|
self.logger.error("Failed during stock game path detection.")
|
|
print("Error: Failed during stock game path detection.")
|
|
return False
|
|
|
|
# Step 7b: Detect Steam Library Info (Needed for Step 8)
|
|
if status_callback:
|
|
status_callback(f"{self._get_progress_timestamp()} Detecting Steam Library info")
|
|
self.logger.info("Step 7b: Detecting Steam Library info...")
|
|
if not self._detect_steam_library_info():
|
|
self.logger.error("Failed to detect necessary Steam Library information.")
|
|
print("Error: Could not find Steam library information.")
|
|
return False
|
|
self.logger.info("Step 7b: Detecting Steam Library info... Done")
|
|
|
|
# Step 8: Update ModOrganizer.ini Paths (gamePath, Binary, workingDirectory)
|
|
if status_callback:
|
|
status_callback(f"{self._get_progress_timestamp()} Updating ModOrganizer.ini paths")
|
|
self.logger.info("Step 8: Updating gamePath, Binary, and workingDirectory paths in ModOrganizer.ini...")
|
|
|
|
# Update gamePath using replace_gamepath method
|
|
modlist_dir_path_obj = Path(self.modlist_dir)
|
|
modlist_ini_path_obj = Path(self.modlist_ini)
|
|
stock_game_path_obj = Path(self.stock_game_path) if self.stock_game_path else None
|
|
# Only call replace_gamepath if we have a valid stock game path
|
|
if stock_game_path_obj:
|
|
if not self.path_handler.replace_gamepath(
|
|
modlist_ini_path=modlist_ini_path_obj,
|
|
new_game_path=stock_game_path_obj,
|
|
modlist_sdcard=self.modlist_sdcard
|
|
):
|
|
self.logger.error("Failed to update gamePath in ModOrganizer.ini. Configuration aborted.")
|
|
print("Error: Failed to update game path in ModOrganizer.ini.")
|
|
return False # Abort on failure
|
|
else:
|
|
self.logger.info("No stock game path found, skipping gamePath update - edit_binary_working_paths will handle all path updates.")
|
|
self.logger.info("Using unified path manipulation to avoid duplicate processing.")
|
|
|
|
# Conditionally update binary and working directory paths
|
|
# Skip for jackify-engine workflows since paths are already correct
|
|
# Exception: Always run for SD card installs to fix Z:/run/media/... to D:/... paths
|
|
if not getattr(self, 'engine_installed', False) or self.modlist_sdcard:
|
|
# Convert steamapps/common path to library root path
|
|
steam_libraries = None
|
|
if self.steam_library:
|
|
# self.steam_library is steamapps/common, need to go up 2 levels to get library root
|
|
steam_library_root = Path(self.steam_library).parent.parent
|
|
steam_libraries = [steam_library_root]
|
|
self.logger.debug(f"Using Steam library root: {steam_library_root}")
|
|
|
|
if not self.path_handler.edit_binary_working_paths(
|
|
modlist_ini_path=modlist_ini_path_obj,
|
|
modlist_dir_path=modlist_dir_path_obj,
|
|
modlist_sdcard=self.modlist_sdcard,
|
|
steam_libraries=steam_libraries
|
|
):
|
|
self.logger.error("Failed to update binary and working directory paths in ModOrganizer.ini. Configuration aborted.")
|
|
print("Error: Failed to update binary and working directory paths in ModOrganizer.ini.")
|
|
return False # Abort on failure
|
|
else:
|
|
self.logger.debug("Skipping path manipulation - jackify-engine already set correct paths in ModOrganizer.ini")
|
|
self.logger.info("Step 8: Updating ModOrganizer.ini paths... Done")
|
|
|
|
# Step 9: Update Resolution Settings (if applicable)
|
|
if hasattr(self, 'selected_resolution') and self.selected_resolution:
|
|
if status_callback:
|
|
status_callback(f"{self._get_progress_timestamp()} Updating resolution settings")
|
|
# Ensure resolution_handler call uses correct args if needed
|
|
# Assuming it uses modlist_dir (str) and game_var_full (str)
|
|
# Construct vanilla game directory path for fallback
|
|
vanilla_game_dir = None
|
|
if self.steam_library and self.game_var_full:
|
|
vanilla_game_dir = str(Path(self.steam_library) / "steamapps" / "common" / self.game_var_full)
|
|
|
|
if not ResolutionHandler.update_ini_resolution(
|
|
modlist_dir=self.modlist_dir,
|
|
game_var=self.game_var_full,
|
|
set_res=self.selected_resolution,
|
|
vanilla_game_dir=vanilla_game_dir
|
|
):
|
|
self.logger.warning("Failed to update resolution settings in some INI files.")
|
|
print("Warning: Failed to update resolution settings.")
|
|
self.logger.info("Step 9: Updating resolution in INI files... Done")
|
|
else:
|
|
self.logger.info("Step 9: Skipping resolution update (no resolution selected).")
|
|
|
|
# Step 10: Create dxvk.conf (skip for special games using vanilla compatdata)
|
|
special_game_type = self.detect_special_game_type(self.modlist_dir)
|
|
self.logger.debug(f"DXVK step - modlist_dir='{self.modlist_dir}', special_game_type='{special_game_type}'")
|
|
|
|
# Force check specific files for debugging
|
|
nvse_path = Path(self.modlist_dir) / "nvse_loader.exe" if self.modlist_dir else None
|
|
enderal_path = Path(self.modlist_dir) / "Enderal Launcher.exe" if self.modlist_dir else None
|
|
self.logger.debug(f"nvse_loader.exe exists: {nvse_path.exists() if nvse_path else 'N/A'}")
|
|
self.logger.debug(f"Enderal Launcher.exe exists: {enderal_path.exists() if enderal_path else 'N/A'}")
|
|
|
|
if special_game_type:
|
|
self.logger.info(f"Step 10: Skipping dxvk.conf creation for {special_game_type.upper()} (uses vanilla compatdata)")
|
|
if status_callback:
|
|
status_callback(f"{self._get_progress_timestamp()} Skipping dxvk.conf for {special_game_type.upper()} modlist")
|
|
else:
|
|
if status_callback:
|
|
status_callback(f"{self._get_progress_timestamp()} Creating dxvk.conf file")
|
|
self.logger.info("Step 10: Creating dxvk.conf file...")
|
|
# Assuming create_dxvk_conf still uses string paths
|
|
# Construct vanilla game directory path for fallback
|
|
vanilla_game_dir = None
|
|
if self.steam_library and self.game_var_full:
|
|
vanilla_game_dir = str(Path(self.steam_library) / "steamapps" / "common" / self.game_var_full)
|
|
|
|
if not self.path_handler.create_dxvk_conf(
|
|
modlist_dir=self.modlist_dir,
|
|
modlist_sdcard=self.modlist_sdcard,
|
|
steam_library=str(self.steam_library) if self.steam_library else None, # Pass as string or None
|
|
basegame_sdcard=self.basegame_sdcard,
|
|
game_var_full=self.game_var_full,
|
|
vanilla_game_dir=vanilla_game_dir
|
|
):
|
|
self.logger.warning("Failed to create dxvk.conf file.")
|
|
print("Warning: Failed to create dxvk.conf file.")
|
|
self.logger.info("Step 10: Creating dxvk.conf... Done")
|
|
|
|
# Step 11a: Small Tasks - Delete Plugin
|
|
if status_callback:
|
|
status_callback(f"{self._get_progress_timestamp()} Deleting incompatible MO2 plugin")
|
|
self.logger.info("Step 11a: Deleting incompatible MO2 plugin (FixGameRegKey.py)...")
|
|
plugin_path = Path(self.modlist_dir) / "plugins" / "FixGameRegKey.py"
|
|
if plugin_path.exists():
|
|
try:
|
|
plugin_path.unlink()
|
|
self.logger.info("FixGameRegKey.py plugin deleted successfully.")
|
|
except Exception as e:
|
|
self.logger.warning(f"Failed to delete FixGameRegKey.py plugin: {e}")
|
|
print("Warning: Failed to delete incompatible plugin file.")
|
|
else:
|
|
self.logger.debug("FixGameRegKey.py plugin not found (this is normal).")
|
|
self.logger.info("Step 11a: Plugin deletion check complete.")
|
|
|
|
# Step 11b: Download Font
|
|
if status_callback:
|
|
status_callback(f"{self._get_progress_timestamp()} Downloading required font")
|
|
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
|
|
if prefix_path_str:
|
|
prefix_path = Path(prefix_path_str)
|
|
fonts_dir = prefix_path / "pfx" / "drive_c" / "windows" / "Fonts"
|
|
font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf"
|
|
font_dest_path = fonts_dir / "seguisym.ttf"
|
|
|
|
# Pass quiet=True to suppress print during configuration steps
|
|
if not self.filesystem_handler.download_file(font_url, font_dest_path, quiet=True):
|
|
self.logger.warning(f"Failed to download {font_url} to {font_dest_path}")
|
|
print("Warning: Failed to download necessary font file (seguisym.ttf).")
|
|
# Continue anyway, not critical for all lists
|
|
else:
|
|
self.logger.info("Font downloaded successfully.")
|
|
else:
|
|
self.logger.error("Could not get WINEPREFIX path, skipping font download.")
|
|
print("Warning: Could not determine Wine prefix path, skipping font download.")
|
|
|
|
# Step 12: Modlist-specific steps
|
|
if status_callback:
|
|
status_callback(f"{self._get_progress_timestamp()} Checking for modlist-specific steps")
|
|
status_callback("") # Blank line after final Prefix Configuration step
|
|
self.logger.info("Step 12: Checking for modlist-specific steps...")
|
|
# ... (rest of the inline logic for step 12) ...
|
|
|
|
# Step 13: Launch options for special games are now set during automated prefix workflow (before Steam restart)
|
|
# This ensures proper timing and avoids the need for a second Steam restart
|
|
special_game_type = self.detect_special_game_type(self.modlist_dir)
|
|
if special_game_type:
|
|
self.logger.info(f"Step 13: Launch options for {special_game_type.upper()} were set during automated workflow")
|
|
else:
|
|
self.logger.debug("Step 13: No special launch options needed for this modlist type")
|
|
|
|
# Do not call status_callback here, the final message is handled in menu_handler
|
|
# if status_callback:
|
|
# status_callback("Configuration completed successfully!")
|
|
|
|
self.logger.info("Configuration steps completed successfully.")
|
|
|
|
# Step 14: Re-enforce Windows 10 mode after modlist-specific configurations (matches legacy script line 1333)
|
|
self._re_enforce_windows_10_mode()
|
|
|
|
return True # Return True on success
|
|
|
|
def _detect_steam_library_info(self) -> bool:
|
|
"""Detects Steam Library path and whether it's on an SD card."""
|
|
self.logger.debug("Detecting Steam Library path...")
|
|
steam_lib_path_str = PathHandler.find_steam_library()
|
|
|
|
if not steam_lib_path_str:
|
|
self.logger.error("PathHandler.find_steam_library() failed to find a Steam library.")
|
|
self.steam_library = None
|
|
self.basegame_sdcard = False # Assume not on SD if path not found
|
|
return False # Indicate failure
|
|
|
|
self.steam_library = steam_lib_path_str
|
|
self.logger.info(f"Detected Steam Library: {self.steam_library}")
|
|
|
|
# Check if the base game library is on SD card
|
|
self.logger.debug(f"Checking if Steam Library {self.steam_library} is on SD card...")
|
|
steam_lib_path_obj = Path(self.steam_library)
|
|
self.basegame_sdcard = self.filesystem_handler.is_sd_card(steam_lib_path_obj)
|
|
self.logger.info(f"Base game library on SD card: {self.basegame_sdcard}")
|
|
|
|
return True
|
|
|
|
def _detect_stock_game_path(self):
|
|
"""Detects common 'Stock Game' or 'Game Root' directories within the modlist path."""
|
|
self.logger.info("Step 7a: Detecting Stock Game/Game Root directory...")
|
|
if not self.modlist_dir:
|
|
self.logger.error("Modlist directory not set, cannot detect stock game path.")
|
|
return False
|
|
|
|
modlist_path = Path(self.modlist_dir)
|
|
common_names = [
|
|
"Stock Game",
|
|
"Game Root",
|
|
"STOCK GAME",
|
|
"Stock Game Folder",
|
|
"Stock Folder",
|
|
"Skyrim Stock",
|
|
Path("root/Skyrim Special Edition") # Special case for some lists
|
|
# Add other common names if needed
|
|
]
|
|
|
|
found_path = None
|
|
for name in common_names:
|
|
potential_path = modlist_path / name
|
|
if potential_path.is_dir():
|
|
found_path = str(potential_path)
|
|
self.logger.info(f"Found potential stock game directory: {found_path}")
|
|
break # Found the first match
|
|
|
|
if found_path:
|
|
self.stock_game_path = found_path
|
|
# Suppress print during configuration
|
|
# print(f"Step 7a: Found stock game directory: {os.path.basename(found_path)}")
|
|
return True
|
|
else:
|
|
self.stock_game_path = None
|
|
self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.")
|
|
# Do not print this warning to the user
|
|
# print("Step 7a: No common Stock Game/Game Root directory found.")
|
|
# Still return True, as the check completed. Lack of this dir isn't always an error.
|
|
return True
|
|
|
|
def verify_proton_setup(self, appid_to_check: str) -> Tuple[bool, str]:
|
|
"""Verifies that Proton is correctly set up for a given AppID.
|
|
|
|
Checks config.vdf for Proton Experimental and existence of compatdata/pfx dir.
|
|
|
|
Args:
|
|
appid_to_check: The AppID string to verify.
|
|
|
|
Returns:
|
|
tuple: (bool success, str status_code)
|
|
Status codes: 'ok', 'invalid_appid', 'config_vdf_missing',
|
|
'config_vdf_error', 'proton_check_failed',
|
|
'wrong_proton_version', 'compatdata_missing',
|
|
'prefix_missing'
|
|
"""
|
|
self.logger.info(f"Verifying Proton setup for AppID: {appid_to_check}")
|
|
|
|
if not appid_to_check or not appid_to_check.isdigit():
|
|
self.logger.error("Invalid AppID provided for verification.")
|
|
return False, 'invalid_appid'
|
|
|
|
proton_tool_name = None
|
|
compatdata_path_found = None
|
|
prefix_exists = False
|
|
|
|
# 1. Find and Parse config.vdf
|
|
config_vdf_path = None
|
|
possible_steam_paths = [
|
|
Path.home() / ".steam/steam",
|
|
Path.home() / ".local/share/Steam",
|
|
Path.home() / ".steam/root"
|
|
]
|
|
for steam_path in possible_steam_paths:
|
|
potential_path = steam_path / "config/config.vdf"
|
|
if potential_path.is_file():
|
|
config_vdf_path = potential_path
|
|
self.logger.debug(f"Found config.vdf at: {config_vdf_path}")
|
|
break
|
|
|
|
if not config_vdf_path:
|
|
self.logger.error("Could not locate Steam's config.vdf file.")
|
|
return False, 'config_vdf_missing'
|
|
|
|
# Add a short delay to allow Steam to potentially finish writing changes
|
|
self.logger.debug("Waiting 2 seconds before reading config.vdf...")
|
|
time.sleep(2)
|
|
|
|
try:
|
|
self.logger.debug(f"Attempting to load VDF file: {config_vdf_path}")
|
|
# CORRECTION: Use the vdf library directly here, not VDFHandler
|
|
with open(str(config_vdf_path), 'r') as f:
|
|
config_data = vdf.load(f, mapper=vdf.VDFDict)
|
|
|
|
# --- Write full config.vdf to a debug file ---
|
|
import json
|
|
debug_dump_path = os.path.expanduser("~/dev/Jackify/configvdf_dump.txt")
|
|
with open(debug_dump_path, "w") as dump_f:
|
|
json.dump(config_data, dump_f, indent=2)
|
|
self.logger.info(f"Full config.vdf dumped to {debug_dump_path}")
|
|
|
|
# --- Log only the relevant section for this AppID ---
|
|
steam_config_section = config_data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {})
|
|
compat_mapping = steam_config_section.get('CompatToolMapping', {})
|
|
app_mapping = compat_mapping.get(appid_to_check, {})
|
|
self.logger.debug("───────────────────────────────────────────────────────────────────")
|
|
self.logger.debug(f"Config.vdf entry for AppID {appid_to_check} (CompatToolMapping):")
|
|
self.logger.debug(json.dumps({appid_to_check: app_mapping}, indent=2))
|
|
self.logger.debug("───────────────────────────────────────────────────────────────────")
|
|
self.logger.debug(f"Steam config section from VDF: {json.dumps(steam_config_section, indent=2)}")
|
|
# --- End Debugging ---
|
|
|
|
# Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name
|
|
compat_mapping = steam_config_section.get('CompatToolMapping', {})
|
|
app_mapping = compat_mapping.get(appid_to_check, {})
|
|
proton_tool_name = app_mapping.get('name') # CORRECTED: Use lowercase 'name'
|
|
self.proton_ver = proton_tool_name # Store detected version
|
|
|
|
if proton_tool_name:
|
|
self.logger.info(f"Proton tool name from config.vdf: {proton_tool_name}")
|
|
else:
|
|
self.logger.warning(f"CompatToolMapping entry not found for AppID {appid_to_check} in config.vdf.")
|
|
# Add more debug info here about what *was* found
|
|
self.logger.debug(f"CompatToolMapping contents: {json.dumps(compat_mapping.get(appid_to_check, 'Key not found'), indent=2)}")
|
|
return False, 'proton_check_failed' # Compatibility not explicitly set
|
|
|
|
except FileNotFoundError:
|
|
self.logger.error(f"Config.vdf file not found during load attempt: {config_vdf_path}")
|
|
return False, 'config_vdf_missing'
|
|
except Exception as e:
|
|
self.logger.error(f"Error parsing config.vdf: {e}", exc_info=True)
|
|
return False, 'config_vdf_error'
|
|
|
|
# 2. Check if the correct Proton version is set (allowing variations)
|
|
# Target: Proton Experimental
|
|
if not proton_tool_name or 'experimental' not in proton_tool_name.lower():
|
|
self.logger.warning(f"Incorrect Proton version detected: '{proton_tool_name}'. Expected 'Proton Experimental'.")
|
|
return False, 'wrong_proton_version'
|
|
|
|
self.logger.info("Proton version check passed ('Proton Experimental' set).")
|
|
|
|
# 3. Check for compatdata / prefix directory existence
|
|
possible_compat_bases = [
|
|
Path.home() / ".steam/steam/steamapps/compatdata",
|
|
Path.home() / ".local/share/Steam/steamapps/compatdata",
|
|
# Add SD card paths if necessary / detectable
|
|
# Path("/run/media/mmcblk0p1/steamapps/compatdata") # Example
|
|
]
|
|
|
|
compat_dir_found = False
|
|
for base_path in possible_compat_bases:
|
|
potential_compat_path = base_path / appid_to_check
|
|
if potential_compat_path.is_dir():
|
|
self.logger.debug(f"Found compatdata directory: {potential_compat_path}")
|
|
compat_dir_found = True
|
|
# Check for prefix *within* the found compatdata dir
|
|
prefix_path = potential_compat_path / "pfx"
|
|
if prefix_path.is_dir():
|
|
self.logger.info(f"Wine prefix directory verified: {prefix_path}")
|
|
prefix_exists = True
|
|
break # Found both compatdata and prefix, exit loop
|
|
else:
|
|
self.logger.warning(f"Compatdata directory found, but prefix missing: {prefix_path}")
|
|
# Keep searching other base paths in case prefix exists elsewhere
|
|
|
|
if not compat_dir_found:
|
|
self.logger.error(f"Compatdata directory not found for AppID {appid_to_check} in standard locations.")
|
|
return False, 'compatdata_missing'
|
|
|
|
if not prefix_exists:
|
|
# This means we found compatdata but not pfx inside any of them
|
|
self.logger.error(f"Wine prefix directory (pfx) not found within any located compatdata directory for AppID {appid_to_check}.")
|
|
return False, 'prefix_missing'
|
|
|
|
# All checks passed
|
|
self.logger.info(f"Proton setup verification successful for AppID {appid_to_check}.")
|
|
return True, 'ok'
|
|
|
|
def run_modlist_configuration_phase(self, context: dict = None) -> bool:
|
|
"""
|
|
Main entry point to run the full modlist configuration sequence.
|
|
This orchestrates all the individual steps.
|
|
"""
|
|
self.logger.info(f"Starting configuration phase for modlist: {self.game_name}")
|
|
# Call the private method that contains the actual steps
|
|
# Pass along the status_callback if it was provided in the context
|
|
status_callback = context.get('status_callback') if context else None
|
|
return self._execute_configuration_steps(status_callback=status_callback)
|
|
|
|
def set_steam_grid_images(self, appid: str, modlist_dir: str):
|
|
"""
|
|
Copies hero, logo, and poster images from the modlist's SteamIcons directory
|
|
to the grid directory of all non-zero Steam user directories, named after the new AppID.
|
|
"""
|
|
steam_icons_dir = Path(modlist_dir) / "SteamIcons"
|
|
if not steam_icons_dir.is_dir():
|
|
self.logger.info(f"No SteamIcons directory found at {steam_icons_dir}, skipping grid image copy.")
|
|
return
|
|
|
|
# Find all non-zero Steam user directories
|
|
userdata_base = Path.home() / ".steam/steam/userdata"
|
|
if not userdata_base.is_dir():
|
|
self.logger.error(f"Steam userdata directory not found at {userdata_base}")
|
|
return
|
|
|
|
for user_dir in userdata_base.iterdir():
|
|
if not user_dir.is_dir() or user_dir.name == "0":
|
|
continue
|
|
grid_dir = user_dir / "config/grid"
|
|
grid_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
images = [
|
|
("grid-hero.png", f"{appid}_hero.png"),
|
|
("grid-logo.png", f"{appid}_logo.png"),
|
|
("grid-tall.png", f"{appid}.png"),
|
|
("grid-tall.png", f"{appid}p.png"),
|
|
]
|
|
|
|
for src_name, dest_name in images:
|
|
src_path = steam_icons_dir / src_name
|
|
dest_path = grid_dir / dest_name
|
|
if src_path.exists():
|
|
try:
|
|
shutil.copyfile(src_path, dest_path)
|
|
self.logger.info(f"Copied {src_path} to {dest_path}")
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to copy {src_path} to {dest_path}: {e}")
|
|
else:
|
|
self.logger.warning(f"Image {src_path} not found; skipping.")
|
|
|
|
def get_modlist_wine_components(self, modlist_name, game_var_full=None):
|
|
"""
|
|
Returns the full list of Wine components to install for a given modlist/game.
|
|
- Always includes the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022)
|
|
- Adds game-specific extras (from bash script logic)
|
|
- Adds any modlist-specific extras (from MODLIST_WINE_COMPONENTS)
|
|
"""
|
|
default_components = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"]
|
|
extras = []
|
|
# Determine game type
|
|
game = (game_var_full or modlist_name or "").lower().replace(" ", "")
|
|
# Add game-specific extras
|
|
if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game:
|
|
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7"]
|
|
elif "falloutnewvegas" in game or "fnv" in game or "oblivion" in game:
|
|
extras += ["d3dx9_43", "d3dx9"]
|
|
# Add modlist-specific extras
|
|
modlist_lower = modlist_name.lower().replace(" ", "") if modlist_name else ""
|
|
for key, components in self.MODLIST_WINE_COMPONENTS.items():
|
|
if key in modlist_lower:
|
|
extras += components
|
|
# Remove duplicates while preserving order
|
|
seen = set()
|
|
full_list = [x for x in default_components + extras if not (x in seen or seen.add(x))]
|
|
return full_list
|
|
|
|
def _is_steam_deck(self):
|
|
try:
|
|
if os.path.exists('/etc/os-release'):
|
|
with open('/etc/os-release') as f:
|
|
if 'steamdeck' in f.read().lower():
|
|
return True
|
|
user_services = subprocess.run(['systemctl', '--user', 'list-units', '--type=service', '--no-pager'], capture_output=True, text=True)
|
|
if 'app-steam@autostart.service' in user_services.stdout:
|
|
return True
|
|
except Exception as e:
|
|
self.logger.warning(f"Error detecting Steam Deck: {e}")
|
|
return False
|
|
|
|
def _prompt_or_set_resolution(self):
|
|
# If on Steam Deck, set 1280x800 automatically
|
|
if self._is_steam_deck():
|
|
self.selected_resolution = "1280x800"
|
|
self.logger.info("Steam Deck detected: setting resolution to 1280x800.")
|
|
else:
|
|
print("Do you wish to set the display resolution? (This can be changed manually later)")
|
|
response = input("Set resolution? (y/N): ").strip().lower()
|
|
if response == 'y':
|
|
while True:
|
|
user_res = input("Enter resolution (e.g., 1920x1080): ").strip()
|
|
if re.match(r'^[0-9]+x[0-9]+$', user_res):
|
|
self.selected_resolution = user_res
|
|
self.logger.info(f"User selected resolution: {user_res}")
|
|
break
|
|
else:
|
|
print("Invalid format. Please use format: 1920x1080")
|
|
else:
|
|
self.selected_resolution = None
|
|
self.logger.info("Resolution setup skipped by user.")
|
|
|
|
def detect_special_game_type(self, modlist_dir: str) -> Optional[str]:
|
|
"""
|
|
Detect if this modlist requires vanilla compatdata instead of new prefix.
|
|
|
|
Detects special game types that need to use existing vanilla game compatdata:
|
|
- FNV: Has nvse_loader.exe
|
|
- Enderal: Has Enderal Launcher.exe
|
|
|
|
Args:
|
|
modlist_dir: Path to the modlist installation directory
|
|
|
|
Returns:
|
|
str: Game type ("fnv", "enderal") or None if not a special game
|
|
"""
|
|
if not modlist_dir:
|
|
return None
|
|
|
|
modlist_path = Path(modlist_dir)
|
|
if not modlist_path.exists() or not modlist_path.is_dir():
|
|
self.logger.debug(f"Modlist directory does not exist: {modlist_dir}")
|
|
return None
|
|
|
|
self.logger.debug(f"Checking for special game type in: {modlist_dir}")
|
|
|
|
# Check ModOrganizer.ini for indicators (nvse/enderal) as an early, robust signal
|
|
try:
|
|
mo2_ini = modlist_path / "ModOrganizer.ini"
|
|
# Also check Somnium's non-standard location
|
|
if not mo2_ini.exists():
|
|
somnium_mo2_ini = modlist_path / "files" / "ModOrganizer.ini"
|
|
if somnium_mo2_ini.exists():
|
|
mo2_ini = somnium_mo2_ini
|
|
|
|
if mo2_ini.exists():
|
|
try:
|
|
content = mo2_ini.read_text(errors='ignore').lower()
|
|
if 'nvse' in content or 'nvse_loader' in content or 'fallout new vegas' in content or 'falloutnv' in content:
|
|
self.logger.info("Detected FNV via ModOrganizer.ini markers")
|
|
return "fnv"
|
|
# Look for Enderal-specific patterns, not just the word "enderal"
|
|
if any(pattern in content for pattern in ['enderal launcher', 'enderal.exe', 'enderal launcher.exe', 'enderalsteam']):
|
|
self.logger.info("Detected Enderal via ModOrganizer.ini markers")
|
|
return "enderal"
|
|
except Exception as e:
|
|
self.logger.debug(f"Failed reading ModOrganizer.ini for detection: {e}")
|
|
except Exception:
|
|
pass
|
|
|
|
# Check for FNV (Fallout New Vegas) and Enderal launchers in common locations
|
|
candidates = [modlist_path]
|
|
try:
|
|
# Include common stock game subfolders if present
|
|
from .path_handler import STOCK_GAME_FOLDERS
|
|
for folder_name in STOCK_GAME_FOLDERS:
|
|
sub = modlist_path / folder_name
|
|
if sub.exists() and sub.is_dir():
|
|
candidates.append(sub)
|
|
except Exception:
|
|
# If import fails, continue with root-only
|
|
pass
|
|
|
|
for base in candidates:
|
|
nvse_loader = base / "nvse_loader.exe"
|
|
if nvse_loader.exists():
|
|
self.logger.info(f"Detected FNV modlist: found nvse_loader.exe in '{base}'")
|
|
return "fnv"
|
|
enderal_launcher = base / "Enderal Launcher.exe"
|
|
if enderal_launcher.exists():
|
|
self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'")
|
|
return "enderal"
|
|
|
|
# As a final heuristic, use known game type if available in handler state
|
|
try:
|
|
game_type = getattr(self, 'game_var', None)
|
|
if isinstance(game_type, str):
|
|
gt = game_type.strip().lower()
|
|
if 'fallout new vegas' in gt or gt == 'fnv':
|
|
self.logger.info("Heuristic detection: game_var indicates FNV")
|
|
return "fnv"
|
|
if 'enderal' in gt:
|
|
self.logger.info("Heuristic detection: game_var indicates Enderal")
|
|
return "enderal"
|
|
except Exception:
|
|
pass
|
|
|
|
# Not a special game type
|
|
self.logger.debug("No special game type detected - standard workflow will be used")
|
|
return None
|
|
|
|
def _re_enforce_windows_10_mode(self):
|
|
"""
|
|
Re-enforce Windows 10 mode after modlist-specific configurations.
|
|
This matches the legacy script behavior (line 1333) where Windows 10 mode
|
|
is re-applied after modlist-specific steps to ensure consistency.
|
|
"""
|
|
try:
|
|
if not hasattr(self, 'appid') or not self.appid:
|
|
self.logger.warning("Cannot re-enforce Windows 10 mode - no AppID available")
|
|
return
|
|
|
|
from ..handlers.winetricks_handler import WinetricksHandler
|
|
from ..handlers.path_handler import PathHandler
|
|
|
|
# Get prefix path for the AppID
|
|
prefix_path = PathHandler.find_compat_data(str(self.appid))
|
|
if not prefix_path:
|
|
self.logger.warning("Cannot re-enforce Windows 10 mode - prefix path not found")
|
|
return
|
|
|
|
# Get wine binary path
|
|
wine_binary = PathHandler.get_wine_binary_for_appid(str(self.appid))
|
|
if not wine_binary:
|
|
self.logger.warning("Cannot re-enforce Windows 10 mode - wine binary not found")
|
|
return
|
|
|
|
# Use winetricks handler to set Windows 10 mode
|
|
winetricks_handler = WinetricksHandler()
|
|
winetricks_handler._set_windows_10_mode(str(prefix_path), wine_binary)
|
|
|
|
self.logger.info("✓ Windows 10 mode re-enforced after modlist-specific configurations")
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"Error re-enforcing Windows 10 mode: {e}")
|
|
|
|
# (Ensure EOF is clean and no extra incorrect methods exist below) |