Files
Jackify/jackify/tools/verify_install.py
T

1299 lines
48 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.fail(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.
_nsf_prefix = '"*mscoree"="native"' in content
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)")
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()