""" Viva New Vegas Post-Install Service Automates the post-installation steps required for Viva New Vegas modlist: 1. Root Mods - Copy files from '__Files Requiring Manual Install' to game root 2. 4GB Patcher - Download Linux version from Nexus, run natively 3. BSA Decompression - Download FNV BSA Decompressor MPI, run via TTW_Linux_Installer These steps are documented at: https://vivanewvegas.moddinglinked.com/wabbajack.html Uses native Linux tools (no Wine required) by downloading from Nexus with OAuth. """ import logging import os import shutil import subprocess import stat import tempfile import zipfile from pathlib import Path from typing import Optional, Callable from ..handlers.subprocess_utils import get_clean_subprocess_env from .nexus_download_service import NexusDownloadService from .nexus_auth_service import NexusAuthService logger = logging.getLogger(__name__) class VNVPostInstallService: """Handles automated post-installation tasks for Viva New Vegas modlist.""" # Nexus mod IDs for required tools LINUX_4GB_PATCHER_MOD_ID = 62552 FNV_BSA_DECOMPRESSOR_MOD_ID = 65854 GAME_DOMAIN = "newvegas" def __init__(self, modlist_install_location: Path, game_root: Path, ttw_installer_path: Optional[Path] = None): """ Initialize VNV post-install service. Args: modlist_install_location: Path to the VNV installation (e.g., ~/VNV) game_root: Path to Fallout New Vegas game root ttw_installer_path: Path to TTW_Linux_Installer executable (for BSA decompression) """ self.modlist_install = modlist_install_location self.game_root = game_root self.ttw_installer_path = ttw_installer_path # VNV-specific paths self.manual_install_dir = self.modlist_install / "__Files Requiring Manual Install" # Download cache directory from jackify.shared.paths import get_jackify_data_dir self.cache_dir = get_jackify_data_dir() / "vnv_post_install_cache" self.cache_dir.mkdir(parents=True, exist_ok=True) # Initialize authentication self.auth_service = NexusAuthService() self.download_service = None def _ensure_auth(self, progress_callback: Optional[Callable[[str], None]] = None) -> bool: """ Ensure we have valid Nexus authentication for downloads. Args: progress_callback: Optional callback for progress updates Returns: True if authenticated """ auth_token = self.auth_service.ensure_valid_auth() if not auth_token: if progress_callback: progress_callback("Nexus authentication required for post-install steps") logger.error("No Nexus authentication available") return False self.download_service = NexusDownloadService(auth_token) return True def should_run_automation(self, modlist_name: str) -> bool: """ Check if this modlist should trigger VNV automation. Args: modlist_name: Name of the installed modlist Returns: True if VNV automation should be offered """ return "viva new vegas" in modlist_name.lower() def get_automation_description(self) -> str: """ Get user-friendly description of what VNV automation does. Returns: Description string for confirmation dialog """ return ( "Viva New Vegas Automation\n\n" "Jackify can automatically perform the following post-install steps:\n\n" "1. Copy root mods to game directory\n" "2. Download and run Linux 4GB patcher\n" "3. Download and run BSA decompressor (reduces loading times)\n\n" "Premium users: Downloads happen automatically\n" "Non-Premium users: You'll be prompted to download files manually\n\n" "Would you like Jackify to automate these steps?" ) def check_already_completed(self) -> dict: """ Check which VNV automation steps have already been completed. Returns: Dict with keys: 'root_mods', '4gb_patch', 'bsa_decompressed' """ # Check if 4GB patch already applied backup_exe = self.game_root / "FalloutNV_backup.exe" already_patched = backup_exe.exists() # Check if root mods copied (look for FNVpatch.exe in game root) root_mods_copied = (self.game_root / "FNVpatch.exe").exists() # Check for BSA decompression marker file marker_file = self.game_root / ".jackify_bsa_decompressed" bsa_decompressed = marker_file.exists() return { 'root_mods': root_mods_copied, '4gb_patch': already_patched, 'bsa_decompressed': bsa_decompressed } def run_all_steps(self, progress_callback: Optional[Callable[[str], None]] = None, manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None, skip_confirmation: bool = False) -> tuple[bool, str]: """ Run all VNV post-install steps in sequence. Args: progress_callback: Optional callback for progress updates manual_file_callback: Optional callback for manual file selection (non-Premium users) Takes (title, instructions) returns Path or None skip_confirmation: Skip user confirmation (for programmatic use) Returns: (success: bool, message: str) """ def update_progress(msg: str): if progress_callback: progress_callback(msg) logger.info(msg) try: # Ensure authentication update_progress("Checking Nexus authentication...") if not self._ensure_auth(progress_callback): return False, "Nexus authentication required. Please authenticate in Settings." # Step 1: Copy root mods update_progress("Step 1/3: Copying root mods to game directory...") success, msg = self.copy_root_mods() if not success: return False, f"Root mods failed: {msg}" update_progress(f"Root mods: {msg}") # Step 2: Run 4GB patcher update_progress("Step 2/3: Downloading and running 4GB patcher...") success, msg = self.run_4gb_patcher(update_progress, manual_file_callback) if not success: return False, f"4GB patcher failed: {msg}" update_progress(f"4GB patcher: {msg}") # Step 3: Run BSA decompressor update_progress("Step 3/3: Downloading and running BSA decompressor...") success, msg = self.run_bsa_decompressor(update_progress, manual_file_callback) if not success: return False, f"BSA decompression failed: {msg}" update_progress(f"BSA decompression: {msg}") return True, "VNV post-install completed successfully" except Exception as e: error_msg = f"VNV post-install failed: {str(e)}" logger.error(error_msg, exc_info=True) return False, error_msg def copy_root_mods(self) -> tuple[bool, str]: """ Copy files from '__Files Requiring Manual Install' to game root. Returns: (success: bool, message: str) """ try: if not self.manual_install_dir.exists(): return False, f"Manual install directory not found: {self.manual_install_dir}" if not self.game_root.exists(): return False, f"Game root directory not found: {self.game_root}" # Copy all files from manual install to game root copied_files = [] for item in self.manual_install_dir.iterdir(): dest = self.game_root / item.name if item.is_file(): shutil.copy2(item, dest) copied_files.append(item.name) logger.debug(f"Copied: {item.name}") elif item.is_dir(): # Merge directories to preserve vanilla game files (e.g., BSA files in Data/) # dirs_exist_ok=True allows adding NVSE to Data/ without deleting vanilla BSAs shutil.copytree(item, dest, dirs_exist_ok=True) copied_files.append(f"{item.name}/") logger.debug(f"Copied directory: {item.name}/") if not copied_files: return False, "No files found to copy" logger.info(f"Copied {len(copied_files)} items to game root") return True, f"Copied {len(copied_files)} items to game root" except Exception as e: error_msg = f"Failed to copy root mods: {str(e)}" logger.error(error_msg, exc_info=True) return False, error_msg def run_4gb_patcher(self, progress_callback: Optional[Callable[[str], None]] = None, manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None) -> tuple[bool, str]: """ Download and run native Linux 4GB patcher. Args: progress_callback: Optional callback for progress updates manual_file_callback: Optional callback for manual file selection Takes (title, instructions) returns Path or None Returns: (success: bool, message: str) """ try: # Check if already patched backup_exe = self.game_root / "FalloutNV_backup.exe" if backup_exe.exists(): logger.info("Game already has 4GB patch (backup exists)") return True, "Game already patched (backup exists)" # Check cache first - look for extracted executable or zip patcher_path = None cached_extracted = list(self.cache_dir.glob("*4gb*_extracted/*")) if cached_extracted: # Use already extracted executable for f in cached_extracted: if f.is_file(): patcher_path = f logger.info(f"Using cached extracted 4GB patcher: {patcher_path}") break if not patcher_path: cached_files = list(self.cache_dir.glob("*4gb*.zip")) if cached_files: patcher_path = cached_files[0] logger.info(f"Using cached 4GB patcher zip: {patcher_path}") if not patcher_path: # Try to download from Nexus # Note: The Linux version is named "FNV4GB for Proton", not "linux" success, patcher_path, msg = self.download_service.download_latest_file( self.GAME_DOMAIN, self.LINUX_4GB_PATCHER_MOD_ID, self.cache_dir, file_name_filter="proton", progress_callback=progress_callback ) if not success: # Download failed - offer manual download logger.error(f"Automatic download failed: {msg}") logger.debug(f"Looking for file with 'proton' in name on mod {self.LINUX_4GB_PATCHER_MOD_ID}") if not manual_file_callback: return False, f"Failed to download 4GB patcher: {msg}\n\nPlease download manually from: https://www.nexusmods.com/newvegas/mods/62552" instructions = ( "Automatic download failed (requires Nexus Premium).\n\n" "Please download the Linux 4GB Patcher manually:\n\n" "1. Visit: https://www.nexusmods.com/newvegas/mods/62552\n\n" "2. Download the file named 'FNV4GB for Linux'\n\n" "3. Select the downloaded file below" ) patcher_path = manual_file_callback("4GB Patcher Required", instructions) if not patcher_path or not patcher_path.exists(): return False, "4GB patcher file not provided" # Copy to cache for future use cached_path = self.cache_dir / patcher_path.name shutil.copy2(patcher_path, cached_path) patcher_path = cached_path logger.info(f"Using manually selected 4GB patcher: {patcher_path}") # Extract if it's a zip file and not already extracted if patcher_path.suffix.lower() == '.zip': extract_dir = self.cache_dir / f"{patcher_path.stem}_extracted" # Extract if not already done if not extract_dir.exists(): logger.info(f"Extracting {patcher_path.name}...") extract_dir.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(patcher_path, 'r') as zip_ref: zip_ref.extractall(extract_dir) logger.info(f"Extracted to {extract_dir}") # Find the executable executables = list(extract_dir.glob("*")) if not executables: return False, "No files found in 4GB patcher zip" # Look for executable file (FalloutNVPatcher or similar) patcher_exe = None for f in executables: if f.is_file() and ('fallout' in f.name.lower() or 'patcher' in f.name.lower() or 'fnv' in f.name.lower()): patcher_exe = f break if not patcher_exe: # Use first file if no obvious match patcher_exe = next((f for f in executables if f.is_file()), None) if not patcher_exe: return False, "No executable found in 4GB patcher zip" patcher_path = patcher_exe logger.info(f"Using patcher executable: {patcher_path.name}") # Make executable patcher_path.chmod(patcher_path.stat().st_mode | stat.S_IEXEC) # Run patcher if progress_callback: progress_callback("Running 4GB patcher...") result = subprocess.run( [str(patcher_path)], cwd=str(self.game_root), capture_output=True, text=True, timeout=60 ) # Check if backup was created (indicates success) if backup_exe.exists(): logger.info("4GB patch applied successfully") return True, "4GB patch applied successfully" else: logger.warning(f"Patcher output: {result.stdout}") if result.stderr: logger.warning(f"Patcher errors: {result.stderr}") return False, "Patcher ran but FalloutNV_backup.exe not created" except subprocess.TimeoutExpired: return False, "4GB patcher timed out after 60 seconds" except Exception as e: error_msg = f"Failed to run 4GB patcher: {str(e)}" logger.error(error_msg, exc_info=True) return False, error_msg def run_bsa_decompressor(self, progress_callback: Optional[Callable[[str], None]] = None, manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None) -> tuple[bool, str]: """ Download FNV BSA Decompressor MPI and run via TTW_Linux_Installer. Args: progress_callback: Optional callback for progress updates manual_file_callback: Optional callback for manual file selection Takes (title, instructions) returns Path or None Returns: (success: bool, message: str) """ try: # Check if already completed marker_file = self.game_root / ".jackify_bsa_decompressed" if marker_file.exists(): logger.info("BSA decompression already completed (marker file exists)") return True, "BSA decompression already completed" if not self.ttw_installer_path or not self.ttw_installer_path.exists(): logger.warning("TTW_Linux_Installer not found, skipping BSA decompression") return True, "BSA decompression skipped (TTW_Linux_Installer not available)" # Check cache first cached_files = list(self.cache_dir.glob("*BSA*.mpi")) if cached_files: mpi_path = cached_files[0] logger.info(f"Using cached BSA Decompressor MPI: {mpi_path}") else: # Also check for exact filename match (handles spaces in filename) exact_path = self.cache_dir / "FNV BSA Decompressor.mpi" if exact_path.exists(): mpi_path = exact_path logger.info(f"Using cached BSA Decompressor MPI: {mpi_path}") else: # Try to download from Nexus success, mpi_path, msg = self.download_service.download_latest_file( self.GAME_DOMAIN, self.FNV_BSA_DECOMPRESSOR_MOD_ID, self.cache_dir, file_name_filter="mpi", progress_callback=progress_callback ) if not success: # Download failed - offer manual download logger.warning(f"Automatic download failed: {msg}") if not manual_file_callback: return False, f"Failed to download BSA Decompressor MPI: {msg}\n\nPlease download manually from: https://www.nexusmods.com/newvegas/mods/65854" instructions = ( "Automatic download failed (requires Nexus Premium).\n\n" "Please download the FNV BSA Decompressor manually:\n" "1. Visit: https://www.nexusmods.com/newvegas/mods/65854\n" "2. Download the .mpi file\n" "3. Select the downloaded file below" ) mpi_path = manual_file_callback("BSA Decompressor Required", instructions) if not mpi_path or not mpi_path.exists(): return False, "BSA Decompressor MPI file not provided" # Validate it's an MPI file if not mpi_path.suffix.lower() == '.mpi': return False, f"Selected file is not an MPI file: {mpi_path}" # Copy to cache for future use cached_path = self.cache_dir / mpi_path.name shutil.copy2(mpi_path, cached_path) mpi_path = cached_path logger.info(f"Using manually selected BSA Decompressor MPI: {mpi_path}") # Create temp output directory with tempfile.TemporaryDirectory() as temp_output: temp_output_path = Path(temp_output) # Create config file for TTW_Linux_Installer (handles spaces in paths better) config_file = self.ttw_installer_path.parent / "ttw-config.json" import json config_data = { "FalloutNVRoot": str(self.game_root), "MpiPackagePath": str(mpi_path), "DestinationPath": str(temp_output_path) } with open(config_file, 'w') as f: json.dump(config_data, f, indent=2) logger.debug(f"Created MPI config file: {config_file}") # Run via TTW_Linux_Installer if progress_callback: progress_callback("Running BSA decompressor...") cmd = [ str(self.ttw_installer_path), "--start" ] logger.info(f"Running BSA decompressor: {' '.join(cmd)}") logger.debug(f"Using config file: {config_file}") logger.debug(f"Config: {json.dumps(config_data, indent=2)}") env = get_clean_subprocess_env() # Stream output and parse progress import re process = subprocess.Popen( cmd, cwd=str(self.ttw_installer_path.parent), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1 ) # Pattern to match progress: "Assets processed: 12345/48649" progress_pattern = re.compile(r'Assets processed: (\d+)/(\d+)') last_progress = None # Capture all output for diagnostics all_output = [] already_modified_detected = False # Stream output line by line for line in process.stdout: line = line.rstrip() all_output.append(line) # Check for "already modified" messages if "already" in line.lower() and ("modified" in line.lower() or "decompressed" in line.lower()): already_modified_detected = True logger.info(f"BSA decompressor reports: {line}") # Check for progress updates match = progress_pattern.search(line) if match: current = int(match.group(1)) total = int(match.group(2)) percent = (current / total * 100) if total > 0 else 0 progress_msg = f"Decompressing BSA files: {current}/{total} ({percent:.1f}%)" # Only send update if progress changed significantly if last_progress is None or current - last_progress >= total // 100: if progress_callback: progress_callback(progress_msg) # Log progress updates (not every single file) logger.debug(f"BSA decompression progress: {current}/{total} ({percent:.1f}%)") last_progress = current # Wait for process to complete return_code = process.wait(timeout=600) # Log full output for debugging failures if return_code != 0: logger.debug(f"BSA decompressor output:\n" + "\n".join(all_output[-50:])) # Last 50 lines # Clean up config file after execution try: if config_file.exists(): config_file.unlink() logger.debug(f"Cleaned up config file: {config_file}") except Exception as e: logger.warning(f"Failed to clean up config file: {e}") if return_code == 0: # Check if files were actually extracted to temp directory extracted_files = list(temp_output_path.rglob("*")) if extracted_files: logger.info(f"BSA decompression extracted {len(extracted_files)} files") # Copy extracted files back to game Data directory data_dir = self.game_root / "Data" copied_count = 0 for extracted_file in extracted_files: if extracted_file.is_file(): # Preserve relative path structure relative_path = extracted_file.relative_to(temp_output_path) dest_file = data_dir / relative_path dest_file.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(extracted_file, dest_file) copied_count += 1 logger.info(f"Copied {copied_count} decompressed files to {data_dir}") # Create marker file to indicate completion marker_file = self.game_root / ".jackify_bsa_decompressed" marker_file.touch() logger.info("BSA decompression completed successfully") return True, "BSA decompression completed successfully" else: # No files extracted - might be already decompressed or failed silently logger.warning("BSA decompressor returned 0 but no files were extracted") # Check if already decompressed by looking for marker marker_file = self.game_root / ".jackify_bsa_decompressed" if marker_file.exists(): logger.info("BSA files already decompressed (marker file exists)") return True, "BSA files already decompressed" else: return False, "BSA decompressor completed but no files were extracted" else: # Exit code 1 often means "already decompressed" - check output and marker marker_file = self.game_root / ".jackify_bsa_decompressed" # If output explicitly said "already modified/decompressed", treat as success if already_modified_detected: logger.info("BSA decompressor reports files already modified - marking as completed") marker_file.touch() return True, "BSA files already decompressed" # Check marker file if marker_file.exists(): logger.info("BSA decompressor returned error but marker file exists - assuming already completed") return True, "BSA decompression already completed" # Try to provide helpful error message based on exit code and output logger.error(f"BSA decompressor failed with exit code {return_code}") error_details = f"BSA decompressor failed with exit code {return_code}." if return_code == 1: error_details += ( "\n\nThis may indicate the BSA files are already decompressed or modified. " "If you've run this before, the step may have already completed. " "Otherwise, try running the decompressor manually from: " "https://www.nexusmods.com/newvegas/mods/65854" ) else: error_details += ( f"\n\nPlease check that:\n" f"1. Fallout New Vegas is properly installed at: {self.game_root}\n" f"2. The BSA files exist in the Data directory\n" f"3. You have write permissions to the game directory\n\n" f"You can complete this step manually using the guide at:\n" f"https://vivanewvegas.moddinglinked.com/wabbajack.html" ) return False, error_details except subprocess.TimeoutExpired: return False, "BSA decompression timed out after 10 minutes" except Exception as e: error_msg = f"Failed to run BSA decompressor: {str(e)}" logger.error(error_msg, exc_info=True) return False, error_msg