Rpatool
Rpatool
/usr/bin/env python3
import sys
import os
import codecs
import pickle
import errno
import random
try:
import pickle5 as pickle
except:
import pickle
if sys.version_info < (3, 8):
print('warning: pickle5 module could not be loaded and Python version is <
3.8,', file=sys.stderr)
print(' newer Ren\'Py games may fail to unpack!', file=sys.stderr)
if sys.version_info >= (3, 5):
print(' if this occurs, fix it by installing pickle5:',
file=sys.stderr)
print(' {} -m pip install pickle5'.format(sys.executable),
file=sys.stderr)
else:
print(' if this occurs, please upgrade to a newer Python (>=
3.5).', file=sys.stderr)
print(file=sys.stderr)
if sys.version_info[0] >= 3:
def _unicode(text):
return text
def _printable(text):
return text
def _unmangle(data):
if type(data) == bytes:
return data
else:
return data.encode('latin1')
def _unpickle(data):
# Specify latin1 encoding to prevent raw byte values from causing an ASCII
decode error.
return pickle.loads(data, encoding='latin1')
elif sys.version_info[0] == 2:
def _unicode(text):
if isinstance(text, unicode):
return text
return text.decode('utf-8')
def _printable(text):
return text.encode('utf-8')
def _unmangle(data):
return data
def _unpickle(data):
return pickle.loads(data)
class RenPyArchive:
file = None
handle = None
files = {}
indexes = {}
version = None
padlength = 0
key = None
verbose = False
def __del__(self):
if self.handle is not None:
self.handle.close()
if magic.startswith(self.RPA3_2_MAGIC):
return 3.2
elif magic.startswith(self.RPA3_MAGIC):
return 3
elif magic.startswith(self.RPA2_MAGIC):
return 2
elif self.file.endswith('.rpi'):
return 1
# Load in indexes.
self.handle.seek(offset)
contents = codecs.decode(self.handle.read(), 'zlib')
indexes = _unpickle(contents)
# Deobfuscate indexes.
if self.version in [3, 3.2]:
obfuscated_indexes = indexes
indexes = {}
for i in obfuscated_indexes.keys():
if len(obfuscated_indexes[i][0]) == 2:
indexes[i] = [ (offset ^ self.key, length ^ self.key) for
offset, length in obfuscated_indexes[i] ]
else:
indexes[i] = [ (offset ^ self.key, length ^ self.key,
prefix) for offset, length, prefix in obfuscated_indexes[i] ]
else:
indexes = pickle.loads(codecs.decode(self.handle.read(), 'zlib'))
return indexes
padding = ''
while length > 0:
padding += chr(random.randint(1, 255))
length -= 1
# If it's in our opened archive index, and our archive handle isn't valid,
something is obviously wrong.
if filename not in self.files and filename in self.indexes and self.handle
is None:
raise IOError(errno.ENOENT, 'the requested file {0} does not exist in
the given Ren\'Py archive'.format(
_printable(filename)))
# Our 'change' is basically removing the file from our indexes first, and
then re-adding it.
self.remove(filename)
self.add(filename, contents)
# Load archive.
def load(self, filename):
filename = _unicode(filename)
# Save current state into a new file, merging archive and internal storage,
rebuilding indexes, and optionally saving in another format version.
def save(self, filename = None):
filename = _unicode(filename)
if filename is None:
filename = self.file
if filename is None:
raise ValueError('no target file found for saving archive')
if self.version != 2 and self.version != 3:
raise ValueError('saving is only supported for version 2 and 3
archives')
archive.write(content)
# Update index.
if self.version == 3:
indexes[file] = [ (offset ^ self.key, len(content) ^ self.key) ]
elif self.version == 2:
indexes[file] = [ (offset, len(content)) ]
offset += len(content)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description='A tool for working with Ren\'Py archive files.',
epilog='The FILE argument can optionally be in ARCHIVE=REAL format, mapping
a file in the archive file system to a file on your real file system. An example of
this: rpatool -x test.rpa script.rpyc=/home/foo/test.rpyc',
add_help=False)
parser.add_argument('archive', metavar='ARCHIVE', help='The Ren\'py archive
file to operate on.')
parser.add_argument('files', metavar='FILE', nargs='*', action='append',
help='Zero or more files to operate on.')
# Normalize files.
if len(arguments.files) > 0 and isinstance(arguments.files[0], list):
arguments.files = arguments.files[0]
try:
archive = RenPyArchive(archive, padlength=padding, key=key,
version=version, verbose=arguments.verbose)
except IOError as e:
print('Could not open archive file {0} for reading: {1}'.format(archive,
e), file=sys.stderr)
sys.exit(1)
if arguments.create or arguments.append:
# We need this seperate function to recursively process directories.
def add_file(filename):
# If the archive path differs from the actual file path, as given in
the argument,
# extract the archive path and actual file path.
if filename.find('=') != -1:
(outfile, filename) = filename.split('=', 2)
else:
outfile = filename
if os.path.isdir(filename):
for file in os.listdir(filename):
# We need to do this in order to maintain a possible
ARCHIVE=REAL mapping between directories.
add_file(outfile + os.sep + file + '=' + filename + os.sep +
file)
else:
try:
with open(filename, 'rb') as file:
archive.add(outfile, file.read())
except Exception as e:
print('Could not add file {0} to archive: {1}'.format(filename,
e), file=sys.stderr)
try:
contents = archive.read(filename)