12 Commits
v0.4.0 ... main

Author SHA1 Message Date
Omni
33b3fbaed2 Release v0.6.0.1 - Hotfix 2026-04-24 19:59:36 +01:00
Omni
2ff09a1448 Release v0.6.0 2026-04-20 20:57:23 +01:00
Omni-guides
69fabb32e6 Update README.md 2026-04-15 20:46:41 +01:00
Omni-guides
6453665620 Update README.md 2026-04-15 16:42:11 +01:00
Omni-guides
cacbbf1fb1 Update README.md 2026-04-15 16:41:47 +01:00
Omni
c3551cd269 Sync from development - prepare for v0.5.0.4 2026-03-29 15:46:37 +01:00
Omni
8e4dd06f11 Sync from development - prepare for v0.5.0.3 2026-03-23 13:46:27 +00:00
Omni
e52e1427f6 Sync from development - prepare for v0.5.0.2 2026-03-15 11:03:28 +00:00
Omni
c294431a35 Sync from development - prepare for v0.5.0.1 2026-03-13 23:04:46 +00:00
Omni
7278efd4cd Remove stale non-premium future plan note 2026-03-13 16:40:25 +00:00
Omni
b29568f590 Clarify non-premium support in README 2026-03-13 16:35:29 +00:00
Omni
3556914560 Sync from development - prepare for v0.5.0 2026-03-13 14:43:25 +00:00
421 changed files with 12255 additions and 2983 deletions

View File

@@ -1,5 +1,130 @@
# Jackify Changelog
## v0.6.0.1 - Hotfix
**Release Date:** 24/04/26
- Resolved some issues with the integration of jackify-engine 0.5.4.
---
## v0.6 - Game Support Expansion, Modding Tool Support, Post-Install Quality
**Release Date:** 20/04/26
### New Game Support
- Additional Game Support - Post-Install automation for BG3, Skyrim VR, and Fallout 4 VR.
- Skyrim VR / Fallout 4 VR: if your modlist needs additional steps you know of, that Jackify does not yet handle, please open an issue on GitHub with your modlist name and the additional steps required. I cannot testing FO4VR directly as I dont own the game.
### Modding Tool Support
- Initial compatibility settings for xEdit, Synthesis, and Pandora are applied automatically during install and configure. Re-apply any time via "Configure Tool Compatibility" in Additional Tasks.
### Steam Shortcut Graphics
- Steam grid artwork now automatically applied to each shortcut, populating all five slots correctly (portrait, landscape, hero, logo, tenfoot).
### First-Launch Reliability
- First Launch Fixes - Skyrim SE modlists should now launch cleanly first time. No more first-launch crash, incorrect AE/CC popup display, initial NXM prompt in MO2, character creation issues, and wrong initial save location.
### Fixes
- Configuration no longer wipes game install paths. Registry writes are now targeted rather than full-prefix replacements.
- Fixed crashes on shutdown caused by force-killing background threads.
### Logging
- Console output reduced to errors only. All informational output goes to the log file and Show Details panel.
### Engine (0.5.4)
- Fixed Nexus sessions silently expiring after installs longer than ~1 hour. The engine now persists refreshed OAuth tokens so you stay logged in across long installs.
- Fixed large downloads hanging indefinitely if a Nexus CDN connection stalled mid-transfer. Downloads now recover automatically and resume from where they left off.
- Removed the disk space pre-flight check, which was incorrectly blocking installs for users with sufficient space. Out-of-disk conditions are still caught and reported if they actually occur.
---
## v0.5.0.4 - Hotfix
**Release Date:** 29/03/26
- Fixed self-update failing silently due to the downloaded archive overwriting the extraction target before the update helper could apply it.
- Engine updated to 0.5.3. NAME_MAX pre-flight check removed — was incorrectly blocking installs on standard filesystems. eCryptFS/fscrypt users still receive an error at the point of failure.
- Fixed Google Drive downloads failing. The Wabbajack CDN proxy was returning a cached broken response for some files; the engine now detects the hash mismatch, retries direct, and constructs a `drive.usercontent.google.com` URL with `confirm=t` to bypass the virus-scan warning page.
- Fixed focus stealing from other windows during the Wine component install phase.
- Fixed a crash on window close from a leaked focus-reclaim timer.
- Baloo file indexer suspended during install and config phases on KDE. No-op elsewhere.
- Fixed Flatpak protontricks install failing on fresh Steam Decks due to Flathub not being registered at user scope.
## v0.5.0.3 - Hotfix
**Release Date:** 23/03/26
- Engine updated to 0.5.2.
- Fixed manual downloads getting stuck on "Browser Opened" when the expected filename has a leading numeric prefix (e.g. `1_filename.zip`) that is absent from the browser-saved file. Both the live download watcher and the startup precheck scan now handle this correctly.
- Fixed "Continue Anyway" on the disk space warning having no effect. The flag was missing from the CLI argument parser, and a separate engine-level registration bug caused it to be rejected regardless. Both are now resolved. The dialog also correctly displays separate download and install space requirements and notes when both paths share the same drive.
- Fixed FNV, FO3, and Enderal modlists losing their game registry paths after configuration. The curated registry files applied during the configuration phase overwrite the Wine prefix registry entirely, wiping the game install paths injected earlier. Jackify now re-injects the correct paths immediately after the curated files are applied.
- Improved detection and guidance for modlists that require the Skyrim Special Edition Creation Kit. If the engine reports missing Creation Kit files, Jackify now surfaces step-by-step instructions for installing and first-launching the Creation Kit via Steam so the required files are in place before retrying.
- Filesystem filename length limit (NAME_MAX) no longer hard-blocks installation on standard filesystems. The check previously triggered incorrectly on ext4/btrfs/XFS. For users on encrypted home directories where the limit is genuinely reduced, Jackify now shows a warning dialog listing the affected files with a "Continue Anyway" option.
- Archive index errors now produce an actionable failure message identifying the specific archive to delete and re-download, rather than a bare engine exception.
- TTW installer temporary working files are now cleaned up after each TTW installation run. These files were previously never removed and could accumulate several GB per install attempt.
- Each GitHub release now includes a `SHA256SUMS` file for verifying your download. See the README for instructions.
## v0.5.0.2 - Hotfix
**Release Date:** 15/03/26
- Disk space warning at install start is no longer a hard block. If the pre-flight check fires before any download or install progress has started, Jackify now shows a warning dialog with the required and available space, a note that modlist updates typically need far less space than a fresh install, and a "Continue Anyway" option. Cancelling still aborts normally.
- Engine: fixed a false-positive in the pre-flight filename length check that could incorrectly trigger on modlist paths using backslash separators.
- Engine: temp folder cleanup at the end of install no longer crashes an otherwise successful installation if a BSA or temp directory is still locked.
## v0.5.0.1 - Hotfix
**Release Date:** 13/03/26
- Fixed Proton prefix creation failing for users who previously had Flatpak Steam installed but have since switched to native Steam.
- Fixed Configure Existing Modlist mangling binary and working directory paths for modlists using a `StockGame` folder (no space variant).
## v0.5.0 - Non-Premium Support, Modlist Update Handling and Overall Reliability Improvements
**Release Date:** 13/03/26
### New in v0.5.0
- Full non-premium install support in both GUI and CLI. Feedback is welcome on this new feature, both positive and negative
- New Jackify Download Manager for Non-Premium accounts, or files Jackify cannot auto-download.
- Improved modlist update handling so existing installs are detected more reliably and Jackify can reuse the existing setup instead of creating duplicate Steam shortcuts.
- Improved Viva New Vegas automation across GUI and CLI paths.
- Improved Wabbajack and Mod Organizer 2 standalone installation workflows.
- Better guidance when Skyrim AE/CC content is missing.
- Further improvements on user-facing logging and error handling
### Manual Download Improvements
- Handles manual downloads more smoothly from start to finish:
- opens required links for you in your system Browser
- watches your download folder
- verifies files and moves them to the correct location automatically,continues with the rest of the modlist install when ready
- Better controls in both GUI and CLI:
- pause/resume download flows, or defer individual archives (useful if one is temporarily unavailable)
- retry deferred items
- reopen file links
- change concurrent browser tab count
- change watch folder
- Deferred items (e.g temporarily unavailable) are retried correctly on later retry/recheck passes.
### Update and Install Reliability
- Worked to improve feature parity between the GUI and CLI frontends, tidying up a few edge cases where CLI behavior did not yet match GUI workflows closely enough.
- Improved update messaging (clearer wording on success/failure).
- Better cancellation handling so stopping a workflow is less likely to leave background processes running.
- Better focus recovery after Steam restart in key workflows.
- Better handling when both Flatpak and native Steam are installed: Jackify now prefers the Steam install that actually contains your installed games, with safe fallback rules if both look valid.
- Install Proton selection now self-heals on startup if the configured Proton was removed, automatically falling back to the best available installed Proton.
- For `/var/home`-based installs (for example Bazzite layouts), ModOrganizer.ini path basis is now aligned so executable/working/game paths resolve correctly.
### Nexus Authentication
- OAuth protocol handler desktop file is now updated if the registered AppImage path no longer matches the current location, preventing silent callback failures after the AppImage is moved or renamed.
- OAuth waiting dialog now includes a "Paste callback URL" button for manual fallback if the browser does not dispatch the jackify:// callback automatically.
### Logging and Error Quality
- Better targeted guidance when required prerequisites or content are missing.
- Improved logging around updater source selection (Nexus/GitHub fallback behavior).
- Better error context while keeping sensitive tokens/keys redacted.
- Install failure fallback now surfaces recent actionable engine output (and resource-limit warnings) instead of only a generic exit-code message.
### Updated jackify-engine to 0.5.0:
- Improved non-premium/manual-download support through a structured manual-download protocol that lets Jackify pause, guide the user, recheck files, and continue installation cleanly once required archives are present.
- Better pre-flight validation before large downloads begin, including earlier checks for game availability, disk space, and filesystem path-length limits.
- More accurate structured error handling for installation failures, with better classification of storage, permission, network, authentication, and validation issues.
---
## v0.4.0 - Error Handling Rewrite
**Release Date:** 2026-02-25

View File

