diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0f4ff0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__ +.idea +dist/ +build +*.egg-info \ No newline at end of file diff --git a/README.md b/README.md index 85d07f3..094d604 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,64 @@ ## About -unrpa is a script to extract files from the RPA archive format created -for [the Ren'Py Visual Novel Engine](http://www.renpy.org/). +unrpa is a tool to extract files from the RPA archive format (from +[the Ren'Py Visual Novel Engine](http://www.renpy.org/). + +It can also be used as a library. + +## Installation + +### Package manager + +The best way to install unrpa is through your package manager, if a package is available for your operating system. +I maintain [an AUR package](https://aur.archlinux.org/packages/unrpa/)) for Arch Linux users. + +### pip + +You can also install unrpa through pip, the Python package manager. You can do this on Windows with: + + py -3 -m pip install "unrpa" + +Or use `python3` rather than `py -3` on unix systems. You can see +[the official documentation](https://packaging.python.org/tutorials/installing-packages/) for more help installing +through pip. + +### From source + +You can also [download the latest release](https://github.com/Lattyware/unrpa/releases/latest) +and extract it. ## Dependencies -You will need Python 3.4 or later in order to run it (either install through +You will need Python 3.7 or later in order to run it (either install through your package manager or [directly from python.org](https://www.python.org/downloads/)). -## Installation +If you are trying to extract more exotic RPA archives, there may be additional dependencies. unrpa should instruct +you how to install them if required. + +### Examples + +When installed through your package manager or pip, you should be able to use unrpa by opening a terminal or command +prompt and doing something like: + + unrpa -mp "path/to/output/dir" "path/to/archive.rpa" + +If you are running from source, you will need execute python directly: -You can [download the latest release](https://github.com/Lattyware/unrpa/releases/latest) -and then run the script as described below. + - On most unix systems, open a terminal in the directory containing unrpa then: + + python3 -m unrpa -mp "path/to/output/dir" "path/to/archive.rpa" + + - On most Windows systems, open a Command Prompt in the directory containing unrpa then: + + py -3 -m unrpa -mp "path\to\output\dir" "path\to\archive.rpa" ## Command Line Usage ``` usage: unrpa [-h] [-v] [-s] [-l] [-p PATH] [-m] [-f VERSION] - [--continue-on-error] + [--continue-on-error] [-o OFFSET] [-k KEY] [--version] FILENAME ``` @@ -30,22 +69,16 @@ usage: unrpa [-h] [-v] [-s] [-l] [-p PATH] [-m] [-f VERSION] |---------------------|--------------------------| | FILENAME | the RPA file to extract. | -| Optional Argument | Description | -|------------------------------|------------------------------------------------------------| -| -h, --help | show this help message and exit | -| -v, --verbose | explain what is being done [default]. | -| -s, --silent | no output. | -| -l, --list | only list contents, do not extract. | -| -p PATH, --path PATH | will extract to the given path. | -| -m, --mkdir | will make any non-existent directories in extraction path. | -| -f VERSION, --force VERSION | forces an archive version. May result in failure. | -| --continue-on-error | try to continue extraction when something goes wrong. | - -### Examples - - - On most unix systems, open a terminal, then: - `python3 unrpa -mp "path/to/output/dir" "path/to/archive.rpa"` - - On most Windows systems, open a Command Prompt, then: - `py -3 unrpa -mp "path\to\output\dir" "path\to\archive.rpa"` - - +| Optional Argument | Description | +|------------------------------|----------------------------------------------------------------| +| -h, --help | show this help message and exit | +| -v, --verbose | explain what is being done [default]. | +| -s, --silent | no output. | +| -l, --list | only list contents, do not extract. | +| -p PATH, --path PATH | will extract to the given path. | +| -m, --mkdir | will make any non-existent directories in extraction path. | +| -f VERSION, --force VERSION | forces an archive version. May result in failure.
Possible versions: RPA-3.0, ZiX-12B, ALT-1.0, RPA-2.0, RPA-1.0. | +| --continue-on-error | try to continue extraction when something goes wrong. | +| -o OFFSET, --offset OFFSET | sets an offset to be used to decode unsupported archives. | +| -k KEY, --key KEY | sets a key to be used to decode unsupported archives. | +| --version | show program's version number and exit | diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..0983e4a --- /dev/null +++ b/mypy.ini @@ -0,0 +1,7 @@ +[mypy] +python_version = 3.7 +warn_unused_configs = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +disallow_any_generics = True +strict_optional = True diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..06a89bf --- /dev/null +++ b/setup.py @@ -0,0 +1,26 @@ +import setuptools # type: ignore + +with open("README.md", "r") as readme: + long_description = readme.read() + +setuptools.setup( + name="unrpa", + version="2.0.0", + author="Gareth Latty", + author_email="gareth@lattyware.co.uk", + description="Extract files from the RPA archive format (from the Ren'Py Visual Novel Engine).", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/Lattyware/unrpa", + packages=setuptools.find_packages(), + python_requires=">=3.7", + keywords="renpy rpa archive extract", + classifiers=[ + "Topic :: System :: Archiving", + "Programming Language :: Python :: 3.7", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Operating System :: OS Independent", + "Environment :: Console", + ], + entry_points={"console_scripts": ["unrpa = unrpa:__main__"]}, +) diff --git a/unrpa b/unrpa deleted file mode 100755 index c8b0c1b..0000000 --- a/unrpa +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env python3 - -""" -unrpa is a tool to extract files from Ren'Py archives (.rpa). - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -import os -import argparse -import sys -import pickle -import zlib -import traceback - - -class Version: - def __init__(self, name): - self.name = name - - def find_offset_and_key(self, file): - raise NotImplementedError() - - def detect(self, extension, first_line): - raise NotImplementedError() - - def __str__(self): - return self.name - - -class RPA1(Version): - def __init__(self): - super().__init__("RPA-1.0") - - def detect(self, extension, first_line): - return extension == ".rpi" - - def find_offset_and_key(self, file): - return 0, None - - -class HeaderBasedVersion(Version): - def __init__(self, name, header): - super().__init__(name) - self.header = header - - def find_offset_and_key(self, file): - raise NotImplementedError() - - def detect(self, extension, first_line): - return first_line.startswith(self.header) - - -class RPA2(HeaderBasedVersion): - def __init__(self): - super().__init__("RPA-2.0", b"RPA-2.0") - - def find_offset_and_key(self, file): - offset = int(file.readline()[8:], 16) - return offset, None - - -class RPA3(HeaderBasedVersion): - def __init__(self): - super().__init__("RPA-3.0", b"RPA-3.0") - - def find_offset_and_key(self, file): - line = file.readline() - parts = line.split() - offset = int(parts[1], 16) - key = int(parts[2], 16) - return offset, key - - -class ALT1(HeaderBasedVersion): - EXTRA_KEY = 0xDABE8DF0 - - def __init__(self): - super().__init__("ALT-1.0", b"ALT-1.0") - - def find_offset_and_key(self, file): - line = file.readline() - parts = line.split() - key = int(parts[1], 16) ^ ALT1.EXTRA_KEY - offset = int(parts[2], 16) - return offset, key - - -class ZiX(HeaderBasedVersion): - def __init__(self): - super().__init__("ZiX-12B", b"ZiX-12B") - - def find_offset_and_key(self, file): - # TODO: see https://github.com/Lattyware/unrpa/issues/15 - raise NotImplementedError() - - -RPA1 = RPA1() -RPA2 = RPA2() -RPA3 = RPA3() -ALT1 = ALT1() -ZiX = ZiX() -Versions = [RPA1, RPA2, RPA3, ALT1, ZiX] - - -class UnRPA: - NAME = "unrpa" - - def __init__(self, filename, verbosity=1, path=None, mkdir=False, version=None, continue_on_error=False, - offset_and_key=None): - self.verbose = verbosity - if path: - self.path = os.path.abspath(path) - else: - self.path = os.getcwd() - self.mkdir = mkdir - self.version = version - self.archive = filename - self.continue_on_error = continue_on_error - self.offset_and_key = offset_and_key - self.tty = sys.stdout.isatty() - - def log(self, verbosity, message): - if self.tty and self.verbose > verbosity: - print("{}: {}".format(UnRPA.NAME, message)) - - def log_tty(self, message): - if not self.tty and self.verbose > 1: - print(message) - - def exit(self, message): - sys.exit("{}: error: {}".format(UnRPA.NAME, message)) - - def extract_files(self): - self.log(0, "extracting files.") - if self.mkdir: - self.make_directory_structure(self.path) - if not os.path.isdir(self.path): - self.exit("path doesn't exist, if you want to create it, use -m.") - - index = self.get_index() - total_files = len(index) - for file_number, (path, data) in enumerate(index.items()): - try: - self.make_directory_structure(os.path.join(self.path, os.path.split(path)[0])) - raw_file = self.extract_file(path, data, file_number, total_files) - with open(os.path.join(self.path, path), "wb") as f: - f.write(raw_file) - except BaseException as e: - if self.continue_on_error: - traceback.print_exc() - self.log(0, - "error extracting (see above), but --continue-on-error was used, so we will keep going.") - else: - raise Exception("There was an error while trying to extract a file. See the nested exception for " - "more. If you wish to try and extract as much from the archive as possible, please " - "use the --continue-on-error flag.") from e - - def list_files(self): - self.log(1, "listing files:") - paths = self.get_index().keys() - for path in sorted(paths): - print(path) - - def extract_file(self, name, data, file_number, total_files): - self.log(1, "[{:04.2%}] {:>3}".format(file_number / float(total_files), name)) - self.log_tty(name) - offset, dlen, start = data[0] - with open(self.archive, "rb") as f: - f.seek(offset) - raw_file = start + f.read(dlen - len(start)) - return raw_file - - def make_directory_structure(self, name): - self.log(2, "creating directory structure: {}".format(name)) - if not os.path.exists(name): - os.makedirs(name) - - def get_index(self): - if not self.version: - self.version = self.detect_version() - - if self.version == ZiX and (not self.offset_and_key): - self.exit("This archive uses the ZiX-12B obfuscation scheme, which is non-standard and not currently " - "supported by unrpa. Please see https://github.com/Lattyware/unrpa/issues/15 for more details.") - elif not self.version: - self.exit("This archive doesn't have a header we recognise, if you know the version of the archive you can " - "try using -f to extract it without the header.") - - with open(self.archive, "rb") as f: - if self.offset_and_key: - offset, key = self.offset_and_key - else: - offset, key = self.version.find_offset_and_key(f) - f.seek(offset) - index = pickle.loads(zlib.decompress(f.read()), encoding="bytes") - if key is not None: - index = self.deobfuscate_index(index, key) - - return {self.ensure_str_path(path).replace("/", os.sep): data for path, data in index.items()} - - def ensure_str_path(self, key): - try: - return key.decode("utf-8") - except AttributeError: - return key - - def detect_version(self): - ext = os.path.splitext(self.archive)[1].lower() - with open(self.archive, "rb") as f: - line = f.readline() - for version in Versions: - if version.detect(ext, line): - return version - return None - - def deobfuscate_index(self, index, key): - return {k: self.deobfuscate_entry(key, v) for k, v in index.items()} - - def deobfuscate_entry(self, key, entry): - if len(entry[0]) == 2: - entry = ((offset, dlen, b"") for offset, dlen in entry) - return [(offset ^ key, dlen ^ key, start) for offset, dlen, start in entry] - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Extract files from the RPA archive format.") - - parser.add_argument("-v", "--verbose", action="count", dest="verbose", default=1, - help="explain what is being done [default].") - parser.add_argument("-s", "--silent", action="store_const", const=0, dest="verbose", - help="no output.") - parser.add_argument("-l", "--list", action="store_true", dest="list", default=False, - help="only list contents, do not extract.") - parser.add_argument("-p", "--path", action="store", type=str, dest="path", default=None, - help="will extract to the given path.") - parser.add_argument("-m", "--mkdir", action="store_true", dest="mkdir", default=False, - help="will make any non-existent directories in extraction path.") - parser.add_argument("-f", "--force", action="store", type=str, dest="version", default=None, - help="forces an archive version. May result in failure. Possible versions: " - + ", ".join(str(version) for version in Versions)) - parser.add_argument("--continue-on-error", action="store_true", dest="continue_on_error", default=False, - help="try to continue extraction when something goes wrong.") - parser.add_argument("-o", "--offset", action="store", type=int, dest="offset", default=None, - help="sets an offset to be used to decode ZiX-12B archives.") - parser.add_argument("-k", "--key", action="store", type=int, dest="key", default=None, - help="sets a key to be used to decode ZiX-12B archives.") - - parser.add_argument("filename", metavar="FILENAME", type=str, help="the RPA file to extract.") - - args = parser.parse_args() - - provided_version = None - if args.version: - for version in Versions: - if args.version.lower() == version.name.lower(): - provided_version = version - break - else: - parser.error("The archive version you gave isn't one we recognise - it needs to be one of: " + - ", ".join(str(version) for version in Versions)) - - provided_offset_and_key = None - if args.key and args.offset: - provided_offset_and_key = (args.offset, args.key) - if bool(args.key) != bool(args.offset): - parser.error("If you set a key or offset, you must set both.") - - if args.list and args.path: - parser.error("option -path: only valid when extracting.") - - if args.mkdir and not args.path: - parser.error("option --mkdir: only valid when --path is set.") - - if not args.mkdir and args.path and not os.path.isdir(args.path): - parser.error("No such directory: '{}'. Use --mkdir to create it.".format(args.path)) - - if args.list and args.verbose == 0: - parser.error("option --list: can't be silent while listing data.") - - if not os.path.isfile(args.filename): - parser.error("No such file: '{}'.".format(args.filename)) - - extractor = UnRPA(args.filename, args.verbose, args.path, args.mkdir, provided_version, args.continue_on_error, - provided_offset_and_key) - if args.list: - extractor.list_files() - else: - extractor.extract_files() diff --git a/unrpa/__init__.py b/unrpa/__init__.py new file mode 100755 index 0000000..591cf86 --- /dev/null +++ b/unrpa/__init__.py @@ -0,0 +1,221 @@ +import io +import os +import pickle +import sys +import traceback +import zlib +from typing import ( + Union, + Tuple, + Optional, + Dict, + cast, + Iterable, + Type, + BinaryIO, + FrozenSet, +) + +from unrpa.errors import ( + OutputDirectoryNotFoundError, + ErrorExtractingFile, + AmbiguousArchiveError, + UnknownArchiveError, +) +from unrpa.versions import rpa, alt, zix +from unrpa.versions.version import Version +from unrpa.view import ArchiveView + +# Offset, Length +SimpleIndexPart = Tuple[int, int] +SimpleIndexEntry = Iterable[SimpleIndexPart] +# Offset, Length, Prefix +ComplexIndexPart = Tuple[int, int, bytes] +ComplexIndexEntry = Iterable[ComplexIndexPart] +IndexPart = Union[SimpleIndexPart, ComplexIndexPart] +IndexEntry = Iterable[IndexPart] + + +class UnRPA: + """Extraction tool for RPA archives.""" + + name = "unrpa" + + error = 0 + info = 1 + debug = 2 + + provided_versions: FrozenSet[Type[Version]] = frozenset( + {*rpa.versions, *alt.versions, *zix.versions} + ) + + def __init__( + self, + filename: str, + verbosity: int = -1, + path: Optional[str] = None, + mkdir: bool = False, + version: Optional[Type[Version]] = None, + continue_on_error: bool = False, + offset_and_key: Optional[Tuple[int, int]] = None, + extra_versions: FrozenSet[Type[Version]] = frozenset(), + ) -> None: + self.verbose = verbosity + if path: + self.path = os.path.abspath(path) + else: + self.path = os.getcwd() + self.mkdir = mkdir + self.version = version + self.archive = filename + self.continue_on_error = continue_on_error + self.offset_and_key = offset_and_key + self.tty = sys.stdout.isatty() + self.versions = UnRPA.provided_versions | extra_versions + + def log( + self, verbosity: int, human_message: str, machine_message: str = None + ) -> None: + if self.tty and self.verbose > verbosity: + print( + human_message if self.tty else machine_message, + file=sys.stderr if verbosity == UnRPA.error else sys.stdout, + ) + + def extract_files(self) -> None: + self.log(UnRPA.error, "Extracting files.") + if self.mkdir: + self.make_directory_structure(self.path) + if not os.path.isdir(self.path): + raise OutputDirectoryNotFoundError(self.path) + + version = self.version() if self.version else self.detect_version() + + with open(self.archive, "rb") as archive: + index = self.get_index(archive, version) + total_files = len(index) + for file_number, (path, data) in enumerate(index.items()): + try: + self.make_directory_structure( + os.path.join(self.path, os.path.split(path)[0]) + ) + file_view = self.extract_file( + path, + data, + file_number, + total_files, + cast(io.BufferedReader, archive), + ) + with open(os.path.join(self.path, path), "wb") as output_file: + version.postprocess(file_view, output_file) + except BaseException as error: + if self.continue_on_error: + self.log( + 0, + f"Error extracting from the archive, but directed to continue on error. Detail: " + f"{traceback.format_exc()}.", + ) + else: + raise ErrorExtractingFile(traceback.format_exc()) from error + + def list_files(self) -> None: + self.log(UnRPA.info, "Listing files:") + with open(self.archive, "rb") as archive: + paths = self.get_index(archive).keys() + for path in sorted(paths): + print(path) + + def extract_file( + self, + name: str, + data: ComplexIndexEntry, + file_number: int, + total_files: int, + archive: io.BufferedIOBase, + ) -> ArchiveView: + self.log( + UnRPA.info, f"[{file_number / float(total_files):04.2%}] {name:>3}", name + ) + offset, length, start = next(iter(data)) + return ArchiveView(archive, offset, length, start) + + def make_directory_structure(self, name: str) -> None: + self.log(UnRPA.debug, f"Creating directory structure: {name}") + if not os.path.exists(name): + os.makedirs(name) + + def get_index( + self, archive: BinaryIO, version: Optional[Version] = None + ) -> Dict[str, ComplexIndexEntry]: + if not version: + version = self.version() if self.version else self.detect_version() + + offset = 0 + key: Optional[int] = None + if self.offset_and_key: + offset, key = self.offset_and_key + else: + offset, key = version.find_offset_and_key(archive) + archive.seek(offset) + index: Dict[bytes, IndexEntry] = pickle.loads( + zlib.decompress(archive.read()), encoding="bytes" + ) + if key is not None: + normal_index = UnRPA.deobfuscate_index(key, index) + else: + normal_index = UnRPA.normalise_index(index) + + return { + UnRPA.ensure_str_path(path).replace("/", os.sep): data + for path, data in normal_index.items() + } + + def detect_version(self) -> Version: + potential = (version() for version in self.versions) + ext = os.path.splitext(self.archive)[1].lower() + with open(self.archive, "rb") as f: + header = f.readline() + detected = {version for version in potential if version.detect(ext, header)} + if len(detected) > 1: + raise AmbiguousArchiveError(detected) + try: + return next(iter(detected)) + except StopIteration: + raise UnknownArchiveError(header) + + @staticmethod + def ensure_str_path(path: Union[str, bytes]) -> str: + if isinstance(path, str): + return path + else: + return path.decode("utf-8", "replace") + + @staticmethod + def deobfuscate_index( + key: int, index: Dict[bytes, IndexEntry] + ) -> Dict[bytes, ComplexIndexEntry]: + return { + path: UnRPA.deobfuscate_entry(key, entry) for path, entry in index.items() + } + + @staticmethod + def deobfuscate_entry(key: int, entry: IndexEntry) -> ComplexIndexEntry: + return [ + (offset ^ key, length ^ key, start) + for offset, length, start in UnRPA.normalise_entry(entry) + ] + + @staticmethod + def normalise_index( + index: Dict[bytes, IndexEntry] + ) -> Dict[bytes, ComplexIndexEntry]: + return {path: UnRPA.normalise_entry(entry) for path, entry in index.items()} + + @staticmethod + def normalise_entry(entry: IndexEntry) -> ComplexIndexEntry: + return [ + (*cast(SimpleIndexPart, part), b"") + if len(part) == 2 + else cast(ComplexIndexPart, part) + for part in entry + ] diff --git a/unrpa/__main__.py b/unrpa/__main__.py new file mode 100644 index 0000000..9252842 --- /dev/null +++ b/unrpa/__main__.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 + +""" +unrpa is a tool to extract files from Ren'Py archives (.rpa). + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import argparse +import os +import sys +from typing import Tuple, Optional, Any + +from unrpa import UnRPA +from unrpa.errors import UnRPAError + +parser = argparse.ArgumentParser( + prog="unrpa", + description="Extract files from the RPA archive format (from the Ren'Py Visual Novel Engine).", +) + +parser.add_argument( + "-v", + "--verbose", + action="count", + dest="verbose", + default=1, + help="explain what is being done [default].", +) +parser.add_argument( + "-s", "--silent", action="store_const", const=0, dest="verbose", help="no output." +) +parser.add_argument( + "-l", + "--list", + action="store_true", + dest="list", + default=False, + help="only list contents, do not extract.", +) +parser.add_argument( + "-p", + "--path", + action="store", + type=str, + dest="path", + default=None, + help="will extract to the given path.", +) +parser.add_argument( + "-m", + "--mkdir", + action="store_true", + dest="mkdir", + default=False, + help="will make any non-existent directories in extraction path.", +) +parser.add_argument( + "-f", + "--force", + action="store", + type=str, + dest="version", + default=None, + help="forces an archive version. May result in failure. Possible versions: " + + ", ".join(version.name for version in UnRPA.provided_versions) + + ".", +) +parser.add_argument( + "--continue-on-error", + action="store_true", + dest="continue_on_error", + default=False, + help="try to continue extraction when something goes wrong.", +) +parser.add_argument( + "-o", + "--offset", + action="store", + type=int, + dest="offset", + default=None, + help="sets an offset to be used to decode unsupported archives.", +) +parser.add_argument( + "-k", + "--key", + action="store", + type=int, + dest="key", + default=None, + help="sets a key to be used to decode unsupported archives.", +) + +parser.add_argument("--version", action="version", version="%(prog)s 2.0.0") + +parser.add_argument( + "filename", metavar="FILENAME", type=str, help="the RPA file to extract." +) + +args: Any = parser.parse_args() + +provided_version = None +if args.version: + try: + provided_version = next( + version + for version in UnRPA.provided_versions + if args.version.lower() == version.name.lower() + ) + except StopIteration: + parser.error( + "The archive version you gave isn’t one we recognise - it needs to be one of: " + + ", ".join(version.name for version in UnRPA.provided_versions) + ) + +provided_offset_and_key: Optional[Tuple[int, int]] = None +if args.key and args.offset: + provided_offset_and_key = (args.offset, args.key) +elif bool(args.key) != bool(args.offset): + parser.error("If you set --key or --offset, you must set both.") + +if args.list and args.path: + parser.error("Option -path: only valid when extracting.") + +if args.mkdir and not args.path: + parser.error("Option --mkdir: only valid when --path is set.") + +if not args.mkdir and args.path and not os.path.isdir(args.path): + parser.error(f"No such directory: “{args.path}”. Use --mkdir to create it.") + +if args.list and args.verbose == 0: + parser.error("Option --list: can’t be silent while listing data.") + +if not os.path.isfile(args.filename): + parser.error(f"No such file: “{args.filename}”.") + +try: + extractor = UnRPA( + args.filename, + args.verbose, + args.path, + args.mkdir, + provided_version, + args.continue_on_error, + provided_offset_and_key, + ) + if args.list: + extractor.list_files() + else: + extractor.extract_files() +except UnRPAError as error: + sys.exit(f"\n\033[31m{error.message}\n{error.cmd_line_help}\033[30m") diff --git a/unrpa/errors.py b/unrpa/errors.py new file mode 100644 index 0000000..e224494 --- /dev/null +++ b/unrpa/errors.py @@ -0,0 +1,59 @@ +from typing import Set, Optional + +from unrpa.versions.version import Version + + +class UnRPAError(Exception): + """Any error specific to unrpa.""" + + def __init__(self, message: str, cmd_line_help: Optional[str] = None): + self.message = message + self.cmd_line_help = cmd_line_help + super().__init__(message) + + +class OutputDirectoryNotFoundError(UnRPAError): + """An error for when the given output directory doesn’t exist.""" + + def __init__(self, path: str) -> None: + super().__init__( + f"The given output directory ({path}) does not exist.", + "If you want to create it, use --mkdir.", + ) + + +class UnknownArchiveError(UnRPAError): + """An error for when auto-detection of archive version gives no result.""" + + def __init__(self, header: bytes) -> None: + self.header = header + decoded = header.decode("utf-8", "replace") + super().__init__( + "Auto-detection of the version for this archived failed—it is likely this archive is a version not " + f"supported. Try updating unrpa, or submitting a bug report. Header: “{decoded.strip()}”", + "You can try using --force to force a specific version rather than relying on auto-detection.", + ) + + +class AmbiguousArchiveError(UnRPAError): + """An error for when auto-detection of archive version gives an ambiguous result.""" + + def __init__(self, detected: Set[Version]) -> None: + self.versions = detected + detected_list = ", ".join(str(version) for version in detected) + super().__init__( + f"Auto-detection of the version for this archive failed because it is ambiguous. It could be any one of: " + f"{detected_list}.", + "You can try using --force to force these versions and see what works.", + ) + + +class ErrorExtractingFile(UnRPAError): + """A wrapping error for when something goes wrong while extracting a file.""" + + def __init__(self, detail: str) -> None: + super().__init__( + "There was an error while trying to extract a file from the archive.", + "If you wish to try and extract as much from the archive as possible, please use --continue-on-error.\n" + f"Error Detail: {detail}", + ) diff --git a/unrpa/versions/__init__.py b/unrpa/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/unrpa/versions/alt.py b/unrpa/versions/alt.py new file mode 100644 index 0000000..606a03f --- /dev/null +++ b/unrpa/versions/alt.py @@ -0,0 +1,21 @@ +from typing import BinaryIO, Tuple, Optional, FrozenSet, Type + +from unrpa.versions.version import HeaderBasedVersion, Version + + +class ALT1(HeaderBasedVersion): + """A short-lived alternative version of RPA-3.0 from mainline Ren'Py.""" + + name = "ALT-1.0" + header = b"ALT-1.0" + extra_key = 0xDABE8DF0 + + def find_offset_and_key(self, archive: BinaryIO) -> Tuple[int, Optional[int]]: + line = archive.readline() + parts = line.split() + key = int(parts[1], 16) ^ ALT1.extra_key + offset = int(parts[2], 16) + return offset, key + + +versions: FrozenSet[Type[Version]] = frozenset({ALT1}) diff --git a/unrpa/versions/errors.py b/unrpa/versions/errors.py new file mode 100644 index 0000000..3747c5a --- /dev/null +++ b/unrpa/versions/errors.py @@ -0,0 +1,21 @@ +from typing import Optional + +from unrpa.errors import UnRPAError + + +class VersionSpecificRequirementUnmetError(UnRPAError): + """An error where the version of the archive has a special need that is unmet.""" + + def __init__(self, message: str, cmd_line_help: Optional[str] = None) -> None: + super().__init__(message, cmd_line_help) + + +class MissingPackageError(VersionSpecificRequirementUnmetError): + """An error where the version of the archive requires a Python package that isn't installed.""" + + def __init__(self, package: str) -> None: + super().__init__( + f"Extracting from this archive requires the package “{package}”.", + f'You can do this by running “pip install "{package}"”. See ' + f"https://packaging.python.org/tutorials/installing-packages for more help on installing python packages.", + ) diff --git a/unrpa/versions/rpa.py b/unrpa/versions/rpa.py new file mode 100644 index 0000000..8e42901 --- /dev/null +++ b/unrpa/versions/rpa.py @@ -0,0 +1,41 @@ +from typing import FrozenSet, BinaryIO, Tuple, Optional, Type + +from unrpa.versions.version import ExtensionBasedVersion, HeaderBasedVersion, Version + + +class RPA1(ExtensionBasedVersion): + """The first official version of the RPA format.""" + + name = "RPA-1.0" + extension = ".rpi" + + def find_offset_and_key(self, archive: BinaryIO) -> Tuple[int, Optional[int]]: + return 0, None + + +class RPA2(HeaderBasedVersion): + """The second official version of the RPA format.""" + + name = "RPA-2.0" + header = b"RPA-2.0" + + def find_offset_and_key(self, archive: BinaryIO) -> Tuple[int, Optional[int]]: + offset = int(archive.readline()[8:], 16) + return offset, None + + +class RPA3(HeaderBasedVersion): + """The third official version of the RPA format.""" + + name = "RPA-3.0" + header = b"RPA-3.0" + + def find_offset_and_key(self, archive: BinaryIO) -> Tuple[int, Optional[int]]: + line = archive.readline() + parts = line.split() + offset = int(parts[1], 16) + key = int(parts[2], 16) + return offset, key + + +versions: FrozenSet[Type[Version]] = frozenset({RPA1, RPA2, RPA3}) diff --git a/unrpa/versions/version.py b/unrpa/versions/version.py new file mode 100644 index 0000000..4890817 --- /dev/null +++ b/unrpa/versions/version.py @@ -0,0 +1,46 @@ +from abc import ABCMeta, abstractmethod +from typing import Tuple, Optional, BinaryIO + +from unrpa.view import ArchiveView + + +class Version(metaclass=ABCMeta): + """An abstract base class for parsing different versions of RPA archive.""" + + name: str + + @abstractmethod + def detect(self, extension: str, first_line: bytes) -> bool: + """Detect if an archive is of this version.""" + raise NotImplementedError() + + @abstractmethod + def find_offset_and_key(self, archive: BinaryIO) -> Tuple[int, Optional[int]]: + """Find the offset and key values for the archive.""" + raise NotImplementedError() + + def postprocess(self, source: ArchiveView, sink: BinaryIO) -> None: + """Allows postprocessing over the data extracted from the archive.""" + for segment in iter(source.read1, b""): + sink.write(segment) + + def __str__(self) -> str: + return self.name + + +class ExtensionBasedVersion(Version, metaclass=ABCMeta): + """A helper for versions where detection is based on the file extension.""" + + extension: str + + def detect(self, extension: str, first_line: bytes) -> bool: + return extension == self.extension + + +class HeaderBasedVersion(Version, metaclass=ABCMeta): + """A helper for versions where detection is based on an in-file header.""" + + header: bytes + + def detect(self, extension: str, first_line: bytes) -> bool: + return first_line.startswith(self.header) diff --git a/unrpa/versions/zix.py b/unrpa/versions/zix.py new file mode 100644 index 0000000..6cb1731 --- /dev/null +++ b/unrpa/versions/zix.py @@ -0,0 +1,124 @@ +import io +import os +import re +import struct +import itertools +from typing import BinaryIO, Tuple, Optional, FrozenSet, Type + +from unrpa.versions.errors import ( + VersionSpecificRequirementUnmetError, + MissingPackageError, +) +from unrpa.versions.version import HeaderBasedVersion, Version +from unrpa.view import ArchiveView + + +class ZiX12B(HeaderBasedVersion): + """A proprietary format with additional obfuscation.""" + + name = "ZiX-12B" + header = b"ZiX-12B" + + magic_constant = 102464652121606009 + magic_keys = ( + 3621826839565189698, + 8167163782024462963, + 5643161164948769306, + 4940859562182903807, + 2672489546482320731, + 8917212212349173728, + 7093854916990953299, + ) + + loader = "loader.pyo" + + struct_format = " None: + self.key: Optional[int] = None + + def find_offset_and_key(self, archive: BinaryIO) -> Tuple[int, Optional[int]]: + path = os.path.join(os.path.dirname(archive.name), ZiX12B.loader) + try: + import uncompyle6 # type: ignore + except ImportError as e: + raise MissingPackageError("uncompyle6") from e + try: + with io.StringIO() as decompiled: + uncompyle6.decompile_file(path, outstream=decompiled) + match = re.search( + r"verificationcode = _string.sha1\('(.*)'\)", decompiled.getvalue() + ) + if match: + verification_code = match.group(1) + else: + raise IncorrectLoaderError() + except ImportError as e: + raise LoaderRequiredError(path) from e + parts = archive.readline().split() + self.key = ZiX12B.sha1(verification_code) + return ZiX12B.offset(parts[-1]), self.key + + def postprocess(self, source: ArchiveView, sink: BinaryIO) -> None: + """Allows postprocessing over the data extracted from the archive.""" + if self.key: + parts = [] + amount = ZiX12B.obfuscated_amount + while amount > 0: + part = source.read(amount) + amount -= len(part) + parts.append(part) + sink.write(ZiX12B.run(b"".join(parts), self.key)) + else: + raise Exception("find_offset_and_key must be called before postprocess") + for segment in iter(source.read1, b""): + sink.write(segment) + + # The following code is reverse engineered from the cython "_string.pyd" file courtesy of omegalink12. + # https://github.com/Lattyware/unrpa/issues/15#issuecomment-485014225 + + @staticmethod + def sha1(code: str) -> int: + a = int("".join(filter(str.isdigit, code))) + ZiX12B.magic_constant + b = round(a ** (1 / 3)) / 23 * 109 + return int(b) + + @staticmethod + def offset(value: bytes) -> int: + a = value[7:5:-1] + b = value[:3] + c = value[5:2:-1] + return int(a + b + c, 16) + + @staticmethod + def run(s: bytes, key: int) -> bytes: + encoded = struct.unpack(ZiX12B.struct_format, s) + decoded = ( + magic_key ^ key ^ part + for (magic_key, part) in zip(itertools.cycle(ZiX12B.magic_keys), encoded) + ) + return struct.pack(ZiX12B.struct_format, *decoded) + + +versions: FrozenSet[Type[Version]] = frozenset({ZiX12B}) + + +class LoaderRequiredError(VersionSpecificRequirementUnmetError): + """An error where the user needs to provide `loader.pyo` to extract this type of archive.""" + + def __init__(self, path: str) -> None: + super().__init__( + f"To extract {ZiX12B.name} archives, the “{ZiX12B.loader}” file is required alongside the archive (we " + f"looked for it at “{path}”). You can find this file in the game you got the archive from, in the “renpy” " + f"directory.", + f"Copy the “{ZiX12B.loader}” file next to the archive you are trying to extract.", + ) + + +class IncorrectLoaderError(VersionSpecificRequirementUnmetError): + def __init__(self) -> None: + super().__init__( + "The provided “{ZiX12B.loader}” file does not appear to be the correct one. Please check it is from the " + "game this archive came from." + ) diff --git a/unrpa/view.py b/unrpa/view.py new file mode 100644 index 0000000..f958cf4 --- /dev/null +++ b/unrpa/view.py @@ -0,0 +1,39 @@ +import io +from typing import cast, Callable + + +class ArchiveView: + """A file-like object that just passes through to the underlying file.""" + + def __init__( + self, archive: io.BufferedIOBase, offset: int, length: int, prefix: bytes + ): + archive.seek(offset) + self.remaining = length + self.sources = [archive] + if prefix: + self.sources.insert(0, cast(io.BufferedIOBase, io.BytesIO(prefix))) + + def read(self, amount: int = -1) -> bytes: + return self.base_read(lambda source: source.read, amount) + + def read1(self, amount: int = -1) -> bytes: + return self.base_read(lambda source: source.read1, amount) + + def base_read( + self, method: Callable[[io.BufferedIOBase], Callable[[int], bytes]], amount: int + ) -> bytes: + if amount < 0 or amount > self.remaining: + amount = self.remaining + if self.sources and self.remaining > 0: + segment = method(self.sources[0])(amount) + if segment: + self.remaining -= len(segment) + return segment + else: + self.sources.pop(0) + return self.base_read(method, amount) + else: + if self.remaining != 0: + raise Exception("End of archive reached before the file should end.") + return b""