#!/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()
