# -*- coding: utf-8 -*- import os import sys import png from math import sqrt, floor, ceil import argparse import yaml import operator import configuration config = configuration.Config() from pokemon_constants import pokemon_constants import trainers import romstr bit_flipped = [ sum(((byte >> i) & 1) << (7 - i) for i in xrange(8)) for byte in xrange(0x100) ] def load_rom(filename=config.rom_path): rom = romstr.RomStr.load(filename=filename) return bytearray(rom) def rom_offset(bank, address): if address < 0x4000 or address >= 0x8000: return address return bank * 0x4000 + address - 0x4000 * bool(bank) def split(list_, interval): """ Split a list by length. """ for i in xrange(0, len(list_), interval): j = min(i + interval, len(list_)) yield list_[i:j] def hex_dump(data, length=0x10): """ just use hexdump -C """ margin = len('%x' % len(data)) output = [] address = 0 for line in split(data, length): output += [ hex(address)[2:].zfill(margin) + ' | ' + ' '.join('%.2x' % byte for byte in line) ] address += length return '\n'.join(output) def get_tiles(image): """ Split a 2bpp image into 8x8 tiles. """ return list(split(image, 0x10)) def connect(tiles): """ Combine 8x8 tiles into a 2bpp image. """ return [byte for tile in tiles for byte in tile] def transpose(tiles, width=None): """ Transpose a tile arrangement along line y=-x. 00 01 02 03 04 05 00 06 0c 12 18 1e 06 07 08 09 0a 0b 01 07 0d 13 19 1f 0c 0d 0e 0f 10 11 <-> 02 08 0e 14 1a 20 12 13 14 15 16 17 03 09 0f 15 1b 21 18 19 1a 1b 1c 1d 04 0a 10 16 1c 22 1e 1f 20 21 22 23 05 0b 11 17 1d 23 00 01 02 03 00 04 08 04 05 06 07 <-> 01 05 09 08 09 0a 0b 02 06 0a 03 07 0b """ if width == None: width = int(sqrt(len(tiles))) # assume square image tiles = sorted(enumerate(tiles), key= lambda (i, tile): i % width) return [tile for i, tile in tiles] def transpose_tiles(image, width=None): return connect(transpose(get_tiles(image), width)) def interleave(tiles, width): """ 00 01 02 03 04 05 00 02 04 06 08 0a 06 07 08 09 0a 0b 01 03 05 07 09 0b 0c 0d 0e 0f 10 11 --> 0c 0e 10 12 14 16 12 13 14 15 16 17 0d 0f 11 13 15 17 18 19 1a 1b 1c 1d 18 1a 1c 1e 20 22 1e 1f 20 21 22 23 19 1b 1d 1f 21 23 """ interleaved = [] left, right = split(tiles[::2], width), split(tiles[1::2], width) for l, r in zip(left, right): interleaved += l + r return interleaved def deinterleave(tiles, width): """ 00 02 04 06 08 0a 00 01 02 03 04 05 01 03 05 07 09 0b 06 07 08 09 0a 0b 0c 0e 10 12 14 16 --> 0c 0d 0e 0f 10 11 0d 0f 11 13 15 17 12 13 14 15 16 17 18 1a 1c 1e 20 22 18 19 1a 1b 1c 1d 19 1b 1d 1f 21 23 1e 1f 20 21 22 23 """ deinterleaved = [] rows = list(split(tiles, width)) for left, right in zip(rows[::2], rows[1::2]): for l, r in zip(left, right): deinterleaved += [l, r] return deinterleaved def interleave_tiles(image, width): return connect(interleave(get_tiles(image), width)) def deinterleave_tiles(image, width): return connect(deinterleave(get_tiles(image), width)) def condense_tiles_to_map(image, pic=0): tiles = get_tiles(image) # Leave the first frame intact for pics. new_tiles = tiles[:pic] tilemap = range(pic) for i, tile in enumerate(tiles[pic:]): if tile not in new_tiles: new_tiles += [tile] # Match the first frame where possible. if tile == new_tiles[i % pic]: tilemap += [i % pic] else: tilemap += [new_tiles.index(tile)] new_image = connect(new_tiles) return new_image, tilemap def to_file(filename, data): """ Apparently open(filename, 'wb').write(bytearray(data)) won't work. """ file = open(filename, 'wb') for byte in data: file.write('%c' % byte) file.close() """ A rundown of Pokemon Crystal's compression scheme: Control commands occupy bits 5-7. Bits 0-4 serve as the first parameter for each command. """ lz_commands = { 'literal': 0, # n values for n bytes 'iterate': 1, # one value for n bytes 'alternate': 2, # alternate two values for n bytes 'blank': 3, # zero for n bytes } """ Repeater commands repeat any data that was just decompressed. They take an additional signed parameter to mark a relative starting point. These wrap around (positive from the start, negative from the current position). """ lz_commands.update({ 'repeat': 4, # n bytes starting from s 'flip': 5, # n bytes in reverse bit order starting from s 'reverse': 6, # n bytes backwards starting from s }) """ The long command is used when 5 bits aren't enough. Bits 2-4 contain a new control code. Bits 0-1 are appended to a new byte as 8-9, allowing a 10-bit parameter. """ lz_commands.update({ 'long': 7, # n is now 10 bits for a new control code }) max_length = 1 << 10 # can't go higher than 10 bits lowmax = 1 << 5 # standard 5-bit param """ If 0xff is encountered instead of a command, decompression ends. """ lz_end = 0xff class Compressed: """ Usage: lz = Compressed(data).output or lz = Compressed().compress(data) or c = Compressed() c.data = data lz = c.compress() """ # The target compressor is not always as efficient as this implementation. # To ignore compatibility and spit out a smaller blob, pass in small=True. small = False # BUG: literal [00] is a byte longer than blank 1. # In other words, blank's real minimum score is 1. # This bug exists in the target compressor as well, # so don't fix until we've given up on replicating it. min_scores = { 'blank': 2, 'iterate': 2, 'alternate': 3, 'repeat': 3, 'reverse': 3, 'flip': 3, } preference = [ 'repeat', 'blank', 'reverse', 'flip', 'iterate', 'alternate', #'literal', ] data = None commands = lz_commands debug = False literal_only = False arg_names = 'data', 'commands', 'debug', 'literal_only' def __init__(self, *args, **kwargs): self.__dict__.update(dict(zip(self.arg_names, args))) self.__dict__.update(kwargs) if self.data is not None: self.compress() def read_byte(self, address=None): if address is None: address = self.address if 0 <= address < len(self.data): return self.data[address] return None def reset_scores(self): self.scores = {} self.offsets = {} for method in self.min_scores.keys(): self.scores[method] = 0 def score_literal(self, method): address = self.address compare = { 'blank': [0], 'iterate': [self.read_byte(address)], 'alternate': [self.read_byte(address), self.read_byte(address + 1)], }[method] length = 0 while self.read_byte(address) == compare[length % len(compare)]: length += 1 address += 1 self.scores[method] = length return compare def precompute_repeat_matches(self): """ This is faster than redundantly searching each time repeats are scored. """ self.indexes = {} for byte in xrange(0x100): self.indexes[byte] = [] index = -1 while 1: try: index = self.data.index(byte, index + 1) except ValueError: break self.indexes[byte].append(index) def score_repeats(self, name, direction=1, mutate=int): address = self.address byte = mutate(self.data[address]) for index in self.indexes[byte]: if index >= address: break length = 1 # we already know the first byte matches while 1: byte = self.read_byte(index + length * direction) if byte == None or mutate(byte) != self.read_byte(address + length): break length += 1 # If repeats are almost entirely zeroes, just keep going and use blank instead. if all(x == 0 for x in self.data[ address + 2 : address + length ]): if self.read_byte(address + length) == 0: # zeroes continue after this chunk continue # Adjust the score for two-byte offsets. two_byte_index = index < address - 0x7f if self.scores[name] >= length - int(two_byte_index): continue self.scores [name] = length self.offsets[name] = index def compress(self, data=None): """ This algorithm is greedy. It aims to match the compressor it's based on as closely as possible. It doesn't, but in the meantime the output is smaller. """ if data is not None: self.data = data self.data = list(bytearray(self.data)) self.address = 0 self.end = len(self.data) self.output = [] self.literal = [] self.precompute_repeat_matches() while self.address < self.end: # Tally up the number of bytes that can be compressed # by a single command from the current address. self.reset_scores() # Check for repetition. Alternating bytes are common since graphics data is planar. _, self.iter, self.alts = map(self.score_literal, ['blank', 'iterate', 'alternate']) # Check if we can repeat any data that the decompressor just output (here, the input data). # This includes the current command's output. for args in [ ('repeat', 1, int), ('reverse', -1, int), ('flip', 1, self.bit_flip), ]: self.score_repeats(*args) # If the scores are too low, try again from the next byte. if self.literal_only or not any( self.min_scores.get(name, score) + int(self.scores[name] > lowmax) < score for name, score in self.scores.items() ): self.literal += [self.read_byte()] self.address += 1 else: self.do_literal() # payload self.do_scored() # unload any literals we're sitting on self.do_literal() self.output += [lz_end] return self.output def bit_flip(self, byte): return bit_flipped[byte] def do_literal(self): if self.literal: length = len(self.literal) self.do_cmd('literal', length) self.literal = [] def do_scored(self): # Which command will compress the longest chunk? winner, score = sorted( self.scores.items(), key = lambda (name, score): ( -(score - self.min_scores[name] - int(score > lowmax)), self.preference.index(name) ) )[0] length = self.do_cmd(winner, score) self.address += length def do_cmd(self, cmd, length): length = min(length, max_length) cmd_length = length - 1 output = [] if length > lowmax: output += [(self.commands['long'] << 5) + (self.commands[cmd] << 2) + (cmd_length >> 8)] output += [cmd_length & 0xff] else: output += [(self.commands[cmd] << 5) + cmd_length] output += { 'literal': self.literal, 'iterate': self.iter, 'alternate': self.alts, 'blank': [], }.get(cmd, []) if cmd in ['repeat', 'reverse', 'flip']: offset = self.offsets[cmd] # Negative offsets are one byte. # Positive offsets are two. if self.address - offset <= 0x7f: offset = self.address - offset + 0x80 offset -= 1 # this is a hack, but it seems to work output += [offset] else: output += [offset / 0x100, offset % 0x100] # big endian if self.debug: print ( cmd, length, '\t', ' '.join(map('{:02x}'.format, output)) ) self.output += output return length class Decompressed: """ Interpret and decompress lz-compressed data, usually 2bpp. """ """ Usage: data = Decompressed(lz).output or data = Decompressed().decompress(lz) or d = Decompressed() d.lz = lz data = d.decompress() To decompress from offset 0x80000 in a rom: data = Decompressed(rom, start=0x80000).output """ lz = None start = 0 commands = lz_commands debug = False arg_names = 'lz', 'start', 'commands', 'debug' def __init__(self, *args, **kwargs): self.__dict__.update(dict(zip(self.arg_names, args))) self.__dict__.update(kwargs) self.command_names = dict(map(reversed, self.commands.items())) self.address = self.start if self.lz is not None: self.decompress() if self.debug: print self.command_list() def command_list(self): """ Print a list of commands that were used. Useful for debugging. """ text = '' for name, attrs in self.used_commands: length = attrs['length'] address = attrs['address'] offset = attrs['offset'] direction = attrs['direction'] text += '{0}: {1}'.format(name, length) text += '\t' + ' '.join( '{:02x}'.format(int(byte)) for byte in self.lz[ address : address + attrs['cmd_length'] ] ) if offset is not None: repeated_data = self.output[ offset : offset + length * direction : direction ] text += ' [' + ' '.join(map('{:02x}'.format, repeated_data)) + ']' text += '\n' return text def decompress(self, lz=None): if lz is not None: self.lz = lz self.lz = bytearray(self.lz) self.used_commands = [] self.output = [] while 1: cmd_address = self.address self.offset = None self.direction = None if (self.byte == lz_end): self.next() break self.cmd = (self.byte & 0b11100000) >> 5 if self.cmd_name == 'long': # 10-bit length self.cmd = (self.byte & 0b00011100) >> 2 self.length = (self.next() & 0b00000011) * 0x100 self.length += self.next() + 1 else: # 5-bit length self.length = (self.next() & 0b00011111) + 1 self.__class__.__dict__[self.cmd_name](self) self.used_commands += [( self.cmd_name, { 'length': self.length, 'address': cmd_address, 'offset': self.offset, 'cmd_length': self.address - cmd_address, 'direction': self.direction, } )] # Keep track of the data we just decompressed. self.compressed_data = self.lz[self.start : self.address] @property def byte(self): return self.lz[ self.address ] def next(self): byte = self.byte self.address += 1 return byte @property def cmd_name(self): return self.command_names.get(self.cmd) def get_offset(self): if self.byte >= 0x80: # negative # negative offset = self.next() & 0x7f offset = len(self.output) - offset - 1 else: # positive offset = self.next() * 0x100 offset += self.next() self.offset = offset def literal(self): """ Copy data directly. """ self.output += self.lz[ self.address : self.address + self.length ] self.address += self.length def iterate(self): """ Write one byte repeatedly. """ self.output += [self.next()] * self.length def alternate(self): """ Write alternating bytes. """ alts = [self.next(), self.next()] self.output += [ alts[x & 1] for x in xrange(self.length) ] def blank(self): """ Write zeros. """ self.output += [0] * self.length def flip(self): """ Repeat flipped bytes from output. Example: 11100100 -> 00100111 """ self._repeat(table=bit_flipped) def reverse(self): """ Repeat reversed bytes from output. """ self._repeat(direction=-1) def repeat(self): """ Repeat bytes from output. """ self._repeat() def _repeat(self, direction=1, table=None): self.get_offset() self.direction = direction # Note: appends must be one at a time (this way, repeats can draw from themselves if required) for i in xrange(self.length): byte = self.output[ self.offset + i * direction ] self.output.append( table[byte] if table else byte ) sizes = [ 5, 6, 7, 5, 6, 7, 5, 6, 7, 5, 5, 7, 5, 5, 7, 5, 6, 7, 5, 6, 5, 7, 5, 7, 5, 7, 5, 6, 5, 6, 7, 5, 6, 7, 5, 6, 6, 7, 5, 6, 5, 7, 5, 6, 7, 5, 7, 5, 7, 5, 7, 5, 7, 5, 7, 5, 7, 5, 7, 5, 6, 7, 5, 6, 7, 5, 7, 7, 5, 6, 7, 5, 6, 5, 6, 6, 6, 7, 5, 7, 5, 6, 6, 5, 7, 6, 7, 5, 7, 5, 7, 7, 6, 6, 7, 6, 7, 5, 7, 5, 5, 7, 7, 5, 6, 7, 6, 7, 6, 7, 7, 7, 6, 6, 7, 5, 6, 6, 7, 6, 6, 6, 7, 6, 6, 6, 7, 7, 6, 7, 7, 5, 5, 6, 6, 6, 6, 5, 6, 5, 6, 7, 7, 7, 7, 7, 5, 6, 7, 7, 5, 5, 6, 7, 5, 6, 7, 5, 6, 7, 6, 6, 5, 7, 6, 6, 5, 7, 7, 6, 6, 5, 5, 5, 5, 7, 5, 6, 5, 6, 7, 7, 5, 7, 6, 7, 5, 6, 7, 5, 5, 6, 6, 5, 6, 6, 6, 6, 7, 6, 5, 6, 7, 5, 7, 6, 6, 7, 6, 6, 5, 7, 5, 6, 6, 5, 7, 5, 6, 5, 6, 6, 5, 6, 6, 7, 7, 6, 7, 7, 5, 7, 6, 7, 7, 5, 7, 5, 6, 6, 6, 7, 7, 7, 7, 5, 6, 7, 7, 7, 5, ] def make_sizes(num_monsters=251): """ Front pics have specified sizes. """ rom = load_rom() base_stats = 0x51424 address = base_stats + 0x11 # pic size sizes = rom[address : address + 0x20 * num_monsters : 0x20] sizes = map(lambda x: str(x & 0xf), sizes) return '\n'.join(' ' * 8 + ', '.join(split(sizes, 16))) def decompress_fx_by_id(i, fxs=0xcfcf6): rom = load_rom() addr = fxs + i * 4 num_tiles = rom[addr] bank = rom[addr+1] address = rom[addr+3] * 0x100 + rom[addr+2] offset = rom_offset(bank, address) fx = Decompressed(rom, start=offset) return fx def rip_compressed_fx(dest='gfx/fx', num_fx=40, fxs=0xcfcf6): for i in xrange(num_fx): name = '%.3d' % i fx = decompress_fx_by_id(i, fxs) filename = os.path.join(dest, name + '.2bpp.lz') to_file(filename, fx.compressed_data) monsters = 0x120000 num_monsters = 251 unowns = 0x124000 num_unowns = 26 unown_dex = 201 def decompress_monster_by_id(rom, mon=0, face='front', crystal=True): """ For Unown, use decompress_unown_by_id instead. """ if crystal: bank_offset = 0x36 else: bank_offset = 0 address = monsters + (mon * 2 + {'front': 0, 'back': 1}.get(face, 0)) * 3 bank = rom[address] + bank_offset address = rom[address+2] * 0x100 + rom[address+1] address = bank * 0x4000 + (address - (0x4000 * bool(bank))) monster = Decompressed(rom, start=address) return monster def rip_compressed_monster_pics(rom, dest='gfx/pics/', face='both', num_mons=num_monsters, crystal=True): """ Extract compressed Pokemon pics from to directory . """ for mon in range(num_mons): mon_name = pokemon_constants[mon + 1].lower().replace('__','_') size = sizes[mon] if mon + 1 == unown_dex: rip_compressed_unown_pics( rom=rom, dest=dest, face=face, num_letters=num_unowns, mon_name=mon_name, size=size, crystal=crystal, ) if face in ['front', 'both']: monster = decompress_monster_by_id(rom, mon, 'front', crystal) filename = 'front.{0}x{0}.2bpp.lz'.format(size) path = os.path.join(dest, mon_name, filename) to_file(path, monster.compressed_data) if face in ['back', 'both']: monster = decompress_monster_by_id(rom, mon, 'back', crystal) filename = 'back.6x6.2bpp.lz' path = os.path.join(dest, mon_name, filename) to_file(path, monster.compressed_data) def decompress_unown_by_id(rom, letter, face='front', crystal=True): if crystal: bank_offset = 0x36 else: bank_offset = 0 address = unowns + (letter * 2 + {'front': 0, 'back': 1}.get(face, 0)) * 3 bank = rom[address] + bank_offset address = rom[address+2] * 0x100 + rom[address+1] address = (bank * 0x4000) + (address - (0x4000 * bool(bank))) unown = Decompressed(rom, start=address) return unown def rip_compressed_unown_pics(rom, dest='gfx/pics/', face='both', num_letters=num_unowns, mon_name='unown', size=sizes[201], crystal=True): """ Extract compressed Unown pics from to directory . """ for letter in range(num_letters): name = mon_name + '_{}'.format(chr(ord('A') + letter)) if face in ['front', 'both']: unown = decompress_unown_by_id(rom, letter, 'front', crystal) filename = 'front.{0}x{0}.2bpp.lz'.format(size) path = os.path.join(dest, name, filename) to_file(path, unown.compressed_data) if face in ['back', 'both']: unown = decompress_unown_by_id(rom, letter, 'back', crystal) filename = 'back.6x6.2bpp.lz' path = os.path.join(dest, name, filename) to_file(path, unown.compressed_data) trainers_offset = 0x128000 num_trainers = 67 trainer_names = [t['constant'] for i, t in trainers.trainer_group_names.items()] def decompress_trainer_by_id(rom, i, crystal=True): rom = load_rom() if crystal: bank_offset = 0x36 else: bank_offset = 0 address = trainers_offset + i * 3 bank = rom[address] + bank_offset address = rom[address+2] * 0x100 + rom[address+1] address = rom_offset(bank, address) trainer = Decompressed(rom, start=address) return trainer def rip_compressed_trainer_pics(rom): for t in xrange(num_trainers): trainer_name = trainer_names[t].lower().replace('_','') trainer = decompress_trainer_by_id(t) filename = os.path.join('gfx/trainers/', trainer_name + '.6x6.2bpp.lz') to_file(filename, trainer.compressed_data) # in order of use (besides repeats) intro_gfx = [ ('logo', 0x109407), ('unowns', 0xE5F5D), ('pulse', 0xE634D), ('background', 0xE5C7D), ('pichu_wooper', 0xE592D), ('suicune_run', 0xE555D), ('suicune_jump', 0xE6DED), ('unown_back', 0xE785D), ('suicune_close', 0xE681D), ('suicune_back', 0xE72AD), ('crystal_unowns', 0xE662D), ] intro_tilemaps = [ ('001', 0xE641D), ('002', 0xE63DD), ('003', 0xE5ECD), ('004', 0xE5E6D), ('005', 0xE647D), ('006', 0xE642D), ('007', 0xE655D), ('008', 0xE649D), ('009', 0xE76AD), ('010', 0xE764D), ('011', 0xE6D0D), ('012', 0xE6C3D), ('013', 0xE778D), ('014', 0xE76BD), ('015', 0xE676D), ('017', 0xE672D), ] def rip_compressed_intro(rom, dest='gfx/intro'): for name, address in intro_gfx: filename = os.path.join(dest, name + '.2bpp.lz') rip_compressed_gfx(rom, address, filename) for name, address in intro_tilemaps: filename = os.path.join(dest, name + '.tilemap.lz') rip_compressed_gfx(rom, address, filename) title_gfx = [ ('suicune', 0x10EF46), ('logo', 0x10F326), ('crystal', 0x10FCEE), ] def rip_compressed_title(rom, dest='gfx/title'): for name, address in title_gfx: filename = os.path.join(dest, name + '.2bpp.lz') rip_compressed_gfx(rom, address, filename) def rip_compressed_tilesets(rom, dest='gfx/tilesets'): tileset_headers = 0x4d596 len_tileset = 15 num_tilesets = 0x25 for tileset in xrange(num_tilesets): addr = tileset * len_tileset + tileset_headers bank = rom[addr] address = rom[addr + 2] * 0x100 + rom[addr + 1] offset = rom_offset(bank, address) filename = os.path.join(dest, tileset_name + '.2bpp.lz') rip_compressed_gfx(rom, address, filename) misc_pics = [ ('player', 0x2BA1A, '6x6'), ('dude', 0x2BBAA, '6x6'), ] misc = [ ('town_map', 0xF8BA0), ('pokegear', 0x1DE2E4), ('pokegear_sprites', 0x914DD), ] def rip_compressed_misc(rom, dest='gfx/misc'): for name, address in misc: filename = os.path.join(dest, name+ '.2bpp.lz') rip_compressed_gfx(rom, address, filename) for name, address, dimensions in misc_pics: filename = os.path.join(dest, name + '.' + dimensions + '.2bpp.lz') rip_compressed_gfx(rom, address, filename) def rip_compressed_gfx(rom, address, filename): gfx = Decompressed(rom, start=address) to_file(filename, gfx.compressed_data) def rip_bulk_gfx(rom, dest='gfx', crystal=True): rip_compressed_monster_pics(rom, dest=os.path.join(dest, 'pics'), crystal=crystal) rip_compressed_trainer_pics(rom, dest=os.path.join(dest, 'trainers'), crystal=crystal) rip_compressed_fx (rom, dest=os.path.join(dest, 'fx')) rip_compressed_intro (rom, dest=os.path.join(dest, 'intro')) rip_compressed_title (rom, dest=os.path.join(dest, 'title')) rip_compressed_tilesets (rom, dest=os.path.join(dest, 'tilesets')) rip_compressed_misc (rom, dest=os.path.join(dest, 'misc')) def decompress_from_address(address, filename='de.2bpp'): """ Write decompressed data from an address to a 2bpp file. """ rom = load_rom() image = Decompressed(rom, start=address) to_file(filename, image.output) def decompress_file(filein, fileout=None): image = bytearray(open(filein).read()) de = Decompressed(image) if fileout == None: fileout = os.path.splitext(filein)[0] to_file(fileout, de.output) def compress_file(filein, fileout=None): image = bytearray(open(filein).read()) lz = Compressed(image) if fileout == None: fileout = filein + '.lz' to_file(fileout, lz.output) def get_uncompressed_gfx(start, num_tiles, filename): """ Grab tiles directly from rom and write to file. """ rom = load_rom() bytes_per_tile = 0x10 length = num_tiles * bytes_per_tile end = start + length image = rom[start:end] to_file(filename, image) def bin_to_rgb(word): red = word & 0b11111 word >>= 5 green = word & 0b11111 word >>= 5 blue = word & 0b11111 return (red, green, blue) def rgb_from_rom(address, length=0x80): rom = load_rom() return convert_binary_pal_to_text(rom[address:address+length]) def convert_binary_pal_to_text_by_filename(filename): pal = bytearray(open(filename).read()) return convert_binary_pal_to_text(pal) def convert_binary_pal_to_text(pal): output = '' words = [hi * 0x100 + lo for lo, hi in zip(pal[::2], pal[1::2])] for word in words: red, green, blue = ['%.2d' % c for c in bin_to_rgb(word)] output += '\tRGB ' + ', '.join((red, green, blue)) output += '\n' return output def read_rgb_macros(lines): colors = [] for line in lines: macro = line.split(" ")[0].strip() if macro == 'RGB': params = ' '.join(line.split(" ")[1:]).split(',') red, green, blue = [int(v) for v in params] colors += [[red, green, blue]] return colors def rewrite_binary_pals_to_text(filenames): for filename in filenames: pal_text = convert_binary_pal_to_text_by_filename(filename) with open(filename, 'w') as out: out.write(pal_text) def dump_monster_pals(): rom = load_rom() pals = 0xa8d6 pal_length = 0x4 for mon in range(251): name = pokemon_constants[mon+1].title().replace('_','') num = str(mon+1).zfill(3) dir = 'gfx/pics/'+num+'/' address = pals + mon*pal_length*2 pal_data = [] for byte in range(pal_length): pal_data.append(rom[address]) address += 1 filename = 'normal.pal' to_file('../'+dir+filename, pal_data) spacing = ' ' * (15 - len(name)) #print name+'Palette:'+spacing+' INCBIN "'+dir+filename+'"' pal_data = [] for byte in range(pal_length): pal_data.append(rom[address]) address += 1 filename = 'shiny.pal' to_file('../'+dir+filename, pal_data) spacing = ' ' * (10 - len(name)) #print name+'ShinyPalette:'+spacing+' INCBIN "'+dir+filename+'"' def dump_trainer_pals(): rom = load_rom() pals = 0xb0d2 pal_length = 0x4 for trainer in range(67): name = trainers.trainer_group_names[trainer+1]['constant'].title().replace('_','') num = str(trainer).zfill(3) dir = 'gfx/trainers/' address = pals + trainer*pal_length pal_data = [] for byte in range(pal_length): pal_data.append(rom[address]) address += 1 filename = num+'.pal' to_file('../'+dir+filename, pal_data) spacing = ' ' * (12 - len(name)) print name+'Palette:'+spacing+' INCBIN"'+dir+filename+'"' def flatten(planar): """ Flatten planar 2bpp image data into a quaternary pixel map. """ strips = [] for bottom, top in split(planar, 2): bottom = bottom top = top strip = [] for i in xrange(7,-1,-1): color = ( (bottom >> i & 1) + (top *2 >> i & 2) ) strip += [color] strips += strip return strips def to_lines(image, width): """ Convert a tiled quaternary pixel map to lines of quaternary pixels. """ tile_width = 8 tile_height = 8 num_columns = width / tile_width height = len(image) / width lines = [] for cur_line in xrange(height): tile_row = cur_line / tile_height line = [] for column in xrange(num_columns): anchor = ( num_columns * tile_row * tile_width * tile_height + column * tile_width * tile_height + cur_line % tile_height * tile_width ) line += image[anchor : anchor + tile_width] lines += [line] return lines def dmg2rgb(word): """ For PNGs. """ def shift(value): while True: yield value & (2**5 - 1) value >>= 5 word = shift(word) # distribution is less even w/ << 3 red, green, blue = [int(color * 8.25) for color in [word.next() for _ in xrange(3)]] alpha = 255 return (red, green, blue, alpha) def rgb_to_dmg(color): """ For PNGs. """ word = (color['r'] / 8) word += (color['g'] / 8) << 5 word += (color['b'] / 8) << 10 return word def pal_to_png(filename): """ Interpret a .pal file as a png palette. """ with open(filename) as rgbs: colors = read_rgb_macros(rgbs.readlines()) a = 255 palette = [] for color in colors: # even distribution over 000-255 r, g, b = [int(hue * 8.25) for hue in color] palette += [(r, g, b, a)] white = (255,255,255,255) black = (000,000,000,255) if white not in palette and len(palette) < 4: palette = [white] + palette if black not in palette and len(palette) < 4: palette = palette + [black] return palette def png_to_rgb(palette): """ Convert a png palette to rgb macros. """ output = '' for color in palette: r, g, b = [color[c] / 8 for c in 'rgb'] output += '\tRGB ' + ', '.join(['%.2d' % hue for hue in (r, g, b)]) output += '\n' return output def read_yaml_arguments(filename, yaml_filename = os.path.join(config.path, 'gfx.yaml'), path_arguments = ['pal_file']): parsed_arguments = {} # Read arguments from gfx.yaml if it exists. if os.path.exists(yaml_filename): yargs = yaml.load(open(yaml_filename)) dirs = os.path.splitext(filename)[0].split('/') current_path = os.path.dirname(filename) path = [] while yargs: for key, value in yargs.items(): # Follow directories to the bottom while picking up keys. # Try not to mistake other files for keys. parsed_path = os.path.join( * (path + [key]) ) for guessed_path in map(parsed_path.__add__, ['', '.png']): if os.path.exists(guessed_path) or '.' in key: if guessed_path != filename: continue if key in path_arguments: value = os.path.join(current_path, value) parsed_arguments[key] = value if not dirs: break yargs = yargs.get(dirs[0], {}) path.append(dirs.pop(0)) return parsed_arguments def read_filename_arguments(filename, yaml_filename = os.path.join(config.path, 'gfx.yaml'), path_arguments = ['pal_file']): """ Infer graphics conversion arguments given a filename. If it exists, ./gfx.yaml is traversed for arguments. Then additional arguments within the filename (separated with ".") are grabbed. """ parsed_arguments = {} parsed_arguments.update(read_yaml_arguments( filename, yaml_filename = yaml_filename, path_arguments = path_arguments )) int_arguments = { 'w': 'width', 'h': 'height', 't': 'tile_padding', } # Filename arguments override yaml. arguments = os.path.splitext(filename)[0].split('.')[1:] for argument in arguments: # Check for integer arguments first (i.e. "w128"). arg = argument[0] param = argument[1:] if param.isdigit(): arg = int_arguments.get(arg, False) if arg: parsed_arguments[arg] = int(param) elif argument == 'arrange': parsed_arguments['norepeat'] = True parsed_arguments['tilemap'] = True # Pic dimensions (i.e. "6x6"). elif 'x' in argument and any(map(str.isdigit, argument)): w, h = argument.split('x') if w.isdigit() and h.isdigit(): parsed_arguments['pic_dimensions'] = (int(w), int(h)) else: parsed_arguments[argument] = True return parsed_arguments def export_2bpp_to_png(filein, fileout=None, pal_file=None, height=0, width=0, tile_padding=0, pic_dimensions=None): if fileout == None: fileout = os.path.splitext(filein)[0] + '.png' image = open(filein, 'rb').read() arguments = { 'width': width, 'height': height, 'pal_file': pal_file, 'tile_padding': tile_padding, 'pic_dimensions': pic_dimensions, } arguments.update(read_filename_arguments(filein)) if pal_file == None: if os.path.exists(os.path.splitext(fileout)[0]+'.pal'): arguments['pal_file'] = os.path.splitext(fileout)[0]+'.pal' result = convert_2bpp_to_png(image, **arguments) width, height, palette, greyscale, bitdepth, px_map = result w = png.Writer( width, height, palette=palette, compression=9, greyscale=greyscale, bitdepth=bitdepth ) with open(fileout, 'wb') as f: w.write(f, px_map) def convert_2bpp_to_png(image, **kwargs): """ Convert a planar 2bpp graphic to png. """ image = bytearray(image) pad_color = bytearray([0]) width = kwargs.get('width', 0) height = kwargs.get('height', 0) tile_padding = kwargs.get('tile_padding', 0) pic_dimensions = kwargs.get('pic_dimensions', None) pal_file = kwargs.get('pal_file', None) interleave = kwargs.get('interleave', False) # Width must be specified to interleave. if interleave and width: image = interleave_tiles(image, width / 8) # Pad the image by a given number of tiles if asked. image += pad_color * 0x10 * tile_padding # Some images are transposed in blocks. if pic_dimensions: w, h = pic_dimensions if not width: width = w * 8 pic_length = w * h * 0x10 trailing = len(image) % pic_length pic = [] for i in xrange(0, len(image) - trailing, pic_length): pic += transpose_tiles(image[i:i+pic_length], h) image = bytearray(pic) + image[len(image) - trailing:] # Pad out trailing lines. image += pad_color * 0x10 * ((w - (len(image) / 0x10) % h) % w) def px_length(img): return len(img) * 4 def tile_length(img): return len(img) * 4 / (8*8) if width and height: tile_width = width / 8 more_tile_padding = (tile_width - (tile_length(image) % tile_width or tile_width)) image += pad_color * 0x10 * more_tile_padding elif width and not height: tile_width = width / 8 more_tile_padding = (tile_width - (tile_length(image) % tile_width or tile_width)) image += pad_color * 0x10 * more_tile_padding height = px_length(image) / width elif height and not width: tile_height = height / 8 more_tile_padding = (tile_height - (tile_length(image) % tile_height or tile_height)) image += pad_color * 0x10 * more_tile_padding width = px_length(image) / height # at least one dimension should be given if width * height != px_length(image): # look for possible combos of width/height that would form a rectangle matches = [] # Height need not be divisible by 8, but width must. # See pokered gfx/minimize_pic.1bpp. for w in range(8, px_length(image) / 2 + 1, 8): h = px_length(image) / w if w * h == px_length(image): matches += [(w, h)] # go for the most square image if len(matches): width, height = sorted(matches, key= lambda (w, h): (h % 8 != 0, w + h))[0] # favor height else: raise Exception, 'Image can\'t be divided into tiles (%d px)!' % (px_length(image)) # convert tiles to lines lines = to_lines(flatten(image), width) if pal_file == None: palette = None greyscale = True bitdepth = 2 px_map = [[3 - pixel for pixel in line] for line in lines] else: # gbc color palette = pal_to_png(pal_file) greyscale = False bitdepth = 8 px_map = [[pixel for pixel in line] for line in lines] return width, height, palette, greyscale, bitdepth, px_map def get_pic_animation(tmap, w, h): """ Generate pic animation data from a combined tilemap of each frame. """ frame_text = '' bitmask_text = '' frames = list(split(tmap, w * h)) base = frames.pop(0) bitmasks = [] for i in xrange(len(frames)): frame_text += '\tdw .frame{}\n'.format(i + 1) for i, frame in enumerate(frames): bitmask = map(operator.eq, frame, base) if bitmask not in bitmasks: bitmasks.append(bitmask) which_bitmask = bitmasks.index(bitmask) mask = iter(bitmask) masked_frame = filter(mask.next, frame) frame_text += '.frame{}\n'.format(i + 1) frame_text += '\tdb ${:02x} ; bitmask\n'.format(which_bitmask) if masked_frame: frame_text += '\tdb {}\n'.format(', '.join( map('${:02x}'.format, masked_frame) )) frame_text += '\n' for i, bitmask in enumerate(bitmasks): bitmask_text += '; {}\n'.format(i) for byte in split(bitmask, 8): byte = int(''.join(map(int.__repr__, reversed(byte))), 2) bitmask_text += '\tdb %{:08b}\n'.format(byte) return frame_text, bitmask_text def dump_pic_animations(addresses={'bitmasks': 'BitmasksPointers', 'frames': 'FramesPointers'}, pokemon=pokemon_constants, rom=load_rom()): """ The code to dump pic animations from rom is mysteriously absent. Here it is again, but now it dumps images instead of text. Said text can then be derived from the images. """ # Labels can be passed in instead of raw addresses. for which, offset in addresses.items(): if type(offset) is str: for line in open('pokecrystal.sym').readlines(): if offset in line.split(): addresses[which] = rom_offset(*map(lambda x: int(x, 16), line[:7].split(':'))) break for i, name in pokemon.items(): if name.lower() == 'unown': continue i -= 1 directory = os.path.join('gfx', 'pics', name.lower()) size = sizes[i] if i > 151 - 1: bank = 0x36 else: bank = 0x35 address = addresses['frames'] + i * 2 address = rom_offset(bank, rom[address] + rom[address + 1] * 0x100) addrs = [] while address not in addrs: addr = rom[address] + rom[address + 1] * 0x100 addrs.append(rom_offset(bank, addr)) address += 2 num_frames = len(addrs) # To go any further, we need bitmasks. # Bitmasks need the number of frames, which we now have. bank = 0x34 address = addresses['bitmasks'] + i * 2 address = rom_offset(bank, rom[address] + rom[address + 1] * 0x100) length = size ** 2 num_bytes = (length + 7) / 8 bitmasks = [] for _ in xrange(num_frames): bitmask = [] bytes_ = rom[ address : address + num_bytes ] for byte in bytes_: bits = map(int, bin(byte)[2:].zfill(8)) bits.reverse() bitmask += bits bitmasks.append(bitmask) address += num_bytes # Back to frames: frames = [] for addr in addrs: bitmask = bitmasks[rom[addr]] num_tiles = len(filter(int, bitmask)) frame = (rom[addr], rom[addr + 1 : addr + 1 + num_tiles]) frames.append(frame) tmap = range(length) * (len(frames) + 1) for i, frame in enumerate(frames): bitmask = bitmasks[frame[0]] tiles = (x for x in frame[1]) for j, bit in enumerate(bitmask): if bit: tmap[(i + 1) * length + j] = tiles.next() filename = os.path.join(directory, 'front.{0}x{0}.2bpp.lz'.format(size)) tiles = get_tiles(Decompressed(open(filename).read()).output) new_tiles = map(tiles.__getitem__, tmap) new_image = connect(new_tiles) filename = os.path.splitext(filename)[0] to_file(filename, new_image) export_2bpp_to_png(filename) def export_png_to_2bpp(filein, fileout=None, palout=None, **kwargs): arguments = { 'tile_padding': 0, 'pic_dimensions': None, 'animate': False, 'stupid_bitmask_hack': [], } arguments.update(kwargs) arguments.update(read_filename_arguments(filein)) image, arguments = png_to_2bpp(filein, **arguments) if fileout == None: fileout = os.path.splitext(filein)[0] + '.2bpp' to_file(fileout, image) tmap = arguments.get('tmap') if tmap != None and arguments['animate'] and arguments['pic_dimensions']: # Generate pic animation data. frame_text, bitmask_text = get_pic_animation(tmap, *arguments['pic_dimensions']) frames_path = os.path.join(os.path.split(fileout)[0], 'frames.asm') with open(frames_path, 'w') as out: out.write(frame_text) bitmask_path = os.path.join(os.path.split(fileout)[0], 'bitmask.asm') # The following Pokemon have a bitmask dummied out. for exception in arguments['stupid_bitmask_hack']: if exception in bitmask_path: bitmasks = bitmask_text.split(';') bitmasks[-1] = bitmasks[-1].replace('1', '0') bitmask_text = ';'.join(bitmasks) with open(bitmask_path, 'w') as out: out.write(bitmask_text) elif tmap != None and arguments.get('tilemap', False): tilemap_path = os.path.splitext(fileout)[0] + '.tilemap' to_file(tilemap_path, tmap) palette = arguments.get('palette') if palout == None: palout = os.path.splitext(fileout)[0] + '.pal' export_palette(palette, palout) def get_image_padding(width, height, wstep=8, hstep=8): padding = { 'left': 0, 'right': 0, 'top': 0, 'bottom': 0, } if width % wstep and width >= wstep: pad = float(width % wstep) / 2 padding['left'] = int(ceil(pad)) padding['right'] = int(floor(pad)) if height % hstep and height >= hstep: pad = float(height % hstep) / 2 padding['top'] = int(ceil(pad)) padding['bottom'] = int(floor(pad)) return padding def png_to_2bpp(filein, **kwargs): """ Convert a png image to planar 2bpp. """ arguments = { 'tile_padding': 0, 'pic_dimensions': False, 'interleave': False, 'norepeat': False, 'tilemap': False, } arguments.update(kwargs) if type(filein) is str: filein = open(filein) assert type(filein) is file width, height, rgba, info = png.Reader(filein).asRGBA8() # png.Reader returns flat pixel data. Nested is easier to work with len_px = len('rgba') image = [] palette = [] for line in rgba: newline = [] for px in xrange(0, len(line), len_px): color = dict(zip('rgba', line[px:px+len_px])) if color not in palette: if len(palette) < 4: palette += [color] else: # TODO Find the nearest match print 'WARNING: %s: Color %s truncated to' % (filein, color), color = sorted(palette, key=lambda x: sum(x.values()))[0] print color newline += [color] image += [newline] assert len(palette) <= 4, '%s: palette should be 4 colors, is really %d (%s)' % (filein, len(palette), palette) # Pad out smaller palettes with greyscale colors greyscale = { 'black': { 'r': 0x00, 'g': 0x00, 'b': 0x00, 'a': 0xff }, 'grey': { 'r': 0x55, 'g': 0x55, 'b': 0x55, 'a': 0xff }, 'gray': { 'r': 0xaa, 'g': 0xaa, 'b': 0xaa, 'a': 0xff }, 'white': { 'r': 0xff, 'g': 0xff, 'b': 0xff, 'a': 0xff }, } preference = 'white', 'black', 'grey', 'gray' for hue in map(greyscale.get, preference): if len(palette) >= 4: break if hue not in palette: palette += [hue] palette.sort(key=lambda x: sum(x.values())) # Game Boy palette order palette.reverse() # Map pixels to quaternary color ids padding = get_image_padding(width, height) width += padding['left'] + padding['right'] height += padding['top'] + padding['bottom'] pad = bytearray([0]) qmap = [] qmap += pad * width * padding['top'] for line in image: qmap += pad * padding['left'] for color in line: qmap += [palette.index(color)] qmap += pad * padding['right'] qmap += pad * width * padding['bottom'] # Graphics are stored in tiles instead of lines tile_width = 8 tile_height = 8 num_columns = max(width, tile_width) / tile_width num_rows = max(height, tile_height) / tile_height image = [] for row in xrange(num_rows): for column in xrange(num_columns): # Split it up into strips to convert to planar data for strip in xrange(min(tile_height, height)): anchor = ( row * num_columns * tile_width * tile_height + column * tile_width + strip * width ) line = qmap[anchor : anchor + tile_width] bottom, top = 0, 0 for bit, quad in enumerate(line): bottom += (quad & 1) << (7 - bit) top += (quad /2 & 1) << (7 - bit) image += [bottom, top] dim = arguments['pic_dimensions'] if dim: if type(dim) in (tuple, list): w, h = dim else: # infer dimensions based on width. w = width / tile_width h = height / tile_height if h % w == 0: h = w tiles = get_tiles(image) pic_length = w * h tile_width = width / 8 trailing = len(tiles) % pic_length new_image = [] for block in xrange(len(tiles) / pic_length): offset = (h * tile_width) * ((block * w) / tile_width) + ((block * w) % tile_width) pic = [] for row in xrange(h): index = offset + (row * tile_width) pic += tiles[index:index + w] new_image += transpose(pic, w) new_image += tiles[len(tiles) - trailing:] image = connect(new_image) # Remove any tile padding used to make the png rectangular. image = image[:len(image) - arguments['tile_padding'] * 0x10] tmap = None if arguments['interleave']: image = deinterleave_tiles(image, num_columns) if arguments['pic_dimensions']: image, tmap = condense_tiles_to_map(image, w * h) elif arguments['norepeat']: image, tmap = condense_tiles_to_map(image) if not arguments['tilemap']: tmap = None arguments.update({ 'palette': palette, 'tmap': tmap, }) return image, arguments def export_palette(palette, filename): """ Export a palette from png to rgb macros in a .pal file. """ if os.path.exists(filename): # Pic palettes are 2 colors (black/white are added later). with open(filename) as rgbs: colors = read_rgb_macros(rgbs.readlines()) if len(colors) == 2: palette = palette[1:3] text = png_to_rgb(palette) with open(filename, 'w') as out: out.write(text) def png_to_lz(filein): name = os.path.splitext(filein)[0] export_png_to_2bpp(filein) image = open(name+'.2bpp', 'rb').read() to_file(name+'.2bpp'+'.lz', Compressed(image).output) def convert_2bpp_to_1bpp(data): """ Convert planar 2bpp image data to 1bpp. Assume images are two colors. """ return data[::2] def convert_1bpp_to_2bpp(data): """ Convert 1bpp image data to planar 2bpp (black/white). """ output = [] for i in data: output += [i, i] return output def export_2bpp_to_1bpp(filename): name, extension = os.path.splitext(filename) image = open(filename, 'rb').read() image = convert_2bpp_to_1bpp(image) to_file(name + '.1bpp', image) def export_1bpp_to_2bpp(filename): name, extension = os.path.splitext(filename) image = open(filename, 'rb').read() image = convert_1bpp_to_2bpp(image) to_file(name + '.2bpp', image) def export_1bpp_to_png(filename, fileout=None): if fileout == None: fileout = os.path.splitext(filename)[0] + '.png' arguments = read_filename_arguments(filename) image = open(filename, 'rb').read() image = convert_1bpp_to_2bpp(image) result = convert_2bpp_to_png(image, **arguments) width, height, palette, greyscale, bitdepth, px_map = result w = png.Writer(width, height, palette=palette, compression=9, greyscale=greyscale, bitdepth=bitdepth) with open(fileout, 'wb') as f: w.write(f, px_map) def export_png_to_1bpp(filename, fileout=None): if fileout == None: fileout = os.path.splitext(filename)[0] + '.1bpp' arguments = read_filename_arguments(filename) image = png_to_1bpp(filename, **arguments) to_file(fileout, image) def png_to_1bpp(filename, **kwargs): image, kwargs = png_to_2bpp(filename, **kwargs) return convert_2bpp_to_1bpp(image) def mass_to_png(directory='gfx'): # greyscale for root, dirs, files in os.walk('./gfx/'): convert_to_png(map(lambda x: os.path.join(root, x), files)) def mass_to_colored_png(directory='gfx'): # greyscale, unless a palette is detected for root, dirs, files in os.walk(directory): for name in files: if os.path.splitext(name)[1] == '.2bpp': pal = None if 'pics' in root: pal = 'normal.pal' elif 'trainers' in root: pal = os.path.splitext(name)[0] + '.pal' if pal != None: pal = os.path.join(root, pal) export_2bpp_to_png(os.path.join(root, name), pal_file=pal) elif os.path.splitext(name)[1] == '.1bpp': export_1bpp_to_png(os.path.join(root, name)) def append_terminator_to_lzs(directory='gfx'): """ Add a terminator to any lz files that were extracted without one. """ for root, dirs, files in os.walk(directory): for filename in files: path = os.path.join(root, filename) if os.path.splitext(path)[1] == '.lz': data = bytearray(open(path,'rb').read()) # don't mistake padding for a missing terminator i = 1 while data[-i] == 0: i += 1 if data[-i] != 0xff: data += [0xff] with open(path, 'wb') as out: out.write(data) def expand_binary_pic_palettes(directory): """ Add white and black to palette files with fewer than 4 colors. Pokemon Crystal only defines two colors for a pic palette to save space, filling in black/white at runtime. Instead of managing palette files of varying length, black and white are added to pic palettes and excluded from incbins. """ for root, dirs, files in os.walk(directory): if os.path.join(directory, 'pics') in root or os.path.join(directory, '/trainers') in root: for name in files: if os.path.splitext(name)[1] == '.pal': filename = os.path.join(root, name) palette = bytearray(open(filename, 'rb').read()) w = bytearray([0xff, 0x7f]) b = bytearray([0x00, 0x00]) if len(palette) == 4: with open(filename, 'wb') as out: out.write(w + palette + b) def convert_to_2bpp(filenames=[]): for filename in filenames: filename, name, extension = try_decompress(filename) if extension == '.1bpp': export_1bpp_to_2bpp(filename) elif extension == '.2bpp': pass elif extension == '.png': export_png_to_2bpp(filename) else: raise Exception, "Don't know how to convert {} to 2bpp!".format(filename) def convert_to_1bpp(filenames=[]): for filename in filenames: filename, name, extension = try_decompress(filename) if extension == '.1bpp': pass elif extension == '.2bpp': export_2bpp_to_1bpp(filename) elif extension == '.png': export_png_to_1bpp(filename) else: raise Exception, "Don't know how to convert {} to 1bpp!".format(filename) def convert_to_png(filenames=[]): for filename in filenames: filename, name, extension = try_decompress(filename) if extension == '.1bpp': export_1bpp_to_png(filename) elif extension == '.2bpp': export_2bpp_to_png(filename) elif extension == '.png': pass else: raise Exception, "Don't know how to convert {} to png!".format(filename) def compress(filenames=[]): for filename in filenames: data = open(filename, 'rb').read() lz_data = Compressed(data).output to_file(filename + '.lz', lz_data) def decompress(filenames=[]): for filename in filenames: name, extension = os.path.splitext(filename) lz_data = open(filename, 'rb').read() data = Decompressed(lz_data).output to_file(name, data) def try_decompress(filename): """ Try to decompress a graphic when determining the filetype. This skips the manual unlz step when attempting to convert lz-compressed graphics to png. """ name, extension = os.path.splitext(filename) if extension == '.lz': decompress([filename]) filename = name name, extension = os.path.splitext(filename) return filename, name, extension def main(): ap = argparse.ArgumentParser() ap.add_argument('mode') ap.add_argument('filenames', nargs='*') args = ap.parse_args() method = { '2bpp': convert_to_2bpp, '1bpp': convert_to_1bpp, 'png': convert_to_png, 'lz': compress, 'unlz': decompress, }.get(args.mode, None) if method == None: raise Exception, "Unknown conversion method!" method(args.filenames) if __name__ == "__main__": main()