mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 00:23:42 +02:00
1347 lines
50 KiB
Python
Executable File
1347 lines
50 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Jackify Install Verifier: standalone diagnostic tool.
|
|
|
|
Run with no arguments to auto-discover installed modlists from Steam shortcuts.
|
|
Or point directly at a prefix and modlist directory.
|
|
|
|
Usage:
|
|
python3 verify_install.py # interactive selection
|
|
python3 verify_install.py <prefix_path> <modlist_dir> # direct
|
|
python3 verify_install.py <prefix_path> <modlist_dir> -g falloutnv
|
|
|
|
No external dependencies required. vdf is bundled in tools/vdf/.
|
|
"""
|
|
|
|
import argparse
|
|
import configparser
|
|
import os
|
|
import re
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import List, Dict, Optional, Tuple
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
try:
|
|
import vdf
|
|
HAS_VDF = True
|
|
except ImportError:
|
|
HAS_VDF = False
|
|
|
|
|
|
def _vdf_extract_library_paths(content: str) -> list:
|
|
"""Extract 'path' values from text-format libraryfolders.vdf."""
|
|
return re.findall(r'"path"\s+"([^"]+)"', content)
|
|
|
|
|
|
def _vdf_extract_compat_tool(content: str, appid: str) -> Optional[str]:
|
|
"""Extract the CompatToolMapping name for a given AppID from text config.vdf."""
|
|
pattern = (
|
|
r'"CompatToolMapping"\s*\{.*?"' + re.escape(appid) + r'"\s*\{.*?"name"\s+"([^"]+)"'
|
|
)
|
|
m = re.search(pattern, content, re.DOTALL | re.IGNORECASE)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def _vdf_parse_binary_shortcuts(data: bytes) -> dict:
|
|
"""Minimal binary VDF parser sufficient for shortcuts.vdf."""
|
|
pos = [0]
|
|
|
|
def read_str():
|
|
end = data.index(b'\x00', pos[0])
|
|
s = data[pos[0]:end].decode('utf-8', errors='replace')
|
|
pos[0] = end + 1
|
|
return s
|
|
|
|
def read_i32():
|
|
v = struct.unpack_from('<i', data, pos[0])[0]
|
|
pos[0] += 4
|
|
return v
|
|
|
|
def read_map():
|
|
result = {}
|
|
while pos[0] < len(data):
|
|
t = data[pos[0]]
|
|
pos[0] += 1
|
|
if t == 0x08:
|
|
break
|
|
key = read_str()
|
|
if t == 0x00:
|
|
result[key] = read_map()
|
|
elif t == 0x01:
|
|
result[key] = read_str()
|
|
elif t == 0x02:
|
|
result[key] = read_i32()
|
|
elif t == 0x03:
|
|
pos[0] += 4 # float32 - skip
|
|
elif t == 0x07:
|
|
pos[0] += 8 # int64 - skip
|
|
return result
|
|
|
|
try:
|
|
if not data or data[0] != 0x00:
|
|
return {}
|
|
pos[0] = 1
|
|
root_key = read_str()
|
|
return {root_key: read_map()}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Game type definitions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
GAME_KEYWORDS = {
|
|
"oblivion_remastered": ["oblivion remastered", "oblivionremastered"],
|
|
"skyrimvr": ["skyrim vr", "skyrimvr"],
|
|
"fallout4vr": ["fallout 4 vr", "fallout4vr", "fo4vr"],
|
|
"cp2077": ["cyberpunk", "cp2077", "cyberpunk 2077"],
|
|
"bg3": ["baldur's gate 3", "baldursgate3", "bg3"],
|
|
"skyrim": ["skyrim", "sse", "skse", "dragonborn"],
|
|
"fallout4": ["fallout 4", "fo4", "f4se", "commonwealth"],
|
|
"falloutnv": ["fallout new vegas", "fonv", "fnv", "new vegas", "nvse", "ttw", "tale of two wastelands"],
|
|
"fallout3": ["fallout 3", "fo3", "fallout3", "fose"],
|
|
"oblivion": ["oblivion", "obse", "shivering isles"],
|
|
"enderal": ["enderal"],
|
|
"starfield": ["starfield"],
|
|
}
|
|
|
|
# My Games directory name per game type
|
|
MY_GAMES_DIR = {
|
|
"skyrim": "Skyrim Special Edition",
|
|
"skyrimvr": "Skyrim VR",
|
|
"fallout4": "Fallout4",
|
|
"fallout4vr": "Fallout4VR",
|
|
"falloutnv": "FalloutNV",
|
|
"fallout3": "Fallout3",
|
|
"oblivion": "Oblivion",
|
|
"oblivion_remastered": "Oblivion Remastered",
|
|
"enderal": "Enderal Special Edition",
|
|
"starfield": "Starfield",
|
|
}
|
|
|
|
# AppData/Local directory names (USVFS anchors or vendor paths)
|
|
APPDATA_LOCAL_DIRS = {
|
|
"skyrim": ["Skyrim Special Edition"],
|
|
"fallout4": ["Fallout4"],
|
|
"cp2077": ["CD Projekt Red/Cyberpunk 2077"],
|
|
"bg3": ["Larian Studios/Baldur's Gate 3"],
|
|
}
|
|
|
|
# Registry injection targets: game_type -> (reg section, key name, reg file)
|
|
# reg file is "user" (user.reg / HKCU) or "system" (system.reg / HKLM)
|
|
GAME_REGISTRY = {
|
|
"falloutnv": (r"Software\\Wow6432Node\\bethesda softworks\\falloutnv", "Installed Path", "system"),
|
|
"fallout3": (r"Software\\Wow6432Node\\bethesda softworks\\fallout3", "Installed Path", "system"),
|
|
"enderal": (r"Software\\Wow6432Node\\SureAI\\Enderal SE", "installed path", "system"),
|
|
"cp2077": (r"Software\\CD Projekt Red\\Cyberpunk 2077", "InstallFolder", "system"),
|
|
"bg3": (r"Software\\Larian Studios\\Baldur's Gate 3", "InstallDir", "system"),
|
|
}
|
|
|
|
# winetricks components Jackify installs per game type (modlist_wine_ops.py)
|
|
_WT_DEFAULT = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"]
|
|
_WT_MODERN = _WT_DEFAULT + ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
|
|
_WT_LEGACY = _WT_DEFAULT + ["d3dx9_43", "d3dx9"]
|
|
|
|
GAME_WINETRICKS = {
|
|
"skyrim": _WT_MODERN, "skyrimvr": _WT_MODERN, "fallout4": _WT_MODERN,
|
|
"fallout4vr": _WT_MODERN, "starfield": _WT_MODERN, "oblivion_remastered": _WT_MODERN,
|
|
"enderal": _WT_MODERN, "cp2077": _WT_MODERN, "bg3": _WT_MODERN,
|
|
"falloutnv": _WT_LEGACY, "fallout3": _WT_LEGACY, "oblivion": _WT_LEGACY,
|
|
}
|
|
|
|
# Seeded files: game_type -> list of (relative_path, description, optional_content_check)
|
|
SEEDED_FILES = {
|
|
"skyrim": [
|
|
("users/steamuser/AppData/Local/Skyrim Special Edition/Plugins.txt",
|
|
"USVFS anchor (empty file)", None),
|
|
("users/steamuser/Documents/My Games/Skyrim Special Edition/SkyrimPrefs.ini",
|
|
"CC popup suppression stub", "bDownloadCC"),
|
|
],
|
|
"fallout4": [
|
|
("users/steamuser/AppData/Local/Fallout4/Plugins.txt",
|
|
"USVFS anchor (empty file)", None),
|
|
],
|
|
"skyrimvr": [
|
|
("users/steamuser/AppData/Local/Skyrim VR/Plugins.txt",
|
|
"USVFS anchor (empty file)", None),
|
|
("users/steamuser/Documents/My Games/Skyrim VR/SkyrimPrefs.ini",
|
|
"VR first-launch stub", "bLoadVRPlayroom"),
|
|
],
|
|
"fallout4vr": [
|
|
("users/steamuser/AppData/Local/Fallout4VR/Plugins.txt",
|
|
"USVFS anchor (empty file)", None),
|
|
],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Result tracking
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class Results:
|
|
def __init__(self):
|
|
self.passes = []
|
|
self.warnings = []
|
|
self.failures = []
|
|
# List of {"name": str, "method": "native"|"winetricks"|"unknown"} dicts
|
|
self.installed_components = []
|
|
|
|
def ok(self, msg):
|
|
self.passes.append(msg)
|
|
print(f" [PASS] {msg}")
|
|
|
|
def warn(self, msg):
|
|
self.warnings.append(msg)
|
|
print(f" [WARN] {msg}")
|
|
|
|
def fail(self, msg):
|
|
self.failures.append(msg)
|
|
print(f" [FAIL] {msg}")
|
|
|
|
def summary(self):
|
|
total = len(self.passes) + len(self.warnings) + len(self.failures)
|
|
print(f"\n{'='*60}")
|
|
print(f"Results: {len(self.passes)} passed, {len(self.warnings)} warnings, {len(self.failures)} failures (of {total} checks)")
|
|
if self.failures:
|
|
print(f"\nFailures:")
|
|
for f in self.failures:
|
|
print(f" - {f}")
|
|
if self.warnings:
|
|
print(f"\nWarnings:")
|
|
for w in self.warnings:
|
|
print(f" - {w}")
|
|
return len(self.failures) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Steam discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
STEAM_ROOTS = [
|
|
Path.home() / ".steam" / "steam",
|
|
Path.home() / ".local" / "share" / "Steam",
|
|
Path.home() / ".steam" / "root",
|
|
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "data" / "Steam",
|
|
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / ".local" / "share" / "Steam",
|
|
]
|
|
|
|
|
|
def _find_steam_roots() -> List[Path]:
|
|
seen = set()
|
|
results = []
|
|
for r in STEAM_ROOTS:
|
|
if not r.is_dir():
|
|
continue
|
|
resolved = r.resolve()
|
|
if resolved not in seen:
|
|
seen.add(resolved)
|
|
results.append(r)
|
|
return results
|
|
|
|
|
|
def _find_steam_library_paths() -> List[Path]:
|
|
"""Parse libraryfolders.vdf from all known Steam roots."""
|
|
libraries = set()
|
|
for root in _find_steam_roots():
|
|
libraries.add(root)
|
|
vdf_path = root / "config" / "libraryfolders.vdf"
|
|
if not vdf_path.is_file():
|
|
continue
|
|
try:
|
|
content = vdf_path.read_text(encoding="utf-8", errors="replace")
|
|
if HAS_VDF:
|
|
data = vdf.loads(content)
|
|
for _, lib_data in data.get("libraryfolders", {}).items():
|
|
if isinstance(lib_data, dict) and "path" in lib_data:
|
|
p = Path(lib_data["path"])
|
|
if p.is_dir():
|
|
libraries.add(p)
|
|
else:
|
|
for path_str in _vdf_extract_library_paths(content):
|
|
p = Path(path_str)
|
|
if p.is_dir():
|
|
libraries.add(p)
|
|
except Exception:
|
|
pass
|
|
return list(libraries)
|
|
|
|
|
|
def _find_compat_data(appid: str) -> Optional[Path]:
|
|
"""Resolve a Steam AppID to its compatdata directory."""
|
|
for lib in _find_steam_library_paths():
|
|
p = lib / "steamapps" / "compatdata" / appid
|
|
if p.is_dir():
|
|
return p
|
|
return None
|
|
|
|
|
|
def _find_shortcuts_vdf_paths() -> List[Path]:
|
|
"""Find all shortcuts.vdf files across Steam userdata directories."""
|
|
paths = []
|
|
for root in _find_steam_roots():
|
|
userdata = root / "userdata"
|
|
if not userdata.is_dir():
|
|
continue
|
|
for user_dir in userdata.iterdir():
|
|
if not user_dir.is_dir():
|
|
continue
|
|
s = user_dir / "config" / "shortcuts.vdf"
|
|
if s.is_file():
|
|
paths.append(s)
|
|
return paths
|
|
|
|
|
|
def _parse_shortcuts_vdf(path: Path) -> List[Dict]:
|
|
"""Return list of shortcut dicts from a binary shortcuts.vdf."""
|
|
try:
|
|
with open(path, "rb") as f:
|
|
raw = f.read()
|
|
if HAS_VDF:
|
|
import io
|
|
data = vdf.binary_load(io.BytesIO(raw))
|
|
else:
|
|
data = _vdf_parse_binary_shortcuts(raw)
|
|
return list(data.get("shortcuts", {}).values())
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _signed_to_unsigned(appid: int) -> int:
|
|
if appid < 0:
|
|
return appid + (2 ** 32)
|
|
return appid
|
|
|
|
|
|
def discover_installed_modlists() -> List[Dict]:
|
|
"""
|
|
Scan Steam shortcuts.vdf for non-Steam shortcuts pointing at ModOrganizer.exe.
|
|
Returns list of dicts: {name, appid (str), modlist_dir, pfx, game_type}
|
|
"""
|
|
results = []
|
|
seen_appids = set()
|
|
|
|
for vdf_path in _find_shortcuts_vdf_paths():
|
|
for shortcut in _parse_shortcuts_vdf(vdf_path):
|
|
exe = shortcut.get("Exe", shortcut.get("exe", "")).strip('"')
|
|
if "ModOrganizer.exe" not in os.path.basename(exe):
|
|
continue
|
|
|
|
name = shortcut.get("AppName", shortcut.get("appname", "Unknown"))
|
|
start_dir = shortcut.get("StartDir", shortcut.get("startdir", "")).strip('"')
|
|
raw_appid = shortcut.get("appid", shortcut.get("AppID", shortcut.get("appId")))
|
|
|
|
if raw_appid is None or not start_dir:
|
|
continue
|
|
|
|
unsigned_appid = str(_signed_to_unsigned(int(raw_appid)))
|
|
if unsigned_appid in seen_appids:
|
|
continue
|
|
seen_appids.add(unsigned_appid)
|
|
|
|
modlist_dir = Path(start_dir)
|
|
# MO2 is sometimes in a files/ subdir
|
|
if not (modlist_dir / "ModOrganizer.ini").exists():
|
|
alt = modlist_dir / "files"
|
|
if (alt / "ModOrganizer.ini").exists():
|
|
modlist_dir = alt
|
|
|
|
compat_data = _find_compat_data(unsigned_appid)
|
|
pfx = (compat_data / "pfx") if compat_data else None
|
|
|
|
game_type = detect_game_type(modlist_dir) if modlist_dir.is_dir() else "unknown"
|
|
|
|
results.append({
|
|
"name": name,
|
|
"appid": unsigned_appid,
|
|
"modlist_dir": modlist_dir,
|
|
"pfx": pfx,
|
|
"game_type": game_type,
|
|
})
|
|
|
|
return results
|
|
|
|
|
|
def select_modlist_interactive() -> Optional[Dict]:
|
|
"""Prompt user to pick from discovered modlists. Returns selected entry or None."""
|
|
print("Scanning Steam shortcuts for installed modlists...")
|
|
modlists = discover_installed_modlists()
|
|
|
|
if not modlists:
|
|
print("No modlists found. Ensure Steam shortcuts exist for ModOrganizer.exe.")
|
|
return None
|
|
|
|
print(f"\nFound {len(modlists)} modlist(s):\n")
|
|
for i, m in enumerate(modlists, 1):
|
|
pfx_status = "pfx found" if (m["pfx"] and m["pfx"].is_dir()) else "pfx NOT found"
|
|
print(f" {i}. {m['name']}")
|
|
print(f" Game: {m['game_type']}")
|
|
print(f" Dir: {m['modlist_dir']}")
|
|
print(f" AppID: {m['appid']} ({pfx_status})")
|
|
print()
|
|
|
|
while True:
|
|
try:
|
|
choice = input(f"Select modlist [1-{len(modlists)}] or q to quit: ").strip()
|
|
if choice.lower() == "q":
|
|
return None
|
|
idx = int(choice) - 1
|
|
if 0 <= idx < len(modlists):
|
|
return modlists[idx]
|
|
except (ValueError, EOFError):
|
|
pass
|
|
print(f"Enter a number between 1 and {len(modlists)}.")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def detect_game_type(modlist_dir: Path) -> str:
|
|
"""Detect game type from the gameName key in ModOrganizer.ini [General]."""
|
|
mo_ini = modlist_dir / "ModOrganizer.ini"
|
|
if not mo_ini.exists():
|
|
mo_ini = modlist_dir / "files" / "ModOrganizer.ini"
|
|
|
|
if mo_ini.exists():
|
|
try:
|
|
for line in mo_ini.read_text(errors="replace").splitlines():
|
|
stripped = line.strip().lower()
|
|
if stripped.startswith("gamename"):
|
|
parts = stripped.split("=", 1)
|
|
if len(parts) != 2:
|
|
continue
|
|
game_name = parts[1].strip()
|
|
for game_type, keywords in GAME_KEYWORDS.items():
|
|
for kw in keywords:
|
|
if kw in game_name:
|
|
return game_type
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback: match against the directory name only
|
|
dir_name = modlist_dir.name.lower()
|
|
for game_type, keywords in GAME_KEYWORDS.items():
|
|
for kw in keywords:
|
|
if kw in dir_name:
|
|
return game_type
|
|
|
|
return "unknown"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def check_prefix_structure(pfx: Path, r: Results):
|
|
"""Verify basic prefix directory structure."""
|
|
print("\n--- Prefix Structure ---")
|
|
drive_c = pfx / "drive_c"
|
|
if drive_c.is_dir():
|
|
r.ok("drive_c exists")
|
|
else:
|
|
r.fail("drive_c missing")
|
|
return
|
|
|
|
for subdir in ["windows/system32", "windows/syswow64", "users/steamuser"]:
|
|
p = drive_c / subdir
|
|
if p.is_dir():
|
|
r.ok(f"{subdir} exists")
|
|
else:
|
|
r.fail(f"{subdir} missing")
|
|
|
|
|
|
def check_registry_base(pfx: Path, r: Results):
|
|
"""Check universal registry entries."""
|
|
print("\n--- Base Registry Entries ---")
|
|
user_reg = pfx / "user.reg"
|
|
system_reg = pfx / "system.reg"
|
|
|
|
if not user_reg.exists():
|
|
r.fail("user.reg missing")
|
|
return
|
|
r.ok("user.reg exists")
|
|
|
|
if not system_reg.exists():
|
|
r.fail("system.reg missing")
|
|
return
|
|
r.ok("system.reg exists")
|
|
|
|
user_content = user_reg.read_text(errors="replace")
|
|
system_content = system_reg.read_text(errors="replace")
|
|
|
|
# FontSmoothingType
|
|
if "FontSmoothingType" in user_content:
|
|
r.ok("FontSmoothingType set")
|
|
else:
|
|
r.warn("FontSmoothingType not found")
|
|
|
|
# ShowDotFiles
|
|
if "ShowDotFiles" in user_content:
|
|
r.ok("ShowDotFiles set")
|
|
else:
|
|
r.warn("ShowDotFiles not found")
|
|
|
|
|
|
|
|
def check_game_registry(pfx: Path, game_type: str, r: Results):
|
|
"""Check game-specific registry entries."""
|
|
if game_type not in GAME_REGISTRY:
|
|
return
|
|
|
|
print(f"\n--- Game Registry ({game_type}) ---")
|
|
section_pattern, key_name, reg_file = GAME_REGISTRY[game_type]
|
|
reg_path = pfx / ("system.reg" if reg_file == "system" else "user.reg")
|
|
if not reg_path.exists():
|
|
r.fail(f"{reg_path.name} missing, cannot check game registry")
|
|
return
|
|
|
|
content = reg_path.read_text(errors="replace")
|
|
section_search = section_pattern.lower().replace("\\\\", "\\")
|
|
content_lower = content.lower().replace("\\\\", "\\")
|
|
|
|
if section_search in content_lower:
|
|
r.ok(f"Game registry section found: {section_pattern}")
|
|
if key_name.lower() in content_lower:
|
|
r.ok(f"Registry key '{key_name}' present")
|
|
else:
|
|
r.fail(f"Registry key '{key_name}' not found in game section")
|
|
else:
|
|
r.fail(f"Game registry section missing: {section_pattern}")
|
|
|
|
|
|
def check_user_directories(pfx: Path, game_type: str, r: Results):
|
|
"""Check My Games and AppData directories."""
|
|
print(f"\n--- User Directories ({game_type}) ---")
|
|
drive_c = pfx / "drive_c"
|
|
|
|
# My Games
|
|
if game_type in MY_GAMES_DIR:
|
|
my_games = drive_c / "users/steamuser/Documents/My Games" / MY_GAMES_DIR[game_type]
|
|
if my_games.is_dir():
|
|
r.ok(f"My Games/{MY_GAMES_DIR[game_type]} exists")
|
|
else:
|
|
r.warn(f"My Games/{MY_GAMES_DIR[game_type]} missing")
|
|
|
|
# AppData/Local
|
|
if game_type in APPDATA_LOCAL_DIRS:
|
|
for subdir in APPDATA_LOCAL_DIRS[game_type]:
|
|
appdata = drive_c / "users/steamuser/AppData/Local" / subdir
|
|
if appdata.is_dir():
|
|
r.ok(f"AppData/Local/{subdir} exists")
|
|
else:
|
|
r.fail(f"AppData/Local/{subdir} missing")
|
|
|
|
|
|
def check_seeded_files(pfx: Path, game_type: str, r: Results):
|
|
"""Check first-launch seeded files."""
|
|
if game_type not in SEEDED_FILES:
|
|
return
|
|
|
|
print(f"\n--- First-Launch Seeded Files ({game_type}) ---")
|
|
drive_c = pfx / "drive_c"
|
|
|
|
for rel_path, description, content_check in SEEDED_FILES[game_type]:
|
|
full_path = drive_c / rel_path
|
|
if full_path.exists():
|
|
r.ok(f"{description}: {rel_path}")
|
|
if content_check:
|
|
content = full_path.read_text(errors="replace")
|
|
if content_check in content:
|
|
r.ok(f" contains expected '{content_check}'")
|
|
else:
|
|
r.warn(f" missing expected content '{content_check}'")
|
|
else:
|
|
r.fail(f"{description} missing: {rel_path}")
|
|
|
|
|
|
def _find_protontricks() -> Optional[str]:
|
|
for candidate in ["protontricks", "flatpak run com.github.Matoking.protontricks"]:
|
|
try:
|
|
result = subprocess.run(
|
|
candidate.split() + ["--version"],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
if result.returncode == 0:
|
|
return candidate
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def check_winetricks_components(pfx: Path, appid: str, game_type: str, r: Results):
|
|
"""Verify winetricks components.
|
|
|
|
Primary: reads pfx/winetricks.log (winetricks writes this itself during install, fast).
|
|
Fallback: protontricks list-installed (slow Flatpak cold-start, 120s timeout).
|
|
"""
|
|
print(f"\n--- Winetricks Components ({game_type}) ---")
|
|
expected = GAME_WINETRICKS.get(game_type, _WT_DEFAULT)
|
|
|
|
installed_output = ""
|
|
source = ""
|
|
|
|
# Fast path: winetricks.log is written by protontricks/winetricks after each install
|
|
log_path = pfx / "winetricks.log"
|
|
if log_path.exists():
|
|
try:
|
|
installed_output = log_path.read_text(errors="replace").lower()
|
|
source = "winetricks.log"
|
|
except Exception as e:
|
|
r.warn(f"Could not read winetricks.log: {e}")
|
|
|
|
# Slow fallback: protontricks list-installed
|
|
if not installed_output:
|
|
pt = _find_protontricks()
|
|
if not pt:
|
|
r.warn("winetricks.log missing and protontricks not found -- cannot verify components")
|
|
return
|
|
try:
|
|
result = subprocess.run(
|
|
pt.split() + ["--no-bwrap", appid, "list-installed"],
|
|
capture_output=True, text=True, timeout=120
|
|
)
|
|
if result.returncode != 0:
|
|
r.warn(f"protontricks list-installed failed (rc={result.returncode}): {result.stderr.strip()[:120]}")
|
|
return
|
|
installed_output = result.stdout.lower()
|
|
source = "protontricks list-installed"
|
|
except Exception as e:
|
|
r.warn(f"protontricks list-installed error: {e}")
|
|
return
|
|
|
|
missing = []
|
|
for component in expected:
|
|
base = component.split("=")[0].lower()
|
|
if base in installed_output or component.lower() in installed_output:
|
|
r.ok(f"{component}")
|
|
else:
|
|
missing.append(component)
|
|
r.fail(f"{component} not found (source: {source})")
|
|
|
|
if not missing:
|
|
r.ok(f"All {len(expected)} expected components verified ({source})")
|
|
|
|
# Report notable extras installed beyond the baseline (e.g. dotnet48 added for .NET Script Framework)
|
|
_extras = ["dotnet48", "dotnet9", "dotnet10", "dotnetdesktop9", "dotnetdesktop10"]
|
|
for extra in _extras:
|
|
if extra not in expected and extra in installed_output:
|
|
r.ok(f"{extra} (extra, installed for this modlist)")
|
|
|
|
# Build the installed_components inventory for GUI display.
|
|
# Read jackify_components.json for native-installed components (has method + timestamp).
|
|
native_record = {}
|
|
jc_path = pfx / "jackify_components.json"
|
|
if jc_path.exists():
|
|
try:
|
|
import json as _json
|
|
native_record = _json.loads(jc_path.read_text(errors="replace"))
|
|
except Exception:
|
|
pass
|
|
|
|
# Parse winetricks.log (one component per line) for winetricks-installed components.
|
|
wt_components = set()
|
|
if source == "winetricks.log":
|
|
for line in installed_output.splitlines():
|
|
name = line.strip()
|
|
if name:
|
|
wt_components.add(name)
|
|
|
|
# Merge: native_record takes precedence (has richer metadata).
|
|
seen = set()
|
|
for name, meta in native_record.items():
|
|
seen.add(name.lower())
|
|
r.installed_components.append({"name": name, "method": meta.get("method", "native")})
|
|
for name in sorted(wt_components):
|
|
if name not in seen:
|
|
seen.add(name)
|
|
r.installed_components.append({"name": name, "method": "winetricks"})
|
|
|
|
|
|
_LEGACY_GAME_TYPES = {"falloutnv", "fallout3", "oblivion"}
|
|
|
|
|
|
def _is_nsf_prefix(pfx: Path) -> bool:
|
|
"""Return True if this prefix has global mscoree=native in Wine DllOverrides.
|
|
|
|
All Skyrim prefixes get a per-exe AppDefaults\\SkyrimSE.exe mscoree override -
|
|
that does not qualify. NSF/CSF prefixes additionally have it in the global
|
|
DllOverrides section, which is what this checks.
|
|
"""
|
|
import re
|
|
user_reg = pfx / 'user.reg'
|
|
if not user_reg.is_file():
|
|
return False
|
|
try:
|
|
c = user_reg.read_text(errors='replace').lower()
|
|
return bool(re.search(
|
|
r'\[hkey_current_user\\software\\wine\\dlloverrides\][^\[]*"?\*mscoree"?\s*=\s*"native"',
|
|
c, re.DOTALL
|
|
))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def check_native_dotnet(pfx: Path, r: Results, game_type: str = ""):
|
|
"""Check native dotnet40/48 installation - only meaningful for NSF/CSF prefixes.
|
|
|
|
Non-NSF prefixes always have phantom NDP keys written by Wine Mono; checking
|
|
them produces false positives. Skip entirely unless global mscoree=native is set.
|
|
"""
|
|
if game_type in _LEGACY_GAME_TYPES:
|
|
return
|
|
if not _is_nsf_prefix(pfx):
|
|
return
|
|
|
|
marker40 = pfx / 'drive_c' / 'windows' / 'dotnet40.installed.workaround'
|
|
sys_reg = pfx / 'system.reg'
|
|
ndp40_ok = False
|
|
if sys_reg.is_file():
|
|
try:
|
|
c = sys_reg.read_text(errors='replace').lower()
|
|
ndp40_ok = 'net framework setup\\\\ndp\\\\v4\\\\full' in c and '"install"=dword:00000001' in c
|
|
except Exception:
|
|
pass
|
|
if marker40.is_file() or ndp40_ok:
|
|
source = "marker+NDP" if (marker40.is_file() and ndp40_ok) else ("marker" if marker40.is_file() else "NDP registry")
|
|
r.ok(f"dotnet40 (NDP seeded, verified via {source})")
|
|
else:
|
|
r.fail("dotnet40 not detected: workaround marker and NDP registry keys both absent")
|
|
|
|
marker = pfx / 'drive_c' / 'windows' / 'dotnet48.installed.workaround'
|
|
ndp_ok = False
|
|
if sys_reg.is_file():
|
|
try:
|
|
c = sys_reg.read_text(errors='replace').lower()
|
|
ndp_ok = 'net framework setup\\\\ndp\\\\v4\\\\full' in c and '"release"' in c
|
|
except Exception:
|
|
pass
|
|
if marker.is_file() or ndp_ok:
|
|
source = "marker+NDP" if (marker.is_file() and ndp_ok) else ("marker" if marker.is_file() else "NDP registry")
|
|
r.ok(f"dotnet48 (native, verified via {source})")
|
|
else:
|
|
r.warn("dotnet48 not detected (expected if modlist uses dotnet40-only stack)")
|
|
|
|
|
|
def check_game_symlink(pfx: Path, modlist_dir: Path, game_type: str, r: Results):
|
|
"""Check the game symlink directory inside the Wine prefix."""
|
|
print(f"\n--- Game Symlink ---")
|
|
common_dir = pfx / "drive_c/Program Files (x86)/Steam/steamapps/common"
|
|
if not common_dir.exists():
|
|
r.warn("Game symlink directory not found in Wine prefix (may be normal for some game types)")
|
|
return
|
|
|
|
links = [p for p in common_dir.iterdir() if p.is_symlink() or p.is_dir()]
|
|
if not links:
|
|
r.warn("No game directories found in fake Steam common path")
|
|
return
|
|
|
|
r.ok(f"Game symlinks present: {len(links)} entr{'y' if len(links) == 1 else 'ies'}")
|
|
|
|
|
|
def check_modlist_dir(modlist_dir: Path, r: Results):
|
|
"""Basic modlist directory checks."""
|
|
print(f"\n--- Modlist Directory ---")
|
|
if not modlist_dir.is_dir():
|
|
r.fail(f"Modlist directory does not exist: {modlist_dir}")
|
|
return
|
|
|
|
mo_exe = None
|
|
for candidate in ["ModOrganizer.exe", "modorganizer.exe"]:
|
|
p = modlist_dir / candidate
|
|
if not p.exists():
|
|
p = modlist_dir / "files" / candidate
|
|
if p.exists():
|
|
mo_exe = p
|
|
break
|
|
|
|
if mo_exe:
|
|
r.ok(f"ModOrganizer.exe found: {mo_exe.relative_to(modlist_dir)}")
|
|
else:
|
|
r.warn("ModOrganizer.exe not found in modlist directory")
|
|
|
|
mo_ini = modlist_dir / "ModOrganizer.ini"
|
|
if not mo_ini.exists():
|
|
mo_ini = modlist_dir / "files" / "ModOrganizer.ini"
|
|
if mo_ini.exists():
|
|
r.ok(f"ModOrganizer.ini found")
|
|
else:
|
|
r.warn("ModOrganizer.ini not found")
|
|
|
|
|
|
def _resolve_wine_path(gp: str, pfx: Optional[Path] = None) -> Optional[Path]:
|
|
"""
|
|
Convert a Wine drive path (Z:\\... or D:\\...) to a real filesystem path.
|
|
Z: always maps to filesystem root.
|
|
D: is resolved via the dosdevices symlink in the prefix when available,
|
|
otherwise falls back to scanning /run/media/ for SD card mounts.
|
|
"""
|
|
unix = gp.replace("\\\\", "/").replace("\\", "/")
|
|
drive = unix[:2].lower()
|
|
rest = unix[2:].lstrip("/")
|
|
|
|
if drive == "z:":
|
|
return Path("/" + rest)
|
|
|
|
if drive == "d:":
|
|
# Prefer reading the dosdevices symlink - it is the definitive mapping
|
|
if pfx:
|
|
dosdev = pfx / "dosdevices" / "d:"
|
|
if dosdev.is_symlink():
|
|
try:
|
|
sd_root = Path(os.readlink(dosdev))
|
|
if not sd_root.is_absolute():
|
|
sd_root = (dosdev.parent / sd_root).resolve()
|
|
return sd_root / rest
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback: scan /run/media/ for mounted volumes containing the path
|
|
for media_root in [Path("/run/media/deck"), Path("/run/media")]:
|
|
if not media_root.is_dir():
|
|
continue
|
|
try:
|
|
for mount in media_root.iterdir():
|
|
if not mount.is_dir():
|
|
continue
|
|
candidate = mount / rest
|
|
if candidate.exists():
|
|
return candidate
|
|
# One level deeper (e.g. /run/media/deck/CardName/...)
|
|
for submount in mount.iterdir():
|
|
if submount.is_dir():
|
|
candidate = submount / rest
|
|
if candidate.exists():
|
|
return candidate
|
|
except Exception:
|
|
continue
|
|
|
|
return None
|
|
|
|
|
|
def check_modorganizer_ini(modlist_dir: Path, r: Results, pfx: Optional[Path] = None):
|
|
"""Check ModOrganizer.ini for gamePath set by Jackify during configuration."""
|
|
print("\n--- ModOrganizer.ini ---")
|
|
mo_ini = modlist_dir / "ModOrganizer.ini"
|
|
if not mo_ini.exists():
|
|
mo_ini = modlist_dir / "files" / "ModOrganizer.ini"
|
|
if not mo_ini.exists():
|
|
r.warn("ModOrganizer.ini not found")
|
|
return
|
|
|
|
game_path_raw = None
|
|
for line in mo_ini.read_text(errors="replace").splitlines():
|
|
if line.strip().lower().startswith("gamepath"):
|
|
parts = line.split("=", 1)
|
|
if len(parts) == 2:
|
|
game_path_raw = parts[1].strip()
|
|
break
|
|
|
|
if not game_path_raw:
|
|
r.fail("gamePath not set in ModOrganizer.ini (configuration may not have run)")
|
|
return
|
|
|
|
gp = game_path_raw
|
|
if gp.startswith("@ByteArray(") and gp.endswith(")"):
|
|
gp = gp[len("@ByteArray("):-1]
|
|
|
|
p = _resolve_wine_path(gp, pfx)
|
|
if p is None:
|
|
r.warn(f"gamePath uses unrecognised drive letter: {gp}")
|
|
return
|
|
|
|
if p.is_dir():
|
|
r.ok(f"gamePath set and exists: {p}")
|
|
else:
|
|
drive = gp[:2].upper()
|
|
if drive == "D:":
|
|
r.fail(f"gamePath set but directory missing (SD card path - card may not be mounted): {p}")
|
|
else:
|
|
r.fail(f"gamePath set but directory missing: {p}")
|
|
|
|
|
|
def check_download_directory(modlist_dir: Path, r: Results, pfx: Optional[Path] = None):
|
|
"""Check download_directory in ModOrganizer.ini is a valid Wine path, not a raw Linux path."""
|
|
print("\n--- download_directory ---")
|
|
mo_ini = modlist_dir / "ModOrganizer.ini"
|
|
if not mo_ini.exists():
|
|
mo_ini = modlist_dir / "files" / "ModOrganizer.ini"
|
|
if not mo_ini.exists():
|
|
r.warn("ModOrganizer.ini not found, cannot check download_directory")
|
|
return
|
|
|
|
raw_value = None
|
|
for line in mo_ini.read_text(errors="replace").splitlines():
|
|
if line.strip().lower().startswith("download_directory"):
|
|
parts = line.split("=", 1)
|
|
if len(parts) == 2:
|
|
raw_value = parts[1].strip()
|
|
# Take the last occurrence - MO2 reads the last one in duplicate-section installs
|
|
if raw_value is None:
|
|
r.warn("download_directory not set in ModOrganizer.ini")
|
|
return
|
|
|
|
# Raw Linux path means Bug 3 fix didn't run or engine wrote an un-normalised value
|
|
if raw_value.startswith("/"):
|
|
r.fail(f"download_directory is a raw Linux path (not normalised to Wine format): {raw_value}")
|
|
return
|
|
|
|
drive = raw_value[:2].upper()
|
|
if drive not in ("Z:", "D:"):
|
|
r.fail(f"download_directory has unexpected format: {raw_value}")
|
|
return
|
|
|
|
p = _resolve_wine_path(raw_value, pfx)
|
|
if p is None:
|
|
r.warn(f"download_directory drive could not be resolved: {raw_value}")
|
|
return
|
|
|
|
if p.is_dir():
|
|
r.ok(f"download_directory set and exists: {p}")
|
|
elif drive == "D:":
|
|
r.fail(f"download_directory missing (SD card path - card may not be mounted): {p}")
|
|
else:
|
|
r.fail(f"download_directory set but directory does not exist: {p}")
|
|
|
|
|
|
def check_proton_version(appid: str, r: Results):
|
|
"""Check the Proton compatibility tool set for this AppID in config.vdf."""
|
|
print("\n--- Proton Version ---")
|
|
if not appid:
|
|
r.warn("AppID unknown, cannot check Proton version")
|
|
return
|
|
steam_roots = _find_steam_roots()
|
|
config_vdf = next((root / "config" / "config.vdf" for root in steam_roots if (root / "config" / "config.vdf").exists()), None)
|
|
if not config_vdf:
|
|
r.warn("config.vdf not found")
|
|
return
|
|
|
|
try:
|
|
content = config_vdf.read_text(encoding="utf-8", errors="replace")
|
|
if HAS_VDF:
|
|
data = vdf.loads(content)
|
|
ctm = data["InstallConfigStore"]["Software"]["Valve"]["Steam"].get("CompatToolMapping", {})
|
|
entry = ctm.get(appid)
|
|
name = entry.get("name", "") if entry else None
|
|
else:
|
|
name = _vdf_extract_compat_tool(content, appid)
|
|
if name:
|
|
r.ok(f"Proton version: {name}")
|
|
elif name is not None:
|
|
r.warn("Proton entry exists but tool name is empty")
|
|
else:
|
|
r.fail(f"No Proton version set for AppID {appid}")
|
|
except Exception as e:
|
|
r.warn(f"Could not read config.vdf: {e}")
|
|
|
|
|
|
def check_shortcut_launch_options(appid: str, game_type: str, r: Results):
|
|
"""Read shortcuts.vdf and verify launch options for this AppID."""
|
|
print("\n--- Launch Options ---")
|
|
if not appid:
|
|
r.warn("Cannot check launch options (AppID unknown)")
|
|
return
|
|
|
|
for root in _find_steam_roots():
|
|
userdata = root / "userdata"
|
|
if not userdata.is_dir():
|
|
continue
|
|
for user_dir in userdata.iterdir():
|
|
s = user_dir / "config" / "shortcuts.vdf"
|
|
if not s.is_file():
|
|
continue
|
|
try:
|
|
with open(s, "rb") as f:
|
|
data = vdf.binary_load(f)
|
|
for _, sc in data.get("shortcuts", {}).items():
|
|
raw_id = sc.get("appid", sc.get("AppID"))
|
|
if raw_id is None:
|
|
continue
|
|
unsigned = str(int(raw_id) + (2 ** 32) if int(raw_id) < 0 else int(raw_id))
|
|
if unsigned != appid:
|
|
continue
|
|
opts = sc.get("LaunchOptions", sc.get("launchoptions", ""))
|
|
if opts:
|
|
r.ok(f"Launch options: {opts}")
|
|
else:
|
|
r.warn("Launch options empty")
|
|
if game_type == "cp2077":
|
|
if "WINEDLLOVERRIDES" in opts:
|
|
r.ok("CP2077 WINEDLLOVERRIDES present")
|
|
else:
|
|
r.fail("CP2077 missing required WINEDLLOVERRIDES in launch options")
|
|
return
|
|
except Exception:
|
|
continue
|
|
|
|
r.warn(f"AppID {appid} not found in any shortcuts.vdf")
|
|
|
|
|
|
def check_nemesis_compat(modlist_dir: Path, r: Results, game_type: str = None):
|
|
"""Check Nemesis symlink and workingDirectory fix were applied."""
|
|
mods_dir = modlist_dir / "mods"
|
|
if not mods_dir.is_dir():
|
|
return
|
|
|
|
nemesis_engine_src = next(
|
|
(d / "Nemesis_Engine" for d in mods_dir.iterdir()
|
|
if (d / "Nemesis_Engine").is_dir()),
|
|
None
|
|
)
|
|
if nemesis_engine_src is None:
|
|
return # modlist has no Nemesis, skip silently
|
|
|
|
print("\n--- Nemesis Compatibility ---")
|
|
|
|
stock_game_dir = next(
|
|
(modlist_dir / name for name in ("Stock Game", "StockGame", "Game Root", "Stock Folder")
|
|
if (modlist_dir / name).is_dir()),
|
|
None
|
|
)
|
|
if stock_game_dir:
|
|
symlink = stock_game_dir / "Data" / "Nemesis_Engine"
|
|
if symlink.is_symlink():
|
|
target = Path(os.readlink(symlink))
|
|
if not target.is_absolute():
|
|
target = (symlink.parent / target).resolve()
|
|
expected = nemesis_engine_src.resolve()
|
|
if target == expected:
|
|
r.ok(f"Nemesis_Engine symlink correct: {symlink}")
|
|
else:
|
|
r.warn(f"Nemesis_Engine symlink points to wrong target: {target} (expected {expected})")
|
|
elif symlink.exists():
|
|
r.ok(f"Nemesis_Engine present at {symlink} (shipped by modlist, not a symlink)")
|
|
else:
|
|
# Nemesis is known broken on Linux (v0.6) regardless of symlink state.
|
|
# Downgraded to WARN - symlink missing indicates Step 16 bug but has no user-visible impact yet.
|
|
r.warn(f"Nemesis_Engine symlink missing at {symlink} (Nemesis known broken in v0.6 - track as post-release bug)")
|
|
else:
|
|
pass # no StockGame dir is a valid modlist structure; nothing to check
|
|
|
|
mo_ini = modlist_dir / "ModOrganizer.ini"
|
|
if not mo_ini.is_file():
|
|
return
|
|
content = mo_ini.read_text(errors="replace")
|
|
nemesis_indices = re.findall(
|
|
r'^(\d+)\\binary=.*Nemesis Unlimited Behavior Engine\.exe',
|
|
content, re.MULTILINE | re.IGNORECASE
|
|
)
|
|
if nemesis_indices:
|
|
for idx in nemesis_indices:
|
|
wd_match = re.search(rf'^{re.escape(idx)}\\workingDirectory=(.*)$', content, re.MULTILINE)
|
|
if wd_match and wd_match.group(1).strip():
|
|
r.fail(f"Nemesis workingDirectory not blank (entry {idx}): '{wd_match.group(1).strip()}'")
|
|
else:
|
|
r.ok(f"Nemesis workingDirectory blank (entry {idx})")
|
|
|
|
|
|
def check_nxmhandler_ini(modlist_dir: Path, r: Results):
|
|
"""Check nxmhandler.ini has noregister=true to suppress MO2 NXM registration popup."""
|
|
print("\n--- NXM Handler ---")
|
|
nxm_ini = modlist_dir / "nxmhandler.ini"
|
|
if not nxm_ini.exists():
|
|
# MO2 creates it on first launch; pre-creation is done by Jackify during configure
|
|
r.warn("nxmhandler.ini not found (Jackify creates it during configure; may not exist before first configure run)")
|
|
return
|
|
|
|
content = nxm_ini.read_text(errors="replace")
|
|
if re.search(r'(?im)^\s*noregister\s*=\s*true\s*$', content):
|
|
r.ok("nxmhandler.ini: noregister=true")
|
|
else:
|
|
r.fail("nxmhandler.ini exists but noregister=true not set (NXM popup will appear on MO2 launch)")
|
|
|
|
|
|
def check_ttw_installation(modlist_dir: Path, game_type: str, r: Results, modlist_name: str = ""):
|
|
"""For TTW-compatible FNV modlists, check whether TaleOfTwoWastelands.esm is present."""
|
|
if game_type != "falloutnv":
|
|
return
|
|
|
|
try:
|
|
from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible
|
|
if not is_ttw_compatible(modlist_name):
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
print("\n--- TTW Installation ---")
|
|
|
|
mods_dir = modlist_dir / "mods"
|
|
if mods_dir.is_dir():
|
|
for esm in mods_dir.rglob("TaleOfTwoWastelands.esm"):
|
|
r.ok(f"TTW ESM found: {esm.relative_to(modlist_dir)}")
|
|
return
|
|
|
|
r.warn("TTW ESM not found - TTW may not have been installed")
|
|
|
|
|
|
def check_tool_compat_config(pfx: Path, game_type: str, r: Results):
|
|
"""Check whether Tool Compatibility Config has been applied to this prefix."""
|
|
# Tool compat is not applied for these game types - nothing to check
|
|
_no_tool_compat = ("falloutnv", "fallout3", "enderal", "cp2077", "bg3", "skyrimvr", "fallout4vr")
|
|
if game_type in _no_tool_compat:
|
|
return
|
|
|
|
print("\n--- Tool Compatibility Config ---")
|
|
user_reg = pfx / "user.reg"
|
|
system_reg = pfx / "system.reg"
|
|
if not user_reg.exists():
|
|
r.warn("user.reg missing, cannot check tool compat config")
|
|
return
|
|
|
|
content = user_reg.read_text(errors="replace")
|
|
|
|
if "AppDefaults\\\\SSEEdit.exe" in content and "winxp" in content:
|
|
r.ok("xEdit WinXP compatibility applied")
|
|
else:
|
|
r.warn("xEdit WinXP compatibility not found (Tool Compat Config may not have been run)")
|
|
|
|
if "Pandora Behaviour Engine" in content:
|
|
r.ok("Pandora window decoration fix applied")
|
|
else:
|
|
r.warn("Pandora fix not found")
|
|
|
|
if "dwrite" in content and "native,builtin" in content:
|
|
r.ok("Global DLL overrides applied")
|
|
else:
|
|
r.warn("Global DLL overrides not found")
|
|
|
|
# Synthesis: mscoree AppDefaults entry is Skyrim-specific
|
|
if game_type == "skyrim":
|
|
if "AppDefaults\\\\SkyrimSE.exe\\\\DllOverrides" in content and "mscoree" in content:
|
|
r.ok("Synthesis mscoree fix applied (SkyrimSE.exe AppDefaults)")
|
|
else:
|
|
r.warn("Synthesis mscoree AppDefaults entry not found")
|
|
|
|
# OnlyUseLatestCLR is written to HKLM during the same Synthesis compat pass
|
|
sys_content = system_reg.read_text(errors="replace") if system_reg.exists() else ""
|
|
if ".NETFramework" in sys_content and "OnlyUseLatestCLR" in sys_content:
|
|
r.ok("OnlyUseLatestCLR set in HKLM")
|
|
else:
|
|
r.warn("OnlyUseLatestCLR not found in system.reg")
|
|
|
|
# .NET 9 SDK is installed natively (not via winetricks) for Synthesis.
|
|
# NSF/CSF prefixes use global *mscoree=native and deliberately skip dotnet9 (win11 flip
|
|
# breaks NSF), so suppress the check when that override is present. Must check the
|
|
# global [Software\Wine\DllOverrides] section specifically, not just any occurrence of
|
|
# the string - every Skyrim modlist (NSF or not) also gets a scoped
|
|
# AppDefaults\SkyrimSE.exe\DllOverrides entry with the same value, which a plain
|
|
# substring search can't tell apart from the global one.
|
|
_global_overrides_match = re.search(r"\[Software\\\\Wine\\\\DllOverrides\][^\[]*", content)
|
|
_nsf_prefix = bool(_global_overrides_match and '"*mscoree"="native"' in _global_overrides_match.group(0))
|
|
sdk_base = pfx / "drive_c" / "Program Files" / "dotnet" / "sdk"
|
|
if _nsf_prefix:
|
|
r.ok(".NET 9 SDK check skipped (NSF/CSF prefix - global mscoree=native detected)")
|
|
elif sdk_base.is_dir():
|
|
net9_dirs = [d for d in sdk_base.iterdir() if d.is_dir() and d.name.startswith("9.")]
|
|
if net9_dirs:
|
|
r.ok(f".NET 9 SDK present: {net9_dirs[0].name}")
|
|
else:
|
|
installed = [d.name for d in sdk_base.iterdir() if d.is_dir()]
|
|
r.warn(f".NET 9 SDK not found under drive_c/Program Files/dotnet/sdk/ (found: {installed or 'none'})")
|
|
else:
|
|
r.warn(".NET 9 SDK not found (drive_c/Program Files/dotnet/sdk/ missing; run Configure Tool Compatibility)")
|
|
|
|
if not _nsf_prefix:
|
|
sys_content = system_reg.read_text(errors="replace") if system_reg.exists() else ""
|
|
_check_nuget_signature_config(pfx, content, sys_content, r)
|
|
|
|
|
|
def _check_nuget_signature_config(pfx: Path, user_reg_content: str, system_reg_content: str, r: Results):
|
|
"""
|
|
Check NuGet package signature validation is configured for Synthesis.
|
|
Three independent pieces, all required (see nuget_signature_service.py):
|
|
the trust-pin NuGet.Config, the SDK's own trusted-root certs imported into
|
|
Wine's cert store, and the offline-revocation/retry-policy env vars.
|
|
"""
|
|
config_path = pfx / "drive_c" / "users" / "steamuser" / "AppData" / "Roaming" / "NuGet" / "NuGet.Config"
|
|
if not config_path.exists():
|
|
r.warn("NuGet.Config not found (Synthesis package restore will likely fail)")
|
|
else:
|
|
config_content = config_path.read_text(errors="replace")
|
|
if "allowUntrustedRoot" in config_content and "trustedSigners" in config_content:
|
|
r.ok("NuGet signature trust policy configured")
|
|
else:
|
|
r.warn(
|
|
"NuGet.Config exists but lacks the trust policy - likely the SDK's "
|
|
"auto-generated stub (dotnet wrote it before Jackify could); delete "
|
|
"it and re-run Configure Tool Compatibility"
|
|
)
|
|
|
|
# X509Store(StoreName.Root, StoreLocation.CurrentUser) lands in user.reg
|
|
# (HKCU) on some Wine versions and system.reg (HKLM) on others - confirmed
|
|
# via a real CachyOS repro (wine-11.0) where the import genuinely succeeded
|
|
# but landed in system.reg while an older Wine build on another machine put
|
|
# it in user.reg. Check both rather than assume one.
|
|
marker = "SystemCertificates\\\\Root\\\\Certificates\\\\"
|
|
root_cert_count = user_reg_content.count(marker) + system_reg_content.count(marker)
|
|
if root_cert_count > 0:
|
|
r.ok(f".NET SDK trusted-root certs imported into Wine cert store ({root_cert_count} entries)")
|
|
else:
|
|
r.warn(".NET SDK trusted-root certs not found in Wine cert store")
|
|
|
|
if "NUGET_CERT_REVOCATION_MODE" in user_reg_content and "NUGET_EXPERIMENTAL_CHAIN_BUILD_RETRY_POLICY" in user_reg_content:
|
|
r.ok("NuGet offline-revocation/retry-policy env vars set")
|
|
else:
|
|
r.warn("NuGet offline-revocation/retry-policy env vars not found in HKCU\\Environment")
|
|
|
|
|
|
def check_steam_artwork(appid: str, r: Results):
|
|
"""Check whether Steam grid artwork was applied for this AppID."""
|
|
print("\n--- Steam Artwork ---")
|
|
if not appid:
|
|
r.warn("AppID unknown, cannot check artwork")
|
|
return
|
|
|
|
expected = [
|
|
(f"{appid}p.png", "portrait"),
|
|
(f"{appid}.png", "landscape"),
|
|
(f"{appid}_hero.png", "hero"),
|
|
(f"{appid}_logo.png", "logo"),
|
|
(f"{appid}_tenfoot.png", "tenfoot"),
|
|
]
|
|
|
|
found_any = False
|
|
for root in _find_steam_roots():
|
|
userdata = root / "userdata"
|
|
if not userdata.is_dir():
|
|
continue
|
|
for user_dir in userdata.iterdir():
|
|
grid_dir = user_dir / "config" / "grid"
|
|
if not grid_dir.is_dir():
|
|
continue
|
|
present = [label for fn, label in expected if (grid_dir / fn).is_file()]
|
|
if present:
|
|
found_any = True
|
|
r.ok(f"Artwork present ({user_dir.name}): {', '.join(present)}")
|
|
|
|
if not found_any:
|
|
r.warn("No Steam grid artwork found for this AppID")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def run_verification(pfx: Path, modlist_dir: Path, game_type: str, appid: str = "", modlist_name: str = "") -> Results:
|
|
"""Callable API for programmatic use. Returns Results; no stdout output."""
|
|
import io
|
|
r = Results()
|
|
_orig = sys.stdout
|
|
sys.stdout = io.StringIO()
|
|
try:
|
|
check_prefix_structure(pfx, r)
|
|
check_registry_base(pfx, r)
|
|
check_game_registry(pfx, game_type, r)
|
|
check_user_directories(pfx, game_type, r)
|
|
check_seeded_files(pfx, game_type, r)
|
|
check_winetricks_components(pfx, appid, game_type, r)
|
|
check_native_dotnet(pfx, r, game_type)
|
|
check_game_symlink(pfx, modlist_dir, game_type, r)
|
|
check_modlist_dir(modlist_dir, r)
|
|
check_modorganizer_ini(modlist_dir, r, pfx=pfx)
|
|
check_download_directory(modlist_dir, r, pfx=pfx)
|
|
check_nxmhandler_ini(modlist_dir, r)
|
|
check_ttw_installation(modlist_dir, game_type, r, modlist_name)
|
|
check_nemesis_compat(modlist_dir, r, game_type)
|
|
check_proton_version(appid, r)
|
|
check_shortcut_launch_options(appid, game_type, r)
|
|
check_steam_artwork(appid, r)
|
|
check_tool_compat_config(pfx, game_type, r)
|
|
finally:
|
|
sys.stdout = _orig
|
|
return r
|
|
|
|
|
|
def _run_verification(pfx: Path, modlist_dir: Path, game_type: str, appid: str = ""):
|
|
print(f"\nJackify Install Verifier")
|
|
print(f"{'='*60}")
|
|
print(f"Prefix: {pfx}")
|
|
print(f"Modlist dir: {modlist_dir}")
|
|
print(f"Game type: {game_type}")
|
|
if appid:
|
|
print(f"AppID: {appid}")
|
|
|
|
if game_type == "unknown":
|
|
print("\nWARNING: Could not detect game type. Use --game-type to specify.")
|
|
print("Continuing with generic checks only.\n")
|
|
|
|
r = Results()
|
|
|
|
check_prefix_structure(pfx, r)
|
|
check_registry_base(pfx, r)
|
|
check_game_registry(pfx, game_type, r)
|
|
check_user_directories(pfx, game_type, r)
|
|
check_seeded_files(pfx, game_type, r)
|
|
check_winetricks_components(pfx, appid, game_type, r)
|
|
check_native_dotnet(pfx, r)
|
|
check_game_symlink(pfx, modlist_dir, game_type, r)
|
|
check_modlist_dir(modlist_dir, r)
|
|
check_modorganizer_ini(modlist_dir, r, pfx=pfx)
|
|
check_download_directory(modlist_dir, r, pfx=pfx)
|
|
check_nxmhandler_ini(modlist_dir, r)
|
|
check_ttw_installation(modlist_dir, game_type, r)
|
|
check_nemesis_compat(modlist_dir, r, game_type)
|
|
check_proton_version(appid, r)
|
|
check_shortcut_launch_options(appid, game_type, r)
|
|
check_steam_artwork(appid, r)
|
|
check_tool_compat_config(pfx, game_type, r)
|
|
|
|
return r.summary()
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Verify a Jackify modlist installation is correctly configured.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
%(prog)s # auto-discover from Steam shortcuts
|
|
%(prog)s /path/to/compatdata/12345/pfx /path/to/modlist
|
|
%(prog)s /path/to/pfx /path/to/modlist -g falloutnv
|
|
""",
|
|
)
|
|
parser.add_argument("prefix", nargs="?", help="Path to the Proton prefix (pfx/ directory). Omit for interactive selection.")
|
|
parser.add_argument("modlist_dir", nargs="?", help="Path to the modlist installation directory.")
|
|
parser.add_argument("--game-type", "-g", help="Override auto-detected game type")
|
|
args = parser.parse_args()
|
|
|
|
if args.prefix is None:
|
|
# Interactive mode: discover from Steam shortcuts
|
|
selected = select_modlist_interactive()
|
|
if selected is None:
|
|
sys.exit(1)
|
|
|
|
modlist_dir = selected["modlist_dir"]
|
|
pfx = selected["pfx"]
|
|
appid = selected["appid"]
|
|
game_type = args.game_type.lower() if args.game_type else selected["game_type"]
|
|
|
|
if pfx is None or not pfx.is_dir():
|
|
print(f"\nError: prefix not found for AppID {appid}.")
|
|
print("The modlist may not have been launched yet (prefix created on first run).")
|
|
sys.exit(1)
|
|
else:
|
|
pfx = Path(args.prefix).expanduser().resolve()
|
|
modlist_dir = Path(args.modlist_dir).expanduser().resolve() if args.modlist_dir else None
|
|
|
|
if not pfx.is_dir():
|
|
print(f"Error: prefix path does not exist: {pfx}")
|
|
sys.exit(1)
|
|
if modlist_dir is None or not modlist_dir.is_dir():
|
|
print(f"Error: modlist directory does not exist: {modlist_dir}")
|
|
sys.exit(1)
|
|
|
|
game_type = args.game_type.lower() if args.game_type else detect_game_type(modlist_dir)
|
|
# Infer appid from the compatdata path if possible (e.g. .../compatdata/12345/pfx)
|
|
appid = pfx.parent.name if pfx.parent.name.isdigit() else ""
|
|
|
|
passed = _run_verification(pfx, modlist_dir, game_type, appid)
|
|
sys.exit(0 if passed else 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|