Files
dev/src/clew/clew.py

198 lines
5.8 KiB
Python

from __future__ import annotations
import argparse
import logging
import os
import shutil
import subprocess
import sys
from collections import deque
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from rpycdec.rpa import extract_rpa
from src.clew import extract_renpy_ast as extract_renpy_ast_script
EXTRACTOR = Path(extract_renpy_ast_script.__file__).resolve()
LOG = logging.getLogger(__name__)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="clew",
description="Extract Ren'Py game files.",
)
parser.add_argument(
"--game-root",
required=True,
type=Path,
help="Game installation root to scan.",
)
parser.add_argument(
"--output-root",
type=Path,
default=Path("output"),
help="Output root. Defaults to repository output/.",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Replace Clew-managed output directories.",
)
parser.add_argument(
"--no-extract-renpy-ast",
action="store_true",
help="Skip Ren'Py AST extraction.",
)
return parser.parse_args(argv)
def resolve_output_root(output_root: Path) -> Path:
if output_root.is_absolute():
return output_root.resolve()
workspace = os.environ.get("BUILD_WORKSPACE_DIRECTORY")
if workspace:
return (Path(workspace) / output_root).resolve()
return output_root.resolve()
def get_output_directory(output_root: Path, folder: str) -> Path:
directory = output_root / folder
directory.mkdir(parents=True, exist_ok=True)
return directory
def extract_archive(archive: Path, destination: Path) -> None:
destination.mkdir(parents=True, exist_ok=True)
with archive.open("rb") as handle:
extract_rpa(handle, output_dir=str(destination))
def extract_game_files(game_root: Path, output_path: Path) -> None:
queue = deque(game_root.iterdir())
while queue:
item = queue.popleft()
destination = output_path / item.relative_to(game_root)
if item.is_dir():
destination.mkdir(parents=True, exist_ok=True)
queue.extend(item.iterdir())
elif item.is_file() and item.suffix.lower() == ".rpa":
extract_archive(item, destination.with_suffix(""))
elif item.is_file():
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, destination)
def find(root: Path, predicate: Callable[[Path], bool]) -> list[Path]:
matches = []
for current, _directories, files in os.walk(root):
current_path = Path(current)
if predicate(current_path):
matches.append(current_path)
for file_name in files:
file_path = current_path / file_name
if predicate(file_path):
matches.append(file_path)
return sorted(matches)
def is_game_root(path: Path) -> bool:
return (
(path / "game").is_dir()
and (path / "renpy").is_dir()
and (path / "lib" / "linux-x86_64" / "python").is_file())
def is_renpy_script(path: Path) -> bool:
return path.is_file() and path.suffix.lower() in {".rpyc", ".rpymc"}
def run_extract_renpy_ast(
bundled_python: Path,
game_root: Path,
compiled_script: Path,
output: Path,
) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
command = [
"steam-run",
str(bundled_python),
str(EXTRACTOR),
"--game-root",
str(game_root),
"--input",
str(compiled_script),
"--output",
str(output),
]
subprocess.run(command, check=True)
def extract_game_ast(extracted_root: Path, ast_root: Path, overwrite: bool) -> None:
game_roots = find(extracted_root, is_game_root)
if not game_roots:
LOG.warning(f"no Ren'Py game roots found under {extracted_root}")
return
tasks = [
(game_root, script_path)
for game_root in game_roots
for script_path in find(game_root / "game", is_renpy_script)
]
with ThreadPoolExecutor() as executor:
futures = {}
for game_root, script_path in tasks:
output_path = (
ast_root
/ game_root.relative_to(extracted_root)
/ script_path.relative_to(game_root).with_suffix(script_path.suffix + ".json")
)
if output_path.exists() and not overwrite:
LOG.info(f"skipping existing AST extraction: {output_path}")
continue
future = executor.submit(
run_extract_renpy_ast,
game_root / "lib" / "linux-x86_64" / "python",
game_root,
script_path,
output_path,
)
futures[future] = script_path
for future in as_completed(futures):
future.result()
LOG.info(f"finished AST extraction: {futures[future]}")
def run(args: argparse.Namespace) -> None:
game_root = args.game_root.resolve()
output_root = resolve_output_root(args.output_root)
if not game_root.is_dir():
LOG.critical(f"game root is not a directory: {game_root}")
raise SystemExit(2)
extracted = get_output_directory(output_root, "extracted")
extract_game_files(game_root, extracted)
if not args.no_extract_renpy_ast:
ast = get_output_directory(output_root, "ast")
extract_game_ast(extracted, ast, args.overwrite)
if __name__ == "__main__":
args = parse_args(sys.argv[1:])
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(levelname)s %(filename)s:%(lineno)d | %(message)s",
datefmt="%Y-%m-%d][%H:%M:%S",
)
run(args)