@@ -14,7 +14,7 @@ Jackify is a Linux application for installing and configuring Wabbajack modlists
## Features
- **Complete Modlist Workflow**: Install from scratch with Nexus Premium, configure a pre-downloaded modlist, or reconfigure an existing modlist already in Steam
- **Complete Modlist Workflow**: Install from scratch, configure a pre-downloaded modlist, or reconfigure an existing modlist already in Steam
- **Game Support**: Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, Enderal, and more
- **Automated Steam Integration**: Steam shortcut creation with full Proton configuration
- **GUI and CLI**: Both interfaces provide identical functionality
@@ -35,17 +35,15 @@ Jackify is a Linux application for installing and configuring Wabbajack modlists
## Requirements
- Linux system (most modern distributions will work)
- Steam installed and configured
- Steam installed and configured**the Snap version of Steam is not supported**
- **Protontricks** — required for modlist configuration
- See [Installing Additional Tools](https://github.com/Omni-guides/Jackify/wiki/Installing-Additional-Tools#installing-protontricks)
- **GE-Proton 10-14** — While other Proton versions may work, GE-Proton 10-14 is highly recommended for ENB compatibility
- See [Installing Additional Tools](https://github.com/Omni-guides/Jackify/wiki/Installing-Additional-Tools#installing-ge-proton)
- **Nexus Mods Premium subscription** (required for automated downloads)
- Non-Premium users can still install modlists via Wabbajack under Proton
- Native non-premium support planned for a future release
- **Nexus Mods account** (Premium required for fully automated downloads; Non-Premium supported with manual browser steps)
- See the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide) for full details on the options available
- **FUSE** (required for AppImage execution, pre-installed on most distributions)
- **Ubuntu/Debian-based distros only** (Ubuntu, Kubuntu, Linux Mint, Pop!_OS, Zorin OS, elementary OS, and others): Qt platform plugin library
- **FUSE2 compatibility (libfuse.so.2) is required for AppImage execution**
- **IF YOU ARE USING an Ubuntu/Debian-based distro** (Ubuntu, Kubuntu, Linux Mint, Pop!_OS, Zorin OS, elementary OS, and others): Qt platform plugin library
- `sudo apt install libxcb-cursor-dev`
## Installation Quick Start
@@ -61,6 +59,14 @@ chmod +x Jackify.AppImage
For CLI mode: `./Jackify.AppImage --cli`
To verify your download, each release includes a `SHA256SUMS` file on the [GitHub releases page](https://github.com/Omni-guides/Jackify/releases/latest). Download it into the same folder as the AppImage, then run:
```bash
sha256sum -c SHA256SUMS
```
You should see `Jackify.AppImage: OK`. If you see a failure, do not run the file.
For a full step-by-step guide with screenshots, see the [User Guide](https://github.com/Omni-guides/Jackify/wiki/User-Guide).
## Supported Games
@@ -91,7 +97,6 @@ At this early stage of development, I'd prefer GitHub Issues for bug reports and
## Future Plans (not guaranteed)
- Continue to expand supported games for fully automated configuration
- Non-Premium / manual download support
- GUI refinements
- Dark/Light theme support

View File

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

View File

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View File

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

View File

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View File

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 154 KiB

View File

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

View File

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

Before

Width:  |  Height:  |  Size: 405 KiB

After

Width:  |  Height:  |  Size: 405 KiB

View File

@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
Wabbajack modlists natively on Linux systems.
"""
__version__ = "0.4.0"
__version__ = "0.6.0.1"

View File

@@ -107,7 +107,7 @@ def get_jackify_engine_path():
logger.warning(f"AppImage engine not found at expected path: {engine_path}")
# Priority 3: Check if THIS process is actually running from Jackify AppImage
# (not just inheriting APPDIR from another AppImage like Cursor)
# (not just inheriting APPDIR from another AppImage context)
appdir = os.environ.get('APPDIR')
if appdir and sys.argv[0] and 'jackify' in sys.argv[0].lower() and '/tmp/.mount_' in sys.argv[0]:
# Only use AppImage path if we're actually running a Jackify AppImage
@@ -179,6 +179,92 @@ class ModlistInstallCLI(
# Initialize process tracking for cleanup
self._current_process = None
@staticmethod
def _normalize_version_token(value: str | None) -> str | None:
if value is None:
return None
token = str(value).strip()
if not token:
return None
return token.lstrip("vV").lower()
@staticmethod
def _normalize_modlist_name(value: str | None) -> str:
return " ".join((value or "").strip().lower().split())
def _get_requested_modlist_version(self) -> str | None:
info = self.context.get("selected_modlist_info") or {}
return self._normalize_version_token(info.get("version"))
def _evaluate_update_candidate(
self,
modlist_name: str,
install_dir: str,
existing_appid: str | None,
) -> tuple[bool, dict]:
from jackify.backend.utils.modlist_meta import read_modlist_meta
result = {
"eligible": False,
"reason": "unknown",
"requested_version": None,
"installed_version": None,
"version_relation": "unknown",
"installed_name": None,
}
if not existing_appid:
result["reason"] = "missing_shortcut_appid"
return False, result
meta = read_modlist_meta(install_dir)
if not meta:
result["reason"] = "missing_meta"
return False, result
installed_name = (meta.get("modlist_name") or "").strip()
result["installed_name"] = installed_name
if self._normalize_modlist_name(installed_name) != self._normalize_modlist_name(modlist_name):
result["reason"] = "modlist_name_mismatch"
return False, result
requested_version = self._get_requested_modlist_version()
installed_version = self._normalize_version_token(meta.get("modlist_version"))
result["requested_version"] = requested_version
result["installed_version"] = installed_version
if requested_version and installed_version:
result["version_relation"] = "same" if requested_version == installed_version else "different"
result["eligible"] = True
result["reason"] = "eligible"
return True, result
def _find_existing_shortcut_appid(self, modlist_name: str, install_dir: str) -> str | None:
try:
install_real = os.path.realpath(install_dir)
candidate_exes = [
os.path.join(install_real, "ModOrganizer.exe"),
os.path.join(install_real, "files", "ModOrganizer.exe"),
]
for exe_path in candidate_exes:
if not os.path.exists(exe_path):
continue
appid = self.shortcut_handler.get_appid_from_vdf(modlist_name, exe_path)
if appid:
return appid
for shortcut in self.shortcut_handler.find_shortcuts_by_exe("ModOrganizer.exe"):
if (
shortcut.get("AppName", "").strip() == modlist_name.strip()
and os.path.realpath(shortcut.get("StartDir", "")) == install_real
):
raw_appid = shortcut.get("appid")
if raw_appid is not None:
return str(int(raw_appid) & 0xFFFFFFFF)
except Exception as e:
self.logger.warning("CLI update detection: failed shortcut lookup: %s", e)
return None
def cleanup(self):
"""Clean up any running jackify-engine process"""
if self._current_process and self._current_process.poll() is None:

View File

@@ -1,4 +1,5 @@
"""CLI configuration phase methods for ModlistInstallCLI (Mixin)."""
import json
import logging
import os
import subprocess
@@ -120,14 +121,16 @@ class ModlistOperationsConfigurationCLIMixin:
if debug_mode:
cmd.append('--debug')
self.logger.info("Adding --debug flag to jackify-engine")
writeback_path = str(auth_service.get_token_writeback_path())
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
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
@@ -166,19 +169,81 @@ class ModlistOperationsConfigurationCLIMixin:
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
clean_env = get_clean_subprocess_env()
self._current_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir)
self._current_process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=False,
env=clean_env,
cwd=engine_dir,
)
proc = self._current_process
def _write_stdin(payload: str) -> bool:
if not proc.stdin or proc.poll() is not None:
return False
try:
proc.stdin.write((payload + '\n').encode('utf-8'))
proc.stdin.flush()
return True
except Exception:
self.logger.debug("Failed writing to engine stdin", exc_info=True)
return False
buffer = b''
inline_progress_active = False
pending_manual = []
while True:
chunk = proc.stdout.read(1)
if not chunk:
break
buffer += chunk
if chunk == b'\n':
if chunk in (b'\n', b'\r'):
line = buffer.decode('utf-8', errors='replace')
decoded = line.rstrip('\r\n')
if decoded.startswith('{'):
try:
event = json.loads(decoded)
except (json.JSONDecodeError, ValueError):
event = None
if event:
event_name = event.get('event')
if event_name == 'manual_download_required':
pending_manual.append(event)
buffer = b''
continue
if event_name == 'manual_download_list_complete':
loop_iter = event.get('loop_iteration', 1)
for item in pending_manual:
item['loop_iteration'] = loop_iter
from jackify.backend.handlers.config_handler import ConfigHandler
raw_limit = ConfigHandler().get('manual_download_concurrent_limit', 2)
try:
manual_limit = int(raw_limit)
except (TypeError, ValueError):
manual_limit = 2
from jackify.frontends.cli.commands.manual_download_flow import run_cli_manual_download_phase
completed = run_cli_manual_download_phase(
events=list(pending_manual),
loop_iteration=loop_iter,
download_dir=actual_download_path,
stdin_write=_write_stdin,
concurrent_limit=max(1, min(5, manual_limit)),
)
if not completed:
if proc.poll() is None:
proc.terminate()
buffer = b''
break
pending_manual.clear()
buffer = b''
continue
if event_name == 'manual_download_phase_complete':
print("All manual downloads confirmed. Resuming installation...")
buffer = b''
continue
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
@@ -197,26 +262,6 @@ class ModlistOperationsConfigurationCLIMixin:
inline_progress_active = False
print(line, end='')
buffer = b''
elif chunk == b'\r':
line = buffer.decode('utf-8', errors='replace')
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
buffer = b''
continue
clean_line = line.rstrip('\r\n')
if clean_line.startswith("Installing files "):
print(f"\r{clean_line}", end='')
inline_progress_active = True
else:
if inline_progress_active:
print()
inline_progress_active = False
print(line, end='')
sys.stdout.flush()
buffer = b''
if buffer:
line = buffer.decode('utf-8', errors='replace')
@@ -237,6 +282,7 @@ class ModlistOperationsConfigurationCLIMixin:
proc.wait()
self._current_process = None
auth_service.apply_token_writeback(writeback_path)
if proc.returncode != 0:
print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}")
self.logger.error(f"Engine exited with code {proc.returncode}.")
@@ -400,6 +446,16 @@ class ModlistOperationsConfigurationCLIMixin:
app_id = None
use_automated_prefix = os.environ.get('JACKIFY_USE_AUTOMATED_PREFIX', '1') == '1'
existing_shortcut_appid = self.context.get('existing_shortcut_appid')
update_existing_install = bool(self.context.get('update_existing_install'))
if update_existing_install and existing_shortcut_appid:
app_id = str(existing_shortcut_appid)
success = True
prefix_path = None
result = True
print(f"\n{COLOR_INFO}Update mode selected. Reusing existing Steam shortcut AppID {app_id}.{COLOR_RESET}")
use_automated_prefix = False
if use_automated_prefix:
print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}")
@@ -535,17 +591,50 @@ class ModlistOperationsConfigurationCLIMixin:
success, prefix_path, app_id = True, None, None
else:
success, prefix_path, app_id = False, None, None
if success:
if success:
if update_existing_install and app_id:
print(f"{COLOR_SUCCESS}Update mode Steam setup confirmed.{COLOR_RESET}")
print(f"{COLOR_INFO}Reusing Steam AppID: {app_id}{COLOR_RESET}")
# Apply artwork and restart Steam -- skipped in update path since the full
# workflow is bypassed, but artwork and Steam state still need refreshing.
_game_type = self.context.get('detected_game') or self.context.get('special_game_type')
try:
from jackify.backend.handlers.modlist_handler import ModlistHandler
ModlistHandler().set_steam_grid_images(str(app_id), install_dir_str, game_type=_game_type)
except Exception as e:
self.logger.warning("Failed to apply Steam artwork in update mode: %s", e)
if _game_type == 'cp2077':
# CP2077 launch options may be absent on lists originally installed
# under v0.5 before CP2077 support was added.
try:
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
from jackify.backend.handlers.config_handler import ConfigHandler
sh = ShortcutHandler(
config_handler=ConfigHandler(),
steamdeck=bool(self.system_info and self.system_info.is_steamdeck),
)
sh.update_shortcut_launch_options(
shortcut_name,
mo2_exe_path,
'WINEDLLOVERRIDES="version=n,b;winmm=n,b" %command%',
)
except Exception as e:
self.logger.warning("Failed to update CP2077 launch options in update mode: %s", e)
try:
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
AutomatedPrefixService(self.system_info).restart_steam()
except Exception as e:
self.logger.warning("Failed to restart Steam in update mode: %s", e)
else:
print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}")
if prefix_path:
print(f"{COLOR_INFO}Proton prefix created at: {prefix_path}{COLOR_RESET}")
if app_id:
print(f"{COLOR_INFO}Steam AppID: {app_id}{COLOR_RESET}")
else:
print(f"{COLOR_ERROR}Automated Steam setup failed. Result: {result}{COLOR_RESET}")
print(f"{COLOR_ERROR}Steam integration was not completed. Please check the logs for details.{COLOR_RESET}")
return
else:
print(f"{COLOR_ERROR}Automated Steam setup failed. Result: {result}{COLOR_RESET}")
print(f"{COLOR_ERROR}Steam integration was not completed. Please check the logs for details.{COLOR_RESET}")
return
from jackify.backend.services.modlist_service import ModlistService
from jackify.backend.models.modlist import ModlistContext
@@ -572,18 +661,28 @@ class ModlistOperationsConfigurationCLIMixin:
progress_callback("")
progress_callback("=== Configuration Phase ===")
print(f"\n{COLOR_INFO}=== Configuration Phase ==={COLOR_RESET}")
self.logger.info("Running post-installation configuration phase using ModlistService")
print(f"\n{COLOR_INFO}=== Configuration Phase ==={COLOR_RESET}")
self.logger.info("Running post-installation configuration phase using ModlistService")
configuration_success = modlist_service.configure_modlist_post_steam(modlist_context)
if configuration_success:
print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}")
self.logger.info("Post-installation configuration completed successfully")
print(f"{COLOR_INFO}Core configuration complete. Checking post-install automation...{COLOR_RESET}")
try:
# Ensure CLI install flow gets the same VNV automation behavior as GUI.
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
from jackify.backend.services.vnv_integration_helper import (
run_vnv_automation_if_applicable,
should_offer_vnv_automation,
)
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.backend.services.vnv_post_install_service import VNVPostInstallService
from jackify.backend.handlers.path_handler import PathHandler
from jackify.frontends.cli.commands.vnv_manual_downloads import (
build_vnv_cli_manual_file_callback,
create_vnv_cli_progress_callback,
ensure_vnv_cli_manual_downloads,
)
modlist_name_for_automation = self.context.get('modlist_name') or shortcut_name or ""
def _confirm_vnv(description: str) -> bool:
@@ -593,31 +692,47 @@ class ModlistOperationsConfigurationCLIMixin:
except (EOFError, KeyboardInterrupt):
return False
return user_input in ("", "y", "yes")
def _manual_vnv_file(title: str, instructions: str):
print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}")
print(instructions)
try:
file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip()
except (EOFError, KeyboardInterrupt):
return None
if not file_input:
return None
selected = Path(file_input).expanduser().resolve()
return selected if selected.exists() else None
automation_ran, vnv_error = run_vnv_automation_if_applicable(
modlist_name=modlist_name_for_automation,
modlist_install_location=Path(install_dir_str),
game_root=None, # Auto-detect from modlist structure.
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=lambda msg: print(msg),
manual_file_callback=_manual_vnv_file,
confirmation_callback=_confirm_vnv,
)
if automation_ran and not vnv_error:
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
if vnv_error:
print(f"{COLOR_WARNING}VNV automation encountered an error: {vnv_error}{COLOR_RESET}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
install_path = Path(install_dir_str)
if should_offer_vnv_automation(modlist_name_for_automation, install_path):
game_paths = PathHandler().find_vanilla_game_paths()
resolved_game_root = game_paths.get('Fallout New Vegas')
vnv_service = VNVPostInstallService(
modlist_install_location=install_path,
game_root=resolved_game_root or install_path,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
)
completed = vnv_service.check_already_completed()
all_vnv_steps_done = (
completed['root_mods']
and completed['4gb_patch']
and completed['bsa_decompressed']
)
if all_vnv_steps_done:
print(f"{COLOR_INFO}VNV post-install steps are already complete.{COLOR_RESET}")
elif _confirm_vnv(vnv_service.get_automation_description()):
if not ensure_vnv_cli_manual_downloads(vnv_service, output_callback=print):
print(f"{COLOR_WARNING}VNV manual downloads were not completed. Skipping VNV automation.{COLOR_RESET}")
else:
progress_callback, close_progress = create_vnv_cli_progress_callback(print)
try:
automation_ran, vnv_error = run_vnv_automation_if_applicable(
modlist_name=modlist_name_for_automation,
modlist_install_location=install_path,
game_root=None, # Auto-detect from modlist structure.
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=progress_callback,
manual_file_callback=build_vnv_cli_manual_file_callback(vnv_service, output_callback=print),
confirmation_callback=lambda _description: True,
)
finally:
close_progress()
if automation_ran and not vnv_error:
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
if vnv_error:
print(f"{COLOR_WARNING}VNV automation encountered an error: {vnv_error}{COLOR_RESET}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
else:
print(f"{COLOR_INFO}VNV automation skipped by user.{COLOR_RESET}")
except Exception as vnv_err:
self.logger.error("VNV post-install automation failed: %s", vnv_err, exc_info=True)
print(f"{COLOR_WARNING}VNV automation could not be completed. Check logs for details.{COLOR_RESET}")
@@ -632,6 +747,7 @@ class ModlistOperationsConfigurationCLIMixin:
except Exception as ttw_err:
self.logger.error("TTW post-install prompt failed: %s", ttw_err, exc_info=True)
print(f"{COLOR_WARNING}TTW integration prompt failed. Check logs for details.{COLOR_RESET}")
print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}")
else:
print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}")
self.logger.warning("Post-installation configuration had issues")

View File

@@ -68,7 +68,7 @@ class ModlistOperationsConfigurationGUIMixin:
if result:
if completion_callback:
completion_callback(True, "Configuration completed successfully!", config_context['name'])
completion_callback(True, "Core configuration complete", config_context['name'])
return True
else:
retry_count += 1
@@ -139,7 +139,7 @@ class ModlistOperationsConfigurationGUIMixin:
if result:
if completion_callback:
completion_callback(True, "Configuration completed successfully!", config_context['name'])
completion_callback(True, "Core configuration complete", config_context['name'])
return True
else:
if progress_callback:

View File

@@ -243,6 +243,46 @@ class ModlistOperationsDiscoveryMixin:
self.context['download_dir'] = download_dir_path
self.logger.debug(f"Download directory context set to: {self.context['download_dir']}")
install_dir_value = self.context.get('install_dir')
install_dir_real = os.path.realpath(str(install_dir_value[0] if isinstance(install_dir_value, tuple) else install_dir_value))
existing_appid = self._find_existing_shortcut_appid(self.context['modlist_name'], install_dir_real)
eligible_update, update_meta = self._evaluate_update_candidate(
self.context['modlist_name'],
install_dir_real,
existing_appid,
)
if eligible_update:
print("\n" + "-" * 28)
print(f"{COLOR_WARNING}Existing modlist installation detected in this directory.{COLOR_RESET}")
relation = update_meta.get("version_relation")
if relation == "different":
print(
f"{COLOR_INFO}Detected version change: installed v{update_meta.get('installed_version')} -> "
f"selected v{update_meta.get('requested_version')}.{COLOR_RESET}"
)
elif relation == "same" and update_meta.get("installed_version"):
print(
f"{COLOR_INFO}Detected same version (v{update_meta.get('installed_version')}). "
"Use update mode for repair/reconfigure behavior." + f"{COLOR_RESET}"
)
print("Choose how to proceed:")
print(" 1. Update existing install (recommended)")
print(" 2. New install with a different Steam shortcut name")
print(" 0. Cancel")
update_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
if update_choice == "1":
self.context['update_existing_install'] = True
self.context['existing_shortcut_appid'] = existing_appid
self.logger.info("CLI update mode selected; reusing AppID %s", existing_appid)
elif update_choice == "2":
print(
f"{COLOR_WARNING}For a new install, choose a different Modlist Name before proceeding.{COLOR_RESET}"
)
return None
else:
self.logger.info("User cancelled at CLI update detection prompt.")
return None
if 'nexus_api_key' not in self.context or not self.context.get('nexus_api_key'):
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()

View File

@@ -17,6 +17,10 @@ from typing import Optional
from .config_handler_encryption import ConfigEncryptionMixin
from .config_handler_directories import ConfigDirectoriesMixin
from .config_handler_proton import ConfigProtonMixin
from jackify.shared.steam_utils import (
STEAM_PREFERENCE_AUTO,
resolve_preferred_steam_installation,
)
logger = logging.getLogger(__name__)
@@ -50,6 +54,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
"resolution": None,
"protontricks_path": None,
"steam_path": None,
"steam_install_preference": STEAM_PREFERENCE_AUTO, # auto|flatpak|native
"nexus_api_key": None, # Base64 encoded API key
"default_install_parent_dir": None, # Parent directory for modlist installations
"default_download_parent_dir": None, # Parent directory for downloads
@@ -62,8 +67,10 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
"proton_path": None, # Install Proton path (for jackify-engine) - None means auto-detect
"proton_version": None, # Install Proton version name - None means auto-detect
"steam_restart_strategy": "jackify", # "jackify" (default) or "simple"
"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
@@ -72,14 +79,13 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
# Perform version migrations
self._migrate_config()
# Normalize/repair Proton selections on every startup so stale deleted versions
# cannot break workflows.
self.normalize_proton_paths_on_boot()
# If steam_path is not set, detect it
if not self.settings["steam_path"]:
self.settings["steam_path"] = self._detect_steam_path()
# Auto-detect and set Proton version ONLY on first run (config file doesn't exist)
# Do NOT overwrite user's saved settings!
if not os.path.exists(self.config_file) and not self.settings.get("proton_path"):
self._auto_detect_proton()
# If jackify_data_dir is not set, initialize it to default
if not self.settings.get("jackify_data_dir"):
@@ -95,35 +101,16 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
str: Path to the Steam installation or None if not found
"""
logger.info("Detecting Steam installation path...")
# Common Steam installation paths
steam_paths = [
os.path.expanduser("~/.steam/steam"),
os.path.expanduser("~/.local/share/Steam"),
os.path.expanduser("~/.steam/root")
]
# Check each path
for path in steam_paths:
if os.path.exists(path):
logger.info(f"Found Steam installation at: {path}")
return path
# If not found in common locations, try to find using libraryfolders.vdf
libraryfolders_vdf_paths = [
os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.steam/root/config/libraryfolders.vdf"),
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf") # Flatpak
]
for vdf_path in libraryfolders_vdf_paths:
if os.path.exists(vdf_path):
# Extract the Steam path from the libraryfolders.vdf path
steam_path = os.path.dirname(os.path.dirname(vdf_path))
logger.info(f"Found Steam installation at: {steam_path}")
return steam_path
preference = self.settings.get("steam_install_preference", STEAM_PREFERENCE_AUTO)
install_type, install_root = resolve_preferred_steam_installation(preference=preference)
if install_root:
logger.info(
"Selected Steam installation: %s (%s)",
install_type,
install_root,
)
return str(install_root)
logger.error("Steam installation not found")
return None
@@ -376,4 +363,4 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM

View File

@@ -3,6 +3,8 @@ Config handler Proton path and version getters and auto-detect.
"""
import logging
from pathlib import Path
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
@@ -10,6 +12,105 @@ logger = logging.getLogger(__name__)
class ConfigProtonMixin:
"""Mixin providing Proton path/version and auto-detect for ConfigHandler."""
@staticmethod
def _is_usable_proton_path(proton_path: Optional[str]) -> bool:
"""Return True when path looks like a valid Proton install directory."""
if not proton_path:
return False
try:
p = Path(str(proton_path)).expanduser()
if not p.is_dir():
return False
# Valve Proton structure
if (p / "dist" / "bin" / "wine").exists():
return True
# GE-Proton structure
if (p / "files" / "bin" / "wine").exists():
return True
return False
except Exception:
return False
@staticmethod
def _best_proton_entry() -> Optional[Dict[str, Any]]:
"""Get best detected Proton entry or None."""
try:
from .wine_utils import WineUtils
return WineUtils.select_best_proton()
except Exception:
return None
def normalize_proton_paths_on_boot(self) -> bool:
"""
Ensure stored Proton paths are valid at startup, repairing stale selections.
Rules:
- If install proton path is missing/invalid, auto-detect next best and persist it.
- If no compatible Proton exists, persist install path/version as null.
- If game proton path is set and invalid, reset it to install proton (or null).
Returns:
True if config values were changed and saved, False otherwise.
"""
changed = False
install_path = self.settings.get("proton_path")
if install_path == "auto":
install_path = None
install_valid = self._is_usable_proton_path(install_path)
if not install_valid:
best = self._best_proton_entry()
if best:
best_path = str(best["path"])
best_name = str(best.get("name") or Path(best_path).name)
if self.settings.get("proton_path") != best_path:
self.settings["proton_path"] = best_path
changed = True
if self.settings.get("proton_version") != best_name:
self.settings["proton_version"] = best_name
changed = True
logger.warning(
"Install Proton path was missing/invalid; auto-selected %s (%s)",
best_name,
best_path,
)
else:
if self.settings.get("proton_path") is not None:
self.settings["proton_path"] = None
changed = True
if self.settings.get("proton_version") is not None:
self.settings["proton_version"] = None
changed = True
logger.warning(
"Install Proton path was missing/invalid and no compatible Proton was found"
)
else:
# Keep proton_version in sync with existing valid path when missing/legacy.
if not self.settings.get("proton_version"):
self.settings["proton_version"] = Path(str(install_path)).name
changed = True
effective_install = self.settings.get("proton_path")
game_path = self.settings.get("game_proton_path")
# Legacy/placeholder values should not persist for runtime resolution.
if game_path in ("same_as_install", "auto"):
target = effective_install
if self.settings.get("game_proton_path") != target:
self.settings["game_proton_path"] = target
changed = True
elif game_path and not self._is_usable_proton_path(game_path):
self.settings["game_proton_path"] = effective_install
changed = True
logger.warning(
"Game Proton path was missing/invalid; reset to install Proton path"
)
if changed:
self.save_config()
return changed
def get_proton_path(self):
"""Retrieve the saved Install Proton path. Always reads fresh from disk."""
try:

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

@@ -279,46 +279,56 @@ class ModlistMenuHandler:
timestamp = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]"
print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}")
# Run the automated workflow
result = prefix_service.run_working_workflow(
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck
)
# Handle the result
if isinstance(result, tuple) and len(result) == 4:
if result[0] == "CONFLICT":
# Handle conflict - ask user what to do
conflicts = result[1]
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
for i, conflict in enumerate(conflicts, 1):
print(f" {i}. Name: {conflict['name']}")
print(f" Executable: {conflict['exe']}")
print(f" Start Directory: {conflict['startdir']}")
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
print(" 1. Use existing shortcut (recommended)")
print(" 2. Create new shortcut anyway")
choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip()
if choice == "1":
# Use existing shortcut
existing_appid = conflicts[0].get('appid')
if existing_appid:
context = {
"name": modlist_name,
"appid": str(existing_appid),
"path": mo2_dir,
"manual_steps_completed": True,
"resolution": None
}
return self.run_modlist_configuration_phase(context)
elif choice == "2":
# Create new shortcut - would need to handle this, but for now just fail
print(f"{COLOR_ERROR}Creating new shortcut with same name not supported in this flow.{COLOR_RESET}")
return True
else:
while True:
result = prefix_service.run_working_workflow(
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck
)
if isinstance(result, tuple) and len(result) == 4:
if result[0] == "CONFLICT":
conflicts = result[1]
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
for i, conflict in enumerate(conflicts, 1):
print(f" {i}. Name: {conflict['name']}")
print(f" Executable: {conflict['exe']}")
print(f" Start Directory: {conflict['startdir']}")
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
print(" 1. Use existing shortcut (recommended)")
print(" 2. Choose a different shortcut name")
choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip()
if choice == "1":
existing_appid = conflicts[0].get('appid')
if existing_appid:
context = {
"name": modlist_name,
"appid": str(existing_appid),
"path": mo2_dir,
"manual_steps_completed": True,
"resolution": None
}
return self.run_modlist_configuration_phase(context)
print(f"{COLOR_ERROR}Could not determine existing shortcut AppID.{COLOR_RESET}")
return True
if choice == "2":
print("")
print(f"{COLOR_PROMPT}Enter a different shortcut name for this modlist.{COLOR_RESET}")
print(f"{COLOR_INFO}(Current conflicting name: {modlist_name}){COLOR_RESET}")
new_name = input(f"{COLOR_PROMPT}New shortcut name (or 'q' to cancel): {COLOR_RESET}").strip()
if new_name.lower() == 'q':
print(f"{COLOR_INFO}Configuration cancelled by user.{COLOR_RESET}")
return True
if not new_name:
print(f"{COLOR_ERROR}Name cannot be empty.{COLOR_RESET}")
continue
if new_name == modlist_name:
print(f"{COLOR_ERROR}Please enter a different name to resolve the conflict.{COLOR_RESET}")
continue
modlist_name = new_name
print(f"{COLOR_INFO}Retrying Steam setup with shortcut name: {modlist_name}{COLOR_RESET}")
continue
print(f"{COLOR_ERROR}Invalid choice.{COLOR_RESET}")
return True
else:
# Success - get the results
success, prefix_path, appid_int, last_timestamp = result
if success and appid_int:
context = {
@@ -330,10 +340,9 @@ class ModlistMenuHandler:
}
self.logger.debug(f"[DEBUG] New Modlist Context (automated workflow): {context}")
return self.run_modlist_configuration_phase(context)
else:
print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}")
return True
else:
print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}")
return True
# Unexpected result format
print(f"{COLOR_ERROR}Automated workflow returned unexpected format.{COLOR_RESET}")
self.logger.error(f"Unexpected result format from automated workflow: {result}")
@@ -566,8 +575,18 @@ class ModlistMenuHandler:
# Run modlist-specific post-install automation (e.g., VNV) before showing completion
# Only in CLI mode - GUI handles this in install_modlist.py
if not gui_mode:
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
from jackify.backend.services.vnv_integration_helper import (
run_vnv_automation_if_applicable,
should_offer_vnv_automation,
)
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.backend.services.vnv_post_install_service import VNVPostInstallService
from jackify.backend.handlers.path_handler import PathHandler
from jackify.frontends.cli.commands.vnv_manual_downloads import (
build_vnv_cli_manual_file_callback,
create_vnv_cli_progress_callback,
ensure_vnv_cli_manual_downloads,
)
from pathlib import Path
modlist_name = context.get('name', '')
@@ -581,33 +600,46 @@ class ModlistMenuHandler:
except (EOFError, KeyboardInterrupt):
return False
return user_input in ("", "y", "yes")
def _manual_vnv_file(title: str, instructions: str):
print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}")
print(instructions)
try:
file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip()
except (EOFError, KeyboardInterrupt):
return None
if not file_input:
return None
selected = Path(file_input).expanduser().resolve()
return selected if selected.exists() else None
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=modlist_path,
game_root=None, # Will be auto-detected
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=lambda msg: print(msg),
manual_file_callback=_manual_vnv_file,
confirmation_callback=_confirm_vnv
)
if automation_ran and not error:
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
if error:
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
if should_offer_vnv_automation(modlist_name, modlist_path):
game_paths = PathHandler().find_vanilla_game_paths()
resolved_game_root = game_paths.get('Fallout New Vegas')
vnv_service = VNVPostInstallService(
modlist_install_location=modlist_path,
game_root=resolved_game_root or modlist_path,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
)
completed = vnv_service.check_already_completed()
all_vnv_steps_done = (
completed['root_mods']
and completed['4gb_patch']
and completed['bsa_decompressed']
)
if all_vnv_steps_done:
print(f"{COLOR_INFO}VNV post-install steps are already complete.{COLOR_RESET}")
elif _confirm_vnv(vnv_service.get_automation_description()):
if not ensure_vnv_cli_manual_downloads(vnv_service, output_callback=print):
print(f"{COLOR_WARNING}VNV manual downloads were not completed. Skipping VNV automation.{COLOR_RESET}")
else:
progress_callback, close_progress = create_vnv_cli_progress_callback(print)
try:
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=modlist_path,
game_root=None, # Will be auto-detected
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=progress_callback,
manual_file_callback=build_vnv_cli_manual_file_callback(vnv_service, output_callback=print),
confirmation_callback=lambda _description: True,
)
finally:
close_progress()
if automation_ran and not error:
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
if error:
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
else:
print(f"{COLOR_INFO}VNV automation skipped by user.{COLOR_RESET}")
except Exception as e:
self.logger.debug(f"VNV automation check skipped: {e}")
# Not an error - just means VNV automation wasn't applicable

View File

@@ -2,7 +2,6 @@
from pathlib import Path
import os
import logging
import requests
import re
from typing import Optional
@@ -147,33 +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.")
self.logger.warning("Modlist registry tweaks failed (non-fatal): %s", e)
# Step 4: Install Wine Components
if status_callback:
@@ -239,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
@@ -258,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):
@@ -276,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")
@@ -309,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")
@@ -400,6 +430,33 @@ 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
# engine-installed workflows skip edit_binary_working_paths.
if not self.path_handler.align_home_path_basis(
modlist_ini_path=modlist_ini_path_obj,
modlist_dir_path=modlist_dir_path_obj,
modlist_sdcard=self.modlist_sdcard,
):
self.logger.error("Failed to align home-path basis in ModOrganizer.ini. Configuration aborted.")
self.logger.error("Failed to align /home path basis in ModOrganizer.ini.")
return False
self.logger.info("Step 8: Updating ModOrganizer.ini paths... Done")
@@ -539,6 +596,9 @@ class ModlistConfigurationMixin:
else:
self.logger.debug("Step 13: No special launch options needed for this modlist type")
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Finalizing post-install configuration")
# Do not call status_callback here, the final message is handled in menu_handler
# if status_callback:
# status_callback("Configuration completed successfully!")
@@ -546,8 +606,51 @@ class ModlistConfigurationMixin:
self.logger.info("Configuration steps completed successfully.")
# Step 14: Re-enforce Windows 10 mode after modlist-specific configurations (matches legacy script line 1333)
if status_callback:
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:
@@ -561,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():
@@ -582,3 +727,72 @@ class ModlistConfigurationMixin:
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

@@ -7,6 +7,7 @@ import shutil
from pathlib import Path
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING
from jackify.shared.paths import get_jackify_logs_dir
logger = logging.getLogger(__name__)
@@ -352,10 +353,16 @@ class ModlistInstallCLITTWMixin:
print(f"\nTTW has been installed to: {ttw_output_path}")
print(f"TTW has been integrated into '{modlist_name}' (modlist.txt + plugins.txt updated).")
print(f"The modlist '{modlist_name}' is now ready to use with TTW.")
print(f"Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
else:
print(f"\n{COLOR_ERROR}TTW installation failed. Check the logs for details.{COLOR_RESET}")
print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}")
print(f"{COLOR_INFO}Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
except Exception as e:
self.logger.error(f"Error during TTW installation: {e}", exc_info=True)
print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}")
print(f"{COLOR_INFO}Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")

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

@@ -166,7 +166,7 @@ class PathHandlerGameMixin:
return False
modlist_path = Path(self.modlist_dir)
preferred_order = [
"Stock Game", "STOCK GAME", "Skyrim Stock", "Stock Game Folder",
"Stock Game", "StockGame", "STOCK GAME", "Skyrim Stock", "Stock Game Folder",
"Stock Folder", Path("root/Skyrim Special Edition"), "Game Root"
]
found_path = 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

View File

@@ -21,13 +21,102 @@ TARGET_EXECUTABLES_LOWER = [
"skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe",
"sfse_loader.exe", "obse64_loader.exe", "falloutnv.exe"
]
STOCK_GAME_FOLDERS = ["Stock Game", "Game Root", "Stock Folder", "Skyrim Stock"]
STOCK_GAME_FOLDERS = ["Stock Game", "StockGame", "Game Root", "Stock Folder", "Skyrim Stock"]
SDCARD_PREFIX = '/run/media/mmcblk0p1/'
class PathHandlerMO2Mixin:
"""Mixin providing ModOrganizer.ini path updates and formatting."""
@staticmethod
def _desired_home_basis_from_modlist_dir(modlist_dir_path: Path) -> Optional[str]:
"""
Determine desired Linux home-path basis from modlist install directory.
Returns:
"/var/home" when modlist dir is under /var/home,
"/home" when modlist dir is under /home,
None otherwise.
"""
try:
posix = modlist_dir_path.as_posix()
except Exception:
posix = str(modlist_dir_path).replace("\\", "/")
if posix.startswith("/var/home/"):
return "/var/home"
if posix.startswith("/home/"):
return "/home"
return None
@staticmethod
def _rewrite_z_home_basis_in_line(line: str, desired_home_basis: str) -> str:
"""
Rewrite only Z:-drive /home -> /var/home path basis in a single INI line.
Preserves slash style (forward or backslash), and leaves D: paths untouched.
"""
if desired_home_basis == "/var/home":
# Z:/home/... -> Z:/var/home/...
# Z:\\home\\... -> Z:\\var\\home\\...
return re.sub(r'([Zz]:[/\\]+)home([/\\]+)', r'\1var\2home\2', line)
return line
def align_home_path_basis(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool) -> bool:
"""
Align gamePath/binary/workingDirectory home-path basis to modlist_dir_path.
This is a targeted post-processing step for Z: paths only:
- If install path is /var/home/... then rewrite Z:/home/... to Z:/var/home/...
- Otherwise do nothing.
"""
if modlist_sdcard:
return True
desired_home_basis = self._desired_home_basis_from_modlist_dir(modlist_dir_path)
# This alignment pass is intentionally one-way:
# only promote Z:/home -> Z:/var/home when install dir uses /var/home.
if desired_home_basis != "/var/home":
return True
if not modlist_ini_path.is_file():
logger.error(f"INI file {modlist_ini_path} does not exist for home-basis alignment")
return False
try:
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
changed = 0
for i, line in enumerate(lines):
stripped = line.strip()
if not (
re.match(r'^\s*gamepath\s*=.*$', stripped, re.IGNORECASE)
or re.match(r'^(\d+)(\\+)\s*binary\s*=.*$', stripped, re.IGNORECASE)
or re.match(r'^(\d+)(\\+)\s*workingDirectory\s*=.*$', stripped, re.IGNORECASE)
):
continue
rewritten = self._rewrite_z_home_basis_in_line(line, desired_home_basis)
if rewritten != line:
lines[i] = rewritten
changed += 1
if changed > 0:
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(
"Aligned ModOrganizer.ini home-path basis to %s for %d line(s): %s",
desired_home_basis,
changed,
modlist_ini_path,
)
else:
logger.debug(
"No home-path basis alignment needed for %s (target %s)",
modlist_ini_path,
desired_home_basis,
)
return True
except Exception as e:
logger.error(f"Error aligning home path basis in {modlist_ini_path}: {e}")
return False
@staticmethod
def _strip_sdcard_path_prefix(path_obj: Path) -> str:
"""Removes SD card mount prefix. Returns path as POSIX-style string."""
@@ -344,10 +433,16 @@ class PathHandlerMO2Mixin:
if "/mods/" in cleaned_value:
idx = cleaned_value.index("/mods/")
rel_path = cleaned_value[idx:].lstrip('/')
elif existing_game_path:
rel_path = None
game_path_base = existing_game_path
else:
rel_path = exe_name
processed_modlist_path = self._strip_sdcard_path_prefix(modlist_dir_path) if modlist_sdcard else str(modlist_dir_path)
new_binary_path = f"{drive_prefix}/{processed_modlist_path}/{rel_path}".replace('\\', '/').replace('//', '/')
if rel_path is not None:
processed_modlist_path = self._strip_sdcard_path_prefix(modlist_dir_path) if modlist_sdcard else str(modlist_dir_path)
new_binary_path = f"{drive_prefix}/{processed_modlist_path}/{rel_path}".replace('\\', '/').replace('//', '/')
else:
new_binary_path = f"{drive_prefix}/{game_path_base}/{exe_name}".replace('\\', '/').replace('//', '/')
formatted_binary_path = PathHandlerMO2Mixin._format_binary_for_mo2(new_binary_path)
if '"' in formatted_binary_path:
formatted_binary_path = formatted_binary_path.replace('"', '')
@@ -439,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
@@ -458,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

@@ -12,6 +12,10 @@ from pathlib import Path
from typing import Optional, List
from datetime import datetime
import vdf
from jackify.shared.steam_utils import (
get_ordered_steam_roots,
STEAM_PREFERENCE_AUTO,
)
logger = logging.getLogger(__name__)
@@ -23,11 +27,7 @@ class PathHandlerSteamMixin:
def find_steam_config_vdf() -> Optional[Path]:
"""Finds the active Steam config.vdf file."""
logger.debug("Searching for Steam config.vdf...")
possible_steam_paths = [
Path.home() / ".steam/steam",
Path.home() / ".local/share/Steam",
Path.home() / ".steam/root"
]
possible_steam_paths = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
for steam_path in possible_steam_paths:
potential_path = steam_path / "config/config.vdf"
if potential_path.is_file():
@@ -40,10 +40,9 @@ class PathHandlerSteamMixin:
def find_steam_library() -> Optional[Path]:
"""Find the primary Steam library common directory containing games."""
logger.debug("Attempting to find Steam library...")
ordered_roots = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
libraryfolders_vdf_paths = [
os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf"),
str(root / "config" / "libraryfolders.vdf") for root in ordered_roots
]
for path in libraryfolders_vdf_paths:
if os.path.exists(path):
@@ -92,14 +91,11 @@ class PathHandlerSteamMixin:
logger.info(f"Using Steam library common path: {library_paths[0]}")
return library_paths[0]
logger.debug("No valid common paths found in VDF, checking default location...")
default_common_path = Path.home() / ".steam/steam/steamapps/common"
if default_common_path.is_dir():
logger.info(f"Using default Steam library common path: {default_common_path}")
return default_common_path
default_common_path_local = Path.home() / ".local/share/Steam/steamapps/common"
if default_common_path_local.is_dir():
logger.info(f"Using default local Steam library common path: {default_common_path_local}")
return default_common_path_local
for root in ordered_roots:
default_common_path = root / "steamapps" / "common"
if default_common_path.is_dir():
logger.info(f"Using default Steam library common path: {default_common_path}")
return default_common_path
logger.error("No valid Steam library common path found in VDF or default locations.")
return None
except Exception as e:
@@ -181,12 +177,8 @@ class PathHandlerSteamMixin:
def get_all_steam_library_paths() -> List[Path]:
"""Finds all Steam library paths listed in all known libraryfolders.vdf files (including Flatpak)."""
logger.info("[DEBUG] Searching for all Steam libraryfolders.vdf files...")
vdf_paths = [
Path.home() / ".steam/steam/config/libraryfolders.vdf",
Path.home() / ".local/share/Steam/config/libraryfolders.vdf",
Path.home() / ".steam/root/config/libraryfolders.vdf",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf",
]
ordered_roots = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
vdf_paths = [root / "config" / "libraryfolders.vdf" for root in ordered_roots]
library_paths = set()
for vdf_path in vdf_paths:
if vdf_path.is_file():

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

@@ -6,6 +6,32 @@ import resource
import sys
import shutil
import logging
import threading
logger = logging.getLogger(__name__)
def suspend_baloo() -> bool:
"""Suspend KDE Baloo file indexer. Safe to call on non-KDE or headless systems."""
if not shutil.which("balooctl"):
return False
try:
subprocess.run(["balooctl", "suspend"], capture_output=True, timeout=5)
logger.debug("Baloo file indexer suspended")
return True
except Exception:
return False
def resume_baloo() -> None:
"""Resume KDE Baloo file indexer. No-op if balooctl is not present."""
if not shutil.which("balooctl"):
return
try:
subprocess.run(["balooctl", "resume"], capture_output=True, timeout=5)
logger.debug("Baloo file indexer resumed")
except Exception:
pass
def get_safe_python_executable():
"""
@@ -154,7 +180,7 @@ class ProcessManager:
"""
Shared process manager for robust subprocess launching, tracking, and cancellation.
"""
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False):
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False, enable_stdin=False):
self.cmd = cmd
# Default to cleaned environment if None to prevent AppImage variable inheritance
if env is None:
@@ -165,14 +191,18 @@ class ProcessManager:
self.text = text
self.bufsize = bufsize
self.separate_stderr = separate_stderr
self.enable_stdin = enable_stdin
self.proc = None
self.process_group_pid = None
self._stdin_lock = threading.Lock()
self._start_process()
def _start_process(self):
stderr_arg = subprocess.PIPE if self.separate_stderr else subprocess.STDOUT
stdin_arg = subprocess.PIPE if self.enable_stdin else None
self.proc = subprocess.Popen(
self.cmd,
stdin=stdin_arg,
stdout=subprocess.PIPE,
stderr=stderr_arg,
env=self.env,
@@ -190,31 +220,45 @@ class ProcessManager:
cleanup_attempts = 0
try:
if self.proc:
# Terminate process group first so child tools don't survive parent exit.
if self.process_group_pid:
try:
os.killpg(self.process_group_pid, signal.SIGTERM)
except Exception:
pass
try:
self.proc.terminate()
try:
self.proc.wait(timeout=timeout_terminate)
return
except subprocess.TimeoutExpired:
pass
except Exception:
pass
try:
self.proc.kill()
try:
self.proc.wait(timeout=timeout_kill)
return
except subprocess.TimeoutExpired:
pass
self.proc.wait(timeout=timeout_terminate)
except subprocess.TimeoutExpired:
pass
except Exception:
pass
# Kill entire process group (catches 7zz and other child processes)
# Escalate to SIGKILL for stubborn children/process group.
if self.process_group_pid:
try:
os.killpg(self.process_group_pid, signal.SIGKILL)
except Exception:
pass
# Last resort: pkill by command name
try:
self.proc.kill()
except Exception:
pass
try:
self.proc.wait(timeout=timeout_kill)
except subprocess.TimeoutExpired:
pass
except Exception:
pass
# Last resort: pkill by command name (kept bounded).
while cleanup_attempts < max_cleanup_attempts:
try:
subprocess.run(['pkill', '-f', os.path.basename(self.cmd[0])], timeout=5, capture_output=True)
@@ -222,9 +266,9 @@ 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.stdout, self.proc.stderr):
for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr):
if pipe:
try:
pipe.close()
@@ -250,4 +294,20 @@ class ProcessManager:
return self.proc.stdout.read(1)
except (ValueError, OSError):
return None
return None
return None
def write_stdin(self, line: str) -> bool:
"""
Write a line to the process stdin. Thread-safe.
Returns True on success, False if stdin is not available or process is gone.
"""
if not self.enable_stdin or not self.proc or not self.proc.stdin:
return False
with self._stdin_lock:
try:
payload = line if line.endswith('\n') else line + '\n'
self.proc.stdin.write(payload.encode())
self.proc.stdin.flush()
return True
except (OSError, BrokenPipeError):
return False

