mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-07 22:57:45 +02:00
623 lines
28 KiB
Python
623 lines
28 KiB
Python
"""
|
|
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
|