#!/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 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(' (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()