View File

@@ -64,21 +64,36 @@ class TTWInstallerBackendMixin:
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, universal_newlines=True
)
error_context: list = []
capturing_explanation = False
if process.stdout:
for line in process.stdout:
line = line.rstrip()
if line:
self.logger.info("TTW_Linux_Installer: %s", line)
lower = line.lower()
if 'failed' in lower or 'cannot continue' in lower or 'error:' in lower:
error_context.append(line.strip())
capturing_explanation = True
elif capturing_explanation and line.startswith(' '):
error_context.append(line.strip())
else:
capturing_explanation = False
process.wait()
ret = process.returncode
if ret == 0:
self.logger.info("TTW installation completed successfully.")
return True, "TTW installation completed successfully!"
self.logger.error("TTW installation process returned non-zero exit code: %s", ret)
if error_context:
return False, "TTW installation failed:\n" + "\n".join(error_context)
return False, f"TTW installation failed with exit code {ret}"
except Exception as e:
self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True)
return False, f"Error executing TTW_Linux_Installer: {e}"
finally:
from jackify.shared.paths import cleanup_stale_tmp
cleanup_stale_tmp()
def start_ttw_installation(self, ttw_mpi_path: Path, ttw_output_path: Path, output_file: Path):
"""Start TTW installation process (non-blocking). Returns (process, error_message)."""
@@ -156,6 +171,8 @@ class TTWInstallerBackendMixin:
process.kill()
except Exception:
pass
from jackify.shared.paths import cleanup_stale_tmp
cleanup_stale_tmp()
def install_ttw_backend_with_output_stream(self, ttw_mpi_path: Path, ttw_output_path: Path, output_callback=None):
"""Install TTW with streaming output (DEPRECATED - use start_ttw_installation instead)."""
@@ -210,6 +227,8 @@ class TTWInstallerBackendMixin:
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, universal_newlines=True
)
error_context: list = []
capturing_explanation = False
if process.stdout:
for line in process.stdout:
line = line.rstrip()
@@ -217,16 +236,29 @@ class TTWInstallerBackendMixin:
self.logger.info("TTW_Linux_Installer: %s", line)
if output_callback:
output_callback(line)
lower = line.lower()
if 'failed' in lower or 'cannot continue' in lower or 'error:' in lower:
error_context.append(line.strip())
capturing_explanation = True
elif capturing_explanation and line.startswith(' '):
error_context.append(line.strip())
else:
capturing_explanation = False
process.wait()
ret = process.returncode
if ret == 0:
self.logger.info("TTW installation completed successfully.")
return True, "TTW installation completed successfully!"
self.logger.error("TTW installation process returned non-zero exit code: %s", ret)
if error_context:
return False, "TTW installation failed:\n" + "\n".join(error_context)
return False, f"TTW installation failed with exit code {ret}"
except Exception as e:
self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True)
return False, f"Error executing TTW_Linux_Installer: {e}"
finally:
from jackify.shared.paths import cleanup_stale_tmp
cleanup_stale_tmp()
@staticmethod
def integrate_ttw_into_modlist(ttw_output_path: Path, modlist_install_dir: Path, ttw_version: str, skip_copy: bool = False) -> bool:
@@ -247,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)")

