feat(clew): dump Ren'Py compiled script objects

This commit is contained in:
2026-09-07 17:35:07 -04:00
parent df03bfbcbb
commit bd8417259c
4 changed files with 354 additions and 8 deletions

View File

@@ -4,6 +4,7 @@ import argparse
import logging
import os
import shutil
import subprocess
import sys
from collections import deque
from pathlib import Path
@@ -19,7 +20,6 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
)
parser.add_argument(
"--game-root",
required=True,
type=Path,
help="Game installation root to scan.",
)
@@ -34,6 +34,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
action="store_true",
help="Replace Clew-managed output directories.",
)
parser.add_argument(
"--dump-renpy-objects",
action="store_true",
help="Dump compiled Ren'Py scripts from output/extracted/ to output/ast-raw/.",
)
return parser.parse_args(argv)
@@ -77,16 +82,96 @@ def extract_game_files(game_root: Path, output_path: Path) -> None:
shutil.copy2(item, destination)
def run(args: argparse.Namespace) -> None:
game_root = args.game_root.resolve()
output_root = resolve_output_root(args.output_root)
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()
)
if not game_root.is_dir():
LOG.critical(f'game root is not a directory: {game_root}')
def find_game_roots(extracted_root: Path) -> list[Path]:
game_roots = []
for root, directories, _files in os.walk(extracted_root):
path = Path(root)
if is_game_root(path):
game_roots.append(path)
directories[:] = []
return sorted(game_roots)
def find_compiled_scripts(game_root: Path) -> list[Path]:
scripts = []
game_directory = game_root / "game"
for suffix in ("*.rpyc", "*.rpymc"):
scripts.extend(game_directory.rglob(suffix))
return sorted(scripts)
def object_dump_output_path(output_root: Path, extracted_root: Path, game_root: Path, script: Path) -> Path:
namespace = game_root.relative_to(extracted_root)
relative_script = script.relative_to(game_root)
return output_root / "ast-raw" / namespace / relative_script.with_suffix(relative_script.suffix + ".json")
def run_renpy_object_dump(game_root: Path, script: Path, output: Path) -> None:
exporter = Path(__file__).with_name("renpy_object_dump.py").resolve()
bundled_python = game_root / "lib" / "linux-x86_64" / "python"
output.parent.mkdir(parents=True, exist_ok=True)
command = [
"steam-run",
str(bundled_python),
str(exporter),
"--game-root",
str(game_root),
"--input",
str(script),
"--output",
str(output),
]
LOG.info("dumping Ren'Py object graph: %s", script)
subprocess.run(command, check=True)
def dump_renpy_objects(output_root: Path, overwrite: bool) -> None:
extracted_root = output_root / "extracted"
if not extracted_root.is_dir():
LOG.critical("extracted output directory does not exist: %s", extracted_root)
raise SystemExit(2)
extracted = get_output_directory(output_root, "extracted")
extract_game_files(game_root, extracted)
game_roots = find_game_roots(extracted_root)
if not game_roots:
LOG.warning("no Ren'Py game roots found under %s", extracted_root)
return
for game_root in game_roots:
scripts = find_compiled_scripts(game_root)
LOG.info("found %d compiled Ren'Py scripts under %s", len(scripts), game_root)
for script in scripts:
output = object_dump_output_path(output_root, extracted_root, game_root, script)
if output.exists() and not overwrite:
LOG.info("skipping existing Ren'Py object dump: %s", output)
continue
run_renpy_object_dump(game_root, script, output)
def run(args: argparse.Namespace) -> None:
output_root = resolve_output_root(args.output_root)
if args.game_root is not None:
game_root = args.game_root.resolve()
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)
elif not args.dump_renpy_objects:
LOG.critical("missing --game-root unless --dump-renpy-objects is set")
raise SystemExit(2)
if args.dump_renpy_objects:
dump_renpy_objects(output_root, args.overwrite)
if __name__ == "__main__":