mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 01:23:46 +02:00
251 lines
10 KiB
Python
251 lines
10 KiB
Python
"""
|
|
VanillaGameFinder
|
|
|
|
Locates vanilla game installations across Steam and Heroic (GOG/Epic)
|
|
without requiring manual path entry from the user.
|
|
|
|
Detection order: Steam appmanifest -> Heroic GOG -> Heroic Epic.
|
|
No manual path override is offered here; that belongs in user settings.
|
|
"""
|
|
import json
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Dict, List, NamedTuple, Optional, Tuple
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Store identifiers returned alongside the detected path.
|
|
STORE_STEAM = "steam"
|
|
STORE_GOG = "gog"
|
|
STORE_EPIC = "epic"
|
|
STORE_UNKNOWN = "unknown"
|
|
|
|
GameLocation = Tuple[Path, str] # (path, store)
|
|
|
|
|
|
class SteamEntry(NamedTuple):
|
|
app_id: str
|
|
dir_names: List[str]
|
|
|
|
|
|
# Maps Jackify game_type -> ordered list of Steam candidates to try.
|
|
# Multiple entries handle games with ambiguous type strings (e.g. skyrim = SSE or LE).
|
|
_STEAM_CATALOG: Dict[str, List[SteamEntry]] = {
|
|
'skyrim': [
|
|
SteamEntry('489830', ['Skyrim Special Edition']),
|
|
SteamEntry('72850', ['Skyrim']),
|
|
],
|
|
'skyrimvr': [SteamEntry('611670', ['Skyrim VR'])],
|
|
'fallout4': [SteamEntry('377160', ['Fallout 4'])],
|
|
'fallout4vr': [SteamEntry('611660', ['Fallout 4 VR'])],
|
|
'falloutnv': [SteamEntry('22380', ['Fallout New Vegas', 'FalloutNV'])],
|
|
'fallout3': [
|
|
SteamEntry('22300', ['Fallout 3', 'Fallout3']),
|
|
SteamEntry('22370', ['Fallout 3 goty', 'Fallout 3 GOTY', 'Fallout3']),
|
|
],
|
|
'oblivion': [SteamEntry('22330', ['Oblivion'])],
|
|
'oblivion_remastered': [SteamEntry('2623190', ['Oblivion Remastered'])],
|
|
'morrowind': [SteamEntry('22320', ['Morrowind'])],
|
|
'starfield': [SteamEntry('1716740', ['Starfield'])],
|
|
'enderal': [
|
|
SteamEntry('976620', ['Enderal Special Edition']),
|
|
SteamEntry('933480', ['Enderal Forgotten Stories', 'Enderal']),
|
|
],
|
|
'bg3': [SteamEntry('1086940', ['Baldurs Gate 3', "Baldur's Gate 3"])],
|
|
'cp2077': [SteamEntry('1091500', ['Cyberpunk 2077'])],
|
|
'witcher3': [SteamEntry('292030', ['The Witcher 3 Wild Hunt', 'The Witcher 3: Wild Hunt'])],
|
|
'darksouls3': [SteamEntry('374320', ['DARK SOULS III'])],
|
|
'eldenring': [SteamEntry('1245620', ['ELDEN RING'])],
|
|
'sekiro': [SteamEntry('814380', ['Sekiro'])],
|
|
'mountandblade2': [SteamEntry('261550', ['Mount & Blade II Bannerlord'])],
|
|
'stardewvalley': [SteamEntry('413150', ['Stardew Valley'])],
|
|
'dragonageinquisition': [SteamEntry('1222690', ['Dragon Age Inquisition'])],
|
|
'hogwartslegacy': [SteamEntry('990080', ['Hogwarts Legacy'])],
|
|
}
|
|
|
|
# Maps Jackify game_type -> list of GOG app IDs (appName in installed.json).
|
|
# Source: Fluorine-Manager/libs/basic_games/gog_utils.py approach + CLF3 known_games.rs IDs.
|
|
_HEROIC_GOG_CATALOG: Dict[str, List[str]] = {
|
|
'falloutnv': ['1454587428'],
|
|
'fallout3': ['1454315831'],
|
|
'oblivion': ['1458058109'],
|
|
'morrowind': ['1440163901'],
|
|
'bg3': ['1456460669'],
|
|
'cp2077': ['1423049311'],
|
|
'witcher3': ['1495134320'],
|
|
'skyrim': ['1711230643'],
|
|
'stardewvalley': ['1453375253'],
|
|
}
|
|
|
|
# Maps Jackify game_type -> list of Epic/Legendary app_name slugs (installed.json key).
|
|
_HEROIC_EPIC_CATALOG: Dict[str, List[str]] = {
|
|
'fallout3': ['adeae8bbfc94427db57c7dfecce3f1d4'],
|
|
'falloutnv': ['5daeb974a22a435988892319b3a4f476'],
|
|
}
|
|
|
|
# Epic installs some games into a language-specific subdirectory inside the install root.
|
|
# Maps game_type -> glob pattern to find the real game directory one level down.
|
|
# Language suffix varies (English, German, French, ...) so we glob rather than hardcode.
|
|
_EPIC_SUBDIR_GLOB: Dict[str, str] = {
|
|
'fallout3': 'Fallout 3 GOTY *',
|
|
'falloutnv': 'Fallout New Vegas *',
|
|
}
|
|
|
|
# Candidate Heroic config roots: native install then Flatpak.
|
|
_HEROIC_CONFIG_ROOTS: List[Path] = [
|
|
Path.home() / '.config' / 'heroic',
|
|
Path.home() / '.var' / 'app' / 'com.heroicgameslauncher.hgl' / 'config' / 'heroic',
|
|
]
|
|
|
|
|
|
class VanillaGameFinder:
|
|
"""
|
|
Locates vanilla (store-installed) game directories for a given Jackify game_type.
|
|
Returns a (Path, store) tuple so callers can warn when the game is not on Steam.
|
|
Searches Steam first, then Heroic-managed stores (GOG, Epic).
|
|
"""
|
|
|
|
def find(self, game_type: str) -> Optional[GameLocation]:
|
|
"""
|
|
Return (path, store) for the detected game installation, or None.
|
|
store is one of: 'steam', 'gog', 'epic', 'unknown'.
|
|
"""
|
|
result = self._find_steam(game_type)
|
|
if result:
|
|
logger.info("VanillaGameFinder: found %s via Steam at %s", game_type, result)
|
|
return result, STORE_STEAM
|
|
|
|
result = self._find_heroic(game_type)
|
|
if result:
|
|
path, store = result
|
|
logger.info("VanillaGameFinder: found %s via %s at %s", game_type, store, path)
|
|
return path, store
|
|
|
|
logger.debug("VanillaGameFinder: no installation found for %s", game_type)
|
|
return None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Steam
|
|
# ------------------------------------------------------------------
|
|
|
|
def _find_steam(self, game_type: str) -> Optional[Path]:
|
|
entries = _STEAM_CATALOG.get(game_type)
|
|
if not entries:
|
|
return None
|
|
try:
|
|
from jackify.backend.handlers.path_handler_steam import PathHandlerSteamMixin
|
|
library_paths = PathHandlerSteamMixin.get_all_steam_library_paths()
|
|
except Exception as e:
|
|
logger.debug("Steam library path detection failed: %s", e)
|
|
return None
|
|
|
|
for library in library_paths:
|
|
steamapps = library / 'steamapps'
|
|
if not steamapps.is_dir():
|
|
continue
|
|
for entry in entries:
|
|
path = self._check_steam_entry(steamapps, entry)
|
|
if path:
|
|
return path
|
|
return None
|
|
|
|
def _check_steam_entry(self, steamapps: Path, entry: SteamEntry) -> Optional[Path]:
|
|
manifest = steamapps / f'appmanifest_{entry.app_id}.acf'
|
|
if not manifest.is_file():
|
|
return None
|
|
try:
|
|
content = manifest.read_text(encoding='utf-8', errors='replace')
|
|
state_match = re.search(r'"StateFlags"\s+"(\d+)"', content)
|
|
if state_match and not (int(state_match.group(1)) & 4):
|
|
logger.debug("Skipping %s: StateFlags=%s (not fully installed)", manifest.name, state_match.group(1))
|
|
return None
|
|
match = re.search(r'"installdir"\s+"([^"]+)"', content)
|
|
if match:
|
|
path = steamapps / 'common' / match.group(1)
|
|
if path.is_dir():
|
|
return path
|
|
for name in entry.dir_names:
|
|
fallback = steamapps / 'common' / name
|
|
if fallback.is_dir():
|
|
return fallback
|
|
except OSError as e:
|
|
logger.debug("Could not read appmanifest %s: %s", manifest, e)
|
|
return None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Heroic (GOG via gog_store/installed.json, Epic via legendaryConfig)
|
|
# Approach adapted from Fluorine-Manager/libs/basic_games/gog_utils.py
|
|
# ------------------------------------------------------------------
|
|
|
|
def _find_heroic(self, game_type: str) -> Optional[Tuple[Path, str]]:
|
|
gog_ids = _HEROIC_GOG_CATALOG.get(game_type)
|
|
if gog_ids:
|
|
path = self._find_heroic_gog(gog_ids)
|
|
if path:
|
|
return path, STORE_GOG
|
|
|
|
epic_ids = _HEROIC_EPIC_CATALOG.get(game_type)
|
|
if epic_ids:
|
|
path = self._find_heroic_epic(epic_ids, game_type=game_type)
|
|
if path:
|
|
return path, STORE_EPIC
|
|
|
|
return None
|
|
|
|
def _find_heroic_gog(self, app_ids: List[str]) -> Optional[Path]:
|
|
id_set = set(app_ids)
|
|
for config_root in _HEROIC_CONFIG_ROOTS:
|
|
installed_file = config_root / 'gog_store' / 'installed.json'
|
|
if not installed_file.is_file():
|
|
continue
|
|
try:
|
|
data = json.loads(installed_file.read_text(encoding='utf-8'))
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
logger.debug("Could not read Heroic GOG installed.json %s: %s", installed_file, e)
|
|
continue
|
|
for entry in data.get('installed', []):
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if entry.get('appName') not in id_set:
|
|
continue
|
|
install_path = entry.get('install_path') or entry.get('installPath', '')
|
|
if install_path:
|
|
path = Path(install_path)
|
|
if path.is_dir():
|
|
return path
|
|
return None
|
|
|
|
def _find_heroic_epic(self, app_ids: List[str], game_type: str = '') -> Optional[Path]:
|
|
id_set = set(app_ids)
|
|
subdir_glob = _EPIC_SUBDIR_GLOB.get(game_type, '')
|
|
for config_root in _HEROIC_CONFIG_ROOTS:
|
|
installed_file = config_root / 'legendaryConfig' / 'legendary' / 'installed.json'
|
|
if not installed_file.is_file():
|
|
continue
|
|
try:
|
|
data = json.loads(installed_file.read_text(encoding='utf-8'))
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
logger.debug("Could not read Heroic Epic installed.json %s: %s", installed_file, e)
|
|
continue
|
|
for app_name, entry in data.items():
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if app_name not in id_set:
|
|
continue
|
|
install_path = entry.get('install_path', '')
|
|
if not install_path:
|
|
continue
|
|
path = Path(install_path)
|
|
if not path.is_dir():
|
|
continue
|
|
# Epic installs some titles into a language-specific subdirectory.
|
|
# Glob for it rather than hardcoding the language suffix.
|
|
if subdir_glob:
|
|
matches = sorted(path.glob(subdir_glob))
|
|
if matches:
|
|
logger.debug("Epic subdir match for %s: %s", game_type, matches[0])
|
|
return matches[0]
|
|
return path
|
|
return None
|