318 lines
9.7 KiB
Python
318 lines
9.7 KiB
Python
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)
|