View File

@@ -14,9 +14,9 @@ class ModlistContext:
"""Context object for modlist operations."""
name: str
install_dir: Path
download_dir: Path
game_type: str
nexus_api_key: str
download_dir: Optional[Path] = None
modlist_value: Optional[str] = None
modlist_source: Optional[str] = None # 'identifier' or 'file'
resolution: Optional[str] = None
@@ -29,8 +29,8 @@ class ModlistContext:
"""Convert string paths to Path objects."""
if isinstance(self.install_dir, str):
self.install_dir = Path(self.install_dir)
if isinstance(self.download_dir, str):
self.download_dir = Path(self.download_dir)
if self.download_dir is not None and isinstance(self.download_dir, str):
self.download_dir = Path(self.download_dir) if self.download_dir else None
if isinstance(self.mo2_exe_path, str):
self.mo2_exe_path = Path(self.mo2_exe_path)

View File

@@ -5,12 +5,56 @@ import logging
import os
import time
import subprocess
import re
logger = logging.getLogger(__name__)
class PrefixCreationMixin:
"""Mixin providing prefix creation methods for AutomatedPrefixService."""
def _get_preferred_steam_root_and_type(self) -> tuple[Optional[Path], Optional[str]]:
"""Resolve the active Steam root/type using the shared v0.5 selector."""
from jackify.shared.steam_utils import (
STEAM_PREFERENCE_AUTO,
resolve_preferred_steam_installation,
)
from ..handlers.config_handler import ConfigHandler
preference = STEAM_PREFERENCE_AUTO
try:
preference = ConfigHandler().get("steam_install_preference", STEAM_PREFERENCE_AUTO)
except Exception:
logger.debug("Could not read steam_install_preference; falling back to auto", exc_info=True)
preferred_type, preferred_root = resolve_preferred_steam_installation(preference)
return preferred_root, preferred_type
def _get_library_roots_for_steam_root(self, steam_root: Path) -> list[Path]:
"""
Read library roots for one chosen Steam install only.
This avoids mixing native and Flatpak libraries in dual-install environments.
"""
roots: list[Path] = [steam_root]
vdf_path = steam_root / "config" / "libraryfolders.vdf"
if not vdf_path.is_file():
return roots
try:
text = vdf_path.read_text(encoding="utf-8", errors="ignore")
for match in re.finditer(r'"path"\s*"([^"]+)"', text):
raw_path = match.group(1).replace("\\\\", "\\")
lib_root = Path(raw_path).expanduser()
try:
resolved = lib_root.resolve()
except (OSError, RuntimeError):
resolved = lib_root
if resolved not in roots:
roots.append(resolved)
except Exception:
logger.debug("Failed reading libraryfolders.vdf for %s", steam_root, exc_info=True)
return roots
def _get_compatdata_path_for_appid(self, appid: int) -> Optional[Path]:
"""
Get the compatdata path for a given AppID.
@@ -31,13 +75,11 @@ class PrefixCreationMixin:
if compatdata_path:
return compatdata_path
# Prefix doesn't exist yet - determine where to create it from libraryfolders.vdf
library_paths = PathHandler.get_all_steam_library_paths()
if library_paths:
# Use the first library (typically the default library)
# Construct compatdata path: library_path/steamapps/compatdata/appid
first_library = library_paths[0]
compatdata_base = first_library / "steamapps" / "compatdata"
# Prefix doesn't exist yet - derive it from the selected active Steam root,
# not from a mixed native/Flatpak library list.
preferred_root, _preferred_type = self._get_preferred_steam_root_and_type()
if preferred_root:
compatdata_base = preferred_root / "steamapps" / "compatdata"
return compatdata_base / str(appid)
# Only fallback if VDF parsing completely fails
@@ -156,36 +198,24 @@ class PrefixCreationMixin:
True if successful, False otherwise
"""
try:
# Determine Steam locations based on installation type
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
all_libraries = path_handler.get_all_steam_library_paths()
# Check if we have Flatpak Steam by looking for .var/app/com.valvesoftware.Steam in library paths
is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in all_libraries)
if is_flatpak_steam and all_libraries:
# Flatpak Steam: Use the actual library root from libraryfolders.vdf
# Compatdata should be in the library root, not the client root
flatpak_library_root = all_libraries[0] # Use first library (typically the default)
flatpak_client_root = flatpak_library_root.parent.parent / ".steam/steam"
if not flatpak_library_root.is_dir():
logger.error(
f"Flatpak Steam library root does not exist: {flatpak_library_root}"
)
return False
steam_root = flatpak_client_root if flatpak_client_root.is_dir() else flatpak_library_root
# CRITICAL: compatdata must be in the library root, not client root
compatdata_dir = flatpak_library_root / "steamapps/compatdata"
proton_common_dir = flatpak_library_root / "steamapps/common"
else:
# Native Steam (or unknown): fall back to legacy ~/.steam/steam layout
steam_root = Path.home() / ".steam/steam"
compatdata_dir = steam_root / "steamapps/compatdata"
proton_common_dir = steam_root / "steamapps/common"
# Determine Steam locations from the selected active Steam install only.
steam_root, steam_type = self._get_preferred_steam_root_and_type()
if not steam_root:
logger.error("Could not determine active Steam root for prefix creation")
return False
if not steam_root.is_dir():
logger.error("Preferred Steam root does not exist: %s", steam_root)
return False
compatdata_dir = steam_root / "steamapps" / "compatdata"
proton_common_dir = steam_root / "steamapps" / "common"
logger.info(
"Prefix creation using preferred Steam install: type=%s root=%s",
steam_type or "unknown",
steam_root,
)
# Ensure compatdata root exists and is a directory we actually want to use
if not compatdata_dir.is_dir():
logger.error(f"Compatdata root does not exist: {compatdata_dir}. Aborting prefix creation.")
@@ -256,4 +286,3 @@ class PrefixCreationMixin:
except Exception as e:
logger.error(f"Error creating prefix: {e}")
return False

