diff --git a/CHANGELOG.md b/CHANGELOG.md
index a90d856..8e94eb0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Jackify Changelog
+## v0.7.2.1 - Mojave Express Support, BSA Decompressor Fix
+**Release Date:** 2026-07-25
+
+### Mojave Express Wabbajack (MEW) Automation
+Jackify can now automate MEW's post-install steps the same way it already does for Viva New Vegas - copying root mods, running the 4GB patcher and BSA decompressor, and applying the Radio Fix.
+
+### Fixes
+- Fixed BSA decompression failing for both VNV and MEW since the v0.7.2 TTW Linux Installer update, which changed how the tool is invoked.
+- Fallout New Vegas modlists now get a Proton version recommendation on the success screen, matching the existing ENB recommendation.
+
## v0.7.2 - TTW Linux Installer 0.2.0
**Release Date:** 2026-07-18
diff --git a/jackify/__init__.py b/jackify/__init__.py
index eb61792..a339979 100644
--- a/jackify/__init__.py
+++ b/jackify/__init__.py
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
Wabbajack modlists natively on Linux systems.
"""
-__version__ = "0.7.2"
+__version__ = "0.7.2.1"
diff --git a/jackify/backend/core/modlist_operations_configuration_cli.py b/jackify/backend/core/modlist_operations_configuration_cli.py
index 9ff2514..d78413d 100644
--- a/jackify/backend/core/modlist_operations_configuration_cli.py
+++ b/jackify/backend/core/modlist_operations_configuration_cli.py
@@ -856,6 +856,13 @@ class ModlistOperationsConfigurationCLIMixin:
print(f"{COLOR_INFO} - GE-Proton{COLOR_RESET}")
print(f"{COLOR_INFO} - Proton 9 (Valve){COLOR_RESET}")
print(f"{COLOR_WARNING} Valve Proton 10 has known ENB compatibility issues.{COLOR_RESET}")
+
+ from jackify.backend.data.modlist_proton_requirements import get_game_proton_warning
+ _game_warning = get_game_proton_warning(detected_game or '')
+ if _game_warning:
+ print(f"\n{COLOR_INFO}Recommended Proton versions for this game (in order of recommendation):{COLOR_RESET}")
+ for _version in _game_warning['recommended']:
+ print(f"{COLOR_INFO} - {_version}{COLOR_RESET}")
try:
# Ensure CLI install flow gets the same VNV automation behavior as GUI.
from jackify.backend.services.vnv_integration_helper import (
@@ -923,6 +930,75 @@ class ModlistOperationsConfigurationCLIMixin:
except Exception as vnv_err:
self.logger.error("VNV post-install automation failed: %s", vnv_err, exc_info=True)
print(f"{COLOR_WARNING}VNV automation could not be completed. Check logs for details.{COLOR_RESET}")
+ try:
+ # Ensure CLI install flow gets the same MEW automation behavior as GUI.
+ from jackify.backend.services.mew_integration_helper import (
+ run_mew_automation_if_applicable,
+ should_offer_mew_automation,
+ )
+ from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
+ from jackify.backend.services.mew_post_install_service import MEWPostInstallService
+ from jackify.backend.handlers.path_handler import PathHandler
+ from jackify.frontends.cli.commands.vnv_manual_downloads import (
+ build_vnv_cli_manual_file_callback,
+ create_vnv_cli_progress_callback,
+ ensure_vnv_cli_manual_downloads,
+ )
+
+ modlist_name_for_mew = self.context.get('modlist_name') or shortcut_name or ""
+ def _confirm_mew(description: str) -> bool:
+ print(f"\n{description}\n")
+ try:
+ user_input = input(f"{COLOR_PROMPT}Run MEW post-install automation now? (Y/n): {COLOR_RESET}").strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ return False
+ return user_input in ("", "y", "yes")
+ install_path_mew = Path(install_dir_str)
+ if should_offer_mew_automation(modlist_name_for_mew, install_path_mew):
+ game_paths = PathHandler().find_vanilla_game_paths()
+ resolved_game_root = game_paths.get('Fallout New Vegas')
+ mew_service = MEWPostInstallService(
+ modlist_install_location=install_path_mew,
+ game_root=resolved_game_root or install_path_mew,
+ ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
+ )
+ completed = mew_service.check_already_completed()
+ all_mew_steps_done = (
+ completed['root_mods']
+ and completed['4gb_patch']
+ and completed['bsa_decompressed']
+ and completed['radio_fix']
+ )
+ if all_mew_steps_done:
+ print(f"{COLOR_INFO}MEW post-install steps are already complete.{COLOR_RESET}")
+ elif _confirm_mew(mew_service.get_automation_description()):
+ if not ensure_vnv_cli_manual_downloads(mew_service.fnv_tools, output_callback=print):
+ print(f"{COLOR_WARNING}MEW manual downloads were not completed. Skipping MEW automation.{COLOR_RESET}")
+ else:
+ progress_callback, close_progress = create_vnv_cli_progress_callback(print)
+ try:
+ automation_ran, mew_error = run_mew_automation_if_applicable(
+ modlist_name=modlist_name_for_mew,
+ modlist_install_location=install_path_mew,
+ game_root=None, # Auto-detect from modlist structure.
+ appid=str(app_id) if app_id else None,
+ ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
+ progress_callback=progress_callback,
+ manual_file_callback=build_vnv_cli_manual_file_callback(mew_service.fnv_tools, output_callback=print),
+ confirmation_callback=lambda _description: True,
+ )
+ finally:
+ close_progress()
+ if automation_ran and not mew_error:
+ print(f"{COLOR_INFO}MEW post-install automation completed.{COLOR_RESET}")
+ if mew_error:
+ print(f"{COLOR_WARNING}MEW automation encountered an error: {mew_error}{COLOR_RESET}")
+ print(f"{COLOR_INFO}You can complete these steps manually by following: https://mojaveexpressguide.com/docs/Installation{COLOR_RESET}")
+ else:
+ print(f"{COLOR_INFO}MEW automation skipped by user.{COLOR_RESET}")
+ except Exception as mew_err:
+ self.logger.error("MEW post-install automation failed: %s", mew_err, exc_info=True)
+ print(f"{COLOR_WARNING}MEW automation could not be completed. Check logs for details.{COLOR_RESET}")
try:
# v0.4.0 contract: offer TTW flow for eligible FNV lists (e.g., Begin Again).
from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible
diff --git a/jackify/backend/data/modlist_proton_requirements.py b/jackify/backend/data/modlist_proton_requirements.py
index fded86d..de56e85 100644
--- a/jackify/backend/data/modlist_proton_requirements.py
+++ b/jackify/backend/data/modlist_proton_requirements.py
@@ -1,4 +1,4 @@
-"""Per-modlist Proton version requirements for ENB compatibility warnings."""
+"""Per-modlist and per-game Proton version requirements/warnings."""
from typing import Optional
@@ -17,3 +17,21 @@ def get_proton_requirement(modlist_name: str) -> Optional[dict[str, str]]:
if not modlist_name:
return None
return MODLIST_PROTON_REQUIREMENTS.get(modlist_name.strip().lower())
+
+
+# Keys are lowercase game_type identifiers (e.g. "falloutnv", "fallout_new_vegas").
+# recommended: Proton builds recommended for this game, in order of recommendation
+GAME_PROTON_WARNINGS: dict[str, dict] = {
+ "falloutnv": {
+ "recommended": ["GE-Proton10-14", "Proton Experimental (latest)", "Proton-CachyOS"],
+ },
+ "fallout_new_vegas": {
+ "recommended": ["GE-Proton10-14", "Proton Experimental (latest)", "Proton-CachyOS"],
+ },
+}
+
+
+def get_game_proton_warning(game_type: str) -> Optional[dict]:
+ if not game_type:
+ return None
+ return GAME_PROTON_WARNINGS.get(game_type.strip().lower())
diff --git a/jackify/backend/handlers/menu_handler_modlist.py b/jackify/backend/handlers/menu_handler_modlist.py
index a931a8a..008553f 100644
--- a/jackify/backend/handlers/menu_handler_modlist.py
+++ b/jackify/backend/handlers/menu_handler_modlist.py
@@ -642,6 +642,71 @@ class ModlistMenuHandler:
self.logger.debug(f"VNV automation check skipped: {e}")
# Not an error - just means VNV automation wasn't applicable
+ from jackify.backend.services.mew_integration_helper import (
+ run_mew_automation_if_applicable,
+ should_offer_mew_automation,
+ )
+ from jackify.backend.services.mew_post_install_service import MEWPostInstallService
+
+ try:
+ def _confirm_mew(description: str) -> bool:
+ print(f"\n{description}\n")
+ try:
+ user_input = input(f"{COLOR_PROMPT}Run MEW post-install automation now? (Y/n): {COLOR_RESET}").strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ return False
+ return user_input in ("", "y", "yes")
+ if should_offer_mew_automation(modlist_name, modlist_path):
+ game_paths = PathHandler().find_vanilla_game_paths()
+ resolved_game_root = game_paths.get('Fallout New Vegas')
+ mew_service = MEWPostInstallService(
+ modlist_install_location=modlist_path,
+ game_root=resolved_game_root or modlist_path,
+ ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
+ )
+ completed = mew_service.check_already_completed()
+ all_mew_steps_done = (
+ completed['root_mods']
+ and completed['4gb_patch']
+ and completed['bsa_decompressed']
+ and completed['radio_fix']
+ )
+ if all_mew_steps_done:
+ print(f"{COLOR_INFO}MEW post-install steps are already complete.{COLOR_RESET}")
+ elif _confirm_mew(mew_service.get_automation_description()):
+ if not ensure_vnv_cli_manual_downloads(mew_service.fnv_tools, output_callback=print):
+ print(f"{COLOR_WARNING}MEW manual downloads were not completed. Skipping MEW automation.{COLOR_RESET}")
+ else:
+ progress_callback, close_progress = create_vnv_cli_progress_callback(print)
+ try:
+ _mew_appid = context.get('appid') or getattr(self.modlist_handler, 'appid', None)
+ if not _mew_appid:
+ _mew_appid = self.shortcut_handler.get_appid_for_shortcut(
+ modlist_name, str(modlist_path / "ModOrganizer.exe")
+ )
+ automation_ran, error = run_mew_automation_if_applicable(
+ modlist_name=modlist_name,
+ modlist_install_location=modlist_path,
+ game_root=None, # Will be auto-detected
+ appid=_mew_appid,
+ ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
+ progress_callback=progress_callback,
+ manual_file_callback=build_vnv_cli_manual_file_callback(mew_service.fnv_tools, output_callback=print),
+ confirmation_callback=lambda _description: True,
+ )
+ finally:
+ close_progress()
+ if automation_ran and not error:
+ print(f"{COLOR_INFO}MEW post-install automation completed.{COLOR_RESET}")
+ if error:
+ print(f"{COLOR_WARNING}MEW automation encountered an error: {error}{COLOR_RESET}")
+ print(f"{COLOR_INFO}You can complete these steps manually by following: https://mojaveexpressguide.com/docs/Installation{COLOR_RESET}")
+ else:
+ print(f"{COLOR_INFO}MEW automation skipped by user.{COLOR_RESET}")
+ except Exception as e:
+ self.logger.debug(f"MEW automation check skipped: {e}")
+ # Not an error - just means MEW automation wasn't applicable
+
if not gui_mode:
try:
from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible
diff --git a/jackify/backend/services/mew_integration_helper.py b/jackify/backend/services/mew_integration_helper.py
new file mode 100644
index 0000000..2250562
--- /dev/null
+++ b/jackify/backend/services/mew_integration_helper.py
@@ -0,0 +1,216 @@
+"""
+MEW Integration Helper
+
+Helper functions to integrate Mojave Express post-install automation into modlist workflows.
+Mirrors vnv_integration_helper.py. Handles detection, confirmation, and execution for:
+- Install Modlist
+- Configure New Modlist
+- Configure Existing Modlist
+"""
+
+import logging
+import configparser
+import re
+from pathlib import Path
+from typing import Optional, Callable, Tuple
+
+from .mew_post_install_service import MEWPostInstallService
+
+logger = logging.getLogger(__name__)
+
+
+def _parse_bytearray_value(value: str) -> str:
+ """Parse Qt @ByteArray format, e.g. '@ByteArray(Mojave Express)' -> 'Mojave Express'."""
+ match = re.match(r'@ByteArray\((.*)\)', value)
+ if match:
+ return match.group(1)
+ return value
+
+
+def _check_modorganizer_ini_profile(modlist_install_location: Path) -> bool:
+ """Check ModOrganizer.ini for a MEW profile name."""
+ try:
+ mo_ini_path = modlist_install_location / "ModOrganizer.ini"
+ if not mo_ini_path.exists():
+ logger.debug(f"ModOrganizer.ini not found at {mo_ini_path}")
+ return False
+
+ config = configparser.ConfigParser()
+ config.read(mo_ini_path, encoding='utf-8-sig')
+
+ if 'General' not in config:
+ logger.debug("No [General] section in ModOrganizer.ini")
+ return False
+
+ selected_profile_raw = config.get('General', 'selected_profile', fallback='')
+ if not selected_profile_raw:
+ logger.debug("No selected_profile in ModOrganizer.ini")
+ return False
+
+ selected_profile = _parse_bytearray_value(selected_profile_raw).strip().lower()
+ logger.debug(f"Found selected_profile: {selected_profile}")
+
+ return selected_profile in ("mojave express", "mew")
+
+ except Exception as e:
+ logger.debug(f"Error checking ModOrganizer.ini for MEW profile: {e}")
+ return False
+
+
+def should_offer_mew_automation(modlist_name: str, modlist_install_location: Optional[Path] = None) -> bool:
+ """
+ Check if MEW automation should be offered for this modlist.
+
+ Detection methods (in order of reliability):
+ 1. Check ModOrganizer.ini selected_profile (most reliable)
+ 2. Check modlist name for MEW patterns
+ """
+ if modlist_install_location:
+ if _check_modorganizer_ini_profile(modlist_install_location):
+ logger.info(f"MEW detected via ModOrganizer.ini profile in {modlist_install_location}")
+ return True
+
+ modlist_name_lower = modlist_name.lower()
+ if "mojave express" in modlist_name_lower or modlist_name_lower == "mew":
+ logger.info(f"MEW detected via name pattern in '{modlist_name}'")
+ return True
+
+ return False
+
+
+def _find_wine_binary() -> Optional[str]:
+ """
+ Locate a wine binary from the configured Proton install.
+
+ Same lookup ModlistWineOpsMixin._find_wine_binary_for_registry() uses; duplicated here
+ (rather than instantiating that mixin's host class) since it has no other dependency.
+ """
+ try:
+ from ..handlers.config_handler import ConfigHandler
+ proton_path = ConfigHandler().get_proton_path()
+ if proton_path:
+ proton_path = Path(proton_path).expanduser()
+ for candidate in (proton_path / "files" / "bin" / "wine", proton_path / "dist" / "bin" / "wine"):
+ if candidate.is_file():
+ return str(candidate)
+
+ from ..handlers.wine_utils import WineUtils
+ best_proton = WineUtils.select_best_proton()
+ if best_proton:
+ return WineUtils.find_proton_binary(best_proton['name'])
+ except Exception as e:
+ logger.debug(f"Error finding Wine binary for MEW automation: {e}")
+ return None
+
+
+def run_mew_automation_if_applicable(
+ modlist_name: str,
+ modlist_install_location: Path,
+ game_root: Optional[Path],
+ appid: Optional[str] = None,
+ ttw_installer_path: Optional[Path] = None,
+ progress_callback: Optional[Callable[[str], None]] = None,
+ manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None,
+ confirmation_callback: Optional[Callable[[str], bool]] = None
+) -> Tuple[bool, Optional[str]]:
+ """
+ Check if MEW automation should run, get user confirmation, and execute if confirmed.
+
+ Args:
+ modlist_name: Name of the installed modlist
+ modlist_install_location: Path to modlist installation
+ game_root: Path to game root directory
+ appid: Steam AppID for the modlist's shortcut, used to resolve the Wine prefix for
+ the Radio Fix step. Radio Fix is skipped (non-fatal) if not provided.
+ ttw_installer_path: Optional path to TTW_Linux_Installer (for BSA decompression)
+ progress_callback: Optional callback for progress updates
+ manual_file_callback: Optional callback for manual file selection (non-Premium)
+ confirmation_callback: Optional callback for user confirmation
+
+ Returns:
+ Tuple of (automation_was_run: bool, error_message: Optional[str])
+ """
+ try:
+ if not should_offer_mew_automation(modlist_name, modlist_install_location):
+ logger.debug(f"Modlist '{modlist_name}' does not require MEW automation")
+ return False, None
+
+ logger.info(f"MEW detected: {modlist_name}")
+
+ resolved_game_root = game_root
+ if resolved_game_root is None:
+ try:
+ from jackify.backend.handlers.path_handler import PathHandler
+ game_paths = PathHandler().find_vanilla_game_paths()
+ resolved_game_root = game_paths.get('Fallout New Vegas')
+ except Exception as detect_err:
+ logger.debug(f"MEW game root auto-detection failed: {detect_err}")
+
+ if resolved_game_root is None:
+ logger.warning("MEW detected but Fallout New Vegas game root could not be resolved")
+ if progress_callback:
+ progress_callback("MEW automation skipped: Fallout New Vegas path not found")
+ return False, None
+
+ wineprefix = None
+ wine_binary = None
+ if appid:
+ try:
+ from ..handlers.protontricks_handler import ProtontricksHandler
+ wineprefix = ProtontricksHandler(steamdeck=False).get_wine_prefix_path(appid)
+ except Exception as e:
+ logger.debug(f"MEW wineprefix lookup failed: {e}")
+ wine_binary = _find_wine_binary()
+ if not wineprefix or not wine_binary:
+ logger.warning("MEW Radio Fix will be skipped: wine prefix/binary unavailable")
+ else:
+ logger.warning("MEW Radio Fix will be skipped: no appid provided")
+
+ mew_service = MEWPostInstallService(
+ modlist_install_location=modlist_install_location,
+ game_root=resolved_game_root,
+ ttw_installer_path=ttw_installer_path,
+ wineprefix=wineprefix,
+ wine_binary=wine_binary,
+ )
+
+ completed = mew_service.check_already_completed()
+ if completed['root_mods'] and completed['4gb_patch'] and completed['bsa_decompressed'] and completed['radio_fix']:
+ logger.info("MEW automation steps already completed")
+ if progress_callback:
+ progress_callback("MEW post-install steps already completed")
+ return False, None
+
+ if not confirmation_callback:
+ logger.error("MEW automation requires confirmation_callback")
+ return False, "MEW automation requires user confirmation"
+
+ description = mew_service.get_automation_description()
+ if not confirmation_callback(description):
+ logger.info("User declined MEW automation")
+ if progress_callback:
+ progress_callback("MEW automation skipped by user")
+ return False, None
+
+ logger.info("Starting MEW post-install automation")
+ if progress_callback:
+ progress_callback("Running MEW post-install automation...")
+
+ success, message = mew_service.run_all_steps(
+ progress_callback=progress_callback,
+ manual_file_callback=manual_file_callback
+ )
+
+ if success:
+ logger.info(f"MEW automation completed: {message}")
+ if progress_callback:
+ progress_callback(f"MEW automation: {message}")
+ return True, None
+ else:
+ logger.error(f"MEW automation failed: {message}")
+ return True, message
+
+ except Exception as e:
+ error_msg = f"MEW automation error: {str(e)}"
+ logger.error(error_msg, exc_info=True)
+ return True, error_msg
diff --git a/jackify/backend/services/mew_post_install_service.py b/jackify/backend/services/mew_post_install_service.py
new file mode 100644
index 0000000..5ab642f
--- /dev/null
+++ b/jackify/backend/services/mew_post_install_service.py
@@ -0,0 +1,248 @@
+"""
+Mojave Express (MEW) Post-Install Service
+
+Automates the post-installation steps documented at:
+https://mojaveexpressguide.com/docs/Installation
+
+1. Root Mods - copy files from the GOG/Steam manual-install folder to game root
+2. 4GB Patcher / BSA Decompression - these are FNV-wide fixes, not MEW-specific; the same
+ native Linux tools VNVPostInstallService already downloads and runs are reused here via
+ composition rather than duplicated.
+3. Radio Fix - runs the modlist's bundled Windows batch script under Wine. Best-effort: MEW's
+ guide does not document a consequence of skipping this step (only that new radio songs
+ won't play), so a failure here is reported but never blocks the rest of automation.
+"""
+
+import logging
+import shutil
+import subprocess
+from pathlib import Path
+from typing import Optional, Callable
+
+from .vnv_post_install_service import VNVPostInstallService
+from ..handlers.subprocess_utils import get_clean_subprocess_env
+
+logger = logging.getLogger(__name__)
+
+
+class MEWPostInstallService:
+ """Handles automated post-installation tasks for the Mojave Express modlist."""
+
+ ROOT_MODS_DIR_NAME = "__GOG or STEAM ONLY Files Requiring Manual Install"
+ RADIO_FIX_DIR_NAME = "__Radio Fix"
+ RADIO_FIX_BAT_NAME = "Run This.bat"
+ RADIO_FIX_SUCCESS_MARKER = "conversion complete"
+
+ def __init__(self, modlist_install_location: Path, game_root: Path,
+ ttw_installer_path: Optional[Path] = None,
+ wineprefix: Optional[str] = None,
+ wine_binary: Optional[str] = None):
+ """
+ Args:
+ modlist_install_location: Path to the MEW installation
+ game_root: Path to Fallout New Vegas game root
+ ttw_installer_path: Path to TTW_Linux_Installer (used for BSA decompression)
+ wineprefix: WINEPREFIX for the modlist's Steam prefix (required for Radio Fix only)
+ wine_binary: Path to the wine binary to use (required for Radio Fix only)
+ """
+ self.modlist_install = modlist_install_location
+ self.game_root = game_root
+ self.wineprefix = wineprefix
+ self.wine_binary = wine_binary
+
+ self.root_mods_dir = self.modlist_install / self.ROOT_MODS_DIR_NAME
+ self.radio_fix_dir = self.modlist_install / self.RADIO_FIX_DIR_NAME
+
+ # 4GB patch and BSA decompression are FNV-wide fixes with no VNV-specific logic in
+ # their implementation; reuse VNVPostInstallService for these two steps (including
+ # its shared download cache) instead of duplicating them.
+ self.fnv_tools = VNVPostInstallService(
+ modlist_install_location=modlist_install_location,
+ game_root=game_root,
+ ttw_installer_path=ttw_installer_path,
+ )
+
+ def should_run_automation(self, modlist_name: str) -> bool:
+ """Check if this modlist should trigger MEW automation."""
+ name = modlist_name.lower()
+ return "mojave express" in name or name == "mew"
+
+ def get_automation_description(self) -> str:
+ return (
+ "Mojave Express 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"
+ "4. Run the Radio Fix (adds new radio songs to in-game radios)\n\n"
+ "Jackify will download the required tools automatically where possible.\n"
+ "If you are not a Nexus Premium member, you will be prompted to\n"
+ "manually download any tools that cannot be fetched automatically.\n\n"
+ "Would you like Jackify to automate these steps?"
+ )
+
+ def get_manual_download_items(self, include_bsa: bool = False) -> list:
+ return self.fnv_tools.get_manual_download_items(include_bsa=include_bsa)
+
+ def check_already_completed(self) -> dict:
+ """
+ Check which MEW automation steps have already been completed.
+
+ Returns:
+ Dict with keys: 'root_mods', '4gb_patch', 'bsa_decompressed', 'radio_fix'
+ """
+ fnv_status = self.fnv_tools.check_already_completed()
+ radio_marker = self.game_root / ".jackify_mew_radio_fixed"
+ return {
+ 'root_mods': (self.game_root / "FNVpatch.exe").exists(),
+ '4gb_patch': fnv_status['4gb_patch'],
+ 'bsa_decompressed': fnv_status['bsa_decompressed'],
+ 'radio_fix': radio_marker.exists(),
+ }
+
+ def copy_root_mods(self) -> tuple[bool, str]:
+ """
+ Copy files from the GOG/Steam manual-install folder to game root.
+
+ Returns:
+ (success: bool, message: str)
+ """
+ try:
+ if not self.root_mods_dir.exists():
+ return False, (
+ f"Manual install directory not found: {self.root_mods_dir}. "
+ "Epic Games installs are not currently supported by this automation."
+ )
+ if not self.game_root.exists():
+ return False, f"Game root directory not found: {self.game_root}"
+
+ copied_files = []
+ for item in self.root_mods_dir.iterdir():
+ dest = self.game_root / item.name
+ if item.is_file():
+ shutil.copy2(item, dest)
+ copied_files.append(item.name)
+ elif item.is_dir():
+ shutil.copytree(item, dest, dirs_exist_ok=True)
+ copied_files.append(f"{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]:
+ return self.fnv_tools.run_4gb_patcher(progress_callback, manual_file_callback)
+
+ 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]:
+ return self.fnv_tools.run_bsa_decompressor(progress_callback, manual_file_callback)
+
+ def run_radio_fix(self, progress_callback: Optional[Callable[[str], None]] = None) -> tuple[bool, str]:
+ """
+ Run the bundled Radio Fix batch script under Wine.
+
+ Non-critical step - see class docstring.
+
+ Returns:
+ (success: bool, message: str)
+ """
+ marker = self.game_root / ".jackify_mew_radio_fixed"
+ if marker.exists():
+ return True, "Radio Fix already completed"
+
+ bat_path = self.radio_fix_dir / self.RADIO_FIX_BAT_NAME
+ if not bat_path.exists():
+ return False, f"Radio Fix script not found: {bat_path}"
+
+ if not self.wine_binary or not self.wineprefix:
+ return False, "Radio Fix requires a Wine prefix, but none was available"
+
+ if progress_callback:
+ progress_callback("Running Radio Fix...")
+
+ env = get_clean_subprocess_env()
+ env['WINEPREFIX'] = self.wineprefix
+ env['WINEDEBUG'] = '-all'
+
+ try:
+ result = subprocess.run(
+ [self.wine_binary, "cmd", "/c", self.RADIO_FIX_BAT_NAME],
+ cwd=str(self.radio_fix_dir),
+ env=env,
+ capture_output=True,
+ text=True,
+ stdin=subprocess.DEVNULL,
+ timeout=1800,
+ )
+ except subprocess.TimeoutExpired:
+ return False, "Radio Fix timed out after 30 minutes"
+ except Exception as e:
+ error_msg = f"Failed to run Radio Fix: {str(e)}"
+ logger.error(error_msg, exc_info=True)
+ return False, error_msg
+
+ output = (result.stdout or "") + (result.stderr or "")
+ if self.RADIO_FIX_SUCCESS_MARKER in output.lower():
+ marker.touch()
+ logger.info("Radio Fix completed successfully")
+ return True, "Radio Fix completed successfully"
+
+ logger.warning(f"Radio Fix did not report success. Output:\n{output[-2000:]}")
+ return False, "Radio Fix ran but did not report success - it may need to be run manually"
+
+ def run_all_steps(self, progress_callback: Optional[Callable[[str], None]] = None,
+ manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None) -> tuple[bool, str]:
+ """
+ Run all MEW post-install steps in sequence.
+
+ Radio Fix failure is logged and reported but does not fail the overall result.
+
+ Returns:
+ (success: bool, message: str)
+ """
+ def update_progress(msg: str):
+ if progress_callback:
+ progress_callback(msg)
+ logger.info(msg)
+
+ try:
+ update_progress("Step 1/4: 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}")
+
+ update_progress("Step 2/4: 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}")
+
+ update_progress("Step 3/4: 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}")
+
+ update_progress("Step 4/4: Running Radio Fix...")
+ success, msg = self.run_radio_fix(update_progress)
+ if success:
+ update_progress(f"Radio Fix: {msg}")
+ else:
+ update_progress(f"Radio Fix skipped: {msg}")
+ logger.warning(f"Radio Fix non-fatal failure: {msg}")
+
+ return True, "MEW post-install completed"
+
+ except Exception as e:
+ error_msg = f"MEW post-install failed: {str(e)}"
+ logger.error(error_msg, exc_info=True)
+ return False, error_msg
diff --git a/jackify/backend/services/native_steam_operations_service.py b/jackify/backend/services/native_steam_operations_service.py
index 78af8bc..b76d873 100644
--- a/jackify/backend/services/native_steam_operations_service.py
+++ b/jackify/backend/services/native_steam_operations_service.py
@@ -113,7 +113,7 @@ class NativeSteamOperationsService:
compatdata_paths = self._find_compatdata_paths()
for compatdata_base in compatdata_paths:
- prefix_path = compatdata_base / appid / "pfx"
+ prefix_path = compatdata_base / str(appid) / "pfx"
logger.debug(f"Checking prefix path: {prefix_path}")
if prefix_path.exists():
diff --git a/jackify/backend/services/vnv_post_install_service.py b/jackify/backend/services/vnv_post_install_service.py
index 53f7add..8b204f8 100644
--- a/jackify/backend/services/vnv_post_install_service.py
+++ b/jackify/backend/services/vnv_post_install_service.py
@@ -13,11 +13,9 @@ Uses native Linux tools (no Wine required) by downloading from Nexus with OAuth.
import logging
import os
-import json
import shutil
import subprocess
import stat
-import tempfile
import zipfile
from pathlib import Path
from typing import Optional, Callable
@@ -521,6 +519,15 @@ class VNVPostInstallService:
"""
Download FNV BSA Decompressor MPI and run via TTW_Linux_Installer.
+ TTW_Linux_Installer v0.2.0 (the pinned build since Jackify v0.7.2) is a Rust rewrite
+ with a plain `install --mpi ... --fnv ... --dest ...` CLI; the old v0.0.7 C# build's
+ `--start` flag and PascalCase ttw-config.json are gone. `--dest` is passed the game
+ root directly (not a scratch dir) because the installer resolves manifest asset
+ locations relative to it and writes final output there itself - see
+ AssetProcessor::new(..., request.destination_path.clone(), ...) in that project's
+ install_runner.rs. Any scratch subdirectory it creates under `--dest` is cleaned up
+ by the installer itself before it exits.
+
Args:
progress_callback: Optional callback for progress updates
manual_file_callback: Optional callback for manual file selection
@@ -590,171 +597,81 @@ class VNVPostInstallService:
return False, f"Failed to prepare BSA Decompressor package: {msg}"
logger.info(msg)
- # Create temp output directory
- with tempfile.TemporaryDirectory() as temp_output:
- temp_output_path = Path(temp_output)
+ if progress_callback:
+ progress_callback("Running BSA decompressor...")
- # Create config file for TTW_Linux_Installer (handles spaces in paths better)
- config_file = self.ttw_installer_path.parent / "ttw-config.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}")
+ cmd = [
+ str(self.ttw_installer_path),
+ "install",
+ "--mpi", str(mpi_path),
+ "--fnv", str(self.game_root),
+ "--dest", str(self.game_root),
+ ]
- # Run via TTW_Linux_Installer
- if progress_callback:
- progress_callback("Ensuring TTW_Linux_Installer is available...")
- progress_callback("Running BSA decompressor...")
+ logger.info(f"Running BSA decompressor: {' '.join(cmd)}")
- cmd = [
- str(self.ttw_installer_path),
- "--start"
- ]
+ env = get_clean_subprocess_env()
- 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)}")
+ # 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
+ )
- env = get_clean_subprocess_env()
+ # Rust installer logs "Progress: NN% - message" lines (see cli.rs run_install)
+ progress_pattern = re.compile(r'Progress:\s*(\d+)%\s*-\s*(.*)')
+ last_percent = None
- # 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
- )
+ all_output = []
+ installation_complete = False
- # Pattern to match progress: "Assets processed: 12345/48649"
- progress_pattern = re.compile(r'Assets processed: (\d+)/(\d+)')
- last_progress = None
+ for line in process.stdout:
+ line = line.rstrip()
+ all_output.append(line)
- # Capture all output for diagnostics
- all_output = []
- already_modified_detected = False
+ if "=== Installation Complete ===" in line:
+ installation_complete = True
- # Stream output line by line
- for line in process.stdout:
- line = line.rstrip()
- all_output.append(line)
+ match = progress_pattern.search(line)
+ if match:
+ percent = int(match.group(1))
+ detail = match.group(2)
+ if last_percent is None or percent != last_percent:
+ if progress_callback:
+ progress_callback(f"Decompressing BSA files: {percent}% - {detail}")
+ last_percent = percent
- # 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}")
+ return_code = process.wait(timeout=600)
- # 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}%)"
+ if return_code != 0:
+ logger.debug("BSA decompressor output:\n" + "\n".join(all_output[-50:]))
- # 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
+ if return_code == 0 and installation_complete:
+ marker_file = self.game_root / ".jackify_bsa_decompressed"
+ marker_file.touch()
+ logger.info("BSA decompression completed successfully")
+ return True, "BSA decompression completed successfully"
- # Wait for process to complete
- return_code = process.wait(timeout=600)
+ marker_file = self.game_root / ".jackify_bsa_decompressed"
+ if marker_file.exists():
+ logger.info("BSA decompressor returned an error but marker file exists - assuming already completed")
+ return True, "BSA decompression already completed"
- # 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
+ logger.error(f"BSA decompressor failed with exit code {return_code}")
+ error_details = (
+ f"BSA decompressor failed with exit code {return_code}.\n\n"
+ f"Please 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"
+ )
+ return False, error_details
except subprocess.TimeoutExpired:
return False, "BSA decompression timed out after 10 minutes"
diff --git a/jackify/frontends/cli/commands/configure_modlist.py b/jackify/frontends/cli/commands/configure_modlist.py
index d4d109c..47b5820 100644
--- a/jackify/frontends/cli/commands/configure_modlist.py
+++ b/jackify/frontends/cli/commands/configure_modlist.py
@@ -130,6 +130,40 @@ class ConfigureModlistCommand:
logger.error("VNV post-install automation failed: %s", e, exc_info=True)
print(f"{COLOR_WARNING}VNV automation could not be completed. Check logs for details.{COLOR_RESET}")
+ # MEW
+ try:
+ from jackify.backend.services.mew_integration_helper import (
+ run_mew_automation_if_applicable,
+ should_offer_mew_automation,
+ )
+ if should_offer_mew_automation(modlist_name, Path(install_dir)):
+ def _confirm_mew(description: str) -> bool:
+ print(f"\n{description}\n")
+ try:
+ ans = input(f"{COLOR_PROMPT}Run MEW post-install automation now? (Y/n): {COLOR_RESET}").strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ return False
+ return ans in ("", "y", "yes")
+
+ from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
+ automation_ran, mew_error = run_mew_automation_if_applicable(
+ modlist_name=modlist_name,
+ modlist_install_location=Path(install_dir),
+ game_root=None,
+ appid=app_id,
+ ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
+ progress_callback=print,
+ manual_file_callback=None,
+ confirmation_callback=_confirm_mew,
+ )
+ if automation_ran and not mew_error:
+ print(f"{COLOR_INFO}MEW post-install automation completed.{COLOR_RESET}")
+ if mew_error:
+ print(f"{COLOR_WARNING}MEW automation encountered an error: {mew_error}{COLOR_RESET}")
+ except Exception as e:
+ logger.error("MEW post-install automation failed: %s", e, exc_info=True)
+ print(f"{COLOR_WARNING}MEW automation could not be completed. Check logs for details.{COLOR_RESET}")
+
# JContainers
try:
from jackify.backend.handlers.modlist_fixup_handler import (
diff --git a/jackify/frontends/gui/dialogs/game_proton_warning_dialog.py b/jackify/frontends/gui/dialogs/game_proton_warning_dialog.py
new file mode 100644
index 0000000..bbb3bcd
--- /dev/null
+++ b/jackify/frontends/gui/dialogs/game_proton_warning_dialog.py
@@ -0,0 +1,135 @@
+"""
+Game Proton Warning Dialog
+
+Shown to recommend a Proton version for the modlist's game type, mirroring
+enb_proton_dialog.py's structure and behavior.
+"""
+
+import logging
+from pathlib import Path
+
+from PySide6.QtWidgets import (
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
+ QSizePolicy, QFrame
+)
+from PySide6.QtCore import Qt, QTimer
+from PySide6.QtGui import QIcon
+
+logger = logging.getLogger(__name__)
+
+
+class GameProtonWarningDialog(QDialog):
+ """Dialog recommending a Proton version for the modlist's game."""
+
+ def __init__(self, modlist_name: str, warning: dict, game_label: str, parent=None):
+ super().__init__(parent)
+ self.modlist_name = modlist_name
+ self.setWindowTitle("Recommended Proton Version")
+ self.setWindowModality(Qt.ApplicationModal)
+ self.setFixedSize(600, 420)
+ self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }")
+
+ layout = QVBoxLayout(self)
+ layout.setSpacing(0)
+ layout.setContentsMargins(30, 30, 30, 30)
+
+ card = QFrame(self)
+ card.setObjectName("gameProtonCard")
+ card.setFrameShape(QFrame.StyledPanel)
+ card.setFrameShadow(QFrame.Raised)
+ card.setFixedWidth(540)
+ card.setMinimumHeight(280)
+ card.setMaximumHeight(16777215)
+ card_layout = QVBoxLayout(card)
+ card_layout.setSpacing(16)
+ card_layout.setContentsMargins(28, 28, 28, 28)
+ card.setStyleSheet(
+ "QFrame#gameProtonCard { "
+ " background: #23272e; "
+ " border-radius: 12px; "
+ " border: 2px solid #3fb7d6;"
+ "}"
+ )
+ card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
+
+ title_label = QLabel("Recommended Proton Version")
+ title_label.setAlignment(Qt.AlignCenter)
+ title_label.setStyleSheet(
+ "QLabel { font-size: 24px; font-weight: 700; color: #3fb7d6; margin-bottom: 4px; }"
+ )
+ card_layout.addWidget(title_label)
+
+ warning_label = QLabel(
+ f"The following Proton versions are recommended for {game_label}:"
+ )
+ warning_label.setAlignment(Qt.AlignCenter)
+ warning_label.setWordWrap(True)
+ warning_label.setStyleSheet(
+ "QLabel { font-size: 14px; color: #e0e0e0; line-height: 1.5; margin-bottom: 12px; padding: 8px; }"
+ )
+ card_layout.addWidget(warning_label)
+
+ recommended = warning.get("recommended", [])
+ recommended_html = "
".join(f"- {v}" for v in recommended)
+ details_text = (
+ "