Sync from development - prepare for v0.3.0

This commit is contained in:
Omni
2026-02-07 18:26:54 +00:00
parent b55e1cf768
commit 12294d3186
169 changed files with 31749 additions and 33649 deletions

View File

@@ -0,0 +1,376 @@
"""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 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"
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 '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