Release v0.6.0

This commit is contained in:
Omni
2026-04-20 20:57:23 +01:00
parent 69fabb32e6
commit 2ff09a1448
144 changed files with 4841 additions and 1306 deletions

View File

@@ -70,7 +70,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
"manual_download_concurrent_limit": 2, # Shared GUI/CLI default for manual download browser tabs
"manual_download_watch_directory": None, # Optional override for manual-download watcher folder
"window_width": None, # Saved window width (None = use dynamic sizing)
"window_height": None # Saved window height (None = use dynamic sizing)
"window_height": None, # Saved window height (None = use dynamic sizing)
}
# Load configuration if exists

View File

@@ -521,11 +521,13 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
# Game-specific Documents directory names (for both Linux home and Wine prefix)
game_docs_dirs = {
"skyrimse": "Skyrim Special Edition",
"skyrimvr": "Skyrim VR",
"fallout4": "Fallout4",
"fallout4vr": "Fallout4VR",
"falloutnv": "FalloutNV",
"oblivion": "Oblivion",
"enderal": "Enderal Special Edition",
"enderalse": "Enderal Special Edition"
"enderalse": "Enderal Special Edition",
}
game_dirs = {
@@ -561,41 +563,193 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
os.makedirs(dir_path, exist_ok=True)
self.logger.debug(f"Created game-specific directory: {dir_path}")
# CRITICAL: Create game-specific Documents directories in Wine prefix
# CP2077 and BG3 use AppData/Local only (no My Games)
appdata_only_dirs = {
"cp2077": os.path.join("CD Projekt Red", "Cyberpunk 2077"),
"bg3": os.path.join("Larian Studios", "Baldur's Gate 3"),
}
# CRITICAL: Create game-specific directories in Wine prefix
# Required for USVFS to virtualize profile INIs on first launch
if game_name in game_docs_dirs:
docs_dir_name = game_docs_dirs[game_name]
# Find compatdata path for this AppID
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
compatdata_path = path_handler.find_compat_data(appid)
if compatdata_path:
# Create Documents/My Games/{GameName} in Wine prefix
wine_docs_path = os.path.join(
str(compatdata_path),
"pfx",
"drive_c",
"users",
"steamuser",
"Documents",
"My Games",
docs_dir_name
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
compatdata_path = path_handler.find_compat_data(appid)
if compatdata_path:
prefix_user = os.path.join(
str(compatdata_path), "pfx", "drive_c", "users", "steamuser"
)
if game_name in appdata_only_dirs:
appdata_path = os.path.join(
prefix_user, "AppData", "Local", appdata_only_dirs[game_name]
)
try:
os.makedirs(appdata_path, exist_ok=True)
self.logger.info(f"Created Wine prefix AppData/Local directory: {appdata_path}")
except Exception as e:
self.logger.warning(f"Could not create AppData/Local directory {appdata_path}: {e}")
elif game_name in game_docs_dirs:
docs_dir_name = game_docs_dirs[game_name]
wine_docs_path = os.path.join(
prefix_user, "Documents", "My Games", docs_dir_name
)
try:
os.makedirs(wine_docs_path, exist_ok=True)
self.logger.info(f"Created Wine prefix Documents directory for USVFS: {wine_docs_path}")
self.logger.debug(f"This allows USVFS to virtualize profile INI files on first launch")
self.logger.info(f"Created Wine prefix Documents directory: {wine_docs_path}")
except Exception as e:
self.logger.warning(f"Could not create Wine prefix Documents directory {wine_docs_path}: {e}")
# Don't fail completely - this is a first-launch optimization
else:
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix Documents directory creation")
self.logger.debug("Wine prefix Documents directories will be created when game runs for first time")
if game_name == "skyrimse":
self._seed_skyrim_first_launch_files(prefix_user, docs_dir_name)
elif game_name == "fallout4":
self._seed_fo4_first_launch_files(prefix_user, docs_dir_name)
elif game_name == "skyrimvr":
self._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
elif game_name == "fallout4vr":
self._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name)
else:
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix directory creation")
return True
except Exception as e:
self.logger.error(f"Error creating required directories: {e}")
return False
def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Skyrim SE/AE needs on first launch.
Two files must exist before first launch to avoid USVFS and engine issues:
1. AppData/Local/Skyrim Special Edition/Plugins.txt - empty anchor file.
USVFS builds its VFS tree at MO2 startup. If this path does not exist,
USVFS logs the directory as missing and skips adding Plugins.txt to the
initial tree. It then tries to reroute the file dynamically, but a mutex
deadlock (thread never releases the write mutex on first launch) blocks
the reroute. The game falls through to the real filesystem, finds no
Plugins.txt, and loads only base-game ESPs - causing a null form crash
for any SKSE plugin that expects modlist ESPs (e.g. BladeAndBlunt.dll).
On second launch the directory exists, USVFS initialises correctly, no crash.
Pre-seeding an empty file gives USVFS its anchor; content is irrelevant
because USVFS reroutes reads to the active MO2 profile's plugins.txt anyway.
2. Documents/My Games/Skyrim Special Edition/SkyrimPrefs.ini - minimal stub.
The CC/AE download prompt is triggered by bDownloadCC=0 (or absent) in
SkyrimPrefs.ini. This check fires before PrivateProfileRedirector (PPR)
hooks the Windows INI API, so the game reads the real prefix path directly,
not the MO2 profile version. A minimal stub with bDownloadCC=1 suppresses
the prompt. PPR redirects all subsequent reads to the active profile once
it loads, so this stub is never read again after early engine init.
Only created if the file does not already exist.
"""
# Fix 1: empty Plugins.txt anchor for USVFS
appdata_sse = os.path.join(prefix_user, "AppData", "Local", "Skyrim Special Edition")
plugins_txt = os.path.join(appdata_sse, "Plugins.txt")
try:
os.makedirs(appdata_sse, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
# Fix 2: minimal SkyrimPrefs.ini at real Documents path to suppress AE popup
skyrimprefs_path = os.path.join(
prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini"
)
try:
if not os.path.exists(skyrimprefs_path):
with open(skyrimprefs_path, 'w', encoding='utf-8') as f:
f.write("[General]\nbDownloadCC=1\n")
self.logger.info(f"Created SkyrimPrefs.ini stub to suppress AE popup: {skyrimprefs_path}")
else:
self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}")
except Exception as e:
self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}")
def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Fallout 4 needs on first launch.
1. AppData/Local/Fallout4/Plugins.txt - empty anchor file for USVFS.
Same mutex deadlock mechanism as Skyrim SE - confirmed to apply to FO4.
INI stub for CC popup suppression is intentionally omitted until the correct
key name in Fallout4Prefs.ini is confirmed via testing.
"""
appdata_fo4 = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
plugins_txt = os.path.join(appdata_fo4, "Plugins.txt")
try:
os.makedirs(appdata_fo4, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Skyrim VR needs on first launch.
1. AppData/Local/Skyrim VR/Plugins.txt - empty anchor file for USVFS.
Same mutex deadlock mechanism as Skyrim SE applies to VR.
2. Documents/My Games/Skyrim VR/SkyrimPrefs.ini - minimal stub with two keys:
- bDownloadCC=1: suppresses the AE/CC download prompt (same engine behaviour
as Skyrim SE; fires before PPR hooks the INI API).
- bLoadVRPlayroom=0: prevents the game loading the Bethesda VR playroom
tutorial on first launch. Without this, SkyrimVR skips the main menu and
drops the user into the playroom, bypassing the modlist's startup sequence.
"""
appdata_vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
plugins_txt = os.path.join(appdata_vr, "Plugins.txt")
try:
os.makedirs(appdata_vr, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
skyrimprefs_path = os.path.join(
prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini"
)
try:
if not os.path.exists(skyrimprefs_path):
with open(skyrimprefs_path, 'w', encoding='utf-8') as f:
f.write("[General]\nbDownloadCC=1\nbLoadVRPlayroom=0\n")
self.logger.info(f"Created SkyrimPrefs.ini stub for VR first-launch: {skyrimprefs_path}")
else:
self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}")
except Exception as e:
self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}")
def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Fallout 4 VR needs on first launch.
1. AppData/Local/Fallout4VR/Plugins.txt - empty anchor file for USVFS.
Same mutex deadlock mechanism as Skyrim SE and FO4 applies to VR.
INI stub is intentionally omitted - the correct key name in Fallout4VRPrefs.ini
has not been confirmed via testing.
"""
appdata_fo4vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
plugins_txt = os.path.join(appdata_fo4vr, "Plugins.txt")
try:
os.makedirs(appdata_fo4vr, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")

View File

@@ -64,7 +64,7 @@ class FilesystemSteamMixin:
default_path = Path.home() / ".steam/steam/steamapps/common"
if default_path.is_dir():
logger.warning(f"Using default Steam library path: {default_path}")
logger.info(f"Using default Steam library path: {default_path}")
return default_path
logger.error("No valid Steam library found via vdf or at default location.")

View File

@@ -18,7 +18,11 @@ class GameDetector:
'fallout3': ['Fallout 3'],
'oblivion': ['Oblivion'],
'starfield': ['Starfield'],
'oblivion_remastered': ['Oblivion Remastered']
'oblivion_remastered': ['Oblivion Remastered'],
'skyrimvr': ['Skyrim VR'],
'fallout4vr': ['Fallout 4 VR'],
'cp2077': ['Cyberpunk 2077'],
'bg3': ["Baldur's Gate 3"],
}
def detect_game_type(self, modlist_name: str) -> Optional[str]:
@@ -26,9 +30,17 @@ class GameDetector:
modlist_lower = modlist_name.lower()
# Check for game-specific keywords in modlist name
# Check for Oblivion Remastered first since "oblivion" is a substring
# Check more specific types before their generic parents
if any(keyword in modlist_lower for keyword in ['oblivion remastered', 'oblivionremastered', 'oblivion_remastered']):
return 'oblivion_remastered'
elif any(keyword in modlist_lower for keyword in ['skyrim vr', 'skyrimvr']):
return 'skyrimvr'
elif any(keyword in modlist_lower for keyword in ['fallout 4 vr', 'fallout4vr', 'fo4vr']):
return 'fallout4vr'
elif any(keyword in modlist_lower for keyword in ['cyberpunk', 'cp2077', 'cyberpunk 2077']):
return 'cp2077'
elif any(keyword in modlist_lower for keyword in ["baldur's gate 3", 'baldursgate3', 'bg3']):
return 'bg3'
elif any(keyword in modlist_lower for keyword in ['skyrim', 'sse', 'skse', 'dragonborn', 'dawnguard']):
return 'skyrim'
elif any(keyword in modlist_lower for keyword in ['fallout 4', 'fo4', 'f4se', 'commonwealth']):
@@ -134,9 +146,37 @@ class GameDetector:
'min_proton_version': '8.0',
'required_dlc': [],
'compatibility_tools': ['protontricks', 'winetricks']
}
},
'skyrimvr': {
'launcher': 'SKSE',
'min_proton_version': '6.0',
'required_dlc': [],
'compatibility_tools': ['protontricks', 'winetricks'],
'notes': 'SteamVR must be installed separately',
},
'fallout4vr': {
'launcher': 'F4SE',
'min_proton_version': '6.0',
'required_dlc': [],
'compatibility_tools': ['protontricks', 'winetricks'],
'notes': 'SteamVR must be installed separately',
},
'cp2077': {
'launcher': 'redmod',
'min_proton_version': '8.0',
'required_dlc': [],
'compatibility_tools': ['protontricks', 'winetricks'],
'notes': 'Requires WINEDLLOVERRIDES=version=n,b;winmm=n,b for Red4ext/CET. Rootbuilder must use COPY mode.',
},
'bg3': {
'launcher': 'bg3_dx11',
'min_proton_version': '8.0',
'required_dlc': [],
'compatibility_tools': ['protontricks', 'winetricks'],
'notes': 'Rootbuilder must use COPY mode.',
},
}
return requirements.get(game_type, {})
def detect_mods(self, modlist_path: Path) -> List[Dict]:

View File

@@ -2,7 +2,6 @@
from pathlib import Path
import os
import logging
import requests
import re
from typing import Optional
@@ -147,52 +146,14 @@ class ModlistConfigurationMixin:
print("───────────────────────────────────────────────────────────────────")
input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}")
self.logger.info("User confirmed completion of manual steps.")
# Step 3: Download and apply curated user.reg.modlist and system.reg.modlist
# Step 3: Apply targeted registry tweaks (replaces wholesale curated reg file overwrite)
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Applying curated registry files for modlist configuration")
self.logger.info("Step 3: Downloading and applying curated user.reg.modlist and system.reg.modlist...")
status_callback(f"{self._get_progress_timestamp()} Applying modlist registry configuration")
self.logger.info("Step 3: Applying modlist registry tweaks...")
try:
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
if not prefix_path_str or not os.path.isdir(prefix_path_str):
raise Exception("Could not determine Wine prefix path for this modlist. Please ensure you have launched the shortcut from Steam at least once.")
user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.modlist"
user_reg_dest = Path(prefix_path_str) / "user.reg"
response = requests.get(user_reg_url, verify=True)
response.raise_for_status()
with open(user_reg_dest, "wb") as f:
f.write(response.content)
self.logger.info(f"Curated user.reg.modlist downloaded and applied to {user_reg_dest}")
system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.modlist"
system_reg_dest = Path(prefix_path_str) / "system.reg"
response = requests.get(system_reg_url, verify=True)
response.raise_for_status()
with open(system_reg_dest, "wb") as f:
f.write(response.content)
self.logger.info(f"Curated system.reg.modlist downloaded and applied to {system_reg_dest}")
self._apply_modlist_registry_tweaks()
except Exception as e:
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist: {e}")
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist. {e}")
return False
self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.")
# The curated registry files overwrite the entire Wine registry, so any
# game-specific entries injected earlier must be re-applied immediately after.
special_game_type = self.detect_special_game_type(self.modlist_dir)
if special_game_type in ["fnv", "fo3", "enderal"]:
self.logger.info(
"Re-injecting %s game registry entries after curated registry overwrite",
special_game_type.upper(),
)
try:
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
AutomatedPrefixService()._inject_game_registry_entries(prefix_path_str, special_game_type)
except Exception as e:
self.logger.error(
"Failed to restore %s registry entries after curated registry overwrite: %s",
special_game_type.upper(),
e,
)
self.logger.error("Could not restore required game registry entries after applying curated registry files.")
return False
self.logger.warning("Modlist registry tweaks failed (non-fatal): %s", e)
# Step 4: Install Wine Components
if status_callback:
@@ -258,18 +219,12 @@ class ModlistConfigurationMixin:
status_callback(f"{self._get_progress_timestamp()} {failure_msg}")
# Continue but user should be aware of potential issues
# Step 4.6: Enable dotfiles visibility for Wine prefix
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Enabling dotfiles visibility")
self.logger.info("Step 4.6: Enabling dotfiles visibility in Wine prefix...")
# Step 4.6: Audit final registry state - confirms all writes survived winetricks
self.logger.info("Step 4.6: Auditing registry state...")
try:
if self.protontricks_handler.enable_dotfiles(self.appid):
self.logger.info("Dotfiles visibility enabled successfully")
else:
self.logger.warning("Failed to enable dotfiles visibility (non-critical, continuing)")
self._audit_registry_state()
except Exception as e:
self.logger.warning(f"Error enabling dotfiles visibility: {e} (non-critical, continuing)")
self.logger.info("Step 4.6: Enabling dotfiles visibility... Done")
self.logger.warning("Registry audit failed (non-fatal): %s", e)
# Step 4.7: Create Wine prefix Documents directories for USVFS
# Critical for USVFS profile INI virtualization on first launch
@@ -277,17 +232,40 @@ class ModlistConfigurationMixin:
status_callback(f"{self._get_progress_timestamp()} Creating Wine prefix Documents directories for USVFS")
self.logger.info("Step 4.7: Creating Wine prefix Documents directories for USVFS...")
try:
if self.appid and self.game_var:
# Map game_var to game_name for create_required_dirs
if self.appid:
# Map detected game type to the key expected by create_required_dirs
game_name_map = {
"skyrim": "skyrimse",
"skyrimspecialedition": "skyrimse",
"skyrimvr": "skyrimvr",
"fallout": "fallout4",
"fallout4": "fallout4",
"fo4": "fallout4",
"fallout4vr": "fallout4vr",
"fnv": "falloutnv",
"falloutnv": "falloutnv",
"oblivion": "oblivion",
"enderalspecialedition": "enderalse"
"enderal": "enderalse",
"enderalspecialedition": "enderalse",
"bg3": "bg3",
"baldursgate3": "bg3",
"cp2077": "cp2077",
"starfield": "starfield",
}
game_name = game_name_map.get(self.game_var.lower(), None)
game_name = game_name_map.get((self.game_var or '').lower(), None)
# Fallback: read gameName= directly from ModOrganizer.ini when loader-based
# detection returned Unknown (e.g. Enderal uses a non-SKSE launcher variant)
if not game_name and self.modlist_dir:
try:
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
_detected = detect_game_type_from_modlist(str(self.modlist_dir))
if _detected:
game_name = game_name_map.get(_detected, _detected)
self.logger.info(f"Step 4.7: game type resolved via gameName= fallback: {_detected} -> {game_name}")
except Exception as _fe:
self.logger.debug(f"Step 4.7 fallback detection failed: {_fe}")
if game_name:
appid_str = str(self.appid)
if self.filesystem_handler.create_required_dirs(game_name, appid_str):
@@ -295,13 +273,42 @@ class ModlistConfigurationMixin:
else:
self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)")
else:
self.logger.debug(f"Game {self.game_var} not in directory creation map, skipping")
self.logger.debug(f"Game {self.game_var!r} not in directory creation map, skipping")
else:
self.logger.warning("AppID or game_var not available, skipping Wine prefix Documents directory creation")
self.logger.warning("AppID not available, skipping Wine prefix Documents directory creation")
except Exception as e:
self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)")
self.logger.info("Step 4.7: Creating Wine prefix Documents directories... Done")
# Step 4.8: Configure nxmhandler.ini to suppress MO2 NXM registration popup
self.logger.info("Step 4.8: Configuring nxmhandler.ini...")
try:
self._configure_nxmhandler_ini()
except Exception as e:
self.logger.debug(f"nxmhandler.ini configuration failed (non-critical): {e}")
self.logger.info("Step 4.8: Configuring nxmhandler.ini... Done")
# Step 4.9: Inject game install path registry entries (FNV/FO3/Enderal/CP2077/BG3).
# Required so the game launcher and engine can locate the base game when
# MO2 is running inside the Proton prefix. Idempotent: safe to run on
# reinstall or re-configure.
self.logger.info("Step 4.9: Injecting game registry entries...")
try:
compatdata_path = self.path_handler.find_compat_data(str(self.appid))
if compatdata_path:
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.services.platform_detection_service import PlatformDetectionService
_svc = AutomatedPrefixService(SystemInfo(
is_steamdeck=PlatformDetectionService.get_instance().is_steamdeck
))
_svc._inject_game_registry_entries(str(compatdata_path), self.game_var or '')
else:
self.logger.debug("Compatdata path not found for game registry injection, skipping")
except Exception as e:
self.logger.warning("Game registry injection failed (non-fatal): %s", e)
self.logger.info("Step 4.9: Injecting game registry entries... Done")
# Step 5: Verify ownership of Modlist directory
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership")
@@ -328,6 +335,10 @@ class ModlistConfigurationMixin:
self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}")
self.logger.info("Step 6: Backing up ModOrganizer.ini... Done")
# Step 6.1: BG3-specific patches to ModOrganizer.ini and MO2 plugins
self._patch_bg3_mod_settings_plugin()
self._set_bg3_rootbuilder_copy_mode()
# Step 6.5: Handle symlinked downloads directory
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Checking for symlinked downloads directory")
@@ -419,6 +430,21 @@ class ModlistConfigurationMixin:
self.logger.info("Set download_directory in ModOrganizer.ini (Install flow)")
else:
self.logger.warning("Could not set download_directory in ModOrganizer.ini")
elif modlist_ini_path_obj.is_file():
# Configure Existing / Configure New flows: no explicit download_dir is set, but the
# INI may have duplicate or mangled entries from the original Wabbajack install.
# Read the first valid value, then re-write all occurrences to that value so MO2
# reads the correct path regardless of which occurrence it picks up last.
existing_linux = self.path_handler.get_download_directory_linux_path(modlist_ini_path_obj)
if existing_linux:
if self.path_handler.set_download_directory(
modlist_ini_path_obj, existing_linux, self.modlist_sdcard
):
self.logger.info("Normalised download_directory entries in ModOrganizer.ini")
else:
self.logger.warning("Could not normalise download_directory in ModOrganizer.ini")
else:
self.logger.debug("No existing download_directory value found in ModOrganizer.ini; skipping normalisation")
# Step 8.5: Align /home vs /var/home basis for Z: paths to match modlist install directory.
# This is intentionally separate from broad binary-path rewriting so it still runs when
@@ -584,6 +610,47 @@ class ModlistConfigurationMixin:
status_callback(f"{self._get_progress_timestamp()} Re-applying final Windows compatibility settings")
self._re_enforce_windows_10_mode()
# Step 15: Apply tool compatibility settings (xEdit, Pandora, DLL overrides).
# Only runs for standard Skyrim SE/AE modlists. Non-Skyrim games (Enderal, FNV,
# FO3, etc.) are excluded because the mscoree AppDefault targets SkyrimSE.exe,
# which is also Enderal's executable, causing a crash on those modlists.
_special_type = self.detect_special_game_type(self.modlist_dir)
try:
from jackify.backend.handlers.config_handler import ConfigHandler
if ConfigHandler().get('auto_tool_compat', True) and _special_type is None:
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Applying tool compatibility settings")
self.logger.info("Step 15: Applying tool compatibility settings...")
compatdata_path = str(wineprefix).replace("/pfx", "").rstrip("/")
wine_bin = self._find_wine_binary_for_registry()
if compatdata_path and wine_bin:
from jackify.backend.services.tool_config_service import apply_tool_config
apply_tool_config(
compatdata_path,
wine_bin,
log=lambda msg: status_callback(f"{self._get_progress_timestamp()} {msg}") if status_callback else None,
install_dotnet9_sdk=True,
install_fxc2_d3dcompiler=True,
)
self.logger.info("Step 15: Tool compatibility settings applied")
else:
self.logger.warning("Step 15: Could not resolve prefix path or wine binary - skipping tool compat")
elif _special_type is not None:
self.logger.info(f"Step 15: Skipping tool compat for {_special_type} modlist")
except Exception as e:
self.logger.warning("Step 15: Tool compatibility settings failed (non-fatal): %s", e)
# Step 16: Nemesis compatibility setup (symlink + workingDirectory fix)
try:
from jackify.backend.services.tool_config_service import setup_nemesis_compatibility
setup_nemesis_compatibility(
modlist_dir=self.modlist_dir,
stock_game_path=self.stock_game_path,
log=lambda msg: status_callback(f"{self._get_progress_timestamp()} {msg}") if status_callback else None,
)
except Exception as e:
self.logger.warning("Step 16: Nemesis setup failed (non-fatal): %s", e)
return True # Return True on success
def run_modlist_configuration_phase(self, context: dict = None) -> bool:
@@ -597,6 +664,48 @@ class ModlistConfigurationMixin:
status_callback = context.get('status_callback') if context else None
return self._execute_configuration_steps(status_callback=status_callback)
def _configure_nxmhandler_ini(self) -> None:
"""
Set noregister=true in nxmhandler.ini in the MO2 install directory.
MO2 reads this flag on startup and skips the NXM handler registration
popup when it is true. On Linux, MO2's NXM handler cannot be registered
usefully via Wine; Jackify will become its own NXM handler in a later cycle.
Safe to apply on every configuration run - always correct on Linux.
"""
if not self.modlist_dir:
return
nxm_ini_path = os.path.join(self.modlist_dir, "nxmhandler.ini")
try:
if os.path.exists(nxm_ini_path):
with open(nxm_ini_path, 'r', encoding='utf-8') as f:
content = f.read()
if re.search(r'(?im)^\s*noregister\s*=\s*true\s*$', content):
self.logger.debug("nxmhandler.ini noregister already true, skipping")
return
# Replace existing noregister=... line if present, otherwise inject after [General]
if re.search(r'(?im)^\s*noregister\s*=', content):
content = re.sub(r'(?im)^\s*noregister\s*=.*$', 'noregister=true', content)
elif re.search(r'(?im)^\s*\[General\]', content):
content = re.sub(r'(?im)(^\s*\[General\]\s*\n)', r'\1noregister=true\n', content)
else:
content += '\n[General]\nnoregister=true\n'
with open(nxm_ini_path, 'w', encoding='utf-8') as f:
f.write(content)
self.logger.info(f"Set noregister=true in {nxm_ini_path}")
else:
# MO2 creates nxmhandler.ini on first run; pre-create with the flag set
with open(nxm_ini_path, 'w', encoding='utf-8') as f:
f.write("[General]\nnoregister=true\n")
self.logger.info(f"Created nxmhandler.ini with noregister=true: {nxm_ini_path}")
except Exception as e:
self.logger.warning(f"Could not configure nxmhandler.ini: {e}")
def _prompt_or_set_resolution(self):
# If on Steam Deck, set 1280x800 automatically
if self._is_steam_deck():
@@ -617,3 +726,73 @@ class ModlistConfigurationMixin:
else:
self.selected_resolution = None
self.logger.info("Resolution setup skipped by user.")
def _patch_bg3_mod_settings_plugin(self) -> None:
"""
Fix a bug in the BG3 MO2 plugin (Alvadus/BG3-MO2-Unofficial-Plugin) where
mods_order_node is conditionally created but unconditionally referenced.
Bug present in upstream source as of 2026-03; author not yet notified.
Safe to apply: always creating the ModOrder node is valid BG3 XML regardless of mod count.
"""
import os
if not self.modlist_dir:
return
plugin_path = os.path.join(
str(self.modlist_dir),
"plugins", "basic_games", "games", "baldursgate3", "modSettings.py"
)
if not os.path.exists(plugin_path):
self.logger.debug("BG3 modSettings.py plugin not found, skipping patch")
return
try:
with open(plugin_path, 'r', encoding='utf-8') as f:
content = f.read()
buggy = (
" if len(mod_settings) > 1:\n"
" mods_order_node = ET.SubElement(children, \"node\")\n"
" mods_order_node.set(\"id\", \"ModOrder\")"
)
fixed = (
" mods_order_node = ET.SubElement(children, \"node\")\n"
" mods_order_node.set(\"id\", \"ModOrder\")"
)
if buggy in content:
content = content.replace(buggy, fixed)
with open(plugin_path, 'w', encoding='utf-8') as f:
f.write(content)
self.logger.info("Applied modSettings.py patch for BG3 MO2 plugin")
elif fixed in content:
self.logger.debug("BG3 modSettings.py already patched, skipping")
else:
self.logger.warning("BG3 modSettings.py patch target not found - plugin may have changed upstream")
except Exception as e:
self.logger.warning(f"Could not patch BG3 modSettings.py: {e} (non-critical, continuing)")
def _set_bg3_rootbuilder_copy_mode(self) -> None:
"""
Switch Root Builder to copy mode in ModOrganizer.ini for BG3 modlists.
Link mode (the shipped default) fails on Linux - files are not accessible
to the game process across the Wine boundary. Copy mode works reliably.
Applied unconditionally: copy mode is safe regardless of drive layout.
Detected by presence of RootBuilder keys rather than game_var (unreliable for BG3).
"""
import os, re
if not self.modlist_dir:
return
mo2_ini = os.path.join(str(self.modlist_dir), "ModOrganizer.ini")
if not os.path.exists(mo2_ini):
self.logger.debug("ModOrganizer.ini not found, skipping Root Builder copy mode patch")
return
try:
with open(mo2_ini, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
if 'RootBuilder\\' not in content and 'RootBuilder/' not in content:
self.logger.debug("Root Builder not present in ModOrganizer.ini, skipping")
return
content = re.sub(r'^(RootBuilder\\copyfiles\s*=).*$', r'\1**', content, flags=re.MULTILINE)
content = re.sub(r'^(RootBuilder\\linkfiles\s*=).*$', r'\1', content, flags=re.MULTILINE)
with open(mo2_ini, 'w', encoding='utf-8') as f:
f.write(content)
self.logger.info("Set Root Builder to copy mode in ModOrganizer.ini")
except Exception as e:
self.logger.warning(f"Could not set Root Builder copy mode: {e} (non-critical, continuing)")

View File

@@ -253,12 +253,13 @@ class ModlistDetectionMixin:
modlist_path = Path(self.modlist_dir)
common_names = [
"Stock Game",
"Game Root",
"StockGame",
"STOCK GAME",
"Stock Game Folder",
"Stock Folder",
"Skyrim Stock",
Path("root/Skyrim Special Edition")
Path("root/Skyrim Special Edition"),
"Game Root",
]
found_path = None
@@ -326,6 +327,15 @@ class ModlistDetectionMixin:
if mo2_ini.exists():
try:
content = mo2_ini.read_text(errors='ignore').lower()
# Extract gameName= for authoritative game type checks.
# Full-content scans can false-positive on plugin setting keys
# (e.g. enable_skyrimVR=false in a Skyrim SE ini).
game_name_value = ""
for _line in content.splitlines():
stripped_line = _line.strip()
if stripped_line.startswith("gamename="):
game_name_value = stripped_line[len("gamename="):]
break
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"
@@ -335,6 +345,18 @@ class ModlistDetectionMixin:
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"
if 'cyberpunk 2077' in content or 'cyberpunk2077' in content or 'cp2077' in content:
self.logger.info("Detected Cyberpunk 2077 via ModOrganizer.ini markers")
return "cp2077"
if "baldur's gate 3" in content or 'baldursgate3' in content or 'bg3' in content:
self.logger.info("Detected Baldur's Gate 3 via ModOrganizer.ini markers")
return "bg3"
if 'skyrim vr' in game_name_value or 'skyrimvr' in game_name_value:
self.logger.info("Detected SkyrimVR via ModOrganizer.ini gameName")
return "skyrimvr"
if 'fallout 4 vr' in game_name_value or 'fallout4vr' in game_name_value:
self.logger.info("Detected Fallout 4 VR via ModOrganizer.ini gameName")
return "fallout4vr"
except Exception as e:
self.logger.debug(f"Failed reading ModOrganizer.ini for detection: {e}")
except Exception:
@@ -364,6 +386,15 @@ class ModlistDetectionMixin:
if enderal_launcher.exists():
self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'")
return "enderal"
cp2077_exe = base / "Cyberpunk2077.exe"
if cp2077_exe.exists():
self.logger.info(f"Detected Cyberpunk 2077 modlist: found Cyberpunk2077.exe in '{base}'")
return "cp2077"
bg3_exe = base / "bg3.exe"
bg3_dx11_exe = base / "bg3_dx11.exe"
if bg3_exe.exists() or bg3_dx11_exe.exists():
self.logger.info(f"Detected BG3 modlist: found BG3 executable in '{base}'")
return "bg3"
# Final heuristic using game_var
try:
@@ -379,6 +410,18 @@ class ModlistDetectionMixin:
if 'enderal' in gt:
self.logger.info("Heuristic detection: game_var indicates Enderal")
return "enderal"
if 'cyberpunk' in gt or 'cp2077' in gt:
self.logger.info("Heuristic detection: game_var indicates Cyberpunk 2077")
return "cp2077"
if "baldur" in gt or 'bg3' in gt:
self.logger.info("Heuristic detection: game_var indicates BG3")
return "bg3"
if 'skyrim vr' in gt or 'skyrimvr' in gt:
self.logger.info("Heuristic detection: game_var indicates SkyrimVR")
return "skyrimvr"
if 'fallout 4 vr' in gt or 'fallout4vr' in gt:
self.logger.info("Heuristic detection: game_var indicates Fallout 4 VR")
return "fallout4vr"
except Exception:
pass

View File

@@ -61,32 +61,9 @@ class ModlistHandler(ModlistDetectionMixin, ModlistConfigurationMixin, ModlistWi
Handles operations related to modlist detection and configuration
"""
# Dictionary mapping modlist name patterns (lowercase, spaces optional)
# to lists of additional Wine components or special actions.
MODLIST_SPECIFIC_COMPONENTS = {
# Pattern: [component1, component2, ... or special_action_string]
"wildlander": ["dotnet48"], # Example from bash script
"licentia": ["dotnet8"], # Example from bash script (needs special handling)
"nolvus": ["dotnet6", "dotnet7"], # Example
# Add other modlists and their specific needs here
# e.g., "fallout4_anotherlife": ["some_component"]
}
# Canonical mapping of modlist-specific Wine components (from omni-guides.sh)
# dotnet4.x components disabled in v0.1.6.2 -- replaced with universal registry fixes
MODLIST_WINE_COMPONENTS = {
# "wildlander": ["dotnet472"], # DISABLED: Universal registry fixes replace dotnet472 installation
# "librum": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
"librum": ["dotnet8"], # dotnet40 replaced with universal registry fixes
# "apostasy": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
"apostasy": ["dotnet8"], # dotnet40 replaced with universal registry fixes
# "nordicsouls": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "livingskyrim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "lsiv": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "ls4": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "lorerim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "lostlegacy": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
}
MODLIST_SPECIFIC_COMPONENTS: dict = {}
MODLIST_WINE_COMPONENTS: dict = {}
def __init__(self, steam_path_or_config: Union[Dict, str, Path, None] = None,
mo2_path: Optional[Union[str, Path]] = None,

View File

@@ -159,14 +159,17 @@ class ModlistInstallCLIConfigurationMixin:
self.logger.info(f"Using machineid: {machineid}")
cmd += ['-o', install_dir_str, '-d', download_dir_str]
writeback_path = str(auth_service.get_token_writeback_path())
# Store original environment values to restore later
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'JACKIFY_TOKEN_WRITEBACK': os.environ.get('JACKIFY_TOKEN_WRITEBACK'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
# Temporarily modify current process's environment
# Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy)
if oauth_info:
@@ -341,7 +344,8 @@ class ModlistInstallCLIConfigurationMixin:
print()
proc.wait()
auth_service.apply_token_writeback(writeback_path)
finally:
# Stop performance monitoring and get summary
if monitoring_started:

View File

@@ -59,33 +59,11 @@ class ModlistWineOpsMixin:
self.logger.error("Could not locate Steam's config.vdf file.")
return False, 'config_vdf_missing'
# Add a short delay to allow Steam to potentially finish writing changes
self.logger.debug("Waiting 2 seconds before reading config.vdf...")
time.sleep(2)
try:
self.logger.debug(f"Attempting to load VDF file: {config_vdf_path}")
# CORRECTION: Use the vdf library directly here, not VDFHandler
self.logger.debug(f"Loading config.vdf: {config_vdf_path}")
with open(str(config_vdf_path), 'r') as f:
config_data = vdf.load(f, mapper=vdf.VDFDict)
config_data = vdf.load(f, mapper=vdf.VDFDict)
# --- Write full config.vdf to a debug file ---
debug_dump_path = os.path.expanduser("~/dev/Jackify/configvdf_dump.txt")
with open(debug_dump_path, "w") as dump_f:
json.dump(config_data, dump_f, indent=2)
self.logger.info(f"Full config.vdf dumped to {debug_dump_path}")
# --- Log only the relevant section for this AppID ---
steam_config_section = config_data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {})
compat_mapping = steam_config_section.get('CompatToolMapping', {})
app_mapping = compat_mapping.get(appid_to_check, {})
self.logger.debug("───────────────────────────────────────────────────────────────────")
self.logger.debug(f"Config.vdf entry for AppID {appid_to_check} (CompatToolMapping):")
self.logger.debug(json.dumps({appid_to_check: app_mapping}, indent=2))
self.logger.debug("───────────────────────────────────────────────────────────────────")
self.logger.debug(f"Steam config section from VDF: {json.dumps(steam_config_section, indent=2)}")
# --- End Debugging ---
# Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name
compat_mapping = steam_config_section.get('CompatToolMapping', {})
app_mapping = compat_mapping.get(appid_to_check, {})
@@ -152,14 +130,24 @@ class ModlistWineOpsMixin:
self.logger.info(f"Proton setup verification successful for AppID {appid_to_check}.")
return True, 'ok'
def set_steam_grid_images(self, appid: str, modlist_dir: str):
def set_steam_grid_images(self, appid: str, modlist_dir: str, game_type: str = None):
"""
Copies hero, logo, and poster images from the modlist's SteamIcons directory
to the grid directory of all non-zero Steam user directories, named after the new AppID.
Copies artwork from the modlist's SteamIcons directory to Steam's grid folder.
Falls back to SteamGridDB if no SteamIcons directory is present and an API key
is configured.
"""
if modlist_dir:
try:
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
detected_game_type = detect_game_type_from_modlist(modlist_dir)
if detected_game_type:
game_type = detected_game_type
except Exception as e:
self.logger.debug(f"Steam artwork game type auto-detect failed for {modlist_dir}: {e}")
steam_icons_dir = Path(modlist_dir) / "SteamIcons"
if not steam_icons_dir.is_dir():
self.logger.info(f"No SteamIcons directory found at {steam_icons_dir}, skipping grid image copy.")
self._try_steamgriddb_artwork(appid, game_type, modlist_dir)
return
# Find all non-zero Steam user directories
@@ -177,8 +165,8 @@ class ModlistWineOpsMixin:
images = [
("grid-hero.png", f"{appid}_hero.png"),
("grid-logo.png", f"{appid}_logo.png"),
("grid-tall.png", f"{appid}.png"),
("grid-tall.png", f"{appid}p.png"),
("grid-wide.png", f"{appid}.png"),
]
for src_name, dest_name in images:
@@ -191,7 +179,85 @@ class ModlistWineOpsMixin:
except Exception as e:
self.logger.error(f"Failed to copy {src_path} to {dest_path}: {e}")
else:
self.logger.warning(f"Image {src_path} not found; skipping.")
self.logger.debug(f"Image {src_path} not found; skipping.")
# Tenfoot: use explicit file if provided, otherwise resize the landscape grid
tenfoot_src = steam_icons_dir / "grid-tenfoot.png"
tenfoot_dest = grid_dir / f"{appid}_tenfoot.png"
wide_src = steam_icons_dir / "grid-wide.png"
if tenfoot_src.exists():
try:
shutil.copyfile(tenfoot_src, tenfoot_dest)
self.logger.info(f"Copied {tenfoot_src} to {tenfoot_dest}")
except Exception as e:
self.logger.error(f"Failed to copy tenfoot image: {e}")
elif wide_src.exists():
try:
from PySide6.QtGui import QImage
img = QImage(str(wide_src))
if not img.isNull():
scaled = img.scaled(600, 350)
scaled.save(str(tenfoot_dest))
self.logger.info(f"Generated tenfoot image from landscape: {tenfoot_dest}")
else:
self.logger.warning(f"Could not load landscape image for tenfoot generation: {wide_src}")
except Exception as e:
self.logger.warning(f"Could not generate tenfoot image: {e}")
def _try_steamgriddb_artwork(self, appid: str, game_type: str = None, modlist_dir: str = None):
"""Fetch default artwork from SteamGridDB when no modlist-provided SteamIcons exist."""
if not game_type and modlist_dir:
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
game_type = detect_game_type_from_modlist(modlist_dir)
if not game_type:
self.logger.warning(f"SteamGridDB fallback skipped: could not detect game type for {modlist_dir}")
return
userdata_base = Path.home() / ".steam/steam/userdata"
if not userdata_base.is_dir():
return
import tempfile
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
from jackify.backend.services.steamgriddb_service import fetch_artwork
count = fetch_artwork(game_type, tmp_dir)
if count == 0:
self.logger.debug(f"SteamGridDB returned no artwork for game type: {game_type}")
return
for user_dir in userdata_base.iterdir():
if not user_dir.is_dir() or user_dir.name == "0":
continue
grid_dir = user_dir / "config/grid"
grid_dir.mkdir(parents=True, exist_ok=True)
images = [
("grid-tall.png", f"{appid}p.png"),
("grid-wide.png", f"{appid}.png"),
("grid-hero.png", f"{appid}_hero.png"),
("grid-logo.png", f"{appid}_logo.png"),
]
for src_name, dest_name in images:
src = tmp_dir / src_name
if src.exists():
try:
shutil.copyfile(src, grid_dir / dest_name)
except Exception as e:
self.logger.warning(f"Failed to copy {src_name}: {e}")
# Generate tenfoot from landscape
wide = tmp_dir / "grid-wide.png"
if wide.exists():
try:
from PySide6.QtGui import QImage
img = QImage(str(wide))
if not img.isNull():
img.scaled(600, 350).save(str(grid_dir / f"{appid}_tenfoot.png"))
except Exception as e:
self.logger.debug(f"Could not generate tenfoot: {e}")
self.logger.info(f"Applied SteamGridDB artwork for game type '{game_type}' ({count} images)")
def get_modlist_wine_components(self, modlist_name, game_var_full=None):
"""
@@ -206,12 +272,16 @@ class ModlistWineOpsMixin:
game = (game_var_full or modlist_name or "").lower().replace(" ", "")
# Add game-specific extras
if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game:
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7"]
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
elif "falloutnewvegas" in game or "fnv" in game or "fallout3" in game or "fo3" in game or "oblivion" in game:
extras += ["d3dx9_43", "d3dx9"]
elif "cp2077" in game or "cyberpunk" in game:
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
elif "bg3" in game or "baldursgate" in game:
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
else:
# Unknown game type install the union of all known component sets
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "d3dx9_43", "d3dx9"]
# Unknown game type - install the union of all known component sets
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "d3dx9_43", "d3dx9"]
# Add modlist-specific extras
modlist_lower = modlist_name.lower().replace(" ", "") if modlist_name else ""
for key, components in self.MODLIST_WINE_COMPONENTS.items():
@@ -224,37 +294,49 @@ class ModlistWineOpsMixin:
def _re_enforce_windows_10_mode(self):
"""
Re-enforce Windows 10 mode after modlist-specific configurations.
This matches the legacy script behavior (line 1333) where Windows 10 mode
is re-applied after modlist-specific steps to ensure consistency.
Re-enforce the final Windows version after modlist-specific configurations.
Re-applies win10 after modlist-specific winetricks components, which can
leave the prefix at a lower version.
"""
try:
if not hasattr(self, 'appid') or not self.appid:
self.logger.warning("Cannot re-enforce Windows 10 mode - no AppID available")
self.logger.warning("Cannot re-enforce Windows 11 mode - no AppID available")
return
from ..handlers.winetricks_handler import WinetricksHandler
from ..handlers.path_handler import PathHandler
# Get prefix path for the AppID
prefix_path = PathHandler.find_compat_data(str(self.appid))
if not prefix_path:
self.logger.warning("Cannot re-enforce Windows 10 mode - prefix path not found")
# Get prefix path for the AppID - must be compatdata/pfx/, not compatdata/
compatdata_path = PathHandler.find_compat_data(str(self.appid))
if not compatdata_path:
self.logger.warning("Cannot re-enforce Windows 11 mode - prefix path not found")
return
prefix_path = compatdata_path / "pfx"
# Use winetricks handler to set Windows 10 mode
# Use winetricks handler to set Windows 11 mode
winetricks_handler = WinetricksHandler()
wine_binary = winetricks_handler._get_wine_binary_for_prefix(str(prefix_path))
if not wine_binary:
self.logger.warning("Cannot re-enforce Windows 10 mode - wine binary not found")
self.logger.warning("Cannot re-enforce Windows 11 mode - wine binary not found")
return
winetricks_handler._set_windows_10_mode(str(prefix_path), wine_binary)
self.logger.info("Windows 10 mode re-enforced after modlist-specific configurations")
env = os.environ.copy()
env['WINEPREFIX'] = str(prefix_path)
env['WINE'] = wine_binary
result = subprocess.run(
[winetricks_handler.winetricks_path, '-q', 'win10'],
env=env,
capture_output=True,
text=True,
timeout=300
)
if result.returncode == 0:
self.logger.info("Windows 11 mode re-enforced after modlist-specific configurations")
else:
self.logger.warning("Could not set Windows 11 mode: %s", result.stderr)
except Exception as e:
self.logger.warning(f"Error re-enforcing Windows 10 mode: {e}")
self.logger.warning(f"Error re-enforcing Windows 11 mode: {e}")
def _handle_symlinked_downloads(self) -> bool:
"""
@@ -380,21 +462,17 @@ class ModlistWineOpsMixin:
env['WINEPREFIX'] = prefix_path
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
# Shutdown any running wineserver processes to ensure clean slate
if wineserver_binary:
self.logger.debug("Shutting down wineserver before applying registry fixes...")
try:
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
self.logger.debug("Wineserver shutdown complete")
except Exception as e:
self.logger.warning(f"Wineserver shutdown failed (non-critical): {e}")
self._wait_for_wineserver(prefix_path)
# Registry fix 1: Set *mscoree=native DLL override (asterisk for full override)
# Use native .NET runtime instead of Wine's
self.logger.debug("Setting *mscoree=native DLL override...")
# Registry fix 1: Set *mscoree=native as a per-exe AppDefaults override for
# SkyrimSE.exe only. A global DllOverrides entry breaks .NET 9/10 bootstrap
# (Synthesis), because the override intercepts mscoree loading for ALL processes
# including the SDK host. Scoping it to SkyrimSE.exe isolates the fix to the
# game process without affecting Synthesis or any other .NET tool.
self.logger.debug("Setting *mscoree=native AppDefaults override for SkyrimSE.exe...")
cmd1 = [
wine_binary, 'reg', 'add',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides',
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
]
@@ -430,43 +508,12 @@ class ModlistWineOpsMixin:
except Exception as e:
self.logger.warning(f"Registry flush failed (non-critical): {e}")
# VERIFICATION: Confirm the registry entries persisted
self.logger.info("Verifying registry entries were applied and persisted...")
verification_passed = True
# Verify *mscoree=native
verify_cmd1 = [
wine_binary, 'reg', 'query',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', '*mscoree'
]
verify_result1 = subprocess.run(verify_cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result1.returncode == 0 and 'native' in verify_result1.stdout:
self.logger.info("VERIFIED: *mscoree=native is set correctly")
ok = result1.returncode == 0 and result2.returncode == 0
if ok:
self.logger.info("Universal dotnet4.x compatibility fixes applied and flushed")
else:
self.logger.error(f"VERIFICATION FAILED: *mscoree=native not found in registry. Query output: {verify_result1.stdout}")
verification_passed = False
# Verify OnlyUseLatestCLR=1
verify_cmd2 = [
wine_binary, 'reg', 'query',
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
'/v', 'OnlyUseLatestCLR'
]
verify_result2 = subprocess.run(verify_cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result2.returncode == 0 and ('0x1' in verify_result2.stdout or 'REG_DWORD' in verify_result2.stdout):
self.logger.info("VERIFIED: OnlyUseLatestCLR=1 is set correctly")
else:
self.logger.error(f"VERIFICATION FAILED: OnlyUseLatestCLR=1 not found in registry. Query output: {verify_result2.stdout}")
verification_passed = False
# Both fixes applied and verified
if result1.returncode == 0 and result2.returncode == 0 and verification_passed:
self.logger.info("Universal dotnet4.x compatibility fixes applied, flushed, and verified successfully")
return True
else:
self.logger.error("Registry fixes failed verification - fixes may not persist across prefix restarts")
return False
self.logger.error("One or more dotnet4.x registry commands failed - see errors above")
return ok
except Exception as e:
self.logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
@@ -506,6 +553,204 @@ class ModlistWineOpsMixin:
self.logger.error(f"Error finding Wine binary: {e}")
return None
def _wait_for_wineserver(self, prefix_path: str) -> None:
"""Wait for wineserver to stop for the given prefix before direct file edits.
Harmless if wineserver is already stopped - exits immediately.
Prevents in-memory hive flush from overwriting direct .reg file edits.
"""
wine_binary = self._find_wine_binary_for_registry()
if not wine_binary:
self.logger.debug("No wine binary found; skipping wineserver wait")
return
wineserver = os.path.join(os.path.dirname(wine_binary), "wineserver")
if not os.path.exists(wineserver):
self.logger.debug("wineserver binary not found; skipping wait")
return
env = os.environ.copy()
env["WINEPREFIX"] = prefix_path
env["WINEDEBUG"] = "-all"
try:
subprocess.run([wineserver, "-w"], env=env, timeout=30, capture_output=True)
self.logger.debug("wineserver stopped for prefix %s", prefix_path)
except Exception as e:
self.logger.debug("wineserver wait returned non-zero (likely already stopped): %s", e)
def _apply_modlist_registry_tweaks(self) -> bool:
"""Write user.reg values required for modlist operation.
- FontSmoothing/Type/Gamma/Orientation (ClearType subpixel rendering)
- HIGHDPIAWARE (prevents Wine DPI scaling on tools)
- ShowDotFiles=Y (MO2 must see hidden dirs inside the prefix)
"""
try:
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
user_reg = os.path.join(prefix_path, "user.reg")
if not os.path.exists(user_reg):
self.logger.warning("user.reg not found at %s; skipping modlist registry tweaks", user_reg)
return False
self._wait_for_wineserver(prefix_path)
tweaks = [
(
"[Control Panel\\\\Desktop]",
'"FontSmoothing"',
'"2"',
),
(
"[Control Panel\\\\Desktop]",
'"FontSmoothingGamma"',
"dword:00000578",
),
(
"[Control Panel\\\\Desktop]",
'"FontSmoothingOrientation"',
"dword:00000001",
),
(
"[Control Panel\\\\Desktop]",
'"FontSmoothingType"',
"dword:00000002",
),
(
"[Software\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\AppCompatFlags\\\\Layers]",
'@',
'"~ HIGHDPIAWARE"',
),
(
"[Software\\\\Wine]",
'"ShowDotFiles"',
'"Y"',
),
]
with open(user_reg, "r", encoding="utf-8", errors="ignore") as f:
lines = f.readlines()
for section, key, value in tweaks:
in_section = False
updated = False
insert_at = None
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.lower() == section.lower():
in_section = True
continue
if stripped.startswith("[") and in_section:
insert_at = i
break
if in_section and stripped.lower().startswith(key.lower()):
lines[i] = f"{key}={value}\n"
updated = True
break
if not updated:
entry = f"{key}={value}\n"
if insert_at is not None:
lines.insert(insert_at, entry)
elif in_section:
lines.append(entry)
else:
lines.append(f"\n{section}\n")
lines.append(entry)
with open(user_reg, "w", encoding="utf-8") as f:
f.writelines(lines)
self.logger.info("Modlist registry tweaks applied (font smoothing, HIGHDPIAWARE, ShowDotFiles)")
return True
except Exception as e:
self.logger.error("Failed to apply modlist registry tweaks: %s", e)
return False
def _audit_registry_state(self) -> bool:
"""Read user.reg and system.reg and log whether every expected value is present.
Returns True only when all checks pass. Logs a WARNING for each missing or
wrong value so the application log always carries a clear post-configuration
record of registry state.
"""
try:
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
user_reg = os.path.join(prefix_path, "user.reg")
system_reg = os.path.join(prefix_path, "system.reg")
def _read(path):
if not os.path.exists(path):
return ""
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
user_content = _read(user_reg)
system_content = _read(system_reg)
checks = [
# (description, file_content, expected_substring)
(
"ShowDotFiles=Y (user.reg)",
user_content,
'"ShowDotFiles"="Y"',
),
(
"FontSmoothing=2 (user.reg)",
user_content,
'"FontSmoothing"="2"',
),
(
"FontSmoothingType=2 (user.reg)",
user_content,
'"FontSmoothingType"=dword:00000002',
),
(
"FontSmoothingGamma (user.reg)",
user_content,
'"FontSmoothingGamma"=dword:00000578',
),
(
"FontSmoothingOrientation (user.reg)",
user_content,
'"FontSmoothingOrientation"=dword:00000001',
),
(
"HIGHDPIAWARE (user.reg)",
user_content,
'HIGHDPIAWARE',
),
(
"*mscoree=native (user.reg)",
user_content,
'"*mscoree"="native"',
),
(
"OnlyUseLatestCLR=1 (system.reg)",
system_content,
'"OnlyUseLatestCLR"=dword:00000001',
),
]
all_ok = True
for description, content, needle in checks:
if needle in content:
self.logger.info("Registry audit [OK] %s", description)
else:
self.logger.warning("Registry audit [MISSING] %s", description)
all_ok = False
if all_ok:
self.logger.info("Registry audit complete - all values confirmed present")
else:
self.logger.warning(
"Registry audit complete - one or more values missing; "
"see [MISSING] entries above"
)
return all_ok
except Exception as e:
self.logger.error("Registry audit failed with exception: %s", e)
return False
def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]:
"""
Recursively search for wine binary within a Proton directory.
@@ -543,4 +788,3 @@ class ModlistWineOpsMixin:
except Exception as e:
self.logger.debug(f"Error during recursive wine search in {proton_path}: {e}")
return None

View File

@@ -180,5 +180,6 @@ class PathHandlerGameMixin:
self.stock_game_path = found_path
return True
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.")
searched = [str(modlist_path / n) for n in preferred_order]
self.logger.info(f"No common Stock Game/Game Root directory found (searched: {searched}). Will assume vanilla game path is needed for some operations.")
return True

View File

@@ -534,7 +534,8 @@ class PathHandlerMO2Mixin:
def set_download_directory(self, modlist_ini_path: Path, download_dir_linux_path, modlist_sdcard: bool) -> bool:
"""
Set download_directory in ModOrganizer.ini to the correct Wine path (Z: or D: for SD card).
Use only when download dir is known (e.g. Install a Modlist flow). Configure New/Existing leave as-is.
Replaces ALL occurrences of the key throughout the file - MO2 reads the last one, and
duplicate [General] sections from Wabbajack installs are common.
"""
if not modlist_ini_path.is_file() or not download_dir_linux_path:
return False
@@ -553,35 +554,62 @@ class PathHandlerMO2Mixin:
formatted = PathHandlerMO2Mixin._format_workingdir_for_mo2(wine_path)
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
in_general = False
download_line_idx = -1
for i, line in enumerate(lines):
if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE):
in_general = True
continue
if in_general and re.match(r'^\s*\[', line):
break
if in_general and re.match(r'^\s*download_directory\s*=', line, re.IGNORECASE):
download_line_idx = i
break
new_line = f"download_directory = {formatted}\n"
if download_line_idx >= 0:
lines[download_line_idx] = new_line
replaced = [i for i, l in enumerate(lines) if re.match(r'^\s*download_directory\s*=', l, re.IGNORECASE)]
if replaced:
for i in replaced:
lines[i] = new_line
else:
if in_general:
insert_idx = next((i for i, l in enumerate(lines) if re.match(r'^\s*\[General\]', l, re.I)), -1)
if insert_idx >= 0:
# No existing entry - insert after [General]
insert_idx = next((i for i, l in enumerate(lines) if re.match(r'^\s*\[General\]', l, re.I)), -1)
if insert_idx >= 0:
insert_idx += 1
while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]):
insert_idx += 1
while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]):
insert_idx += 1
lines.insert(insert_idx, new_line)
lines.insert(insert_idx, new_line)
else:
lines.append("[General]\n")
lines.append(new_line)
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(f"Set download_directory in ModOrganizer.ini to {formatted}")
logger.info(f"Set download_directory in ModOrganizer.ini to {formatted} ({len(replaced)} occurrence(s))")
return True
except Exception as e:
logger.error(f"Error setting download_directory in {modlist_ini_path}: {e}")
return False
def get_download_directory_linux_path(self, modlist_ini_path: Path) -> Optional[str]:
"""
Read the first valid download_directory value from ModOrganizer.ini and convert to a Linux path.
Returns None if no valid Z: or D: path is found.
"""
if not modlist_ini_path.is_file():
return None
try:
with open(modlist_ini_path, 'r', encoding='utf-8-sig') as f:
lines = f.readlines()
except UnicodeDecodeError:
try:
with open(modlist_ini_path, 'r', encoding='latin-1') as f:
lines = f.readlines()
except Exception:
return None
except Exception:
return None
for line in lines:
m = re.match(r'^\s*download_directory\s*=\s*(.+)$', line, re.IGNORECASE)
if not m:
continue
raw = m.group(1).strip()
# Expect Z:\\path\\... or D:\\path\\... (MO2 doubles backslashes in the file)
drive_m = re.match(r'^([ZzDd]):(.+)$', raw)
if not drive_m:
continue
drive, rest = drive_m.group(1).upper(), drive_m.group(2)
# Collapse doubled backslashes back to single separators
rest = re.sub(r'\\\\', '/', rest).replace('\\', '/')
if drive == 'Z':
return '/' + rest.lstrip('/')
# D: (SD card) - return as-is with leading slash; caller handles sdcard prefix
return '/' + rest.lstrip('/')
return None

