| #!/usr/bin/env python | |
| # blorbtool.py: A (semi-)multifunctional Blorb utility | |
| # Created by Andrew Plotkin (erkyrath@eblong.com) | |
| # Last updated: October 10, 2024 | |
| # This script is in the public domain. | |
| # When listing chunks, you'll see output that looks like: | |
| # 'GLUL' (232192 bytes, start 60) | |
| # "60" means that the IFF chunk starts at byte 60 in the blorb file. There's | |
| # always an eight-byte header, so the actual Glulx data file starts at byte | |
| # 68 (and is then 232192 bytes long). | |
| # | |
| # For AIFF chunks, you'll see: | |
| # 'FORM'/'AIFF' (8536+8 bytes, start 324266) | |
| # The AIFF data implicitly includes the eight-byte header, which is why the | |
| # length says "+8". Start at byte 324266 and read 8544 bytes. | |
| # We use the print() function for Python 2/3 compatibility | |
| from __future__ import print_function | |
| # We use the Py2 raw_input() function. In Py3 there is no such function, | |
| # but we define a back-polyfill. (I'm lazy.) | |
| try: | |
| raw_input | |
| except NameError: | |
| raw_input = input | |
| import sys | |
| import os | |
| import optparse | |
| import re | |
| import collections | |
| import struct | |
| import base64 | |
| import json | |
| try: | |
| import readline | |
| except: | |
| pass | |
| try: | |
| # Python 3.3 and up | |
| os_replace = os.replace | |
| except AttributeError: | |
| if (os.name != 'nt'): | |
| # Older Python (on Unix) | |
| os_replace = os.rename | |
| else: | |
| # On Windows, os.rename can't replace an existing file. | |
| def os_replace(src, dst): | |
| try: | |
| os.remove(dst) | |
| except: | |
| pass | |
| os.rename(src, dst) | |
| popt = optparse.OptionParser(usage='blorbtool.py BLORBFILE [ command ]') | |
| popt.add_option('-n', '--new', | |
| action='store_true', dest='newfile', | |
| help='create a new blorb file instead of loading one in') | |
| popt.add_option('-o', '--output', | |
| action='store', dest='output', metavar='BLORBFILE', | |
| help='blorb file to write to (if requested)') | |
| popt.add_option('-f', '--force', | |
| action='store_true', dest='force', | |
| help='overwrite files without confirming') | |
| popt.add_option('-v', '--verbose', | |
| action='store_true', dest='verbose', | |
| help='verbose stack traces on error') | |
| popt.add_option('-l', '--commands', | |
| action='store_true', dest='listcommands', | |
| help='list all commands (and exit)') | |
| (opts, args) = popt.parse_args() | |
| def dict_append(map, key, val): | |
| ls = map.get(key) | |
| if (not ls): | |
| ls = [] | |
| map[key] = ls | |
| ls.append(val) | |
| def confirm_input(prompt): | |
| ln = raw_input(prompt+' >') | |
| if (ln.lower().startswith('y')): | |
| return True | |
| class BlorbChunk: | |
| def __init__(self, blorbfile, typ, start, len, formtype=None): | |
| self.blorbfile = blorbfile | |
| self.type = typ | |
| self.start = start | |
| self.len = len | |
| self.formtype = formtype | |
| self.literaldata = None | |
| self.filedata = None | |
| self.filestart = None | |
| def __repr__(self): | |
| return '<BlorbChunk %s at %d, len %d>' % (typestring(self.type), self.start, self.len) | |
| def data(self, max=None): | |
| if (self.literaldata): | |
| if (max is not None): | |
| return self.literaldata[0:max] | |
| else: | |
| return self.literaldata | |
| if (self.filedata): | |
| fl = open(self.filedata, 'rb') | |
| if (self.filestart is not None): | |
| fl.seek(self.filestart) | |
| if (max is not None): | |
| dat = fl.read(max) | |
| else: | |
| dat = fl.read() | |
| fl.close() | |
| return dat | |
| self.blorbfile.formchunk.seek(self.start) | |
| toread = self.len | |
| if (max is not None): | |
| toread = min(self.len, max) | |
| return self.blorbfile.formchunk.read(toread) | |
| def describe(self): | |
| if (not self.formtype): | |
| return '%s (%d bytes, start %d)' % (typestring(self.type), self.len, self.start) | |
| else: | |
| return '%s/%s (%d+8 bytes, start %d)' % (typestring(self.type), typestring(self.formtype), self.len, self.start) | |
| def display(self): | |
| print('* %s' % (self.describe(),)) | |
| if (self.type == b'RIdx'): | |
| # Index chunk | |
| dat = self.data() | |
| (subdat, dat) = (dat[:4], dat[4:]) | |
| num = struct.unpack('>I', subdat)[0] | |
| print('%d resources:' % (num,)) | |
| while (dat): | |
| (subdat, dat) = (dat[:12], dat[12:]) | |
| subls = struct.unpack('>4c2I', subdat) | |
| usage = b''.join(subls[0:4]) | |
| print(' %s %d: starts at %d' % (typestring(usage), subls[-2], subls[-1])) | |
| elif (self.type == b'IFmd'): | |
| # Metadata chunk | |
| dat = self.data() | |
| print(dat.decode('utf-8')) | |
| elif (self.type == b'Fspc'): | |
| # Frontispiece chunk | |
| dat = self.data() | |
| if (len(dat) != 4): | |
| print('Warning: invalid contents!') | |
| else: | |
| num = struct.unpack('>I', dat[0:4])[0] | |
| print('Frontispiece is pict number', num) | |
| elif (self.type == b'RDes'): | |
| # Resource description chunk | |
| dat = self.data() | |
| (subdat, dat) = (dat[:4], dat[4:]) | |
| count = struct.unpack('>I', subdat)[0] | |
| print('%d entries:' % (count,)) | |
| for ix in range(count): | |
| if (len(dat) < 12): | |
| print('Warning: contents too short!') | |
| break | |
| (subdat, dat) = (dat[:12], dat[12:]) | |
| subls = struct.unpack('>4c2I', subdat) | |
| restype = b''.join(subls[0:4]) | |
| strlen = subls[-1] | |
| num = subls[-2] | |
| if (len(dat) < strlen): | |
| print('Warning: contents too short!') | |
| break | |
| (subdat, dat) = (dat[:strlen], dat[strlen:]) | |
| print(' %s resource %d: "%s"' % (typestring(restype), num, subdat.decode('utf-8'))) | |
| if (len(dat) > 0): | |
| print('Warning: contents too long!') | |
| elif (self.type == b'APal'): | |
| # Adaptive palette | |
| dat = self.data() | |
| if (len(dat) % 4 != 0): | |
| print('Warning: invalid contents!') | |
| else: | |
| ls = [] | |
| while (dat): | |
| (subdat, dat) = (dat[:4], dat[4:]) | |
| num = struct.unpack('>I', subdat)[0] | |
| ls.append(str(num)) | |
| print('Picts using adaptive palette:', ' '.join(ls)) | |
| elif (self.type == b'Loop'): | |
| # Looping | |
| dat = self.data() | |
| if (len(dat) % 8 != 0): | |
| print('Warning: invalid contents!') | |
| else: | |
| while (dat): | |
| (subdat, dat) = (dat[:8], dat[8:]) | |
| (num, count) = struct.unpack('>II', subdat) | |
| print('Sound %d repeats %d times' % (num, count)) | |
| elif (self.type == b'RelN'): | |
| # Release number | |
| dat = self.data() | |
| if (len(dat) != 2): | |
| print('Warning: invalid contents!') | |
| else: | |
| num = struct.unpack('>H', dat)[0] | |
| print('Release number', num) | |
| elif (self.type == b'SNam'): | |
| # Story name (obsolete) | |
| dat = self.data() | |
| if (len(dat) % 2 != 0): | |
| print('Warning: invalid contents!') | |
| else: | |
| ls = [] | |
| while (dat): | |
| (subdat, dat) = (dat[:2], dat[2:]) | |
| num = struct.unpack('>H', subdat)[0] | |
| ls.append(chr(num)) | |
| print('Story name:', ''.join(ls)) | |
| elif (self.type in (b'TEXT', b'ANNO', b'AUTH', b'(c) ')): | |
| dat = self.data() | |
| print(dat.decode()) | |
| elif (self.type == b'Reso'): | |
| # Resolution chunk | |
| dat = self.data() | |
| if (len(dat)-24) % 28 != 0: | |
| print('Warning: invalid contents!') | |
| else: | |
| (subdat, dat) = (dat[:24], dat[24:]) | |
| subls = struct.unpack('>6I', subdat) | |
| print('Standard window size %dx%d, min %dx%d, max %dx%d' % subls) | |
| while (dat): | |
| (subdat, dat) = (dat[:28], dat[28:]) | |
| subls = struct.unpack('>7I', subdat) | |
| print('Pict %d: standard ratio: %d/%d, min %d/%d, max %d/%d' % subls) | |
| else: | |
| dat = self.data(16) | |
| strdat = repr(dat) | |
| if (re.match('[a-z][\'\"]', strdat)): | |
| strdat = strdat[1:] | |
| if (len(dat) == self.len): | |
| print('contents: %s' % (strdat,)) | |
| else: | |
| print('beginning: %s' % (strdat,)) | |
| class BlorbFile: | |
| def __init__(self, filename, outfilename=None): | |
| self.chunks = [] | |
| self.chunkmap = {} | |
| self.chunkatpos = {} | |
| self.usages = [] | |
| self.usagemap = {} | |
| self.filename = filename | |
| self.outfilename = outfilename | |
| if (not self.outfilename): | |
| self.outfilename = self.filename | |
| if (not self.filename): | |
| # No loading; create an empty file. | |
| self.file = None | |
| self.formchunk = None | |
| self.changed = True | |
| chunk = BlorbChunk(self, b'RIdx', -1, 4) | |
| chunk.literaldata = struct.pack('>I', 0) | |
| self.add_chunk(chunk, None, None, 0) | |
| return | |
| self.changed = False | |
| self.file = open(filename, 'rb') | |
| formchunk = Chunk(self.file) | |
| self.formchunk = formchunk | |
| if (formchunk.getname() != b'FORM'): | |
| raise Exception('This does not appear to be a Blorb file.') | |
| formtype = formchunk.read(4) | |
| if (formtype != b'IFRS'): | |
| raise Exception('This does not appear to be a Blorb file.') | |
| formlen = formchunk.getsize() | |
| while formchunk.tell() < formlen: | |
| chunk = Chunk(formchunk) | |
| start = formchunk.tell() | |
| size = chunk.getsize() | |
| formtype = None | |
| if chunk.getname() == b'FORM': | |
| formtype = chunk.read(4) | |
| subchunk = BlorbChunk(self, chunk.getname(), start, size, formtype) | |
| self.chunks.append(subchunk) | |
| chunk.skip() | |
| chunk.close() | |
| for chunk in self.chunks: | |
| self.chunkatpos[chunk.start] = chunk | |
| dict_append(self.chunkmap, chunk.type, chunk) | |
| # Sanity checks. Also get the usage list. | |
| ls = self.chunkmap.get(b'RIdx') | |
| if (not ls): | |
| raise Exception('No resource index chunk!') | |
| elif (len(ls) != 1): | |
| print('Warning: too many resource index chunks!') | |
| else: | |
| chunk = ls[0] | |
| if (self.chunks[0] is not chunk): | |
| print('Warning: resource index chunk is not first!') | |
| dat = chunk.data() | |
| numres = struct.unpack('>I', dat[0:4])[0] | |
| if (numres*12+4 != chunk.len): | |
| print('Warning: resource index chunk has wrong size!') | |
| for ix in range(numres): | |
| subdat = dat[4+ix*12 : 16+ix*12] | |
| typ = struct.unpack('>4c', subdat[0:4]) | |
| typ = b''.join(typ) | |
| num = struct.unpack('>I', subdat[4:8])[0] | |
| start = struct.unpack('>I', subdat[8:12])[0] | |
| subchunk = self.chunkatpos.get(start) | |
| if (not subchunk): | |
| print('Warning: resource (%s, %d) refers to a nonexistent chunk!' % (typestring(typ), num)) | |
| self.usages.append( (typ, num, subchunk) ) | |
| self.usagemap[(typ, num)] = subchunk | |
| def close(self): | |
| if (self.formchunk): | |
| self.formchunk.close() | |
| self.formchunk = None | |
| if (self.file): | |
| self.file.close() | |
| self.file = None | |
| def sanity_check(self): | |
| if (len(self.usages) != len(self.usagemap)): | |
| print('Warning: internal mismatch (usages)!') | |
| if (len(self.chunks) != len(self.chunkatpos)): | |
| print('Warning: internal mismatch (chunks)!') | |
| def chunk_position(self, chunk): | |
| try: | |
| return self.chunks.index(chunk) | |
| except: | |
| return None | |
| def save_if_needed(self): | |
| if self.changed: | |
| try: | |
| self.save() | |
| except CommandError as ex: | |
| print(str(ex)) | |
| def canonicalize(self): | |
| self.sanity_check() | |
| try: | |
| indexchunk = self.chunkmap[b'RIdx'][0] | |
| except: | |
| raise CommandError('There is no index chunk, so this cannot be a legal blorb file.') | |
| indexchunk.len = 4 + 12*len(self.usages) | |
| pos = 12 | |
| for chunk in self.chunks: | |
| chunk.savestart = pos | |
| pos = pos + 8 + chunk.len | |
| if (pos % 2): | |
| pos = pos+1 | |
| self.usages.sort(key=lambda tup:tup[2].savestart) | |
| ls = [] | |
| ls.append(struct.pack('>I', len(self.usages))) | |
| for (typ, num, chunk) in self.usages: | |
| ls.append(typ) | |
| ls.append(struct.pack('>II', num, chunk.savestart)) | |
| dat = b''.join(ls) | |
| if (len(dat) != indexchunk.len): | |
| print('Warning: index chunk length does not match!') | |
| indexchunk.literaldata = dat | |
| def save(self, outfilename=None): | |
| if (outfilename): | |
| self.outfilename = outfilename | |
| if (not self.changed and (self.outfilename == self.filename)): | |
| raise CommandError('No changes need saving.') | |
| if (not self.outfilename): | |
| raise CommandError('No pathname supplied for saving.') | |
| if (os.path.exists(self.outfilename) and not opts.force): | |
| if (not confirm_input('File %s exists. Rewrite?' % (self.outfilename,))): | |
| print('Cancelled.') | |
| return | |
| self.canonicalize() | |
| tmpfilename = self.outfilename + '~TEMP' | |
| fl = open(tmpfilename, 'wb') | |
| fl.write(b'FORM----IFRS') | |
| pos = 12 | |
| for chunk in self.chunks: | |
| fl.write(chunk.type) | |
| fl.write(struct.pack('>I', chunk.len)) | |
| pos = pos+8 | |
| dat = chunk.data() | |
| fl.write(dat) | |
| pos = pos+len(dat) | |
| if (pos % 2): | |
| fl.write(b'\0') | |
| pos = pos+1 | |
| fl.seek(4) | |
| fl.write(struct.pack('>I', pos-8)) | |
| fl.close() | |
| os_replace(tmpfilename, self.outfilename) | |
| print('Wrote file:', self.outfilename) | |
| return self.outfilename | |
| def delete_chunk(self, delchunk): | |
| self.chunks = [ chunk for chunk in self.chunks if (chunk is not delchunk) ] | |
| ls = self.chunkmap[delchunk.type] | |
| ls = [ chunk for chunk in ls if (chunk is not delchunk) ] | |
| if (ls): | |
| self.chunkmap[delchunk.type] = ls | |
| else: | |
| self.chunkmap.pop(delchunk.type) | |
| self.chunkatpos.pop(delchunk.start) | |
| self.usages = [ tup for tup in self.usages if (tup[2] is not delchunk) ] | |
| ls = [ key for (key,val) in self.usagemap.items() if (val is delchunk) ] | |
| for key in ls: | |
| self.usagemap.pop(key) | |
| self.changed = True | |
| def add_chunk(self, chunk, use=None, num=None, pos=None): | |
| if (pos is None): | |
| self.chunks.append(chunk) | |
| else: | |
| self.chunks.insert(pos, chunk) | |
| self.chunkatpos[chunk.start] = chunk | |
| dict_append(self.chunkmap, chunk.type, chunk) | |
| if (use is not None): | |
| self.usages.append( (use, num, chunk) ) | |
| self.usagemap[(use,num)] = chunk | |
| self.changed = True | |
| class CommandError(Exception): | |
| pass | |
| class Chunk: | |
| """This is a copy of the Python standard library "chunk" class, as | |
| shipped in Python 3.12.7. The module is due to be removed from | |
| Python 3.13 so we need to stash it here. | |
| This class is copyright by the Python Software Foundation, | |
| PSF License v2. | |
| """ | |
| def __init__(self, file, align=True, bigendian=True, inclheader=False): | |
| self.closed = False | |
| self.align = align # whether to align to word (2-byte) boundaries | |
| if bigendian: | |
| strflag = '>' | |
| else: | |
| strflag = '<' | |
| self.file = file | |
| self.chunkname = file.read(4) | |
| if len(self.chunkname) < 4: | |
| raise EOFError | |
| try: | |
| self.chunksize = struct.unpack_from(strflag+'L', file.read(4))[0] | |
| except struct.error: | |
| raise EOFError from None | |
| if inclheader: | |
| self.chunksize = self.chunksize - 8 # subtract header | |
| self.size_read = 0 | |
| try: | |
| self.offset = self.file.tell() | |
| except (AttributeError, OSError): | |
| self.seekable = False | |
| else: | |
| self.seekable = True | |
| def getname(self): | |
| """Return the name (ID) of the current chunk.""" | |
| return self.chunkname | |
| def getsize(self): | |
| """Return the size of the current chunk.""" | |
| return self.chunksize | |
| def close(self): | |
| if not self.closed: | |
| try: | |
| self.skip() | |
| finally: | |
| self.closed = True | |
| def isatty(self): | |
| if self.closed: | |
| raise ValueError("I/O operation on closed file") | |
| return False | |
| def seek(self, pos, whence=0): | |
| """Seek to specified position into the chunk. | |
| Default position is 0 (start of chunk). | |
| If the file is not seekable, this will result in an error. | |
| """ | |
| if self.closed: | |
| raise ValueError("I/O operation on closed file") | |
| if not self.seekable: | |
| raise OSError("cannot seek") | |
| if whence == 1: | |
| pos = pos + self.size_read | |
| elif whence == 2: | |
| pos = pos + self.chunksize | |
| if pos < 0 or pos > self.chunksize: | |
| raise RuntimeError | |
| self.file.seek(self.offset + pos, 0) | |
| self.size_read = pos | |
| def tell(self): | |
| if self.closed: | |
| raise ValueError("I/O operation on closed file") | |
| return self.size_read | |
| def read(self, size=-1): | |
| """Read at most size bytes from the chunk. | |
| If size is omitted or negative, read until the end | |
| of the chunk. | |
| """ | |
| if self.closed: | |
| raise ValueError("I/O operation on closed file") | |
| if self.size_read >= self.chunksize: | |
| return b'' | |
| if size < 0: | |
| size = self.chunksize - self.size_read | |
| if size > self.chunksize - self.size_read: | |
| size = self.chunksize - self.size_read | |
| data = self.file.read(size) | |
| self.size_read = self.size_read + len(data) | |
| if self.size_read == self.chunksize and \ | |
| self.align and \ | |
| (self.chunksize & 1): | |
| dummy = self.file.read(1) | |
| self.size_read = self.size_read + len(dummy) | |
| return data | |
| def skip(self): | |
| """Skip the rest of the chunk. | |
| If you are not interested in the contents of the chunk, | |
| this method should be called so that the file points to | |
| the start of the next chunk. | |
| """ | |
| if self.closed: | |
| raise ValueError("I/O operation on closed file") | |
| if self.seekable: | |
| try: | |
| n = self.chunksize - self.size_read | |
| # maybe fix alignment | |
| if self.align and (self.chunksize & 1): | |
| n = n + 1 | |
| self.file.seek(n, 1) | |
| self.size_read = self.size_read + n | |
| return | |
| except OSError: | |
| pass | |
| while self.size_read < self.chunksize: | |
| n = min(8192, self.chunksize - self.size_read) | |
| dummy = self.read(n) | |
| if not dummy: | |
| raise EOFError | |
| class BlorbTool: | |
| def show_commands(): | |
| print('blorbtool commands:') | |
| print() | |
| print('list -- list all chunks') | |
| print('index -- list all resources in the index chunk') | |
| print('display -- display contents of all chunks') | |
| print('display TYPE -- contents of chunk(s) of that type') | |
| print('display USE NUM -- contents of chunk by use and number (e.g., "display Exec 0")') | |
| print('export TYPE FILENAME -- export the chunk of that type to a file') | |
| print('export USE NUM FILENAME -- export a chunk by use and number') | |
| print('import TYPE FILENAME -- import a file as a chunk of that type') | |
| print('import USE NUM TYPE FILENAME -- import a file as a resource of that use, number, and type') | |
| print('delete TYPE -- delete chunk(s) of that type') | |
| print('delete USE NUM -- delete chunk by use and number') | |
| print('giload DIRECTORY -- export the Exec and Pict chunks for use with Quixe') | |
| print('save -- write out changes') | |
| print('reload -- discard changes and reload existing blorb file') | |
| show_commands = staticmethod(show_commands) | |
| def __init__(self): | |
| self.is_interactive = False | |
| self.has_quit = False | |
| def set_interactive(self, val): | |
| self.is_interactive = val | |
| def quit_yet(self): | |
| return self.has_quit | |
| def handle(self, args=None): | |
| try: | |
| if (self.is_interactive): | |
| args = raw_input('>').split() | |
| if (not args): | |
| return | |
| argname = args.pop(0) | |
| if (argname in self.aliasmap): | |
| argname = self.aliasmap[argname] | |
| cmd = getattr(self, 'cmd_'+argname, None) | |
| if (not cmd): | |
| raise CommandError('Unknown command: ' + argname) | |
| return | |
| cmd(args) | |
| except KeyboardInterrupt: | |
| # EOF or interrupt. Pass it on. | |
| raise | |
| except EOFError: | |
| # EOF or interrupt. Pass it on. | |
| raise | |
| except CommandError as ex: | |
| print(str(ex)) | |
| except Exception as ex: | |
| # Unexpected exception: print it. | |
| print(ex.__class__.__name__+':', str(ex)) | |
| if (opts.verbose): | |
| raise | |
| def parse_int(self, val, label=''): | |
| if (label): | |
| label = label+': ' | |
| try: | |
| return int(val) | |
| except: | |
| raise CommandError(label+'integer required') | |
| def parse_chunk_type(self, val, label=''): | |
| if (label): | |
| label = label+': ' | |
| if len(val) > 4: | |
| raise CommandError(label+'chunk type must be 1-4 characters') | |
| return val.ljust(4).encode() | |
| aliasmap = { '?':'help', 'q':'quit', 'write':'save', 'restart':'reload', 'restore':'reload' } | |
| def cmd_quit(self, args): | |
| if (args): | |
| raise CommandError('usage: quit') | |
| self.has_quit = True | |
| def cmd_help(self, args): | |
| if (args): | |
| raise CommandError('usage: help') | |
| self.show_commands() | |
| def cmd_list(self, args): | |
| if (args): | |
| raise CommandError('usage: list') | |
| print(len(blorbfile.chunks), 'chunks:') | |
| for chunk in blorbfile.chunks: | |
| print(' %s' % (chunk.describe(),)) | |
| def cmd_index(self, args): | |
| if (args): | |
| raise CommandError('usage: index') | |
| print(len(blorbfile.usages), 'resources:') | |
| for (use, num, chunk) in blorbfile.usages: | |
| print(' %s %d: %s' % (typestring(use), num, chunk.describe())) | |
| def cmd_display(self, args): | |
| if (not args): | |
| ls = blorbfile.chunks | |
| elif (len(args) == 1): | |
| typ = self.parse_chunk_type(args[0], 'display') | |
| ls = [ chunk for chunk in blorbfile.chunks if chunk.type == typ ] | |
| if (not ls): | |
| raise CommandError('No chunks of type %s' % (typestring(typ),)) | |
| elif (len(args) == 2): | |
| use = self.parse_chunk_type(args[0], 'display') | |
| num = self.parse_int(args[1], 'display (second argument)') | |
| chunk = blorbfile.usagemap.get( (use, num) ) | |
| if (not chunk): | |
| raise CommandError('No resource with usage %s, number %d' % (typestring(use), num)) | |
| ls = [ chunk ] | |
| else: | |
| raise CommandError('usage: display | display TYPE | display USE NUM') | |
| for chunk in ls: | |
| chunk.display() | |
| def cmd_export(self, args): | |
| if (len(args) == 2): | |
| typ = self.parse_chunk_type(args[0], 'export') | |
| ls = [ chunk for chunk in blorbfile.chunks if chunk.type == typ ] | |
| if (not ls): | |
| raise CommandError('No chunks of type %s' % (typestring(typ),)) | |
| if (len(ls) != 1): | |
| raise CommandError('%d chunks of type %s' % (len(ls), typestring(typ),)) | |
| chunk = ls[0] | |
| elif (len(args) == 3): | |
| use = self.parse_chunk_type(args[0], 'export') | |
| num = self.parse_int(args[1], 'export (second argument)') | |
| chunk = blorbfile.usagemap.get( (use, num) ) | |
| if (not chunk): | |
| raise CommandError('No resource with usage %s, number %d' % (typestring(use), num)) | |
| else: | |
| raise CommandError('usage: export TYPE FILENAME | export USE NUM FILENAME') | |
| outfilename = args[-1] | |
| if (outfilename == blorbfile.filename): | |
| raise CommandError('You can\'t export a chunk over the original blorb file!') | |
| if (os.path.exists(outfilename) and not opts.force): | |
| if (not confirm_input('File %s exists. Overwrite?' % (outfilename,))): | |
| print('Cancelled.') | |
| return | |
| outfl = open(outfilename, 'wb') | |
| if (chunk.formtype and chunk.formtype != b'FORM'): | |
| # For an AIFF file, we must include the FORM/length header. | |
| # (Unless it's an overly nested AIFF.) | |
| outfl.write(b'FORM') | |
| outfl.write(struct.pack('>I', chunk.len)) | |
| outfl.write(chunk.data()) | |
| finallen = outfl.tell() | |
| outfl.close() | |
| print('Wrote %d bytes to %s.' % (finallen, outfilename)) | |
| def cmd_import(self, args): | |
| origchunk = None | |
| if (len(args) == 2): | |
| typ = self.parse_chunk_type(args[0], 'import') | |
| use = None | |
| num = None | |
| ls = [ chunk for chunk in blorbfile.chunks if chunk.type == typ ] | |
| if (ls): | |
| origchunk = ls[0] | |
| elif (len(args) == 4): | |
| use = self.parse_chunk_type(args[0], 'import') | |
| num = self.parse_int(args[1], 'import (second argument)') | |
| typ = self.parse_chunk_type(args[2], 'import (third argument)') | |
| origchunk = blorbfile.usagemap.get( (use, num) ) | |
| else: | |
| raise CommandError('usage: import TYPE FILENAME | import USE NUM TYPE FILENAME') | |
| infilename = args[-1] | |
| if (infilename == blorbfile.filename): | |
| raise CommandError('You can\'t import the original blorb file as a chunk!') | |
| fl = open(infilename, 'rb') | |
| filestart = None | |
| formtype = None | |
| dat = fl.read(5) | |
| if (dat[0:4] == b'FORM' and bytes_to_intarray(dat)[4] < 0x20): | |
| # This is an AIFF file, and must be embedded | |
| filestart = 8 | |
| fl.seek(8, 0) | |
| formtype = fl.read(4) | |
| if (typ != b'FORM'): | |
| # We accept the formtype as a synonym here, if the user | |
| # got it right. | |
| if (typ != formtype): | |
| raise CommandError('This IFF file has form type \'%s\', not \'%s\'.' % (formtype, typ)) | |
| typ = b'FORM' | |
| fl.seek(0, 2) | |
| filelen = fl.tell() | |
| fl.close() | |
| if (filestart): | |
| filelen = filelen - 8 | |
| fakestart = min(list(blorbfile.chunkatpos.keys()) + [0]) - 1 | |
| if origchunk: | |
| # Replace existing chunk | |
| pos = blorbfile.chunk_position(origchunk) | |
| blorbfile.delete_chunk(origchunk) | |
| else: | |
| pos = None | |
| chunk = BlorbChunk(blorbfile, typ, fakestart, filelen) | |
| chunk.filedata = infilename | |
| if (filestart): | |
| chunk.filestart = filestart | |
| chunk.formtype = formtype | |
| blorbfile.add_chunk(chunk, use, num, pos) | |
| if pos is None: | |
| print('Added chunk, length %d' % (filelen,)) | |
| else: | |
| print('Replaced chunk, new length %d' % (filelen,)) | |
| def cmd_giload(self, args): | |
| prefix = '' | |
| if (len(args) == 1): | |
| outdirname = args[0] | |
| elif (len(args) == 2): | |
| outdirname = args[0] | |
| prefix = args[1] | |
| else: | |
| raise CommandError('usage: giload DIRECTORY | giload DIRECTORY PREFIX') | |
| if (not (os.path.exists(outdirname) and os.path.isdir(outdirname))): | |
| raise CommandError('Not a directory: %s' % (outdirname)) | |
| chunk = blorbfile.usagemap.get( (b'Exec', 0) ) | |
| if (not chunk): | |
| raise CommandError('No resource with usage %s, number %d' % (typestring(use), num)) | |
| chunkdat = chunk.data() | |
| if (chunk.formtype and chunk.formtype != b'FORM'): | |
| chunkdat = b'FORM' + struct.pack('>I', chunk.len) + chunkdat | |
| outfl = open(os.path.join(outdirname, 'game.ulx.js'), 'w') | |
| chunkdatenc = base64.b64encode(chunkdat).decode() | |
| outfl.write('$(document).ready(function() {\n') | |
| outfl.write(" GiLoad.load_run(null, '%s', 'base64');\n" % (chunkdatenc,)) | |
| outfl.write('});\n') | |
| outfl.close() | |
| alttexts = {} | |
| ls = blorbfile.chunkmap.get(b'RDes') | |
| if (ls): | |
| chunk = ls[0] | |
| alttexts = analyze_resourcedescs(chunk) | |
| outfl = open(os.path.join(outdirname, 'resourcemap.js'), 'w') | |
| outfl.write('/* resourcemap.js generated by blorbtool.py */\n') | |
| outfl.write('StaticImageInfo = {\n') | |
| usages = [ (num, chunk) for (use, num, chunk) in blorbfile.usages if (use == b'Pict') ] | |
| usages.sort() # on num | |
| first = True | |
| wholemap = collections.OrderedDict() | |
| for (num, chunk) in usages: | |
| try: | |
| (suffix, size) = analyze_pict(chunk) | |
| except Exception as ex: | |
| print('Error on Pict chunk %d: %s' % (num, ex)) | |
| continue | |
| picfilename = 'pict-%d.%s' % (num, suffix) | |
| map = collections.OrderedDict() | |
| map['image'] = num | |
| map['url'] = os.path.join(prefix, picfilename) | |
| if (b'Pict', num) in alttexts: | |
| map['alttext'] = alttexts.get( (b'Pict',num) ).decode('utf-8') | |
| map['width'] = size[0] | |
| map['height'] = size[1] | |
| wholemap['pict-%d' % (num,)] = map | |
| indexdat = json.dumps(map, indent=2) | |
| if (first): | |
| first = False | |
| else: | |
| outfl.write(',\n') | |
| outfl.write('%d: %s\n' % (num, indexdat)) | |
| outfl2 = open(os.path.join(outdirname, picfilename), 'wb') | |
| if (chunk.formtype and chunk.formtype != b'FORM'): | |
| outfl2.write(b'FORM') | |
| outfl2.write(struct.pack('>I', chunk.len)) | |
| outfl2.write(chunk.data()) | |
| outfl2.close() | |
| outfl.write('};\n') | |
| outfl.close() | |
| outfl = open(os.path.join(outdirname, 'resourcemap.json'), 'w') | |
| json.dump(wholemap, outfl, indent=2) | |
| outfl.write('\n') | |
| outfl.close() | |
| print('Wrote Quixe-compatible data to directory "%s".' % (outdirname,)) | |
| def cmd_delete(self, args): | |
| if (len(args) == 1): | |
| typ = self.parse_chunk_type(args[0], 'delete') | |
| ls = [ chunk for chunk in blorbfile.chunks if chunk.type == typ ] | |
| if (not ls): | |
| raise CommandError('No chunks of type %s' % (typestring(typ),)) | |
| elif (len(args) == 2): | |
| use = self.parse_chunk_type(args[0], 'delete') | |
| num = self.parse_int(args[1], 'delete (second argument)') | |
| chunk = blorbfile.usagemap.get( (use, num) ) | |
| if (not chunk): | |
| raise CommandError('No resource with usage %s, number %d' % (typestring(use), num)) | |
| ls = [ chunk ] | |
| else: | |
| raise CommandError('usage: delete TYPE | delete USE NUM') | |
| for chunk in ls: | |
| blorbfile.delete_chunk(chunk) | |
| print('Deleted %d chunk%s' % (len(ls), ('' if len(ls)==1 else 's'))) | |
| def cmd_reload(self, args): | |
| global blorbfile | |
| if (args): | |
| raise CommandError('usage: reload') | |
| filename = blorbfile.filename | |
| blorbfile.close() | |
| blorbfile = BlorbFile(filename) | |
| print('Reloaded %s.' % (filename,)) | |
| def cmd_save(self, args): | |
| global blorbfile | |
| if (len(args) == 0): | |
| outfilename = None | |
| elif (len(args) == 1): | |
| outfilename = args[0] | |
| else: | |
| raise CommandError('usage: save | save FILENAME') | |
| filename = blorbfile.save(outfilename) | |
| if (filename): | |
| # Reload, so that the blorbfile's Chunk (and its chunks) | |
| # refer to the new file. (The reloaded blorbfile will have | |
| # changed == False, too.) | |
| blorbfile.close() | |
| blorbfile = BlorbFile(filename) | |
| def cmd_dump(self, args): | |
| print('### chunks:', blorbfile.chunks) | |
| print('### chunkmap:', blorbfile.chunkmap) | |
| print('### chunkatpos:', blorbfile.chunkatpos) | |
| print('### usages:', blorbfile.usages) | |
| print('### usagemap:', blorbfile.usagemap) | |
| # Some utility functions. | |
| def typestring(dat): | |
| return "'" + dat.decode() + "'" | |
| def bytes_to_intarray(dat): | |
| if (bytes is str): | |
| # Python 2 | |
| return [ ord(val) for val in dat ] | |
| else: | |
| # Python 3 | |
| return [ val for val in dat ] | |
| def intarray_to_bytes(arr): | |
| if (bytes is str): | |
| # Python 2 | |
| return b''.join([ chr(val) for val in arr ]) | |
| else: | |
| # Python 3 | |
| return bytes(arr) | |
| def analyze_resourcedescs(chunk): | |
| res = {} | |
| dat = chunk.data() | |
| (subdat, dat) = (dat[:4], dat[4:]) | |
| count = struct.unpack('>I', subdat)[0] | |
| for ix in range(count): | |
| if (len(dat) < 12): | |
| break | |
| (subdat, dat) = (dat[:12], dat[12:]) | |
| subls = struct.unpack('>4c2I', subdat) | |
| usage = b''.join(subls[0:4]) | |
| strlen = subls[-1] | |
| num = subls[-2] | |
| if (len(dat) < strlen): | |
| break | |
| (subdat, dat) = (dat[:strlen], dat[strlen:]) | |
| res[(usage, num)] = subdat | |
| return res | |
| def analyze_pict(chunk): | |
| if (chunk.type == b'JPEG'): | |
| size = parse_jpeg(chunk.data()) | |
| return ('jpeg', size) | |
| if (chunk.type == b'PNG '): | |
| size = parse_png(chunk.data()) | |
| return ('png', size) | |
| raise Exception('Unrecognized Pict type: %s' % (chunk.type,)) | |
| def parse_png(dat): | |
| dat = bytes_to_intarray(dat) | |
| pos = 0 | |
| sig = dat[pos:pos+8] | |
| pos += 8 | |
| if sig != [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]: | |
| raise Exception('PNG signature does not match') | |
| while pos < len(dat): | |
| clen = (dat[pos] << 24) | (dat[pos+1] << 16) | (dat[pos+2] << 8) | dat[pos+3] | |
| pos += 4 | |
| ctyp = intarray_to_bytes(dat[pos:pos+4]) | |
| pos += 4 | |
| #print('Chunk:', ctyp, 'len', clen) | |
| if ctyp == b'IHDR': | |
| width = (dat[pos] << 24) | (dat[pos+1] << 16) | (dat[pos+2] << 8) | dat[pos+3] | |
| pos += 4 | |
| height = (dat[pos] << 24) | (dat[pos+1] << 16) | (dat[pos+2] << 8) | dat[pos+3] | |
| pos += 4 | |
| return (width, height) | |
| pos += clen | |
| pos += 4 | |
| raise Exception('No PNG header block found') | |
| def parse_jpeg(dat): | |
| dat = bytes_to_intarray(dat) | |
| #print('Length:', len(dat)) | |
| pos = 0 | |
| while pos < len(dat): | |
| if dat[pos] != 0xFF: | |
| raise Exception('marker is not FF') | |
| while dat[pos] == 0xFF: | |
| pos += 1 | |
| marker = dat[pos] | |
| pos += 1 | |
| if marker == 0x01 or (marker >= 0xD0 and marker <= 0xD9): | |
| #print('FF%02X*' % (marker,)) | |
| continue | |
| clen = (dat[pos] << 8) | dat[pos+1] | |
| #print('FF%02X, len %d' % (marker, clen)) | |
| if (marker >= 0xC0 and marker <= 0xCF and marker != 0xC8): | |
| if clen <= 7: | |
| raise Exception('SOF block is too small') | |
| bits = dat[pos+2] | |
| height = (dat[pos+3] << 8) | dat[pos+4] | |
| width = (dat[pos+5] << 8) | dat[pos+6] | |
| return (width, height) | |
| pos += clen | |
| raise Exception('SOF block not found') | |
| # Actual work begins here. | |
| if (opts.listcommands): | |
| BlorbTool.show_commands() | |
| sys.exit(-1) | |
| if (not args and not opts.newfile): | |
| popt.print_help() | |
| sys.exit(-1) | |
| filename = None | |
| if (args): | |
| filename = args.pop(0) | |
| if (opts.newfile and not opts.output): | |
| opts.output = filename | |
| filename = None | |
| try: | |
| blorbfile = BlorbFile(filename, opts.output) | |
| except Exception as ex: | |
| print(ex.__class__.__name__+':', str(ex)) | |
| if (opts.verbose): | |
| raise | |
| sys.exit(-1) | |
| # If args exist, execute them as a command. If not, loop grabbing and | |
| # executing commands until we discover that the user has executed Quit. | |
| # (The handler catches all exceptions except KeyboardInterrupt.) | |
| try: | |
| tool = BlorbTool() | |
| if (args): | |
| tool.set_interactive(False) | |
| tool.handle(args) | |
| blorbfile.sanity_check() | |
| blorbfile.save_if_needed() | |
| else: | |
| tool.set_interactive(True) | |
| while (not tool.quit_yet()): | |
| tool.handle() | |
| blorbfile.sanity_check() | |
| blorbfile.save_if_needed() | |
| print('<exiting>') | |
| except KeyboardInterrupt: | |
| print('<interrupted>') | |
| except EOFError: | |
| print('<eof>') | |
| blorbfile.close() | |
Xet Storage Details
- Size:
- 38.1 kB
- Xet hash:
- 0b54ff3d8dfde4eca3a6d391684d6e96210c64898edef70dec1c38dd0a3ba5b8
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.