Changed file format
- Added gitignore
This commit is contained in:
parent
74f26d5dfd
commit
816d8d517e
2 changed files with 309 additions and 0 deletions
468
rpatool.py
Executable file
468
rpatool.py
Executable file
|
@ -0,0 +1,468 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
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
|
||||
|
||||
RPA2_MAGIC = 'RPA-2.0 '
|
||||
RPA3_MAGIC = 'RPA-3.0 '
|
||||
RPA3_2_MAGIC = 'RPA-3.2 '
|
||||
|
||||
# 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):
|
||||
self.padlength = padlength
|
||||
self.key = key
|
||||
self.verbose = verbose
|
||||
|
||||
if file is not None:
|
||||
self.load(file)
|
||||
else:
|
||||
self.version = version
|
||||
|
||||
def __del__(self):
|
||||
if self.handle is not None:
|
||||
self.handle.close()
|
||||
|
||||
# Determine archive version.
|
||||
def get_version(self):
|
||||
self.handle.seek(0)
|
||||
magic = self.handle.readline().decode('utf-8')
|
||||
|
||||
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
|
||||
|
||||
raise ValueError('the given file is not a valid Ren\'Py archive, or an unsupported version')
|
||||
|
||||
# Extract file indexes from opened archive.
|
||||
def extract_indexes(self):
|
||||
self.handle.seek(0)
|
||||
indexes = None
|
||||
|
||||
if self.version in [2, 3, 3.2]:
|
||||
# Fetch metadata.
|
||||
metadata = self.handle.readline()
|
||||
vals = metadata.split()
|
||||
offset = int(vals[1], 16)
|
||||
if self.version == 3:
|
||||
self.key = 0
|
||||
for subkey in vals[2:]:
|
||||
self.key ^= int(subkey, 16)
|
||||
elif self.version == 3.2:
|
||||
self.key = 0
|
||||
for subkey in vals[3:]:
|
||||
self.key ^= int(subkey, 16)
|
||||
|
||||
# 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
|
||||
|
||||
# Generate pseudorandom padding (for whatever reason).
|
||||
def generate_padding(self):
|
||||
length = random.randint(1, self.padlength)
|
||||
|
||||
padding = ''
|
||||
while length > 0:
|
||||
padding += chr(random.randint(1, 255))
|
||||
length -= 1
|
||||
|
||||
return bytes(padding, 'utf-8')
|
||||
|
||||
# Converts a filename to archive format.
|
||||
def convert_filename(self, filename):
|
||||
(drive, filename) = os.path.splitdrive(os.path.normpath(filename).replace(os.sep, '/'))
|
||||
return filename
|
||||
|
||||
# Debug (verbose) messages.
|
||||
def verbose_print(self, message):
|
||||
if self.verbose:
|
||||
print(message)
|
||||
|
||||
|
||||
# List files in archive and current internal storage.
|
||||
def list(self):
|
||||
return list(self.indexes.keys()) + list(self.files.keys())
|
||||
|
||||
# Check if a file exists in the archive.
|
||||
def has_file(self, filename):
|
||||
filename = _unicode(filename)
|
||||
return filename in self.indexes.keys() or filename in self.files.keys()
|
||||
|
||||
# Read file from archive or internal storage.
|
||||
def read(self, filename):
|
||||
filename = self.convert_filename(_unicode(filename))
|
||||
|
||||
# Check if the file exists in our 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(
|
||||
_printable(filename)))
|
||||
|
||||
# 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)))
|
||||
|
||||
# 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:
|
||||
self.verbose_print('Reading file {0} from internal storage...'.format(_printable(filename)))
|
||||
return self.files[filename]
|
||||
# We need to read the file from our open archive.
|
||||
else:
|
||||
# Read offset and length, seek to the offset and read the file contents.
|
||||
if len(self.indexes[filename][0]) == 3:
|
||||
(offset, length, prefix) = self.indexes[filename][0]
|
||||
else:
|
||||
(offset, length) = self.indexes[filename][0]
|
||||
prefix = ''
|
||||
|
||||
self.verbose_print('Reading file {0} from data file {1}... (offset = {2}, length = {3} bytes)'.format(
|
||||
_printable(filename), self.file, offset, length))
|
||||
self.handle.seek(offset)
|
||||
return _unmangle(prefix) + self.handle.read(length - len(prefix))
|
||||
|
||||
# Modify a file in archive or internal storage.
|
||||
def change(self, filename, contents):
|
||||
filename = _unicode(filename)
|
||||
|
||||
# Our 'change' is basically removing the file from our indexes first, and then re-adding it.
|
||||
self.remove(filename)
|
||||
self.add(filename, contents)
|
||||
|
||||
# Add a file to the internal storage.
|
||||
def add(self, filename, contents):
|
||||
filename = self.convert_filename(_unicode(filename))
|
||||
if filename in self.files or filename in self.indexes:
|
||||
raise ValueError('file {0} already exists in archive'.format(_printable(filename)))
|
||||
|
||||
self.verbose_print('Adding file {0} to archive... (length = {1} bytes)'.format(
|
||||
_printable(filename), len(contents)))
|
||||
self.files[filename] = contents
|
||||
|
||||
# Remove a file from archive or internal storage.
|
||||
def remove(self, filename):
|
||||
filename = _unicode(filename)
|
||||
if filename in self.files:
|
||||
self.verbose_print('Removing file {0} from internal storage...'.format(_printable(filename)))
|
||||
del self.files[filename]
|
||||
elif filename in self.indexes:
|
||||
self.verbose_print('Removing file {0} from archive indexes...'.format(_printable(filename)))
|
||||
del self.indexes[filename]
|
||||
else:
|
||||
raise IOError(errno.ENOENT, 'the requested file {0} does not exist in this archive'.format(_printable(filename)))
|
||||
|
||||
# Load archive.
|
||||
def load(self, filename):
|
||||
filename = _unicode(filename)
|
||||
|
||||
if self.handle is not None:
|
||||
self.handle.close()
|
||||
self.file = filename
|
||||
self.files = {}
|
||||
self.handle = open(self.file, 'rb')
|
||||
self.version = self.get_version()
|
||||
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.
|
||||
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')
|
||||
|
||||
self.verbose_print('Rebuilding archive index...')
|
||||
# Fill our own files structure with the files added or changed in this session.
|
||||
files = self.files
|
||||
# First, read files from the current archive into our files structure.
|
||||
for file in list(self.indexes.keys()):
|
||||
content = self.read(file)
|
||||
# Remove from indexes array once read, add to our own array.
|
||||
del self.indexes[file]
|
||||
files[file] = content
|
||||
|
||||
# Predict header length, we'll write that one last.
|
||||
offset = 0
|
||||
if self.version == 3:
|
||||
offset = 34
|
||||
elif self.version == 2:
|
||||
offset = 25
|
||||
archive = open(filename, 'wb')
|
||||
archive.seek(offset)
|
||||
|
||||
# Build our own indexes while writing files to the archive.
|
||||
indexes = {}
|
||||
self.verbose_print('Writing files to archive file...')
|
||||
for file, content in files.items():
|
||||
# Generate random padding, for whatever reason.
|
||||
if self.padlength > 0:
|
||||
padding = self.generate_padding()
|
||||
archive.write(padding)
|
||||
offset += len(padding)
|
||||
|
||||
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)
|
||||
|
||||
# Write the indexes.
|
||||
self.verbose_print('Writing archive index to archive file...')
|
||||
archive.write(codecs.encode(pickle.dumps(indexes, self.PICKLE_PROTOCOL), 'zlib'))
|
||||
# Now write the header.
|
||||
self.verbose_print('Writing header to archive file... (version = RPAv{0})'.format(self.version))
|
||||
archive.seek(0)
|
||||
if self.version == 3:
|
||||
archive.write(codecs.encode('{}{:016x} {:08x}\n'.format(self.RPA3_MAGIC, offset, self.key)))
|
||||
else:
|
||||
archive.write(codecs.encode('{}{:016x}\n'.format(self.RPA2_MAGIC, offset)))
|
||||
# We're done, close it.
|
||||
archive.close()
|
||||
|
||||
# Reload the file in our inner database.
|
||||
self.load(filename)
|
||||
|
||||
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.')
|
||||
|
||||
parser.add_argument('-l', '--list', action='store_true', help='List files in archive ARCHIVE.')
|
||||
parser.add_argument('-x', '--extract', action='store_true', help='Extract FILEs from ARCHIVE.')
|
||||
parser.add_argument('-c', '--create', action='store_true', help='Creative ARCHIVE from FILEs.')
|
||||
parser.add_argument('-d', '--delete', action='store_true', help='Delete FILEs from ARCHIVE.')
|
||||
parser.add_argument('-a', '--append', action='store_true', help='Append FILEs to ARCHIVE.')
|
||||
|
||||
parser.add_argument('-2', '--two', action='store_true', help='Use the RPAv2 format for creating/appending to archives.')
|
||||
parser.add_argument('-3', '--three', action='store_true', help='Use the RPAv3 format for creating/appending to archives (default).')
|
||||
|
||||
parser.add_argument('-k', '--key', metavar='KEY', help='The obfuscation key used for creating RPAv3 archives, in hexadecimal (default: 0xDEADBEEF).')
|
||||
parser.add_argument('-p', '--padding', metavar='COUNT', help='The maximum number of bytes of padding to add between files (default: 0).')
|
||||
parser.add_argument('-o', '--outfile', help='An alternative output archive file when appending to or deleting from archives, or output directory when extracting.')
|
||||
|
||||
parser.add_argument('-h', '--help', action='help', help='Print this help and exit.')
|
||||
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.')
|
||||
arguments = parser.parse_args()
|
||||
|
||||
# Determine RPA version.
|
||||
if arguments.two:
|
||||
version = 2
|
||||
else:
|
||||
version = 3
|
||||
|
||||
# Determine RPAv3 key.
|
||||
if 'key' in arguments and arguments.key is not None:
|
||||
key = int(arguments.key, 16)
|
||||
else:
|
||||
key = 0xDEADBEEF
|
||||
|
||||
# Determine padding bytes.
|
||||
if 'padding' in arguments and arguments.padding is not None:
|
||||
padding = int(arguments.padding)
|
||||
else:
|
||||
padding = 0
|
||||
|
||||
# Determine output file/directory and input archive
|
||||
if arguments.create:
|
||||
archive = None
|
||||
output = _unicode(arguments.archive)
|
||||
else:
|
||||
archive = _unicode(arguments.archive)
|
||||
if 'outfile' in arguments and arguments.outfile is not None:
|
||||
output = _unicode(arguments.outfile)
|
||||
else:
|
||||
# Default output directory for extraction is the current directory.
|
||||
if arguments.extract:
|
||||
output = '.'
|
||||
else:
|
||||
output = _unicode(arguments.archive)
|
||||
|
||||
# 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)
|
||||
|
||||
# Iterate over the given files to add to archive.
|
||||
for filename in arguments.files:
|
||||
add_file(_unicode(filename))
|
||||
|
||||
# Set version for saving, and save.
|
||||
archive.version = version
|
||||
try:
|
||||
archive.save(output)
|
||||
except Exception as e:
|
||||
print('Could not save archive file: {0}'.format(e), file=sys.stderr)
|
||||
elif arguments.delete:
|
||||
# Iterate over the given files to delete from the archive.
|
||||
for filename in arguments.files:
|
||||
try:
|
||||
archive.remove(filename)
|
||||
except Exception as e:
|
||||
print('Could not delete file {0} from archive: {1}'.format(filename, e), file=sys.stderr)
|
||||
|
||||
# Set version for saving, and save.
|
||||
archive.version = version
|
||||
try:
|
||||
archive.save(output)
|
||||
except Exception as e:
|
||||
print('Could not save archive file: {0}'.format(e), file=sys.stderr)
|
||||
elif arguments.extract:
|
||||
# Either extract the given files, or all files if no files are given.
|
||||
if len(arguments.files) > 0:
|
||||
files = arguments.files
|
||||
else:
|
||||
files = archive.list()
|
||||
|
||||
# Create output directory if not present.
|
||||
if not os.path.exists(output):
|
||||
os.makedirs(output)
|
||||
|
||||
# Iterate over files to extract.
|
||||
for filename in files:
|
||||
if filename.find('=') != -1:
|
||||
(outfile, filename) = filename.split('=', 2)
|
||||
else:
|
||||
outfile = filename
|
||||
|
||||
try:
|
||||
contents = archive.read(filename)
|
||||
|
||||
# Create output directory for file if not present.
|
||||
if not os.path.exists(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:
|
||||
file.write(contents)
|
||||
except Exception as e:
|
||||
print('Could not extract file {0} from archive: {1}'.format(filename, e), file=sys.stderr)
|
||||
elif arguments.list:
|
||||
# Print the sorted file list.
|
||||
list = archive.list()
|
||||
list.sort()
|
||||
for file in list:
|
||||
print(file)
|
||||
else:
|
||||
print('No operation given :(')
|
||||
print('Use {0} --help for usage details.'.format(sys.argv[0]))
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue