# .-=[ rch2 ]===============================================================-. # # | RCH 2.0 Package Manipulation Library (c) 2007 by IceDragon of QuickFox | # # |---------------------------------------[ http://icerealm.quickfox.org/ ]--| # # | This library allows for parsing and extraction of the RCH2 package files | # # | used by Dragon's Eye Production in their furcsetup.exe installer | # # | REQUIRED LIBRARIES: bz2, struct, os | # # |--[ Changelog ]-----------------------------------------------------------| # # | 1.0.0 - Initial revision. | # # '-==============================================================[ 1.0.0 ]=-' # ###--# Imports #--### import bz2 import struct import os ###--# Class Declarations #--### class RCH2File: """This class represents an RCH file (enclosed or not) and contains a list of subfiles one can manipulate.""" ## Variables ## fd = None # File descriptor of the RCH file itself. fz2Files = [] # List of FZ2 sub-files. ## Constructor ## def __init__( self, fileName = None, fdProvided = False ): """Initializes an RCH2 file instance, optionally opening an RCH2 file if the additional argument is specified.""" if fileName != None: self.openFile( fileName, fdProvided ) # /__init__() ## Destructor ## def __del__( self ): """Performs pre-termination tasks such as closing a possibly open file descriptor with closeFile() and clearing off the subfiles.""" if self.fd != None and not self.fd.closed: self.fd.close() self.purgeFiles() # /__del__() ## Primary Functions ## def openFile( self, fileName, fdProvided = False ): """Open an RCH2 file and read all the data for display/extraction. If the optional `fdProvided` argument is True, fileName is considered an already open file descriptor rather than a filename! This allows for reading of RCH2 data from within an enclosing file.""" if fdProvided: self.fd = fileName else: self.fd = open( fileName, "rb" ) # Remembering initial position. initPosition = self.fd.tell() # Reading RCH2 header to test against a valid RCH2 file. header = self.fd.read( 6 ) if header != "RCH2.0": self.fd.seek( initPosition ) raise Exception( "Non-RCH2 file header `%s` - unable to process data" % header ) # Clearing any existing file references. self.purgeFiles() loop = True while loop: # Reading file data. # If there are problems in this particular area, then the RCH2 file # simply ended and we should just stop reading rather than throwing an # error. try: # Letting the FZ2File class to fill itself (as well as share the # file descriptor with it for extraction!) fzFile = FZ2File( self.fd ) except Exception,e: loop = False continue self.addFile( fzFile ) # /while # Returning read pointer back to the initial position, be it 0 or # possibly something else if the file was already open. self.fd.seek( initPosition ) # /openFile() def closeFile( self ): """This function closes an open by openFile() file descriptor.""" self.fd.close() # /closeFile() def saveFile( self, newFilename = None ): """Saving RCH2 files is not yet supported!""" raise Exception( "Unsupported function saveFile()" ) # /saveFile() ## Secondary Functions ## def getFileList( self ): """This function returns the main file list you can look through to get data on each of them. The list is sorted in the way it was readen from the source file.""" return self.fz2Files # /getFileList() def addFile( self, fzFile, nodupOverride = False ): """This function adds a subfile represented by FZ2File class to the list of files in this RCH2 container. The file can already be within this very container or external, or have the same filename. In such case, it will not be re-added and if a filename like this exists, an exception will be thrown to point out the error. To prevent duplicate checks in order to avoid speed degradation, you can specify the second parameter as True. This function is normally used on newly created RCH2File classes to fill them with files before generating the RCH2 with saveFile(), or on existing classes to add files to the archive and commit changes with the same saveFile() function. NOTE: addFile() and delFile() handle file references in the class and not the data itself! In order to commit changes, you must use saveFile() to save the RCH2 file.""" if not nodupOverride and self.fileExists( fzFile.fileName ): raise Exception( "Attempted to add a filename that already exists: " + fzFile.fileName ) if fzFile not in self.fz2Files: self.fz2Files.append( fzFile ) # /addFile() def delFile( self, fzFile ): """This function removes a subfile represented by FZ2File class from the list of files in this RCH2 container. The file must exist within the list or an exception will be thrown! NOTE: addFile() and delfile() handle file references in the class and not the data itself! In order to commit changes, you must use saveFile() to save the RCH2 file.""" if fzFile not in self.fz2Files: raise Exception( "Attempt to remove non-existent reference from RCH2File (%s)" % fzFile.fileName ) self.fz2Files.remove( fzFile ) # /delFile() def fileExists( self, fileName ): """This function returns whether or not a file with a specific filename already exists in the list of files. NOTE: Name lookup is case-sensitive!""" for fzFile in self.fz2Files: if fzFile.fileName == fileName: return True return False # /fileExists() def purgeFiles( self ): """This function removes all the subfiles in the list in order to reinitialize it or prepare the class for termination.""" for fzFile in self.fz2Files: del fzFile self.fz2Files = [] # /purgeFiles() # /RCH2File class FZ2File: """Subfile representation class - this class contains information on an individual subfile stored in an RCH2 file - the RCH2File class.""" ## Variables ## fd = None # Source file descriptor for this subfile. fileName = '' # Name of the file or its install path. fileSize = 0 # Size of the enclosed file. fileCRC = 0 # CRC32 summary of the enclosed file. fileDataOffset = 0 # File data offset in the RCH2 file. ## Constructor ## def __init__( self, fd = None ): """Fills the subfile data by reading raw RCH2 data from a specified file descriptor (processData() function). If none was specified, the variables are left initialized to their starting value.""" self.fd = fd self.processData() # /__init__() ## Destructor ## def __del__( self ): """Performs pre-termination tasks such as closing the file descriptor if still open.""" if type(self.fd) == file and not self.fd.closed and self.fileExternal: self.fd.close() # /__del__() ## Functions ## def processData( self ): """Reads binary data from a specific file descriptor and parses it into the local variables. Used to read an RCH2 subfile into the variables. This function will return an exception if it fails to identify the beginning of such a subfile (no FZ header) or the filename size is more than 20,000 characters (which may indicate end of RCH2 file).""" subheader = self.fd.read( 2 ) if subheader != "FZ": self.fd.seek( -2, 1 ) raise Exception( "Lost track or end of RCH2 file - non-FZ subheader `%s`" % subheader ) fnSize = struct.unpack( 'H', self.fd.read(2) )[0] if fnSize > 20000: self.fd.seek( -2, 1 ) raise Exception( "Filename too long or end of RCH2 file (%d)" % fnSize ) self.fileName = self.fd.read( fnSize ) (self.fileSize,self.fileCRC) = struct.unpack( 'II', self.fd.read(8) ) self.fileDataOffset = self.fd.tell() self.fd.seek( self.fileSize, 1 ) # /processData() def getData( self ): """This function returns the contents of the subfile. NOTE: If the contents are compressed, they will NOT be decompressed first! You will have to do so manually with bz2.decompress() should it be necessary!""" initPosition = self.fd.tell() self.fd.seek( self.fileDataOffset ) data = self.fd.read( self.fileSize ) self.fd.seek( initPosition ) return data # /getData() def extract( self, fileName = None, variables = {} ): """Extracts the contents of the subfile into a file with a specific name. If the name was not specified, the locally stored fileName will be used. If the file is stored in a folder (or more) that don't exist, the system will try to create them. This function will extract bz2 subfiles, should it be necessary (if the fileCompressed setting is set, in other words). NOTE: It is known that furcsetup.exe uses Windows environment variables such as %ALLUSERSPROFILE% and local to it variables such as $INSTALLDIR$! These variables need to be explicitly configured in the `variables` argument in the following syntax: variables = { "%ALLUSERSPROFILE%": 'c:\\Documents and Settings\\All Users\\', "$INSTALLDIR$": 'c:\\Program Files\\Furcadia\\', "$HOME": 'c:\\Documents and Settings\\IceDragon\\My Documents\\' } The lookup flow is as follows (before exception is thrown): 1) Variable checked against `variables` argument. 2) Variable checked against environment variables (os.environ).""" if fileName == None: fileName = self.fileName # We let an external function do this in case and someone wants to do # the path assignment manually, but still wants assistance here. pFilename = processPath( fileName, variables ) # Creating the path (if any and if doesn't exist yet)... (Path,File) = os.path.split( pFilename ) if Path != '' and not os.path.exists( Path ): os.makedirs( Path ) # Opening file (and hope it succeeds). targetFile = open( pFilename, 'w' ) # Moving to the right location in the RCH file (and again, hope the fd # variable was set..) if type( self.fd ) != file: raise Exception( "Attempted to extract a subfile without its container's descriptor (fd not set)" ) initPosition = self.fd.tell() self.fd.seek( self.fileDataOffset ) # Decompressing data. targetFile.write( bz2.decompress( self.getData() ) ) targetFile.close() # Returning file pointer back where it was. self.fd.seek( initPosition ) # /extract() # /FZ2File ###--# Global Variables #--### # This list contains all the known header offsets. It is referenced before an # attempt to manually find such a header (which takes significantly longer). knownHeaderOffsets = [ 0x2f000, 0x30000 ] # This is the header signature + "FZ" so we can detect the header itself rather # than other related to it data in furcsetup or so. rch2HeaderSig = 'RCH2.0FZ' # This is a version string for other scripts to be able to identify what the # library supports and what not. version = ( 1, 0, 0 ) ###--# Functions #--### # Find the RCH2 file header and return the offset. If header wasn't found, -1 is # returned. The file position pointer is automatically set to the correct # location, as well! def findHeader( fd ): global knownHeaderOffsets, rch2HeaderSig # Trying the beginning of file. fd.seek( 0 ) header = fd.read( len(rch2HeaderSig) ) if header == rch2HeaderSig: fd.seek( 0 ) return 0 # Trying known offsets for offset in knownHeaderOffsets: fd.seek( offset ) header = fd.read( len(rch2HeaderSig) ) if header == rch2HeaderSig: fd.seek( offset ) return offset # Trying manually (takes a while, but what else can we do?..) # Also, I'm not reading the entire file and using data.find() to save # memory. An installer can take up to 9 megs now... fd.seek( 0,2 ) curPos = 0 eofPos = fd.tell() fd.seek( 0 ) buff = '' while( curPos < eofPos ): curPos -= len( buff ) buff += fd.read( min( 4096 - len( buff ), (eofPos-curPos) ) ) loc = buff.find( rch2HeaderSig ) if loc >= 0: fd.seek( curPos + loc ) return fd.tell() curPos += len( buff ) buff = buff[(len(rch2HeaderSig) * -1):] # /while # Header not found return -1 # /findHeader() # This function receives a path with possible environment variables or Furcadia # installer-specific variables. The function tries to resolve them and return a # physical path instead. # # The custom variables are specified in the following way: # variables = { # "%ALLUSERSPROFILE%": 'c:\\Documents and Settings\\All Users\\', # "$INSTALLDIR$": 'c:\\Program Files\\Furcadia\\', # "$HOME": 'c:\\Documents and Settings\\IceDragon\\My Documents\\' # } # # The lookup flow is as follows (before an exception is thrown): # 1) Variable checked against `variables` argument. # 2) Variable checked against environment variables (os.environ). def processPath( path, variables = {} ): # I know I can use RegExp here and I know how, but since I want to C++ it at # one point, I'm going to use this rather nasty method here... newPath = '' varType = '' # U:UNIX %:Windows $:Installer varCap = False # Variable capture enabled/disabled for ch in path: # Converting Windows or Linux slashes to the correct separator. if ch in '\\/': ch = os.path.sep if varCap: if ch not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_': varCap = False if ch not in '%$': varType = 'U' # No % or $ ending, gotta be UNIX. else: var += ch # Parsing variable. if var in variables.keys(): newPath += variables[ var ] else: if varType == 'U': envVar = var[1:] elif varType in '$%': envVar = var[1:-1] eVar = os.getenv( envVar ) if eVar != None: newPath += eVar else: raise Exception( "Unidentified variable - %s" % var ) # /else # /if ch not in ... else: var += ch continue # /if varCap if ch in '%$': varType = ch var = ch varCap = True continue newPath += ch # /for # If the loop has finished in varCap mode, we had a UNIX var as a suffix # apparently, gotta handle it. Repeating logic, I know.. if varCap: # Parsing variable. if var in variables.keys(): newPath += variables[ var ] else: eVar = os.getenv( var[1:] ) if eVar != None: newPath += eVar else: raise Exception( "Unidentified UNIX variable - %s" % var ) # /if varCap # Return processed path (or whatever was left of it..) return newPath # /processPath() ###--# END OF FILE #--###