build(python): vendor rpycdec

This commit is contained in:
2026-09-06 15:06:03 -04:00
parent 64773e0948
commit c85993c196
61 changed files with 6644 additions and 464 deletions

View File

@@ -0,0 +1,317 @@
import argparse
import logging
import os
import re
import sys
from rpycdec.apk import extract_apk
from rpycdec.decompile import decompile
from rpycdec.rpa import create_rpa, extract_rpa
from rpycdec.save import dump_save_info, extract_save, generate_new_key, restore_save
from rpycdec.translate import extract_translations
logger = logging.getLogger(__name__)
def regular_expression(value: str) -> str:
"""Validate a command-line regular expression."""
try:
re.compile(value)
except re.error as exc:
raise argparse.ArgumentTypeError(f"invalid regular expression: {exc}") from exc
return value
def non_empty_suffix(value: str) -> str:
"""Validate a command-line file suffix."""
if not value:
raise argparse.ArgumentTypeError("suffix must not be empty")
return value
def decompile_files(srcs: list[str], **kwargs):
"""
decompile rpyc file or directory.
"""
for src in srcs:
decompile(
src,
output_path=kwargs.get("output"),
dis=kwargs.get("disassemble", False),
)
def extract_rpa_files(srcs: list[str], **kwargs):
"""
extract rpa archive.
"""
for src in srcs:
with open(src, "rb") as f:
output_path = kwargs.get("output") or os.path.dirname(src)
extract_rpa(
f,
output_dir=output_path,
expressions=kwargs.get("expressions"),
suffixes=kwargs.get("suffixes"),
)
def create_rpa_archive(srcs: list[str], output: str, **kwargs):
"""
create rpa archive.
"""
create_rpa(
output,
srcs,
base_dir=kwargs.get("base"),
)
def run_extract_translations(
srcs: list[str], language: str, output: str | None = None, **kwargs
):
"""
extract translations from rpy files.
"""
for src in srcs:
# Default output to current directory, extract_translations will create tl/{language}/
dest = output or "."
extract_translations(
game_dir=src,
output_dir=dest,
language=language,
include_dialogues=kwargs.get("include_dialogues", True),
include_strings=kwargs.get("include_strings", True),
empty_translation=kwargs.get("empty_translation", False),
)
def extract_game_from_apk(apk_path: str, **kwargs):
"""
Extract Ren'Py game from Android APK file.
"""
extract_apk(
apk_path=apk_path,
output_dir=kwargs.get("output"),
)
SECURITY_WARNING = (
"\033[33mSecurity Warning: This tool uses a restricted pickle unpickler with "
"whitelist-based class loading to mitigate arbitrary code execution risks. "
"However, no safeguard is perfect. Only process files from trusted sources.\033[0m"
)
def main():
"""
command line tool entry.
"""
logging.basicConfig(level=logging.INFO)
argparser = argparse.ArgumentParser()
argparser.add_argument(
"--verbose", "-v", action="store_true", help="verbose output"
)
subparsers = argparser.add_subparsers(
title="subcommands", dest="command", help="subcommand help"
)
decompile_parser = subparsers.add_parser("decompile", help="decompile rpyc file")
decompile_parser.add_argument("src", nargs="+", help="rpyc file or directory")
decompile_parser.add_argument(
"--output",
"-o",
help="output path",
)
decompile_parser.add_argument(
"--disassemble", "-d", action="store_true", help="disassemble rpyc file"
)
decompile_parser.set_defaults(
func=lambda args: decompile_files(args.src, **vars(args))
)
unrpa_parser = subparsers.add_parser("unrpa", help="extract rpa archive")
unrpa_parser.add_argument("file", nargs=1, help="rpa archive")
unrpa_parser.add_argument(
"--output",
"-o",
help="output path",
)
unrpa_parser.add_argument(
"--expression",
"-e",
dest="expressions",
action="append",
type=regular_expression,
metavar="REGEX",
help=(
"extract archive paths matching this regular expression; may be repeated"
),
)
unrpa_parser.add_argument(
"--suffix",
"-s",
dest="suffixes",
action="extend",
nargs="+",
type=non_empty_suffix,
metavar="SUFFIX",
help=(
"extract archive paths ending with any of these suffixes "
"(case-insensitive); may be repeated"
),
)
unrpa_parser.set_defaults(
func=lambda args: extract_rpa_files(args.file, **vars(args))
)
rpa_parser = subparsers.add_parser("rpa", help="create rpa archive")
rpa_parser.add_argument("src", nargs="+", help="file or directory to add")
rpa_parser.add_argument(
"--output",
"-o",
required=True,
help="output .rpa archive path",
)
rpa_parser.add_argument(
"--base",
help=(
"base directory for archive names "
"(default: source directory for a single directory, otherwise common parent)"
),
)
rpa_parser.set_defaults(
func=lambda args: create_rpa_archive(
args.src,
args.output,
base=args.base,
)
)
extract_game_parser = subparsers.add_parser(
"extract-game", help="extract Ren'Py game from Android APK file"
)
extract_game_parser.add_argument("apk", help="Android APK file path")
extract_game_parser.add_argument(
"--output",
"-o",
help="output directory (default: APK filename without extension)",
)
extract_game_parser.set_defaults(
func=lambda args: extract_game_from_apk(args.apk, **vars(args))
)
extract_translate_parser = subparsers.add_parser(
"extract-translate",
help="""extract translations from rpy files and save to tl/<language> directory.""",
)
extract_translate_parser.add_argument(
"src", nargs="+", help="source game directory"
)
extract_translate_parser.add_argument(
"--output",
"-o",
help="output directory (default: current directory, creates tl/<language>/)",
)
extract_translate_parser.add_argument(
"--language",
"-l",
required=True,
help="target language code (e.g. chinese, japanese)",
)
extract_translate_parser.add_argument(
"--no-dialogues",
action="store_true",
help="skip dialogue translations",
)
extract_translate_parser.add_argument(
"--no-strings",
action="store_true",
help="skip string translations",
)
extract_translate_parser.add_argument(
"--empty",
action="store_true",
help="generate empty translations (default: copy original)",
)
extract_translate_parser.set_defaults(
func=lambda args: run_extract_translations(
args.src,
args.language,
output=args.output,
include_dialogues=not args.no_dialogues,
include_strings=not args.no_strings,
empty_translation=args.empty,
)
)
save_parser = subparsers.add_parser("save", help="save operations")
save_subparsers = save_parser.add_subparsers(
title="save subcommands", dest="save_command", help="save subcommand help"
)
save_extract_parser = save_subparsers.add_parser(
"extract", help="extract save data to JSON files for editing"
)
save_extract_parser.add_argument("path", nargs=1, help="save-file path")
save_extract_parser.add_argument("dest", nargs="?", help="extract to directory")
save_extract_parser.add_argument(
"--disassemble", "-d", action="store_true", help="print pickle disassembly"
)
save_extract_parser.add_argument(
"--verbose", "-V", action="store_true", help="verbose class loading output"
)
save_extract_parser.set_defaults(
func=lambda args: extract_save(
args.path[0],
args.dest or "",
dissemble=args.disassemble,
verbose=getattr(args, "verbose", False),
)
)
save_restore_parser = save_subparsers.add_parser(
"restore", help="restore save data from extracted JSON directory"
)
save_restore_parser.add_argument("path", nargs=1, help="extracted directory")
save_restore_parser.add_argument("dest", nargs="?", help="restore to save-file")
save_restore_parser.add_argument(
"--key", "-k", help="path to security_keys.txt for re-signing"
)
save_restore_parser.set_defaults(
func=lambda args: restore_save(
args.path[0], args.dest or "", key_file=args.key or ""
)
)
save_info_parser = save_subparsers.add_parser(
"info", help="show save file information"
)
save_info_parser.add_argument("path", nargs=1, help="save-file path")
save_info_parser.set_defaults(func=lambda args: dump_save_info(args.path[0]))
save_genkey_parser = save_subparsers.add_parser(
"genkey", help="generate a new signing key (requires ecdsa library)"
)
save_genkey_parser.add_argument(
"output",
nargs="?",
default="security_keys.txt",
help="output key file path (default: security_keys.txt)",
)
save_genkey_parser.set_defaults(func=lambda args: generate_new_key(args.output))
args = argparser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
if not args.command:
argparser.print_help()
return
# Show security warning for commands that process pickle data
if args.command in ("decompile", "save", "unrpa") and not os.environ.get(
"RPYCDEC_NO_WARNING"
):
print(SECURITY_WARNING, file=sys.stderr)
args.func(args)