From df03bfbcbb77ad552ae87231d0191d009f5512df Mon Sep 17 00:00:00 2001 From: alexion Date: Mon, 7 Sep 2026 15:39:34 -0400 Subject: [PATCH] feat(clew): extract Ren'Py game files --- .gitignore | 3 ++ src/clew/BUILD.bazel | 8 ++++ src/clew/clew.py | 101 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 src/clew/BUILD.bazel create mode 100644 src/clew/clew.py diff --git a/.gitignore b/.gitignore index 2653e87..47f7783 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ Thumbs.db # Bazel convenience symlinks /bazel-* +# local generated output +/output/ + # language package-manager caches and outputs node_modules/ dist/ diff --git a/src/clew/BUILD.bazel b/src/clew/BUILD.bazel new file mode 100644 index 0000000..06a8412 --- /dev/null +++ b/src/clew/BUILD.bazel @@ -0,0 +1,8 @@ +load("@rules_python//python:defs.bzl", "py_binary") + +py_binary( + name = "clew", + srcs = ["clew.py"], + main = "clew.py", + deps = ["//third_party/python:rpycdec"], +) diff --git a/src/clew/clew.py b/src/clew/clew.py new file mode 100644 index 0000000..76a3723 --- /dev/null +++ b/src/clew/clew.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import argparse +import logging +import os +import shutil +import sys +from collections import deque +from pathlib import Path + +from rpycdec.rpa import extract_rpa + +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.", + ) + 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 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 __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) -- 2.47.3