"""The game installation: locating it (Steam on Windows / Linux / Proton), its WADs and language. A `Game` is built from any folder path; nothing here is hard-coded to one machine. """ import glob import hashlib import os import re import struct import sys APPID = 200260 FOLDER_NAMES = ("Batman Arkham City GOTY", "Batman Arkham City") class GameError(Exception): """User-facing problem with the game folder (message is meant to be shown as-is).""" def _iglob_one(base, *parts): """Case-insensitive path join (Linux file systems are case sensitive).""" cur = base for p in parts: if not os.path.isdir(cur): return None hit = next((e for e in os.listdir(cur) if e.lower() == p.lower()), None) if hit is None: return None cur = os.path.join(cur, hit) return cur # --- Steam discovery ------------------------------------------------------------------------ def steam_roots(): roots = [] if sys.platform == "win32": try: import winreg with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Valve\Steam") as k: roots.append(winreg.QueryValueEx(k, "SteamPath")[0]) except OSError: pass roots += [r"C:\Program Files (x86)\Steam", r"C:\Program Files\Steam"] else: h = os.path.expanduser("~") roots += [os.path.join(h, ".steam", "steam"), os.path.join(h, ".local", "share", "Steam"), os.path.join(h, ".var", "app", "com.valvesoftware.Steam", ".local", "share", "Steam"), os.path.join(h, "snap", "steam", "common", ".local", "share", "Steam")] out = [] for r in roots: r = os.path.normpath(r) if os.path.isdir(r) and r not in out: out.append(r) return out def steam_libraries(): libs = [] for root in steam_roots(): libs.append(root) vdf = _iglob_one(root, "steamapps", "libraryfolders.vdf") if vdf: for m in re.finditer(r'"path"\s+"([^"]+)"', open(vdf, encoding="utf-8", errors="replace").read()): libs.append(m.group(1).replace("\\\\", "\\")) seen, out = set(), [] for l in libs: n = os.path.normcase(os.path.normpath(l)) if n not in seen and os.path.isdir(l): seen.add(n) out.append(os.path.normpath(l)) return out def _true_case(path): """Windows paths from the registry may be lower-cased; rebuild the real spelling.""" path = os.path.normpath(path) drive, rest = os.path.splitdrive(path) cur = drive.upper() + os.sep if drive else os.sep for part in [p for p in rest.split(os.sep) if p]: hit = next((e for e in os.listdir(cur) if e.lower() == part.lower()), part) if os.path.isdir(cur) else part cur = os.path.join(cur, hit) return cur def find_installs(): """All Arkham City folders found in Steam libraries.""" out = [] for lib in steam_libraries(): for name in FOLDER_NAMES: p = _iglob_one(lib, "steamapps", "common", name) if p and is_game_dir(p) and _true_case(p) not in out: out.append(_true_case(p)) return out def is_game_dir(path): return bool(path) and _iglob_one(path, "BmGame", "CookedPCConsole") is not None # --- Game ------------------------------------------------------------------------------------ class Game: def __init__(self, root): root = os.path.normpath(os.path.abspath(root)) if not is_game_dir(root): raise GameError("'%s' is not a Batman: Arkham City folder (BmGame\\CookedPCConsole is missing)." % root) self.root = root self.cooked = _iglob_one(root, "BmGame", "CookedPCConsole") self.archives = self._find_archives() if not self.archives: raise GameError("No audio archives (.wad) found in %s." % self.cooked) def _find_archives(self): arch = {} for p in glob.glob(os.path.join(self.cooked, "*", "*.wad")): m = re.search(r"_LOC_([A-Za-z]{3})\.wad$", p) if m: arch[m.group(1).upper()] = p elif os.path.basename(os.path.dirname(p)).upper() == "SFX": arch["SFX"] = p return dict(sorted(arch.items())) @property def languages(self): return [a for a in self.archives if a != "SFX"] def voice_archive(self, lang): """The speech archive used for a language: its own, or INT for the languages the game has no voices for (the game plays the INT voices with their own subtitles and menu text).""" lang = (lang or "INT").upper() if lang in self.languages: return lang return "INT" if "INT" in self.languages else self.languages[0] def wad(self, archive): try: return self.archives[archive.upper()] except KeyError: raise GameError("This game has no '%s' audio archive (available: %s)." % (archive, ", ".join(self.archives))) def rel(self, path): return os.path.relpath(path, self.root).replace("\\", "/") @property def exe(self): return _iglob_one(self.root, "Binaries", "Win32", "BatmanAC.exe") @property def store_id(self): """Stable id of this install, used to name its originals store.""" return hashlib.sha1(os.path.normcase(self.root).encode("utf-8")).hexdigest()[:12] # language ------------------------------------------------------------------------------- def config_dirs(self): """Possible BmGame\\Config folders (Windows Documents, Proton prefix).""" dirs = [os.path.join(os.path.expanduser("~"), "Documents", "WB Games", "Batman Arkham City GOTY", "BmGame", "Config")] steamapps = os.path.dirname(os.path.dirname(self.root)) # .../steamapps pfx = os.path.join(steamapps, "compatdata", str(APPID), "pfx", "drive_c", "users", "steamuser") for docs in ("Documents", "My Documents"): dirs.append(os.path.join(pfx, docs, "WB Games", "Batman Arkham City GOTY", "BmGame", "Config")) return [d for d in dirs if os.path.isdir(d)] def all_languages(self): """Every language of the game: the voice languages (speech WADs) and the text languages (folders of BmGame/Localization; the game plays the INT voices for those without their own).""" loc = _iglob_one(self.root, "BmGame", "Localization") texts = [d.upper() for d in os.listdir(loc) if os.path.isdir(os.path.join(loc, d))] if loc else [] return sorted(set(self.languages) | set(texts)) def language(self): """The language the game is set to (voices or texts only, e.g. JPN; `voice_archive` gives its speech archive); falls back to INT, then the first voice language.""" for d in self.config_dirs(): for f in ("UserEngine.ini", "BmEngine.ini"): p = os.path.join(d, f) if not os.path.exists(p): continue raw = open(p, "rb").read() txt = raw.decode("utf-16") if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else raw.decode("latin-1") m = re.search(r"^\s*Language\s*=\s*(\w+)", txt, re.M | re.I) if m and m.group(1).upper() in self.all_languages(): return m.group(1).upper() if "INT" in self.archives: return "INT" return self.languages[0] if self.languages else "SFX" # safety ----------------------------------------------------------------------------------- def check_writable(self, path): """Raise GameError if the file cannot be replaced (game running, read-only, ...).""" try: with open(path, "r+b"): pass except PermissionError: raise GameError("Cannot write %s.\nClose the game (and anything else using the file) and try again." % os.path.basename(path)) d = os.path.dirname(path) if not os.access(d, os.W_OK): raise GameError("The folder %s is not writable." % d) def wad_fingerprint(path): """Identity of a WAD's game build: SHA-1 over its entry IDs and the sizes of all entries except stream packs (whose size legitimately changes when music is replaced). In-place sound edits do not change it; a different game build or language does.""" from .inject import is_pack from .wad import Wad w = Wad(path) h = hashlib.sha1() for e in w.entries: h.update(struct.pack("