--- /dev/null
+#! /bin/bash
+
+# print instructions if no arguments given
+if [ $# -eq 0 ] || [ $1 == '-h' ]
+then
+ printf "
+roll v1.1 by Rodney DuPlessis
+
+roll is a command line script written in bash to roll arbitrary numbers of arbitrarily-sided dice.
+roll also allows for a modifier and to set the minimum accepted value of each individual roll.
+
+----
+
+The basic syntax is:
+
+roll [#]d[sides]
+
+Where:
+[#] is the number of dice you want to roll
+[sides] is the number of sides of the dice you want to roll
+
+----
+
+Optional:
+
+
+You may add a modifier to add or subtract from the final result:
+
+roll [#]d[sides][+/-][mod]
+
+Where:
+[+/-] is either a plus (+) or minus (-) sign
+[mod] is a modifier to add to the result
+
+
+You may also use the \"-min\" flag to set a minimum outcome for each individual die roll:
+
+roll [#]d[sides] -min [min#]
+
+Where:
+[min#] sets a minimum to round up to (any individual die that rolls less than this number will be rounded up)
+Note: don't forget to add a space before and after the \"-min\" flag.
+
+----
+
+Example usage:
+
+roll 3d15
+
+will roll 3 15-sided dice
+
+
+roll 5d6+10
+
+will roll 5 6-sided dice and add 10 to the total result
+
+
+roll 7d203-11
+
+will roll 7 203-sided dice and subtract 11 from the total result
+
+
+roll 6d10+12 -min 6
+
+will roll 6 10-sided dice, rounding up to 6 any individual die that results in less than 6, and then add 12 to the total result.
+
+"
+ exit 1
+fi
+
+if [ $# -eq 2 ] || [ $# -gt 3 ]
+then
+ printf "
+Syntax Error: Wrong number of arguments
+
+Type 'roll -h' to see correct syntax
+
+"
+ exit 2
+fi
+
+
+# parse the argument
+NUMDICE=$(echo $1 | sed 's/d.*//')
+
+# if no number of dice given, default to 1
+if [[ $NUMDICE == '' ]]
+then
+ NUMDICE=1
+fi
+
+# check for modifier
+if [ $(echo $1 | grep '+') ] # check for positive modifier
+then
+ FACES=$(echo $1 | sed -e 's/.*d\(.*\)+.*/\1/')
+ MODIFIER=$(echo $1 | sed 's/.*+/0+/' | bc)
+
+else
+ if [ $(echo $1 | grep '-') ] # check for negative modifier
+ then
+ FACES=$(echo $1 | sed -e 's/.*d\(.*\)-.*/\1/')
+ MODIFIER=$(echo $1 | sed 's/.*-/0-/' | bc)
+ else # If there is no modifier
+ FACES=$(echo $1 | sed -e 's/.*d\(.*\)/\1/')
+ MODIFIER=0
+ fi
+fi
+
+if [ $# -eq 3 ] # check if there are extra arguments
+then
+ if [ $2 = "-min" ] # check that the flag is "-min" (accept no others)
+ then
+ MIN=$3
+ else
+ printf "
+Syntax Error: $2 is not a valid flag
+
+Type 'roll -h' to see correct syntax
+"
+ exit 1
+ fi
+else # if there is no minimum, set it to 0
+ MIN=0
+fi
+
+# int number regex to check args against
+INT='^-?[0-9]+$'
+# check if args are integer numbers
+if ! [[ $NUMDICE =~ $INT ]] || ! [[ $FACES =~ $INT ]] || ! [[ $MODIFIER =~ $INT ]] || ! [[ $MIN =~ $INT ]]
+then
+ printf "
+Syntax Error: arguments must be integer (whole) numbers.
+
+Type 'roll -h' to see correct syntax
+
+"
+ exit 1
+fi
+
+if [ $FACES -eq 0 ]
+then
+ printf "
+Syntax Error: Dice cannot have 0 faces... That would break reality.
+
+Type 'roll -h' to see correct syntax
+
+"
+ exit 1
+fi
+
+if [ $FACES -lt 0 ]
+then
+ printf "
+Syntax Error: Seriously? You want me to roll dice with a negative number of faces? Excuse me while I alter the laws of physics.
+
+Type 'roll -h' to see correct syntax
+
+"
+ exit 1
+fi
+
+if [ $NUMDICE -eq 0 ]
+then
+ printf "
+You roll no dice. I award you no points... and may God have mercy on your soul.
+
+"
+ exit 1
+fi
+
+if [ $NUMDICE -lt 0 ]
+then
+ printf "
+Syntax Error: I cannot roll a negative number of dice. Are you asking me to unroll dice? brb while I wage war against entropy...
+
+Type 'roll -h' to see correct syntax
+
+"
+ exit 1
+fi
+
+# Set the total to zero before rolling
+RESULT=0
+TOTALROUND=0
+
+# do the rolls
+for i in $(eval echo "{1..$NUMDICE}")
+do
+ ROUNDED=""
+ ROLL=$(( ( RANDOM % $FACES ) + 1 ))
+ if [ $ROLL -lt $MIN ]
+ then
+ ROUNDAMT=$(($MIN - $ROLL))
+ let TOTALROUND+=$ROUNDAMT
+ ROUNDED=" (Rounded up from $ROLL)"
+ ROLL=$MIN
+ fi
+ echo "roll "$i": "$ROLL $ROUNDED
+ let RESULT+=$ROLL
+
+done
+
+echo
+
+# add optional modifier
+if [ $MODIFIER -ne 0 ]
+then
+ echo "Rolled total:" $RESULT
+ let RESULT+=$MODIFIER
+ printf '%s: %+i \n' "Modifier" $MODIFIER
+fi
+
+echo "TOTAL:" $RESULT
--- /dev/null
+#!/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]))
+