View File

@@ -90,7 +90,7 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
# Alternative format: "[timestamp] StatusText (current/total) - speed [- Xunit remaining]"
# Example: "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s"
# Example (engine 0.4.8+): "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s - 23.1GB remaining"
# Timestamp prefix is now optional engine no longer emits [HH:MM:SS].
# Timestamp prefix is now optional - engine no longer emits [HH:MM:SS].
self.timestamp_status_pattern = re.compile(
r'(?:\[[^\]]+\]\s+)?(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)(?:\s*-\s*([\d.]+)\s*(B|KB|MB|GB|TB)\s+remaining)?',
re.IGNORECASE
@@ -157,10 +157,17 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
ParsedLine with extracted information
"""
result = ParsedLine(message=line.strip())
if not line.strip():
return result
# Suppress internal engine lines that are not user-facing
_suppress_prefixes = (
"Refreshing OAuth Token",
)
if any(line.strip().startswith(p) for p in _suppress_prefixes):
return ParsedLine()
# Try to extract phase information
phase_info = self._extract_phase(line)
if phase_info:

View File

@@ -20,11 +20,11 @@ class ProgressParserPhaseMixin:
phase = self._map_section_to_phase(section_name)
return (phase, section_match.group(1).strip())
# [FILE_PROGRESS] lines drive file activity only skip phase extraction for them
# [FILE_PROGRESS] lines drive file activity only - skip phase extraction for them
if '[FILE_PROGRESS]' in line:
return None
# Make the [timestamp] prefix optional engine no longer emits it.
# Make the [timestamp] prefix optional - engine no longer emits it.
action_match = re.search(
r'(?:\[.*?\]\s*)?(Installing|Downloading|Extracting|Validating|Processing|Checking existing)',
line,

View File

@@ -87,7 +87,7 @@ class ProtontricksCommandsMixin:
env['WINETRICKS'] = str(winetricks_path)
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
else:
self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks")
self.logger.info("Bundled winetricks not found - native protontricks will use system winetricks")
cabextract_path = self._get_bundled_cabextract_path()
if cabextract_path:
cabextract_dir = str(cabextract_path.parent)
@@ -95,7 +95,7 @@ class ProtontricksCommandsMixin:
env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir
self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
else:
self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract")
self.logger.info("Bundled cabextract not found - native protontricks will use system cabextract")
else:
self.logger.debug(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")

View File

@@ -74,7 +74,7 @@ class ProtontricksPrefixMixin:
self.logger.debug("ShowDotFiles already present in correct format in user.reg")
dotfiles_set_success = True
else:
self.logger.warning(f"user.reg not found at {user_reg_path}, creating it.")
self.logger.info(f"user.reg not found at {user_reg_path}, creating it.")
with open(user_reg_path, 'w', encoding='utf-8') as f:
f.write('[Software\\\\Wine] 1603891765\n')
f.write('"ShowDotFiles"="Y"\n')
@@ -157,6 +157,10 @@ class ProtontricksPrefixMixin:
self.logger.info("=" * 80)
env = self._get_clean_subprocess_env()
env["WINEDEBUG"] = "-all"
# Preserve the desktop display variables for Step 4. The validated fix
# for the blank taskbar popup regression was keeping DISPLAY available.
# Do not strip extra desktop activation vars here without a reproduced,
# evidence-backed need.
if self.which_protontricks == 'native':
winetricks_path = self._get_bundled_winetricks_path()
@@ -164,7 +168,7 @@ class ProtontricksPrefixMixin:
env['WINETRICKS'] = str(winetricks_path)
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
else:
self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks")
self.logger.info("Bundled winetricks not found - native protontricks will use system winetricks")
cabextract_path = self._get_bundled_cabextract_path()
if cabextract_path:
cabextract_dir = str(cabextract_path.parent)
@@ -172,7 +176,7 @@ class ProtontricksPrefixMixin:
env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir
self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
else:
self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract")
self.logger.info("Bundled cabextract not found - native protontricks will use system cabextract")
else:
self.logger.info(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")

View File

@@ -81,7 +81,7 @@ class ProtontricksSteamMixin:
self.logger.warning(f"Failed to set permission for Steam library folder {lib_path}: {e}")
if steamdeck:
self.logger.warning("Checking for SDCard and setting permissions appropriately...")
self.logger.info("Checking for SDCard and setting permissions appropriately...")
result = subprocess.run(["df", "-h"], capture_output=True, text=True, env=env)
for line in result.stdout.splitlines():
if "/run/media" in line:

View File

@@ -104,12 +104,16 @@ class ShortcutCreationMixin:
except Exception as e:
self.logger.error(f"Error determining STEAM_COMPAT_MOUNTS: {e}", exc_info=True)
dotnet_vars = 'DOTNET_ROOT="" DOTNET_MULTILEVEL_LOOKUP=0'
final_launch_options = launch_options
if compat_mounts_str:
if final_launch_options:
final_launch_options = f"{compat_mounts_str} {final_launch_options}"
else:
final_launch_options = compat_mounts_str
env_prefix_parts = [p for p in [compat_mounts_str, dotnet_vars] if p]
if env_prefix_parts:
prefix = " ".join(env_prefix_parts)
if final_launch_options:
final_launch_options = f"{prefix} {final_launch_options}"
else:
final_launch_options = prefix
if not final_launch_options.strip().endswith("%command%"):
if final_launch_options:
@@ -138,7 +142,6 @@ class ShortcutCreationMixin:
except Exception as e:
self.logger.error(f"Error creating shortcut: {e}", exc_info=True)
print(f"An error occurred while creating the shortcut: {e}")
return False, None
def _is_steam_deck(self):

View File

@@ -165,7 +165,7 @@ class ShortcutDiscoveryMixin:
self.logger.info(f"Found AppID {appid} for shortcut '{name}' (no exe validation)")
return str(int(appid) & 0xFFFFFFFF)
self.logger.warning(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'")
self.logger.debug(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'")
return None
except Exception as e:

View File

@@ -300,7 +300,6 @@ class ShortcutVDFManagementMixin:
try:
shutil.copy2(safe_backup, shortcuts_file)
self.logger.info(f"Restored shortcuts.vdf from pre-restart backup")
print("Restored shortcuts file after Steam restart")
return
except Exception as e:
self.logger.error(f"Failed to restore from pre-restart backup: {e}")
@@ -310,9 +309,8 @@ class ShortcutVDFManagementMixin:
try:
shutil.copy2(backup, shortcuts_file)
self.logger.info(f"Restored shortcuts.vdf from regular backup")
print("Restored shortcuts file after Steam restart")
except Exception as e:
self.logger.error(f"Failed to restore from backup: {e}")
print("Failed to restore shortcuts file. You may need to recreate your shortcut.")
self.logger.warning("shortcuts.vdf restore failed - shortcut may need to be recreated")
else:
self.logger.info(f"shortcuts.vdf verified intact after restart")

View File

@@ -266,7 +266,7 @@ class ProcessManager:
pass
cleanup_attempts += 1
finally:
# Always close pipes unblocks threads blocked on read(1) or iterating stderr
# Always close pipes - unblocks threads blocked on read(1) or iterating stderr
if self.proc:
for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr):
if pipe:

View File

@@ -279,7 +279,7 @@ class TTWInstallerBackendMixin:
mod_folder_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}" if ttw_version else "[NoDelete] Tale of Two Wastelands"
target_mod_dir = mods_dir / mod_folder_name
if skip_copy:
# TTW was installed directly to target_mod_dir no copy needed
# TTW was installed directly to target_mod_dir - no copy needed
logger.info("TTW already at target location, skipping copy: %s", target_mod_dir)
else:
logger.info("Copying TTW output to %s", target_mod_dir)

View File

@@ -26,18 +26,21 @@ class WabbajackParser:
'Fallout4': 'fallout4',
'FalloutNewVegas': 'falloutnv',
'Oblivion': 'oblivion',
'Skyrim': 'skyrim', # Legacy Skyrim
'Fallout3': 'fallout3', # For completeness
'SkyrimVR': 'skyrim', # Treat as Skyrim
'Fallout4VR': 'fallout4', # Treat as Fallout 4
'Enderal': 'enderal', # Enderal: Forgotten Stories
'EnderalSpecialEdition': 'enderal', # Enderal SE
'Skyrim': 'skyrim',
'Fallout3': 'fallout3',
'SkyrimVR': 'skyrimvr',
'Fallout4VR': 'fallout4vr',
'Enderal': 'enderal',
'EnderalSpecialEdition': 'enderal',
'Cyberpunk2077': 'cp2077',
'BaldursGate3': 'bg3',
}
# List of supported games in Jackify
self.supported_games = [
'skyrim', 'fallout4', 'falloutnv', 'fallout3', 'oblivion',
'starfield', 'oblivion_remastered', 'enderal'
'starfield', 'oblivion_remastered', 'enderal',
'skyrimvr', 'fallout4vr', 'bg3',
]
def parse_wabbajack_game_type(self, wabbajack_path: Path) -> Optional[tuple]:
@@ -98,6 +101,23 @@ class WabbajackParser:
self.logger.error(f"Error parsing .wabbajack file {wabbajack_path}: {e}")
return None
def parse_wabbajack_readme(self, wabbajack_path: Path) -> Optional[str]:
"""
Extract the readme URL from a .wabbajack file.
Returns the URL string, or None if not present or unreadable.
"""
try:
with zipfile.ZipFile(wabbajack_path, 'r') as zip_file:
modlist_files = [f for f in zip_file.namelist() if f in ['modlist', 'modlist.json']]
if not modlist_files:
return None
with zip_file.open(modlist_files[0]) as f:
data = json.load(f)
return data.get('Readme') or None
except Exception:
return None
def is_supported_game(self, game_type: str) -> bool:
"""
Check if a game type is supported by Jackify's post-install configuration.
@@ -128,12 +148,16 @@ class WabbajackParser:
"""
display_names = {
'skyrim': 'Skyrim Special Edition',
'fallout4': 'Fallout 4',
'fallout4': 'Fallout 4',
'falloutnv': 'Fallout New Vegas',
'oblivion': 'Oblivion',
'starfield': 'Starfield',
'oblivion_remastered': 'Oblivion Remastered',
'enderal': 'Enderal'
'enderal': 'Enderal',
'skyrimvr': 'Skyrim VR',
'fallout4vr': 'Fallout 4 VR',
'cp2077': 'Cyberpunk 2077',
'bg3': "Baldur's Gate 3",
}
return [display_names.get(game, game) for game in self.supported_games]

View File

@@ -6,7 +6,6 @@ Extracted from wine_utils for file-size and domain separation.
"""
import os
import re
import subprocess
import logging
from typing import Optional
@@ -56,39 +55,6 @@ class WineUtilsConfigMixin:
logger.error(f"Error performing additional tasks: {e}")
return False
@staticmethod
def modlist_specific_steps(modlist: str, appid: str) -> bool:
"""Perform modlist-specific configuration steps. Returns True on success."""
try:
modlist_configs = {
"wildlander": ["dotnet48", "dotnet472", "vcrun2019"],
"septimus|sigernacollection|licentia|aldrnari|phoenix": ["dotnet48", "dotnet472"],
"masterstroke": ["dotnet48", "dotnet472"],
"diablo": ["dotnet48", "dotnet472"],
"living_skyrim": ["dotnet48", "dotnet472", "dotnet462"],
"nolvus": ["dotnet8"]
}
modlist_lower = modlist.lower().replace(" ", "")
if "wildlander" in modlist_lower:
logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!")
return True
for pattern, components in modlist_configs.items():
if re.search(pattern.replace("|", "|.*"), modlist_lower):
logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!")
for component in components:
if component == "dotnet8":
logger.info("Downloading .NET 8 Runtime")
pass
else:
logger.info(f"Installing {component}...")
pass
return True
logger.debug(f"No specific steps needed for {modlist}")
return True
except Exception as e:
logger.error(f"Error performing modlist-specific steps: {e}")
return False
@staticmethod
def fnv_launch_options(game_var: str, compat_data_path: Optional[str], modlist: str) -> bool:
"""Set up Fallout New Vegas launch options. Returns True on success."""

View File

@@ -136,7 +136,7 @@ class WineUtilsProtonMixin:
if fallback_path != 'auto':
fallback_wine_bin = Path(fallback_path) / "files/bin/wine"
if fallback_wine_bin.is_file():
logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
logger.info(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
return str(fallback_wine_bin)
except Exception:
pass

View File

@@ -36,7 +36,6 @@ def _get_clean_winetricks_base_env() -> dict:
env["PATH"] = path or "/usr/bin:/bin"
return env
class WinetricksEnvMixin:
"""Mixin providing env build and dependency check for WinetricksHandler.install_wine_components."""
@@ -54,10 +53,11 @@ class WinetricksEnvMixin:
env['WINEDEBUG'] = '-all'
env['WINEPREFIX'] = wineprefix
env['WINETRICKS_GUI'] = 'none'
if 'DISPLAY' in env:
env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d'
else:
env['DISPLAY'] = env.get('DISPLAY', '')
env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d'
# Preserve the desktop display variables for Step 4. The validated fix
# for the blank taskbar popup regression was keeping DISPLAY available.
# Do not strip extra desktop activation vars here without a reproduced,
# evidence-backed need.
try:
from ..handlers.config_handler import ConfigHandler
@@ -243,7 +243,10 @@ class WinetricksEnvMixin:
if not found:
missing_deps.append(dep_name)
if dep_name in bundled_tools_list:
self.logger.warning(f" {dep_name}: NOT FOUND (neither bundled nor system)")
if dep_name == 'aria2c':
self.logger.debug(f" {dep_name}: NOT FOUND (optional - curl/wget used if available)")
else:
self.logger.warning(f" {dep_name}: NOT FOUND (neither bundled nor system)")
else:
self.logger.warning(f" {dep_name}: NOT FOUND (system only - not bundled)")