#!/usr/bin/env python

# blorbtool.py: A (semi-)multifunctional Blorb utility
# Created by Andrew Plotkin ([email protected])
# 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()