206 lines
8.0 KiB
Python
206 lines
8.0 KiB
Python
"""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")
|
|
LANGS = ("INT", "ITA", "FRA", "DEU", "ESN", "ESM", "POL", "RUS", "JPN", "KOR", "POR")
|
|
|
|
|
|
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 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 language(self):
|
|
"""Speech language the game is set to (falls back to INT, then the first available)."""
|
|
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.archives:
|
|
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("<II", e.hash, 0 if is_pack(path, e.hash) else e.size))
|
|
return "%d-%s" % (len(w.entries), h.hexdigest()[:16])
|
|
|
|
|
|
def default_game_dir(settings=None):
|
|
"""Last used folder from settings, else the first Steam install found."""
|
|
if settings and settings.get("game_dir") and is_game_dir(settings["game_dir"]):
|
|
return settings["game_dir"]
|
|
found = find_installs()
|
|
return found[0] if found else None
|