Files
Jackify/jackify/backend/handlers/modlist_detection.py
2026-02-25 20:54:28 +00:00

387 lines
17 KiB
Python

"""Detection and discovery methods for ModlistHandler (Mixin)."""
from pathlib import Path
from typing import Dict, List, Optional
import os
import re
import logging
import subprocess
logger = logging.getLogger(__name__)
class ModlistDetectionMixin:
"""Mixin providing detection and discovery methods for ModlistHandler.
These methods are separated for code organization but require
ModlistHandler's instance attributes (self.logger, self.path_handler, etc.)
"""
def _detect_modlists_from_shortcuts(self) -> bool:
"""
Detect modlists from Steam shortcuts.vdf entries
"""
self.logger.info("Detecting modlists from Steam shortcuts")
return False
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 _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"
}
# Short name lookup
short_name_lookup = {
"Skyrim Special Edition": "Skyrim",
"Fallout 4": "Fallout",
"Fallout New Vegas": "FNV",
"Oblivion": "Oblivion"
}
try:
with open(self.modlist_ini, 'r', encoding='utf-8', errors='ignore') as f:
ini_content = f.read().lower()
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():
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])
return True
else:
self.logger.warning(f"Could not detect game type from ModOrganizer.ini content. Check INI for known loaders (skse64, f4se, nvse, obse).")
self.game_var = "Unknown"
self.game_var_full = "Unknown"
return False
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:
config_vdf_path = self.path_handler.find_steam_config_vdf()
if config_vdf_path and config_vdf_path.exists():
import vdf
with open(config_vdf_path, 'r') as f:
data = vdf.load(f)
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
self.logger.info(f"Detected Proton tool from config.vdf: {self.proton_ver}")
return True
elif tool_name:
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.")
# 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.")
return False
# Method 1: Check system.reg
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()
match = re.search(r'"SteamClientProtonVersion"="([^"]+)"\r?', content)
if match:
version_str = match.group(1).strip()
if version_str:
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
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:
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.")
self.logger.warning(f"Could not detect Proton version for AppID {self.appid} from prefix files.")
return False
def _detect_steam_library_info(self) -> bool:
"""Detects Steam Library path and whether it's on an SD card."""
from .path_handler import PathHandler
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
return False
self.steam_library = steam_lib_path_str
self.logger.info(f"Detected Steam Library: {self.steam_library}")
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")
]
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
if found_path:
self.stock_game_path = 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.")
return True
def _is_steam_deck(self):
"""Detect if running on Steam Deck."""
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 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
try:
mo2_ini = modlist_path / "ModOrganizer.ini"
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"
if 'fose' in content or 'fose_loader' in content or ('fallout 3' in content and 'fallout 4' not in content):
self.logger.info("Detected FO3 via ModOrganizer.ini markers")
return "fo3"
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 and Enderal launchers in common locations
candidates = [modlist_path]
try:
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:
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"
fose_loader = base / "fose_loader.exe"
if fose_loader.exists():
self.logger.info(f"Detected FO3 modlist: found fose_loader.exe in '{base}'")
return "fo3"
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"
# Final heuristic using game_var
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 'fallout 3' in gt or gt == 'fo3':
self.logger.info("Heuristic detection: game_var indicates FO3")
return "fo3"
if 'enderal' in gt:
self.logger.info("Heuristic detection: game_var indicates Enderal")
return "enderal"
except Exception:
pass
self.logger.debug("No special game type detected - standard workflow will be used")
return None