Add Python 3 support

This commit is contained in:
SibWaf 2017-05-04 21:23:41 +03:00
parent fc78e81f39
commit fd3aebd7d3

222
rpatool
View file

@ -1,59 +1,80 @@
#!/usr/bin/env python2 #!/usr/bin/env python
from __future__ import print_function
import sys import sys
import os import os
import codecs
import pickle import pickle
import errno import errno
import random import random
if sys.version_info[0] >= 3:
def _ensure_unicode(text):
return text
def _prepare_unicode_for_print(text):
return text
elif sys.version_info[0] == 2:
def _ensure_unicode(text):
if isinstance(text, unicode):
return text
return unicode(text, 'utf-8')
def _prepare_unicode_for_print(text):
return text.encode('utf-8')
class RenPyArchive: class RenPyArchive:
file = None file = None
handle = None handle = None
files = {} files = {}
indexes = {} indexes = {}
version = None version = None
padlength = 0 padlength = 0
key = None key = None
verbose = False verbose = False
RPA2_MAGIC = 'RPA-2.0 ' RPA2_MAGIC = 'RPA-2.0 '
RPA3_MAGIC = 'RPA-3.0 ' RPA3_MAGIC = 'RPA-3.0 '
# For backward compatibility, otherwise Python3-packed archives won't be read by Python2
PICKLE_PROTOCOL = 2
def __init__(self, file = None, version = 3, padlength = 0, key = 0xDEADBEEF, verbose = False): def __init__(self, file = None, version = 3, padlength = 0, key = 0xDEADBEEF, verbose = False):
self.padlength = padlength self.padlength = padlength
self.key = key self.key = key
self.verbose = verbose self.verbose = verbose
if file is not None: if file is not None:
self.load(file) self.load(file)
else: else:
self.version = version self.version = version
def __del__(self): def __del__(self):
if self.handle is not None: if self.handle is not None:
self.handle.close() self.handle.close()
# Determine archive version. # Determine archive version.
def get_version(self): def get_version(self):
self.handle.seek(0) self.handle.seek(0)
magic = self.handle.readline().decode('utf-8') magic = self.handle.readline().decode('utf-8')
if magic.startswith(self.RPA3_MAGIC): if magic.startswith(self.RPA3_MAGIC):
return 3 return 3
elif magic.startswith(self.RPA2_MAGIC): elif magic.startswith(self.RPA2_MAGIC):
return 2 return 2
elif self.file.endswith('.rpi'): elif self.file.endswith('.rpi'):
return 1 return 1
raise ValueError('the given file is not a valid Ren\'Py archive, or an unsupported version') raise ValueError('the given file is not a valid Ren\'Py archive, or an unsupported version')
# Extract file indexes from opened archive. # Extract file indexes from opened archive.
def extract_indexes(self): def extract_indexes(self):
self.handle.seek(0) self.handle.seek(0)
indexes = None indexes = None
if self.version == 2 or self.version == 3: if self.version == 2 or self.version == 3:
# Fetch metadata. # Fetch metadata.
metadata = self.handle.readline() metadata = self.handle.readline()
@ -63,11 +84,11 @@ class RenPyArchive:
self.key = 0 self.key = 0
for subkey in vals[2:]: for subkey in vals[2:]:
self.key ^= int(subkey, 16) self.key ^= int(subkey, 16)
# Load in indexes. # Load in indexes.
self.handle.seek(offset) self.handle.seek(offset)
indexes = pickle.loads(self.handle.read().decode('zlib')) indexes = pickle.loads(codecs.decode(self.handle.read(), 'zlib'))
# Deobfuscate indexes. # Deobfuscate indexes.
if self.version == 3: if self.version == 3:
obfuscated_indexes = indexes obfuscated_indexes = indexes
@ -78,55 +99,58 @@ class RenPyArchive:
else: else:
indexes[i] = [ (offset ^ self.key, length ^ self.key, prefix) for offset, length, prefix in obfuscated_indexes[i] ] indexes[i] = [ (offset ^ self.key, length ^ self.key, prefix) for offset, length, prefix in obfuscated_indexes[i] ]
else: else:
indexes = pickle.loads(self.handle.read().decode('zlib')) indexes = pickle.loads(codecs.decode(self.handle.read(), 'zlib'))
return indexes return indexes
# Generate pseudorandom padding (for whatever reason). # Generate pseudorandom padding (for whatever reason).
def generate_padding(self): def generate_padding(self):
length = random.randint(1, self.padlength) length = random.randint(1, self.padlength)
padding = '' padding = ''
while length > 0: while length > 0:
padding += chr(random.randint(1, 255)) padding += chr(random.randint(1, 255))
length -= 1 length -= 1
return padding return padding
# Converts a filename to archive format. # Converts a filename to archive format.
def convert_filename(self, filename): def convert_filename(self, filename):
(drive, filename) = os.path.splitdrive(os.path.normpath(filename).replace(os.sep, '/')) (drive, filename) = os.path.splitdrive(os.path.normpath(filename).replace(os.sep, '/'))
return filename return filename
# Debug (verbose) messages. # Debug (verbose) messages.
def verbose_print(self, message): def verbose_print(self, message):
if self.verbose: if self.verbose:
print(message) print(message)
# List files in archive and current internal storage. # List files in archive and current internal storage.
def list(self): def list(self):
return self.indexes.keys() + self.files.keys() return list(self.indexes.keys()) + list(self.files.keys())
# Check if a file exists in the archive. # Check if a file exists in the archive.
def has_file(self, filename): def has_file(self, filename):
filename = _ensure_unicode(filename)
return filename in self.indexes.keys() or filename in self.files.keys() return filename in self.indexes.keys() or filename in self.files.keys()
# Read file from archive or internal storage. # Read file from archive or internal storage.
def read(self, filename): def read(self, filename):
filename = self.convert_filename(filename) filename = self.convert_filename(_ensure_unicode(filename))
# Check if the file exists in our indexes. # Check if the file exists in our indexes.
if filename not in self.files and filename not in self.indexes: if filename not in self.files and filename not in self.indexes:
raise IOError(errno.ENOENT, 'the requested file {0} does not exist in the given Ren\'Py archive'.format(filename)) raise IOError(errno.ENOENT, 'the requested file {0} does not exist in the given Ren\'Py archive'.format(
_prepare_unicode_for_print(filename)))
# If it's in our opened archive index, and our archive handle isn't valid, something is obviously wrong. # 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: 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(filename)) raise IOError(errno.ENOENT, 'the requested file {0} does not exist in the given Ren\'Py archive'.format(
_prepare_unicode_for_print(filename)))
# Check our simplified internal indexes first, in case someone wants to read a file they added before without saving, for some unholy reason. # Check our simplified internal indexes first, in case someone wants to read a file they added before without saving, for some unholy reason.
if filename in self.files: if filename in self.files:
self.verbose_print('Reading file {0} from internal storage...'.format(filename.encode('utf-8'))) self.verbose_print('Reading file {0} from internal storage...'.format(_prepare_unicode_for_print(filename)))
return self.files[filename] return self.files[filename]
# We need to read the file from our open archive. # We need to read the file from our open archive.
else: else:
@ -136,40 +160,46 @@ class RenPyArchive:
else: else:
(offset, length) = self.indexes[filename][0] (offset, length) = self.indexes[filename][0]
prefix = '' prefix = ''
self.verbose_print('Reading file {0} from data file {1}... (offset = {2}, length = {3} bytes)'.format(filename.encode('utf-8'), self.file, offset, length)) self.verbose_print('Reading file {0} from data file {1}... (offset = {2}, length = {3} bytes)'.format(
_prepare_unicode_for_print(filename), self.file, offset, length))
self.handle.seek(offset) self.handle.seek(offset)
return prefix + self.handle.read(length - len(prefix)) return codecs.encode(prefix) + self.handle.read(length - len(prefix))
# Modify a file in archive or internal storage. # Modify a file in archive or internal storage.
def change(self, filename, contents): def change(self, filename, contents):
filename = _ensure_unicode(filename)
# Our 'change' is basically removing the file from our indexes first, and then re-adding it. # Our 'change' is basically removing the file from our indexes first, and then re-adding it.
self.remove(filename) self.remove(filename)
self.add(filename, contents) self.add(filename, contents)
# Add a file to the internal storage. # Add a file to the internal storage.
def add(self, filename, contents): def add(self, filename, contents):
filename = unicode(self.convert_filename(filename), 'utf-8') filename = self.convert_filename(_ensure_unicode(filename))
if filename in self.files or filename in self.indexes: if filename in self.files or filename in self.indexes:
raise ValueError('file {0} already exists in archive'.format(filename)) raise ValueError('file {0} already exists in archive'.format(_prepare_unicode_for_print(filename)))
self.verbose_print('Adding file {0} to archive... (length = {1} bytes)'.format(filename.encode('utf-8'), len(contents))) self.verbose_print('Adding file {0} to archive... (length = {1} bytes)'.format(
_prepare_unicode_for_print(filename), len(contents)))
self.files[filename] = contents self.files[filename] = contents
# Remove a file from archive or internal storage. # Remove a file from archive or internal storage.
def remove(self, filename): def remove(self, filename):
filename = unicode(self.convert_filename(filename), 'utf-8') filename = _ensure_unicode(filename)
if filename in self.files: if filename in self.files:
self.verbose_print('Removing file {0} from internal storage...'.format(filename.encode('utf-8'))) self.verbose_print('Removing file {0} from internal storage...'.format(_prepare_unicode_for_print(filename)))
del self.files[filename] del self.files[filename]
elif filename in self.indexes: elif filename in self.indexes:
self.verbose_print('Removing file {0} from archive indexes...'.format(filename.encode('utf-8'))) self.verbose_print('Removing file {0} from archive indexes...'.format(_prepare_unicode_for_print(filename)))
del self.indexes[filename] del self.indexes[filename]
else: else:
raise IOError(errno.ENOENT, 'the requested file {0} does not exist in this archive'.format(filename.encode('utf-8'))) raise IOError(errno.ENOENT, 'the requested file {0} does not exist in this archive'.format(_prepare_unicode_for_print(filename)))
# Load archive. # Load archive.
def load(self, filename): def load(self, filename):
filename = _ensure_unicode(filename)
if self.handle is not None: if self.handle is not None:
self.handle.close() self.handle.close()
self.file = filename self.file = filename
@ -177,26 +207,28 @@ class RenPyArchive:
self.handle = open(self.file, 'rb') self.handle = open(self.file, 'rb')
self.version = self.get_version() self.version = self.get_version()
self.indexes = self.extract_indexes() self.indexes = self.extract_indexes()
# Save current state into a new file, merging archive and internal storage, rebuilding indexes, and optionally saving in another format version. # 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): def save(self, filename = None):
filename = _ensure_unicode(filename)
if filename is None: if filename is None:
filename = self.file filename = self.file
if filename is None: if filename is None:
raise ValueError('no target file found for saving archive') raise ValueError('no target file found for saving archive')
if self.version != 2 and self.version != 3: if self.version != 2 and self.version != 3:
raise ValueError('saving is only supported for version 2 and 3 archives') raise ValueError('saving is only supported for version 2 and 3 archives')
self.verbose_print('Rebuilding archive index...') self.verbose_print('Rebuilding archive index...')
# Fill our own files structure with the files added or changed in this session. # Fill our own files structure with the files added or changed in this session.
files = self.files files = self.files
# First, read files from the current archive into our files structure. # First, read files from the current archive into our files structure.
for file in self.indexes.keys(): for file in list(self.indexes.keys()):
content = self.read(file) content = self.read(file)
# Remove from indexes array once read, add to our own array. # Remove from indexes array once read, add to our own array.
del self.indexes[file] del self.indexes[file]
files[file] = content files[file] = content
# Predict header length, we'll write that one last. # Predict header length, we'll write that one last.
offset = 0 offset = 0
if self.version == 3: if self.version == 3:
@ -205,7 +237,7 @@ class RenPyArchive:
offset = 25 offset = 25
archive = open(filename, 'wb') archive = open(filename, 'wb')
archive.seek(offset) archive.seek(offset)
# Build our own indexes while writing files to the archive. # Build our own indexes while writing files to the archive.
indexes = {} indexes = {}
self.verbose_print('Writing files to archive file...') self.verbose_print('Writing files to archive file...')
@ -215,7 +247,7 @@ class RenPyArchive:
padding = self.generate_padding() padding = self.generate_padding()
archive.write(padding) archive.write(padding)
offset += len(padding) offset += len(padding)
archive.write(content) archive.write(content)
# Update index. # Update index.
if self.version == 3: if self.version == 3:
@ -223,31 +255,31 @@ class RenPyArchive:
elif self.version == 2: elif self.version == 2:
indexes[file] = [ (offset, len(content)) ] indexes[file] = [ (offset, len(content)) ]
offset += len(content) offset += len(content)
# Write the indexes. # Write the indexes.
self.verbose_print('Writing archive index to archive file...') self.verbose_print('Writing archive index to archive file...')
archive.write(pickle.dumps(indexes, pickle.HIGHEST_PROTOCOL).encode('zlib')) archive.write(codecs.encode(pickle.dumps(indexes, self.PICKLE_PROTOCOL), 'zlib'))
# Now write the header. # Now write the header.
self.verbose_print('Writing header to archive file... (version = RPAv{0})'.format(self.version)) self.verbose_print('Writing header to archive file... (version = RPAv{0})'.format(self.version))
archive.seek(0) archive.seek(0)
if self.version == 3: if self.version == 3:
archive.write('RPA-3.0 %016x %08x\n' % (offset, self.key)) archive.write(codecs.encode('{}{:016x} {:08x}\n'.format(self.RPA3_MAGIC, offset, self.key)))
else: else:
archive.write('RPA-2.0 %016x\n' % (offset)) archive.write(codecs.encode('{}{:016x}\n'.format(self.RPA2_MAGIC, offset)))
# We're done, close it. # We're done, close it.
archive.close() archive.close()
# Reload the file in our inner database. # Reload the file in our inner database.
self.load(filename) self.load(filename)
if __name__ == "__main__": if __name__ == "__main__":
import argparse import argparse
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='A tool for working with Ren\'Py archive files.', 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', 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) add_help=False)
parser.add_argument('archive', metavar='ARCHIVE', help='The Ren\'py archive file to operate on.') 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.') parser.add_argument('files', metavar='FILE', nargs='*', action='append', help='Zero or more files to operate on.')
@ -268,50 +300,50 @@ if __name__ == "__main__":
parser.add_argument('-v', '--verbose', action='store_true', help='Be a bit more verbose while performing operations.') parser.add_argument('-v', '--verbose', action='store_true', help='Be a bit more verbose while performing operations.')
parser.add_argument('-V', '--version', action='version', version='rpatool v0.8', help='Show version information.') parser.add_argument('-V', '--version', action='version', version='rpatool v0.8', help='Show version information.')
arguments = parser.parse_args() arguments = parser.parse_args()
# Determine RPA version. # Determine RPA version.
if arguments.two: if arguments.two:
version = 2 version = 2
else: else:
version = 3 version = 3
# Determine RPAv3 key. # Determine RPAv3 key.
if 'key' in arguments and arguments.key is not None: if 'key' in arguments and arguments.key is not None:
key = int(arguments.key, 16) key = int(arguments.key, 16)
else: else:
key = 0xDEADBEEF key = 0xDEADBEEF
# Determine padding bytes. # Determine padding bytes.
if 'padding' in arguments and arguments.padding is not None: if 'padding' in arguments and arguments.padding is not None:
padding = int(arguments.padding) padding = int(arguments.padding)
else: else:
padding = 0 padding = 0
# Determine output file/directory and input archive # Determine output file/directory and input archive
if arguments.create: if arguments.create:
archive = None archive = None
output = arguments.archive output = _ensure_unicode(arguments.archive)
else: else:
archive = arguments.archive archive = _ensure_unicode(arguments.archive)
if 'outfile' in arguments and arguments.outfile is not None: if 'outfile' in arguments and arguments.outfile is not None:
output = arguments.outfile output = _ensure_unicode(arguments.outfile)
else: else:
# Default output directory for extraction is the current directory. # Default output directory for extraction is the current directory.
if arguments.extract: if arguments.extract:
output = '.' output = '.'
else: else:
output = arguments.archive output = _ensure_unicode(arguments.archive)
# Normalize files. # Normalize files.
if len(arguments.files) > 0 and isinstance(arguments.files[0], list): if len(arguments.files) > 0 and isinstance(arguments.files[0], list):
arguments.files = arguments.files[0] arguments.files = arguments.files[0]
try: try:
archive = RenPyArchive(archive, padlength=padding, key=key, version=version, verbose=arguments.verbose) archive = RenPyArchive(archive, padlength=padding, key=key, version=version, verbose=arguments.verbose)
except IOError as (errno, errstr): except IOError as e:
sys.stderr.write('Could not open archive file {0} for reading: {1}\n'.format(archive, errstr)) print('Could not open archive file {0} for reading: {1}'.format(archive, e), file=sys.stderr)
sys.exit(1) sys.exit(1)
if arguments.create or arguments.append: if arguments.create or arguments.append:
# We need this seperate function to recursively process directories. # We need this seperate function to recursively process directories.
def add_file(filename): def add_file(filename):
@ -321,7 +353,7 @@ if __name__ == "__main__":
(outfile, filename) = filename.split('=', 2) (outfile, filename) = filename.split('=', 2)
else: else:
outfile = filename outfile = filename
if os.path.isdir(filename): if os.path.isdir(filename):
for file in os.listdir(filename): for file in os.listdir(filename):
# We need to do this in order to maintain a possible ARCHIVE=REAL mapping between directories. # We need to do this in order to maintain a possible ARCHIVE=REAL mapping between directories.
@ -331,32 +363,32 @@ if __name__ == "__main__":
with open(filename, 'rb') as file: with open(filename, 'rb') as file:
archive.add(outfile, file.read()) archive.add(outfile, file.read())
except Exception as e: except Exception as e:
sys.stderr.write('Could not add file {0} to archive: {1}\n'.format(filename, e)) print('Could not add file {0} to archive: {1}'.format(filename, e), file=sys.stderr)
# Iterate over the given files to add to archive. # Iterate over the given files to add to archive.
for filename in arguments.files: for filename in arguments.files:
add_file(filename) add_file(_ensure_unicode(filename))
# Set version for saving, and save. # Set version for saving, and save.
archive.version = version archive.version = version
try: try:
archive.save(output) archive.save(output)
except Exception as e: except Exception as e:
sys.stderr.write('Could not save archive file: {0}\n'.format(e)) print('Could not save archive file: {0}'.format(e), file=sys.stderr)
elif arguments.delete: elif arguments.delete:
# Iterate over the given files to delete from the archive. # Iterate over the given files to delete from the archive.
for filename in arguments.files: for filename in arguments.files:
try: try:
archive.remove(filename) archive.remove(filename)
except Exception as e: except Exception as e:
sys.stderr.write('Could not delete file {0} from archive: {1}\n'.format(filename, e)) print('Could not delete file {0} from archive: {1}'.format(filename, e), file=sys.stderr)
# Set version for saving, and save. # Set version for saving, and save.
archive.version = version archive.version = version
try: try:
archive.save(output) archive.save(output)
except Exception as e: except Exception as e:
sys.stderr.write('Could not save archive file: {0}\n'.format(e)) print('Could not save archive file: {0}'.format(e), file=sys.stderr)
elif arguments.extract: elif arguments.extract:
# Either extract the given files, or all files if no files are given. # Either extract the given files, or all files if no files are given.
if len(arguments.files) > 0: if len(arguments.files) > 0:
@ -367,32 +399,32 @@ if __name__ == "__main__":
# Create output directory if not present. # Create output directory if not present.
if not os.path.exists(output): if not os.path.exists(output):
os.makedirs(output) os.makedirs(output)
# Iterate over files to extract. # Iterate over files to extract.
for filename in files: for filename in files:
if filename.find('=') != -1: if filename.find('=') != -1:
(outfile, filename) = filename.split('=', 2) (outfile, filename) = filename.split('=', 2)
else: else:
outfile = filename outfile = filename
try: try:
contents = archive.read(filename) contents = archive.read(filename)
# Create output directory for file if not present. # Create output directory for file if not present.
if not os.path.exists(os.path.dirname(os.path.join(output, outfile))): if not os.path.exists(os.path.dirname(os.path.join(output, outfile))):
os.makedirs(os.path.dirname(os.path.join(output, outfile))) os.makedirs(os.path.dirname(os.path.join(output, outfile)))
with open(os.path.join(output, outfile), 'wb') as file: with open(os.path.join(output, outfile), 'wb') as file:
file.write(contents) file.write(contents)
except Exception as e: except Exception as e:
sys.stderr.write('Could not extract file {0} from archive: {1}\n'.format(filename, e)) print('Could not extract file {0} from archive: {1}'.format(filename, e), file=sys.stderr)
elif arguments.list: elif arguments.list:
# Print the sorted file list. # Print the sorted file list.
list = archive.list() list = archive.list()
list.sort() list.sort()
for file in list: for file in list:
print file.encode('utf-8') print(file)
else: else:
print 'No operation given :(' print('No operation given :(')
print 'Use {0} --help for usage details.'.format(sys.argv[0]) print('Use {0} --help for usage details.'.format(sys.argv[0]))