#!/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 # direct python3 verify_install.py -g falloutnv No external dependencies required. vdf is bundled in tools/vdf/. """ import argparse import base64 import configparser import hashlib 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(' (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. # Enderal SE shares the Skyrim SE engine/plugin format and gets the same # xEdit/Pandora/DLL-overrides/Synthesis fixes (see modlist_configuration.py # Step 15) - only the SkyrimSE.exe-scoped mscoree entry is skipped for it, # handled below. _no_tool_compat = ("falloutnv", "fallout3", "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/dotnet checks apply to both Skyrim and Enderal SE (same engine and # plugin format). The mscoree AppDefaults entry itself is Skyrim-only: it is # scoped to SkyrimSE.exe, which is also Enderal's own game process, so # Step 15 deliberately skips writing it there - don't flag it missing. if game_type in ("skyrim", "enderal"): 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 10 SDK is installed natively (not via winetricks) for Synthesis. # NSF/CSF prefixes use global *mscoree=native and deliberately skip the SDK install (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 10 SDK check skipped (NSF/CSF prefix - global mscoree=native detected)") elif sdk_base.is_dir(): net10_dirs = [d for d in sdk_base.iterdir() if d.is_dir() and d.name.startswith("10.")] if net10_dirs: r.ok(f".NET 10 SDK present: {net10_dirs[0].name}") else: installed = [d.name for d in sdk_base.iterdir() if d.is_dir()] r.warn(f".NET 10 SDK not found under drive_c/Program Files/dotnet/sdk/ (found: {installed or 'none'})") else: r.warn(".NET 10 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) _PEM_CERT_RE = re.compile( r"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", re.DOTALL ) def _sdk_trusted_root_thumbprints(pfx: Path) -> Optional[set]: """ Compute the expected set of trusted-root cert thumbprints from the newest installed .NET SDK's bundled PEM files - the same source nuget_signature_service.install_nuget_cert reads from and imports. """ sdk_root = pfx / "drive_c" / "Program Files" / "dotnet" / "sdk" if not sdk_root.is_dir(): return None for version_dir in sorted(sdk_root.iterdir(), reverse=True): trustedroots = version_dir / "trustedroots" codesign = trustedroots / "codesignctl.pem" timestamp = trustedroots / "timestampctl.pem" if not (codesign.exists() and timestamp.exists()): continue thumbprints = set() for bundle in (codesign, timestamp): text = bundle.read_text(encoding="utf-8", errors="replace") for match in _PEM_CERT_RE.finditer(text): der = base64.b64decode("".join(match.group(1).split())) thumbprints.add(hashlib.sha1(der).hexdigest().upper()) return thumbprints return None 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. # # A raw "any entries present" check is not enough: wine regedit has been # observed to exit 0 while silently dropping a single cert out of a ~400 # entry batch import - the exact root cause of a long-standing Synthesis # NU3028 failure. Compare against the SDK's own PEM bundles to catch a # partial import, not just an empty one. thumb_re = re.compile(r"SystemCertificates\\\\Root\\\\Certificates\\\\([0-9A-Fa-f]+)") present = set(thumb_re.findall(user_reg_content)) | set(thumb_re.findall(system_reg_content)) expected = _sdk_trusted_root_thumbprints(pfx) if expected is None: if present: r.ok(f".NET SDK trusted-root certs imported into Wine cert store ({len(present)} entries)") else: r.warn(".NET SDK trusted-root certs not found in Wine cert store") else: missing = expected - present if not missing: r.ok(f".NET SDK trusted-root certs fully imported into Wine cert store ({len(expected)} entries)") else: r.warn( f".NET SDK trusted-root certs incomplete in Wine cert store - " f"{len(missing)} of {len(expected)} missing (Synthesis restore may fail " f"with NU3028/NU3037; re-run Configure Tool Compatibility)" ) 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()