Files
mrleo1nid f9eb5c1212
CI / frontend (push) Failing after 15s
CI / backend (push) Failing after 37s
feat: improve RenPy conversion and support MIST runtime
2026-10-25 23:16:33 +03:00

101 lines
3.9 KiB
Python

"""Archive extraction and locating the game directory inside it."""
import os
import shutil
import subprocess
import zipfile
from pathlib import Path
class ArchiveError(Exception):
pass
def _safe_target(root: Path, name: str) -> Path | None:
target = (root / name).resolve()
return target if target.is_relative_to(root.resolve()) else None
def extract(archive: Path, filename: str, dest: Path, *, only_scripts: bool = False) -> None:
dest.mkdir(parents=True, exist_ok=True)
if filename.lower().endswith(".zip") and zipfile.is_zipfile(archive):
_extract_zip(archive, dest, only_scripts=only_scripts)
return
seven = shutil.which("7zz") or shutil.which("7z")
if not seven:
raise ArchiveError("Для этого формата нужен 7-Zip (7zz), он не установлен")
filters = [f"-ir!*{suffix}" for suffix in SCRIPT_EXTS | {".tar"}] if only_scripts else []
proc = subprocess.run([seven, "x", "-y", "-snl-", *filters, f"-o{dest}", str(archive)],
capture_output=True, text=True)
if proc.returncode not in (0, 1): # 1 = warnings
raise ArchiveError(f"7-Zip: {proc.stderr.strip() or proc.stdout.strip()[-500:]}")
_drop_escaping_links(dest)
# .tar.gz etc. extract to a single inner .tar first
inner = list(dest.iterdir())
if len(inner) == 1 and inner[0].suffix == ".tar":
tar = inner[0]
extract(tar, tar.name, dest / "_tar", only_scripts=only_scripts)
tar.unlink()
SCRIPT_EXTS = {".rpy", ".rpyc", ".rpa", ".rpym", ".rpymc"}
def _extract_zip(archive: Path, dest: Path, *, only_scripts: bool = False) -> None:
with zipfile.ZipFile(archive) as zf:
for info in zf.infolist():
name = info.filename
if only_scripts and Path(name).suffix.lower() not in SCRIPT_EXTS:
continue
if info.flag_bits & 0x800 == 0:
# legacy zips store names in cp437; many were really written as cp866/utf-8
try:
name = name.encode("cp437").decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError):
pass
target = _safe_target(dest, name)
if target is None:
continue
if info.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, target.open("wb") as out:
shutil.copyfileobj(src, out, 1 << 20)
def _drop_escaping_links(root: Path) -> None:
for dirpath, dirnames, filenames in os.walk(root):
for name in dirnames + filenames:
p = Path(dirpath, name)
if p.is_symlink():
p.unlink()
def find_game_dir(root: Path) -> Path:
"""Find the Ren'Py `game` directory (the one holding scripts or archives)."""
best: tuple[int, Path] | None = None
for dirpath, _, filenames in os.walk(root):
exts = {Path(f).suffix.lower() for f in filenames}
if not exts & {".rpy", ".rpyc", ".rpa", ".rpymc"}:
continue
p = Path(dirpath)
depth = len(p.relative_to(root).parts)
score = (0 if p.name.lower() == "game" else 100) + depth
# Only engine internals inside this distribution matter. A collection's
# parent directory may itself be named Renpy.
if p.name.lower() in ("renpy", "common") or "renpy" in [x.lower() for x in p.relative_to(root).parts]:
continue
if best is None or score < best[0]:
best = (score, p)
if best is None:
raise ArchiveError("В архиве не найдена игра Ren'Py (нет .rpy/.rpyc/.rpa)")
game = best[1]
# scripts in a subfolder of game/ — walk up to game/ itself
for parent in [game, *game.parents]:
if parent.name.lower() == "game":
return parent
if parent == root:
break
return game