View File

@@ -19,66 +19,68 @@ logger = logging.getLogger(__name__)
class GameUtilsMixin:
"""Mixin for game-related utility operations"""
def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
"""
Generate launch options for FNV/Enderal games that require vanilla compatdata.
Args:
special_game_type: "fnv" or "enderal"
modlist_install_dir: Directory where the modlist is installed
Returns:
Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
"""
if not special_game_type or special_game_type not in ["fnv", "enderal"]:
return None
logger.info(f"Generating {special_game_type.upper()} launch options")
# Map game types to AppIDs
appid_map = {"fnv": "22380", "enderal": "976620"}
appid = appid_map[special_game_type]
# Find vanilla game compatdata
from ..handlers.path_handler import PathHandler
compatdata_path = PathHandler.find_compat_data(appid)
if not compatdata_path:
logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
return None
# Create STEAM_COMPAT_DATA_PATH string
compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
# Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
compat_mounts_str = ""
try:
all_libs = PathHandler.get_all_steam_library_paths()
main_steam_lib_path_obj = PathHandler.find_steam_library()
if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
main_steam_lib_path = main_steam_lib_path_obj.parent.parent
else:
main_steam_lib_path = main_steam_lib_path_obj
mount_paths = []
if main_steam_lib_path:
main_resolved = main_steam_lib_path.resolve()
for lib_path in all_libs:
if lib_path.resolve() != main_resolved:
mount_paths.append(str(lib_path.resolve()))
if mount_paths:
mount_paths_str = ':'.join(mount_paths)
compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
except Exception as e:
logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
# Combine all launch options
launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
launch_options = ' '.join(launch_options.split()) # Clean up spacing
logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}")
return launch_options
# TODO post-0.6: remove this method - dead code, never called.
# Superseded by registry injection (game paths written directly into the modlist prefix).
# def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
# """
# Generate launch options for FNV/Enderal games that require vanilla compatdata.
#
# Args:
# special_game_type: "fnv" or "enderal"
# modlist_install_dir: Directory where the modlist is installed
#
# Returns:
# Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
# """
# if not special_game_type or special_game_type not in ["fnv", "enderal"]:
# return None
#
# logger.info(f"Generating {special_game_type.upper()} launch options")
#
# # Map game types to AppIDs
# appid_map = {"fnv": "22380", "enderal": "976620"}
# appid = appid_map[special_game_type]
#
# # Find vanilla game compatdata
# from ..handlers.path_handler import PathHandler
# compatdata_path = PathHandler.find_compat_data(appid)
# if not compatdata_path:
# logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
# return None
#
# # Create STEAM_COMPAT_DATA_PATH string
# compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
#
# # Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
# compat_mounts_str = ""
# try:
# all_libs = PathHandler.get_all_steam_library_paths()
# main_steam_lib_path_obj = PathHandler.find_steam_library()
# if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
# main_steam_lib_path = main_steam_lib_path_obj.parent.parent
# else:
# main_steam_lib_path = main_steam_lib_path_obj
#
# mount_paths = []
# if main_steam_lib_path:
# main_resolved = main_steam_lib_path.resolve()
# for lib_path in all_libs:
# if lib_path.resolve() != main_resolved:
# mount_paths.append(str(lib_path.resolve()))
#
# if mount_paths:
# mount_paths_str = ':'.join(mount_paths)
# compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
# logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
# except Exception as e:
# logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
#
# # Combine all launch options
# launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
# launch_options = ' '.join(launch_options.split()) # Clean up spacing
#
# logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}")
# return launch_options
def _find_steam_game(self, app_id: str, common_names: list) -> Optional[str]:
"""Find a Steam game installation path by AppID and common names"""
@@ -140,36 +142,90 @@ class GameUtilsMixin:
return None
def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str):
def _detect_skyrim_se_modlist(self, modlist_dir: str) -> bool:
"""
Return True if modlist_dir is a Skyrim SE (non-VR) modlist.
Used only to trigger first-launch seeding when special_game_type is None.
Other games are not yet confirmed to need this treatment.
"""
if not modlist_dir:
return False
try:
mo2_ini = Path(modlist_dir) / "ModOrganizer.ini"
if not mo2_ini.exists():
mo2_ini = Path(modlist_dir) / "files" / "ModOrganizer.ini"
if not mo2_ini.exists():
return False
content = mo2_ini.read_text(errors='ignore').lower()
# Anchor VR check to gameName= to avoid false positives from plugin
# setting keys like enable_skyrimVR=false appearing in SE modlists.
for _line in content.splitlines():
if _line.strip().startswith("gamename="):
game_name_value = _line.strip()[len("gamename="):]
if 'skyrim vr' in game_name_value or 'skyrimvr' in game_name_value:
return False
break
return 'skyrim special edition' in content or 'skse64_loader' in content
except Exception as e:
logger.debug(f"Could not check Skyrim SE detection for {modlist_dir}: {e}")
return False
def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str,
modlist_dir: Optional[str] = None):
"""
Pre-create game-specific user directories to prevent first-launch issues.
Creates both My Documents/My Games and AppData/Local directories for the game.
This prevents issues where games fail to create these on first launch under Proton.
special_game_type covers FNV/FO3/Enderal (vanilla-compatdata games). For standard
games like Skyrim SE that aren't "special" in that sense, modlist_dir is used to
detect what directories to seed.
"""
# Map game types to their directory names
# Bethesda-pattern games: same name used for both My Games and AppData/Local
game_dir_names = {
"skyrim": "Skyrim Special Edition",
"skyrimvr": "Skyrim VR",
"fnv": "FalloutNV",
"fo3": "Fallout3",
"fo4": "Fallout4",
"fallout4vr": "Fallout4VR",
"oblivion": "Oblivion",
"oblivion_remastered": "Oblivion Remastered",
"enderal": "Enderal Special Edition",
"starfield": "Starfield"
"starfield": "Starfield",
}
# Get the directory name for this game type
game_dir_name = game_dir_names.get(special_game_type)
if not game_dir_name:
logger.debug(f"No user directory mapping for game type: {special_game_type}")
return
# Non-Bethesda games: AppData/Local only, with a vendor-namespaced subdirectory
game_appdata_only = {
"cp2077": os.path.join("CD Projekt Red", "Cyberpunk 2077"),
"bg3": os.path.join("Larian Studios", "Baldur's Gate 3"),
}
# special_game_type covers FNV/FO3/Enderal (vanilla-compatdata games).
# Skyrim SE returns None from detect_special_game_type but still needs seeding.
game_type = special_game_type
if special_game_type is None and modlist_dir and self._detect_skyrim_se_modlist(modlist_dir):
game_type = "skyrim"
base_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser")
if game_type in game_appdata_only:
appdata_dir = os.path.join(base_path, "AppData", "Local", game_appdata_only[game_type])
try:
os.makedirs(appdata_dir, exist_ok=True)
logger.info(f"Created AppData/Local directory: {appdata_dir}")
except Exception as e:
logger.warning(f"Failed to create AppData/Local directory {appdata_dir}: {e}")
return
game_dir_name = game_dir_names.get(game_type)
if not game_dir_name:
logger.debug(f"No user directory mapping for game type: {game_type}")
return
directories_to_create = [
os.path.join(base_path, "Documents", "My Games", game_dir_name),
os.path.join(base_path, "AppData", "Local", game_dir_name)
os.path.join(base_path, "AppData", "Local", game_dir_name),
]
created_count = 0
@@ -184,90 +240,46 @@ class GameUtilsMixin:
if created_count > 0:
logger.info(f"Created {created_count} user directories for {game_dir_name}")
def _get_lorerim_preferred_proton(self):
"""Get Lorerim's preferred Proton 9 version with specific priority order"""
if game_type == "skyrim":
self._seed_skyrim_first_launch_files(base_path, game_dir_name)
elif game_type == "fo4":
self._seed_fo4_first_launch_files(base_path, game_dir_name)
elif game_type == "skyrimvr":
self._seed_skyrimvr_first_launch_files(base_path, game_dir_name)
elif game_type == "fallout4vr":
self._seed_fallout4vr_first_launch_files(base_path, game_dir_name)
def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""Delegate to FileSystemHandler to seed Skyrim first-launch fix files."""
try:
from jackify.backend.handlers.wine_utils import WineUtils
# Get all available Proton versions
available_versions = WineUtils.scan_all_proton_versions()
if not available_versions:
logger.warning("No Proton versions found for Lorerim override")
return None
# Priority order for Lorerim:
# 1. GEProton9-27 (specific version)
# 2. Other GEProton-9 versions (latest first)
# 3. Valve Proton 9 (any version)
preferred_candidates = []
for version in available_versions:
version_name = version['name']
# Priority 1: GEProton9-27 specifically
if version_name == 'GE-Proton9-27':
logger.info(f"Lorerim: Found preferred GE-Proton9-27")
return version_name
# Priority 2: Other GE-Proton 9 versions
elif version_name.startswith('GE-Proton9-'):
preferred_candidates.append(('ge_proton_9', version_name, version))
# Priority 3: Valve Proton 9
elif 'Proton 9' in version_name:
preferred_candidates.append(('valve_proton_9', version_name, version))
# Return best candidate if any found
if preferred_candidates:
# Sort by priority (GE-Proton first, then by name for latest)
preferred_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
best_candidate = preferred_candidates[0]
logger.info(f"Lorerim: Selected {best_candidate[1]} as best Proton 9 option")
return best_candidate[1]
logger.warning("Lorerim: No suitable Proton 9 versions found, will use user settings")
return None
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
fsh = FileSystemHandler()
fsh._seed_skyrim_first_launch_files(prefix_user, docs_dir_name)
except Exception as e:
logger.error(f"Error detecting Lorerim Proton preference: {e}")
return None
logger.warning(f"Could not seed Skyrim first-launch files: {e}")
def _store_proton_override_notification(self, modlist_name: str, proton_version: str):
"""Store Proton override information for end-of-install notification"""
def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""Delegate to FileSystemHandler to seed Fallout 4 first-launch fix files."""
try:
# Store override info for later display
if not hasattr(self, '_proton_overrides'):
self._proton_overrides = []
self._proton_overrides.append({
'modlist': modlist_name,
'proton_version': proton_version,
'reason': f'{modlist_name} requires Proton 9 for optimal compatibility'
})
logger.debug(f"Stored Proton override notification: {modlist_name}{proton_version}")
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
fsh = FileSystemHandler()
fsh._seed_fo4_first_launch_files(prefix_user, docs_dir_name)
except Exception as e:
logger.error(f"Failed to store Proton override notification: {e}")
logger.warning(f"Could not seed FO4 first-launch files: {e}")
def _show_proton_override_notification(self, progress_callback=None):
"""Display any Proton override notifications to the user"""
def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""Delegate to FileSystemHandler to seed Skyrim VR first-launch fix files."""
try:
if hasattr(self, '_proton_overrides') and self._proton_overrides:
for override in self._proton_overrides:
notification_msg = f"PROTON OVERRIDE: {override['modlist']} configured to use {override['proton_version']} for optimal compatibility"
if progress_callback:
progress_callback("")
progress_callback(f"{self._get_progress_timestamp()} {notification_msg}")
logger.info(notification_msg)
# Clear notifications after display
self._proton_overrides = []
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
fsh = FileSystemHandler()
fsh._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
except Exception as e:
logger.error(f"Failed to show Proton override notification: {e}")
logger.warning(f"Could not seed SkyrimVR first-launch files: {e}")
def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""Delegate to FileSystemHandler to seed Fallout 4 VR first-launch fix files."""
try:
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
fsh = FileSystemHandler()
fsh._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name)
except Exception as e:
logger.warning(f"Could not seed FO4VR first-launch files: {e}")

