mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 02:07:44 +02:00
Release v0.6.0
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user