Initial commit
This commit is contained in:
205
ackit/game.py
Normal file
205
ackit/game.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user