View File

@@ -20,23 +20,6 @@ class ProtonOperationsMixin:
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.wine_utils import WineUtils
# Check for Lorerim-specific Proton override first
modlist_normalized = modlist_name.lower().replace(" ", "") if modlist_name else ""
if modlist_normalized == 'lorerim':
lorerim_proton = self._get_lorerim_preferred_proton()
if lorerim_proton:
logger.info(f"Lorerim detected: Using {lorerim_proton} instead of user settings")
self._store_proton_override_notification("Lorerim", lorerim_proton)
return lorerim_proton
# Check for Lost Legacy-specific Proton override (needs Proton 9 for ENB compatibility)
if modlist_normalized == 'lostlegacy':
lostlegacy_proton = self._get_lorerim_preferred_proton() # Use same logic as Lorerim
if lostlegacy_proton:
logger.info(f"Lost Legacy detected: Using {lostlegacy_proton} instead of user settings (ENB compatibility)")
self._store_proton_override_notification("Lost Legacy", lostlegacy_proton)
return lostlegacy_proton
config_handler = ConfigHandler()
user_proton_path = config_handler.get_game_proton_path()

View File

@@ -1,5 +1,6 @@
"""Registry operations mixin for AutomatedPrefixService."""
import os
import re
import subprocess
import logging
from pathlib import Path
@@ -74,7 +75,7 @@ class RegistryOperationsMixin:
def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str):
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists.
Direct file editing is preferred over `wine reg add` faster, no Wine
Direct file editing is preferred over `wine reg add` - faster, no Wine
process overhead, and works even when Proton isn't on PATH. Falls back
to subprocess wine reg add when the reg files haven't been created yet.
"""
@@ -91,10 +92,12 @@ class RegistryOperationsMixin:
fix1 = fix2 = False
# Targeted per-exe override for SkyrimSE.exe only - see modlist_wine_ops.py
# for rationale. Global DllOverrides entry breaks .NET 9/10 bootstrap.
if os.path.exists(user_reg):
fix1 = self._reg_set_value(
user_reg,
"[Software\\\\Wine\\\\DllOverrides]",
"[Software\\\\Wine\\\\AppDefaults\\\\SkyrimSE.exe\\\\DllOverrides]",
'"*mscoree"',
'"native"',
)
@@ -123,7 +126,7 @@ class RegistryOperationsMixin:
r1 = subprocess.run(
[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'],
env=env, capture_output=True, text=True, errors='replace',
)
@@ -145,6 +148,53 @@ class RegistryOperationsMixin:
logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
return False
def _apply_cp2077_dll_overrides(self, modlist_compatdata_path: str) -> bool:
"""Write CP2077 DLL overrides directly into the prefix user.reg.
MO2 on Linux launches each executable through a separate Proton invocation,
so WINEDLLOVERRIDES set in Steam launch options is not inherited by the game
process. Writing the overrides into user.reg ensures they are always applied
regardless of how the process is started.
version and winmm are the entry-point DLLs for CET and Red4ext respectively.
Without native,builtin for both, neither mod framework can inject into the
game process and CP2077 exits immediately.
"""
try:
user_reg = os.path.join(modlist_compatdata_path, "pfx", "user.reg")
if not os.path.exists(user_reg):
logger.warning("user.reg not found, cannot apply CP2077 DLL overrides")
return False
section = "[Software\\\\Wine\\\\DllOverrides]"
overrides = [
('"version"', '"native,builtin"'),
('"winmm"', '"native,builtin"'),
]
for key, val in overrides:
self._reg_set_value(user_reg, section, key, val)
logger.info("Applied CP2077 DLL overrides (version, winmm) to prefix registry")
return True
except Exception as e:
logger.error(f"Failed to apply CP2077 DLL overrides: {e}")
return False
@staticmethod
def _wow64_counterpart(section: str) -> str:
"""Return the Wow6432Node counterpart for a registry section, or vice versa.
NaK writes both paths for every game so both 32-bit and 64-bit lookups
resolve correctly regardless of the calling process's bitness.
"""
low = section.lower()
if "wow6432node" in low:
# Strip Wow6432Node to get the 64-bit path
return re.sub(r'(?i)wow6432node\\\\', '', section)
else:
# Insert Wow6432Node after the opening [Software\\
return re.sub(r'(?i)(\[Software\\\\)', r'\1Wow6432Node\\\\', section)
def _reg_set_value(self, reg_path: str, section: str, key: str, value: str) -> bool:
"""Set or add a key=value pair in a Wine .reg text file."""
try:
@@ -319,19 +369,19 @@ class RegistryOperationsMixin:
"name": "Fallout New Vegas",
"common_names": ["Fallout New Vegas", "FalloutNV"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]",
"path_key": "Installed Path",
"path_key": "installed path",
},
"22300": { # Fallout 3 AppID
"name": "Fallout 3",
"common_names": ["Fallout 3", "Fallout3", "Fallout 3 GOTY"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]",
"path_key": "Installed Path",
"path_key": "installed path",
},
"22370": { # Fallout 3 GOTY AppID alias
"name": "Fallout 3",
"common_names": ["Fallout 3 GOTY", "Fallout 3"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]",
"path_key": "Installed Path",
"path_key": "installed path",
},
"976620": { # Enderal Special Edition AppID
"name": "Enderal",
@@ -339,6 +389,72 @@ class RegistryOperationsMixin:
"registry_section": "[Software\\\\Wow6432Node\\\\SureAI\\\\Enderal SE]",
"path_key": "installed path",
},
"1091500": { # Cyberpunk 2077 AppID
"name": "Cyberpunk 2077",
"common_names": ["Cyberpunk 2077"],
"registry_section": "[Software\\\\CD Projekt Red\\\\Cyberpunk 2077]",
"path_key": "InstallFolder",
},
"1086940": { # Baldur's Gate 3 AppID
"name": "Baldur's Gate 3",
"common_names": ["Baldur's Gate 3", "BaldursGate3"],
"registry_section": "[Software\\\\Larian Studios\\\\Baldur's Gate 3]",
"path_key": "InstallDir",
},
"611670": { # Skyrim VR AppID (64-bit, no Wow6432Node)
"name": "Skyrim VR",
"common_names": ["Skyrim VR", "SkyrimVR"],
"registry_section": "[Software\\\\Bethesda Softworks\\\\Skyrim VR]",
"path_key": "Installed Path",
},
"611660": { # Fallout 4 VR AppID (64-bit, no Wow6432Node)
"name": "Fallout 4 VR",
"common_names": ["Fallout 4 VR", "Fallout4VR"],
"registry_section": "[Software\\\\Bethesda Softworks\\\\Fallout 4 VR]",
"path_key": "Installed Path",
},
"22330": { # Oblivion AppID
"name": "Oblivion",
"common_names": ["Oblivion", "Elder Scrolls IV Oblivion"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\oblivion]",
"path_key": "installed path",
},
"1716740": { # Starfield AppID (64-bit, no Wow6432Node)
"name": "Starfield",
"common_names": ["Starfield"],
"registry_section": "[Software\\\\Bethesda Softworks\\\\Starfield]",
"path_key": "Installed Path",
},
"489830": { # Skyrim Special Edition AppID (64-bit, no Wow6432Node)
"name": "Skyrim Special Edition",
"common_names": ["Skyrim Special Edition", "SkyrimSE", "Skyrim Anniversary Edition"],
"registry_section": "[Software\\\\Bethesda Softworks\\\\Skyrim Special Edition]",
"path_key": "Installed Path",
},
"377160": { # Fallout 4 AppID (64-bit, no Wow6432Node)
"name": "Fallout 4",
"common_names": ["Fallout 4", "Fallout4"],
"registry_section": "[Software\\\\Bethesda Softworks\\\\Fallout4]",
"path_key": "Installed Path",
},
"22320": { # Morrowind AppID (32-bit, Wow6432Node)
"name": "Morrowind",
"common_names": ["Morrowind", "Elder Scrolls III Morrowind"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\morrowind]",
"path_key": "Installed Path",
},
"292030": { # The Witcher 3 AppID (64-bit, no Wow6432Node)
"name": "The Witcher 3",
"common_names": ["The Witcher 3", "Witcher 3", "The Witcher 3 Wild Hunt"],
"registry_section": "[Software\\\\CD Projekt Red\\\\The Witcher 3]",
"path_key": "InstallFolder",
},
"2623190": { # Oblivion Remastered AppID (64-bit UE5, no Wow6432Node)
"name": "Oblivion Remastered",
"common_names": ["Oblivion Remastered", "OblivionRemastered"],
"registry_section": "[Software\\\\Bethesda Softworks\\\\Oblivion Remastered]",
"path_key": "Installed Path",
},
}
pfx_path = Path(modlist_compatdata_path) / "pfx"
@@ -359,24 +475,22 @@ class RegistryOperationsMixin:
game_dir_name = Path(game_path).name
canonical_win_path = f"C:\\Program Files (x86)\\Steam\\steamapps\\common\\{game_dir_name}"
wine_val = canonical_win_path.replace("\\", "\\\\") + "\\\\"
success = self._reg_set_value(
system_reg_path,
config["registry_section"],
f'"{config["path_key"]}"',
f'"{wine_val}"',
)
key = f'"{config["path_key"]}"'
val = f'"{wine_val}"'
success = self._reg_set_value(system_reg_path, config["registry_section"], key, val)
self._reg_set_value(system_reg_path, self._wow64_counterpart(config["registry_section"]), key, val)
if success:
logger.info(f"Registry set to canonical path for {config['name']}: {canonical_win_path}")
else:
logger.warning(f"Failed to set canonical registry path for {config['name']}")
else:
# Symlink failed fall back to writing the real Z:/D: path
# Symlink failed - fall back to writing the real Z:/D: path
logger.warning(f"Symlink failed for {config['name']}, writing real path to registry")
success = self._update_registry_path(
system_reg_path,
config["registry_section"],
config["path_key"],
game_path
system_reg_path, config["registry_section"], config["path_key"], game_path
)
self._update_registry_path(
system_reg_path, self._wow64_counterpart(config["registry_section"]), config["path_key"], game_path
)
if success:
logger.info(f"Updated registry entry for {config['name']} (real path fallback)")

View File

@@ -269,10 +269,8 @@ exit"""
def get_ttw_installer_path() -> Optional[Path]:
"""Get path to TTW_Linux_Installer if available"""
try:
from jackify.shared.paths import get_jackify_data_dir
ttw_path = get_jackify_data_dir() / "TTW_Linux_Installer" / "ttw_linux_gui"
if ttw_path.exists():
return ttw_path
from .ttw_installer_service import get_ttw_installer_path
return get_ttw_installer_path()
except Exception:
pass
return None
@@ -375,7 +373,7 @@ exit"""
def get_prefix_path(self, appid: int) -> Optional[Path]:
"""
Get the path to the Proton prefix for the given AppID.
Uses same Flatpak detection as create_prefix_with_proton_wrapper.
Uses the same preferred Steam install selection as create_prefix_with_proton_wrapper.
Args:
appid: The AppID (unsigned, positive number)
@@ -383,20 +381,11 @@ exit"""
Returns:
Path to the prefix directory, or None if not found
"""
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
all_libraries = path_handler.get_all_steam_library_paths()
steam_root, _steam_type = self._get_preferred_steam_root_and_type()
if not steam_root:
return None
# Check if Flatpak Steam
is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in all_libraries)
if is_flatpak_steam and all_libraries:
# Flatpak Steam: use first library root
library_root = all_libraries[0]
compatdata_dir = library_root / "steamapps/compatdata"
else:
# Native Steam
compatdata_dir = Path.home() / ".steam/steam/steamapps/compatdata"
compatdata_dir = steam_root / "steamapps" / "compatdata"
# Ensure we use the absolute value (unsigned AppID)
prefix_dir = compatdata_dir / str(abs(appid))
@@ -405,4 +394,3 @@ exit"""
return prefix_dir
else:
return None

Some files were not shown because too many files have changed in this diff Show More