diff options
-rw-r--r-- | pokemontools/audio.py | 83 | ||||
-rw-r--r-- | pokemontools/battle_animations.py | 324 | ||||
-rw-r--r-- | pokemontools/crystal.py | 95 | ||||
-rw-r--r-- | pokemontools/data/pokecrystal/gbhw.asm | 102 | ||||
-rw-r--r-- | pokemontools/data/pokecrystal/hram.asm | 71 | ||||
-rw-r--r-- | pokemontools/gfx.py | 116 | ||||
-rw-r--r-- | pokemontools/labels.py | 26 | ||||
-rw-r--r-- | pokemontools/map_editor.py | 36 | ||||
-rw-r--r-- | pokemontools/map_gfx.py | 380 | ||||
-rw-r--r-- | pokemontools/preprocessor.py | 96 | ||||
-rw-r--r-- | pokemontools/scan_includes.py | 36 | ||||
-rw-r--r-- | pokemontools/sym.py | 33 | ||||
-rw-r--r-- | pokemontools/vba/path.py | 664 | ||||
-rw-r--r-- | pokemontools/wram.py | 74 |
14 files changed, 1916 insertions, 220 deletions
diff --git a/pokemontools/audio.py b/pokemontools/audio.py index 38fd65f..0cdfcbc 100644 --- a/pokemontools/audio.py +++ b/pokemontools/audio.py @@ -5,14 +5,13 @@ import os from math import ceil from gbz80disasm import get_global_address, get_local_address - -import crystal +from labels import line_has_label from crystal import music_classes as sound_classes - from crystal import ( Command, SingleByteParam, MultiByteParam, + PointerLabelParam, load_rom, ) @@ -23,10 +22,34 @@ import configuration conf = configuration.Config() +def is_comment(asm): + return asm.startswith(';') + +def asm_sort(asm_def): + """ + Sort key for asm lists. + + Usage: + list.sort(key=asm_sort) + sorted(list, key=asm_sort) + """ + address, asm, last_address = asm_def + return ( + address, + last_address, + not is_comment(asm), + not line_has_label(asm), + asm + ) + def sort_asms(asms): - """sort and remove duplicates from a list of tuples - format (address, asm, last_address)""" - return sorted(set(asms), key=lambda (x,y,z):(x,z,not y.startswith(';'), ':' not in y)) + """ + Sort and remove duplicates from an asm list. + + Format: [(address, asm, last_address), ...] + """ + return sorted(set(asms), key=asm_sort) + class NybbleParam: size = 0.5 @@ -43,15 +66,18 @@ class NybbleParam: def parse(self): self.nybble = (rom[self.address] >> {'lo': 0, 'hi': 4}[self.which]) & 0xf -class HiNybbleParam(NybbleParam): - which = 'hi' def to_asm(self): return '%d' % self.nybble + @staticmethod + def from_asm(value): + return value + +class HiNybbleParam(NybbleParam): + which = 'hi' + class LoNybbleParam(NybbleParam): which = 'lo' - def to_asm(self): - return '%d' % self.nybble class PitchParam(HiNybbleParam): def to_asm(self): @@ -66,14 +92,23 @@ class PitchParam(HiNybbleParam): pitch += '_' return pitch +class NoteDurationParam(LoNybbleParam): + def to_asm(self): + self.nybble += 1 + return LoNybbleParam.to_asm(self) + + @staticmethod + def from_asm(value): + value = str(int(value) - 1) + return LoNybbleParam.from_asm(value) class Note(Command): macro_name = "note" - size = 1 + size = 0 end = False param_types = { 0: {"name": "pitch", "class": PitchParam}, - 1: {"name": "duration", "class": LoNybbleParam}, + 1: {"name": "duration", "class": NoteDurationParam}, } allowed_lengths = [2] override_byte_check = True @@ -83,6 +118,7 @@ class Note(Command): self.params = [] byte = rom[self.address] current_address = self.address + size = 0 for (key, param_type) in self.param_types.items(): name = param_type["name"] class_ = param_type["class"] @@ -92,12 +128,20 @@ class Note(Command): self.params += [obj] current_address += obj.size + size += obj.size + + # can't fit bytes into nybbles + if obj.size > 0.5: + if current_address % 1: + current_address = int(ceil(current_address)) + if size % 1: + size = int(ceil(size)) self.params = dict(enumerate(self.params)) - # obj sizes were 0.5, but were working with ints + # obj sizes were 0.5, but we're working with ints current_address = int(ceil(current_address)) - self.size = int(ceil(self.size)) + self.size += int(ceil(size)) self.last_address = current_address return True @@ -105,7 +149,6 @@ class Note(Command): class Noise(Note): macro_name = "noise" - size = 0 end = False param_types = { 0: {"name": "duration", "class": LoNybbleParam}, @@ -155,7 +198,7 @@ class Channel: # label any jumps or calls for key, param in class_.param_types.items(): - if param['class'] == crystal.PointerLabelParam: + if param['class'] == PointerLabelParam: label_address = class_.params[key].parsed_address label = '%s_branch_%x' % ( self.base_label, @@ -199,10 +242,10 @@ class Channel: output = sort_asms(self.output + self.labels) text = '' for i, (address, asm, last_address) in enumerate(output): - if ':' in asm: + if line_has_label(asm): # dont print labels for empty chunks for (address_, asm_, last_address_) in output[i:]: - if ':' not in asm_: + if not line_has_label(asm_): text += '\n' + asm + '\n' break else: @@ -214,7 +257,7 @@ class Channel: for class_ in sound_classes: if class_.id == i: return class_ - if self.channel in [4, 8]: return Noise + if self.channel == 8: return Noise return Note @@ -320,7 +363,6 @@ def dump_sounds(origin, names, base_label='Sound_'): sound_at = read_bank_address_pointer(origin + i * 3) sound = Sound(sound_at, base_label + name) output = sound.to_asm(labels) + '\n' - # incbin trailing commands that didnt get picked up index = addresses.index((sound.start_address, sound.last_address)) if index + 1 < len(addresses): @@ -386,5 +428,4 @@ def generate_crystal_cry_pointers(): if __name__ == '__main__': dump_crystal_music() - dump_crystal_sfx() diff --git a/pokemontools/battle_animations.py b/pokemontools/battle_animations.py new file mode 100644 index 0000000..ffc89e4 --- /dev/null +++ b/pokemontools/battle_animations.py @@ -0,0 +1,324 @@ +# coding: utf-8 + +import os +from new import classobj + +import configuration +conf = configuration.Config() + +from crystal import ( + SingleByteParam, + PointerLabelParam, + DecimalParam, + BigEndianParam, + Command, + load_rom +) + +from gbz80disasm import get_local_address, get_global_address +from audio import sort_asms + + +from wram import read_constants + +rom = bytearray(load_rom()) + +sfx_constants = read_constants(os.path.join(conf.path, 'constants/sfx_constants.asm')) +class SoundEffectParam(SingleByteParam): + def to_asm(self): + if self.byte in sfx_constants.keys(): + sfx_constant = sfx_constants[self.byte] + return sfx_constant + return SingleByteParam.to_asm(self) + +anim_gfx_constants = read_constants(os.path.join(conf.path, 'constants/gfx_constants.asm')) +class AnimGFXParam(SingleByteParam): + def to_asm(self): + if self.byte in anim_gfx_constants.keys(): + return anim_gfx_constants[self.byte] + return SingleByteParam.to_asm(self) + +anims = read_constants(os.path.join(conf.path, 'constants/animation_constants.asm')) +objs = { k: v for k, v in anims.items() if 'ANIM_OBJ' in v } +bgs = { k: v for k, v in anims.items() if 'ANIM_BG' in v } +anims = { k: v.replace('ANIM_','') for k, v in anims.items() } +from move_constants import moves +anims.update(moves) + +class AnimObjParam(SingleByteParam): + def to_asm(self): + if self.byte in objs.keys(): + return objs[self.byte] + return SingleByteParam.to_asm(self) + +class BGEffectParam(SingleByteParam): + def to_asm(self): + if self.byte in bgs.keys(): + return bgs[self.byte] + return SingleByteParam.to_asm(self) + + +battle_animation_commands = { + 0xd0: ['anim_obj', ['obj', AnimObjParam], ['x', DecimalParam], ['y', DecimalParam], ['param', SingleByteParam]], + 0xd1: ['anim_1gfx', ['gfx1', AnimGFXParam]], + 0xd2: ['anim_2gfx', ['gfx1', AnimGFXParam], ['gfx2', AnimGFXParam]], + 0xd3: ['anim_3gfx', ['gfx1', AnimGFXParam], ['gfx2', AnimGFXParam], ['gfx3', AnimGFXParam]], + 0xd4: ['anim_4gfx', ['gfx1', AnimGFXParam], ['gfx2', AnimGFXParam], ['gfx3', AnimGFXParam], ['gfx4', AnimGFXParam]], + 0xd5: ['anim_5gfx', ['gfx1', AnimGFXParam], ['gfx2', AnimGFXParam], ['gfx3', AnimGFXParam], ['gfx4', AnimGFXParam], ['gfx5', AnimGFXParam]], + 0xd6: ['anim_incobj', ['id', SingleByteParam]], + 0xd7: ['anim_setobj', ['id', SingleByteParam], ['obj', AnimObjParam]], # bug: second param is interpreted as a command if not found in the object array + 0xd8: ['anim_incbgeffect', ['effect', BGEffectParam]], + 0xd9: ['anim_enemyfeetobj'], + 0xda: ['anim_playerheadobj'], + 0xdb: ['anim_checkpokeball'], + 0xdc: ['anim_transform'], + 0xdd: ['anim_raisesub'], + 0xde: ['anim_dropsub'], + 0xdf: ['anim_resetobp0'], + 0xe0: ['anim_sound', ['tracks', SingleByteParam], ['id', SoundEffectParam]], + 0xe1: ['anim_cry', ['pitch', SingleByteParam]], + 0xe2: ['anim_minimizeopp'], # unused + 0xe3: ['anim_oamon'], + 0xe4: ['anim_oamoff'], + 0xe5: ['anim_clearobjs'], + 0xe6: ['anim_beatup'], + 0xe7: ['anim_0xe7'], # nothing + 0xe8: ['anim_updateactorpic'], + 0xe9: ['anim_minimize'], + 0xea: ['anim_0xea'], # nothing + 0xeb: ['anim_0xeb'], # nothing + 0xec: ['anim_0xec'], # nothing + 0xed: ['anim_0xed'], # nothing + 0xee: ['anim_jumpand', ['value', SingleByteParam], ['address', PointerLabelParam]], + 0xef: ['anim_jumpuntil', ['address', PointerLabelParam]], + 0xf0: ['anim_bgeffect', ['effect', BGEffectParam], ['unknown', SingleByteParam], ['unknown', SingleByteParam], ['unknown', SingleByteParam]], + 0xf1: ['anim_bgp', ['colors', SingleByteParam]], + 0xf2: ['anim_obp0', ['colors', SingleByteParam]], + 0xf3: ['anim_obp1', ['colors', SingleByteParam]], + 0xf4: ['anim_clearsprites'], + 0xf5: ['anim_0xf5'], # nothing + 0xf6: ['anim_0xf6'], # nothing + 0xf7: ['anim_0xf7'], # nothing + 0xf8: ['anim_jumpif', ['value', SingleByteParam], ['address', PointerLabelParam]], + 0xf9: ['anim_setvar', ['value', SingleByteParam]], + 0xfa: ['anim_incvar'], + 0xfb: ['anim_jumpvar', ['value', SingleByteParam], ['address', PointerLabelParam]], + 0xfc: ['anim_jump', ['address', PointerLabelParam]], + 0xfd: ['anim_loop', ['count', SingleByteParam], ['address', PointerLabelParam]], + 0xfe: ['anim_call', ['address', PointerLabelParam]], + 0xff: ['anim_ret'], +} + +battle_animation_enders = [ + 'anim_jump', + 'anim_ret', +] + +def create_battle_animation_classes(): + classes = [] + for cmd, command in battle_animation_commands.items(): + cmd_name = command[0] + params = { + 'id': cmd, + 'size': 1, + 'end': cmd_name in battle_animation_enders, + 'macro_name': cmd_name, + 'param_types': {}, + } + for i, (name, class_) in enumerate(command[1:]): + params['param_types'][i] = {'name': name, 'class': class_} + params['size'] += class_.size + class_name = cmd_name + 'Command' + class_ = classobj(class_name, (Command,), params) + globals()[class_name] = class_ + classes += [class_] + return classes + +battle_animation_classes = create_battle_animation_classes() + + +class BattleAnimWait(Command): + macro_name = 'anim_wait' + size = 1 + end = macro_name in battle_animation_enders + param_types = { + 0: {'name': 'duration', 'class': DecimalParam}, + } + override_byte_check = True + + +class BattleAnim: + """ + A list of battle animation commands read from a given address. + + Results in a list of commands (self.output) and a list of labels (self.labels). + Format is (address, asm, last_address). Includes any subroutines and their output. + + To convert to text, use self.to_asm(). + + For combining multiple BattleAnims, take self.output + self.labels from each + and sort with sort_asms. + """ + + def __init__(self, address, base_label=None, label=None, used_labels=[], macros=[]): + self.start_address = address + self.address = address + + self.base_label = base_label + if self.base_label == None: + self.base_label = 'BattleAnim_' + hex(self.start_address) + + self.label = label + if self.label == None: + self.label = self.base_label + + self.used_labels = used_labels + + self.output = [] + self.labels = [] + self.label_asm = ( + self.start_address, + '%s: ; %x' % (self.label, self.start_address), + self.start_address + ) + self.labels += [self.label_asm] + self.used_labels += [self.label_asm] + + self.macros = macros + + self.parse() + + def parse(self): + + done = False + while not done: + cmd = rom[self.address] + class_ = self.get_command_class(cmd)(address=self.address) + asm = class_.to_asm() + + # label jumps/calls + for key, param in class_.param_types.items(): + if param['class'] == PointerLabelParam: + label_address = class_.params[key].parsed_address + label = '%s_branch_%x' % (self.base_label, label_address) + label_def = '%s: ; %x' % (label, label_address) + label_asm = (label_address, label_def, label_address) + if label_asm not in self.used_labels: + self.labels += [label_asm] + asm = asm.replace('$%x' % get_local_address(label_address), label) + + self.output += [(self.address, '\t' + asm, self.address + class_.size)] + self.address += class_.size + + done = class_.end + # infinite loops are enders + if class_.macro_name == 'anim_loop': + if class_.params[0].byte == 0: + done = True + + # last_address comment + self.output += [(self.address, '; %x\n' % self.address, self.address)] + + # parse any other branches too + self.labels = list(set(self.labels)) + for address, asm, last_address in self.labels: + if not (self.start_address <= address < self.address) and (address, asm, last_address) not in self.used_labels: + self.used_labels += [(address, asm, last_address)] + sub = BattleAnim( + address=address, + base_label=self.base_label, + label=asm.split(':')[0], + used_labels=self.used_labels, + macros=self.macros + ) + self.output += sub.output + self.labels += sub.labels + + self.output = list(set(self.output)) + self.labels = list(set(self.labels)) + + def to_asm(self): + output = sort_asms(self.output + self.labels) + text = '' + for (address, asm, last_address) in output: + text += asm + '\n' + return text + + def get_command_class(self, cmd): + if cmd < 0xd0: + return BattleAnimWait + for class_ in self.macros: + if class_.id == cmd: + return class_ + return None + + +def battle_anim_label(i): + """ + Return a label matching the name of a battle animation by id. + """ + if i in anims.keys(): + base_label = 'BattleAnim_%s' % anims[i].title().replace('_','') + else: + base_label = 'BattleAnim_%d' % i + return base_label + +def dump_battle_anims(table_address=0xc906f, num_anims=278, macros=battle_animation_classes): + """ + Dump each battle animation from a pointer table. + """ + + asms = [] + + asms += [(table_address, 'BattleAnimations: ; %x' % table_address, table_address)] + + address = table_address + bank = address / 0x4000 + + for i in xrange(num_anims): + pointer_address = address + anim_address = rom[pointer_address] + rom[pointer_address + 1] * 0x100 + anim_address = get_global_address(anim_address, bank) + base_label = battle_anim_label(i) + address += 2 + + # anim pointer + asms += [(pointer_address, '\tdw %s' % base_label, address)] + + # anim script + anim = BattleAnim( + address=anim_address, + base_label=base_label, + macros=macros + ) + asms += anim.output + anim.labels + + asms += [(address, '; %x\n' % address, address)] + + # jp sonicboom + anim = BattleAnim( + address=0xc9c00, + base_label='BattleAnim_Sonicboom_JP', + macros=macros + ) + asms += anim.output + anim.labels + + asms = sort_asms(asms) + return asms + +def asm_list_to_text(asms): + output = '' + last = asms[0][0] + for addr, asm, last_addr in asms: + if addr > last: + # incbin any unknown areas + output += '\nINCBIN "baserom.gbc", $%x, $%x - $%x\n\n\n' % (last, addr, last) + if addr >= last: + output += asm + '\n' + last = last_addr + return output + +if __name__ == '__main__': + asms = dump_battle_anims() + print asm_list_to_text(asms) + diff --git a/pokemontools/crystal.py b/pokemontools/crystal.py index 4e556d8..f663a87 100644 --- a/pokemontools/crystal.py +++ b/pokemontools/crystal.py @@ -70,6 +70,11 @@ OldTextScript = old_text_script import configuration conf = configuration.Config() +data_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), "data/pokecrystal/") +conf.wram = os.path.join(data_path, "wram.asm") +conf.gbhw = os.path.join(data_path, "gbhw.asm") +conf.hram = os.path.join(data_path, "hram.asm") + from map_names import map_names from song_names import song_names @@ -175,7 +180,7 @@ def how_many_until(byte, starting, rom): def load_map_group_offsets(map_group_pointer_table, map_group_count, rom=None): """reads the map group table for the list of pointers""" map_group_offsets = [] # otherwise this method can only be used once - data = rom.interval(map_group_pointer_table, map_group_count*2, strings=False, rom=rom) + data = rom.interval(map_group_pointer_table, map_group_count*2, strings=False) data = helpers.grouper(data) for pointer_parts in data: pointer = pointer_parts[0] + (pointer_parts[1] << 8) @@ -250,7 +255,10 @@ class TextScript: see: http://hax.iimarck.us/files/scriptingcodes_eng.htm#InText """ base_label = "UnknownText_" - def __init__(self, address, map_group=None, map_id=None, debug=False, label=None, force=False, show=None): + def __init__(self, address, map_group=None, map_id=None, debug=False, label=None, force=False, show=None, script_parse_table=None, text_command_classes=None): + self.text_command_classes = text_command_classes + self.script_parse_table = script_parse_table + self.address = address # $91, $84, $82, $54, $8c # 0x19768c is a a weird problem? @@ -426,7 +434,7 @@ def parse_text_engine_script_at(address, map_group=None, map_id=None, debug=True """ if is_script_already_parsed_at(address) and not force: return script_parse_table[address] - return TextScript(address, map_group=map_group, map_id=map_id, debug=debug, show=show, force=force) + return TextScript(address, map_group=map_group, map_id=map_id, debug=debug, show=show, force=force, script_parse_table=script_parse_table, text_command_classes=text_command_classes) def find_text_addresses(): """returns a list of text pointers @@ -561,7 +569,7 @@ def parse_text_at3(address, map_group=None, map_id=None, debug=False): if deh: return deh else: - text = TextScript(address, map_group=map_group, map_id=map_id, debug=debug) + text = TextScript(address, map_group=map_group, map_id=map_id, debug=debug, script_parse_table=script_parse_table, text_command_classes=text_command_classes) if text.is_valid(): return text else: @@ -776,7 +784,7 @@ HexByte=DollarSignByte class ItemLabelByte(DollarSignByte): def to_asm(self): - label = item_constants.item_constants.find_item_label_by_id(self.byte) + label = item_constants.find_item_label_by_id(self.byte) if label: return label elif not label: @@ -846,8 +854,6 @@ class PointerLabelParam(MultiByteParam): # bank can be overriden if "bank" in kwargs.keys(): if kwargs["bank"] != False and kwargs["bank"] != None and kwargs["bank"] in [True, "reverse"]: - # not +=1 because child classes set size=3 already - self.size = self.default_size + 1 self.given_bank = kwargs["bank"] #if kwargs["bank"] not in [None, False, True, "reverse"]: # raise Exception("bank cannot be: " + str(kwargs["bank"])) @@ -920,8 +926,11 @@ class PointerLabelParam(MultiByteParam): bank_part = "$%.2x" % (pointers.calculate_bank(caddress)) else: bank_part = "BANK("+label+")" + # for labels, expand bank_part at build time + if bank in ["reverse", True] and label: + return pointer_part # return the asm based on the order the bytes were specified to be in - if bank == "reverse": # pointer, bank + elif bank == "reverse": # pointer, bank return pointer_part+", "+bank_part elif bank == True: # bank, pointer return bank_part+", "+pointer_part @@ -937,13 +946,22 @@ class PointerLabelParam(MultiByteParam): raise Exception("this should never happen") class PointerLabelBeforeBank(PointerLabelParam): - bank = True # bank appears first, see calculate_pointer_from_bytes_at size = 3 - byte_type = "dw" + bank = True # bank appears first, see calculate_pointer_from_bytes_at + byte_type = 'db' + + @staticmethod + def from_asm(value): + return 'BANK({0})\n\tdw {0}'.format(value) class PointerLabelAfterBank(PointerLabelParam): - bank = "reverse" # bank appears last, see calculate_pointer_from_bytes_at size = 3 + bank = "reverse" # bank appears last, see calculate_pointer_from_bytes_at + byte_type = 'dw' + + @staticmethod + def from_asm(value): + return '{0}\n\tdb BANK({0})'.format(value) class ScriptPointerLabelParam(PointerLabelParam): pass @@ -2363,7 +2381,7 @@ pksv_crystal_more = { 0xA1: ["halloffame"], 0xA2: ["credits"], 0xA3: ["warpfacing", ["facing", SingleByteParam], ["map_group", MapGroupParam], ["map_id", MapIdParam], ["x", SingleByteParam], ["y", SingleByteParam]], - 0xA4: ["storetext", ["pointer", PointerLabelBeforeBank], ["memory", SingleByteParam]], + 0xA4: ["storetext", ["memory", SingleByteParam]], 0xA5: ["displaylocation", ["id", SingleByteParam], ["memory", SingleByteParam]], 0xA6: ["trainerclassname", ["id", SingleByteParam]], 0xA7: ["name", ["type", SingleByteParam], ["id", SingleByteParam]], @@ -2938,7 +2956,7 @@ class Script: if start_address in stop_points and force == False: if debug: logging.debug( - "script parsing is stopping at stop_point={address} at map_group={map_group} map_id={map_id}" + "script parsing is stopping at stop_point={stop_point} at map_group={map_group} map_id={map_id}" .format( stop_point=hex(start_address), map_group=str(map_group), @@ -6910,7 +6928,7 @@ def list_texts_in_bank(bank): Narrows down the list of objects that you will be inserting into Asm. """ if len(all_texts) == 0: - raise Exception("all_texts is blank.. main() will populate it") + raise Exception("all_texts is blank.. parse_rom() will populate it") assert bank != None, "list_texts_in_banks must be given a particular bank" @@ -6928,7 +6946,7 @@ def list_movements_in_bank(bank, all_movements): Narrows down the list of objects to speed up Asm insertion. """ if len(all_movements) == 0: - raise Exception("all_movements is blank.. main() will populate it") + raise Exception("all_movements is blank.. parse_rom() will populate it") assert bank != None, "list_movements_in_bank must be given a particular bank" assert 0 <= bank < 0x80, "bank doesn't exist in the ROM (out of bounds)" @@ -6947,7 +6965,7 @@ def dump_asm_for_texts_in_bank(bank, start=50, end=100, rom=None): # load and parse the ROM if necessary if rom == None or len(rom) <= 4: rom = load_rom() - main() + parse_rom() # get all texts # first 100 look okay? @@ -6967,7 +6985,7 @@ def dump_asm_for_texts_in_bank(bank, start=50, end=100, rom=None): def dump_asm_for_movements_in_bank(bank, start=0, end=100, all_movements=None): if rom == None or len(rom) <= 4: rom = load_rom() - main() + parse_rom() movements = list_movements_in_bank(bank, all_movements)[start:end] @@ -6983,7 +7001,7 @@ def dump_things_in_bank(bank, start=50, end=100): # load and parse the ROM if necessary if rom == None or len(rom) <= 4: rom = load_rom() - main() + parse_rom() things = list_things_in_bank(bank)[start:end] @@ -7020,6 +7038,14 @@ def write_all_labels(all_labels, filename="labels.json"): fh.close() return True +def setup_wram_labels(config=conf): + """ + Get all wram labels and store it on the module. + """ + wramproc = wram.WRAMProcessor(config=config) + wramproc.initialize() + wram.wram_labels = wramproc.wram_labels + def get_ram_label(address): """ returns a label assigned to a particular ram address @@ -7267,16 +7293,28 @@ Command.trainer_group_maximums = trainer_group_maximums SingleByteParam.map_internal_ids = map_internal_ids MultiByteParam.map_internal_ids = map_internal_ids -def main(rom=None): +def add_map_offsets_into_map_names(map_group_offsets, map_names=None): + """ + Add the offsets for each map into the map_names variable. + """ + # add the offsets into our map structure, why not (johto maps only) + return [map_names[map_group_id+1].update({"offset": offset}) for map_group_id, offset in enumerate(map_group_offsets)] + +rom_parsed = False + +def parse_rom(rom=None): if not rom: # read the rom and figure out the offsets for maps rom = direct_load_rom() + # make wram.wram_labels available + setup_wram_labels() + # figure out the map offsets map_group_offsets = load_map_group_offsets(map_group_pointer_table=map_group_pointer_table, map_group_count=map_group_count, rom=rom) - # add the offsets into our map structure, why not (johto maps only) - [map_names[map_group_id+1].update({"offset": offset}) for map_group_id, offset in enumerate(map_group_offsets)] + # populate the map_names structure with the offsets + add_map_offsets_into_map_names(map_group_offsets, map_names=map_names) # parse map header bytes for each map parse_all_map_headers(map_names, all_map_headers=all_map_headers) @@ -7292,5 +7330,20 @@ def main(rom=None): # improve duplicate trainer names make_trainer_group_name_trainer_ids(trainer_group_table) + global rom_parsed + rom_parsed = True + + return map_names + +def cachably_parse_rom(rom=None): + """ + Calls parse_rom if it hasn't been called and completed yet. + """ + global rom_parsed + if not rom_parsed: + return parse_rom(rom=rom) + else: + return map_names + if __name__ == "crystal": pass diff --git a/pokemontools/data/pokecrystal/gbhw.asm b/pokemontools/data/pokecrystal/gbhw.asm new file mode 100644 index 0000000..0f12e48 --- /dev/null +++ b/pokemontools/data/pokecrystal/gbhw.asm @@ -0,0 +1,102 @@ +; Graciously aped from http://nocash.emubase.de/pandocs.htm . + +; MBC3 +MBC3SRamEnable EQU $0000 +MBC3RomBank EQU $2000 +MBC3SRamBank EQU $4000 +MBC3LatchClock EQU $6000 +MBC3RTC EQU $a000 + +SRAM_DISABLE EQU $00 +SRAM_ENABLE EQU $0a + +NUM_SRAM_BANKS EQU 4 + +RTC_S EQU $08 ; Seconds 0-59 (0-3Bh) +RTC_M EQU $09 ; Minutes 0-59 (0-3Bh) +RTC_H EQU $0a ; Hours 0-23 (0-17h) +RTC_DL EQU $0b ; Lower 8 bits of Day Counter (0-FFh) +RTC_DH EQU $0c ; Upper 1 bit of Day Counter, Carry Bit, Halt Flag + ; Bit 0 Most significant bit of Day Counter (Bit 8) + ; Bit 6 Halt (0=Active, 1=Stop Timer) + ; Bit 7 Day Counter Carry Bit (1=Counter Overflow) + +; interrupt flags +VBLANK EQU 0 +LCD_STAT EQU 1 +TIMER EQU 2 +SERIAL EQU 3 +JOYPAD EQU 4 + +; Hardware registers +rJOYP EQU $ff00 ; Joypad (R/W) +rSB EQU $ff01 ; Serial transfer data (R/W) +rSC EQU $ff02 ; Serial Transfer Control (R/W) +rSC_ON EQU 7 +rSC_CGB EQU 1 +rSC_CLOCK EQU 0 +rDIV EQU $ff04 ; Divider Register (R/W) +rTIMA EQU $ff05 ; Timer counter (R/W) +rTMA EQU $ff06 ; Timer Modulo (R/W) +rTAC EQU $ff07 ; Timer Control (R/W) +rTAC_ON EQU 2 +rTAC_4096_HZ EQU 0 +rTAC_262144_HZ EQU 1 +rTAC_65536_HZ EQU 2 +rTAC_16384_HZ EQU 3 +rIF EQU $ff0f ; Interrupt Flag (R/W) +rNR10 EQU $ff10 ; Channel 1 Sweep register (R/W) +rNR11 EQU $ff11 ; Channel 1 Sound length/Wave pattern duty (R/W) +rNR12 EQU $ff12 ; Channel 1 Volume Envelope (R/W) +rNR13 EQU $ff13 ; Channel 1 Frequency lo (Write Only) +rNR14 EQU $ff14 ; Channel 1 Frequency hi (R/W) +rNR21 EQU $ff16 ; Channel 2 Sound Length/Wave Pattern Duty (R/W) +rNR22 EQU $ff17 ; Channel 2 Volume Envelope (R/W) +rNR23 EQU $ff18 ; Channel 2 Frequency lo data (W) +rNR24 EQU $ff19 ; Channel 2 Frequency hi data (R/W) +rNR30 EQU $ff1a ; Channel 3 Sound on/off (R/W) +rNR31 EQU $ff1b ; Channel 3 Sound Length +rNR32 EQU $ff1c ; Channel 3 Select output level (R/W) +rNR33 EQU $ff1d ; Channel 3 Frequency's lower data (W) +rNR34 EQU $ff1e ; Channel 3 Frequency's higher data (R/W) +rNR41 EQU $ff20 ; Channel 4 Sound Length (R/W) +rNR42 EQU $ff21 ; Channel 4 Volume Envelope (R/W) +rNR43 EQU $ff22 ; Channel 4 Polynomial Counter (R/W) +rNR44 EQU $ff23 ; Channel 4 Counter/consecutive; Inital (R/W) +rNR50 EQU $ff24 ; Channel control / ON-OFF / Volume (R/W) +rNR51 EQU $ff25 ; Selection of Sound output terminal (R/W) +rNR52 EQU $ff26 ; Sound on/off +rLCDC EQU $ff40 ; LCD Control (R/W) +rSTAT EQU $ff41 ; LCDC Status (R/W) +rSCY EQU $ff42 ; Scroll Y (R/W) +rSCX EQU $ff43 ; Scroll X (R/W) +rLY EQU $ff44 ; LCDC Y-Coordinate (R) +rLYC EQU $ff45 ; LY Compare (R/W) +rDMA EQU $ff46 ; DMA Transfer and Start Address (W) +rBGP EQU $ff47 ; BG Palette Data (R/W) - Non CGB Mode Only +rOBP0 EQU $ff48 ; Object Palette 0 Data (R/W) - Non CGB Mode Only +rOBP1 EQU $ff49 ; Object Palette 1 Data (R/W) - Non CGB Mode Only +rWY EQU $ff4a ; Window Y Position (R/W) +rWX EQU $ff4b ; Window X Position minus 7 (R/W) +rKEY1 EQU $ff4d ; CGB Mode Only - Prepare Speed Switch +rVBK EQU $ff4f ; CGB Mode Only - VRAM Bank +rHDMA1 EQU $ff51 ; CGB Mode Only - New DMA Source, High +rHDMA2 EQU $ff52 ; CGB Mode Only - New DMA Source, Low +rHDMA3 EQU $ff53 ; CGB Mode Only - New DMA Destination, High +rHDMA4 EQU $ff54 ; CGB Mode Only - New DMA Destination, Low +rHDMA5 EQU $ff55 ; CGB Mode Only - New DMA Length/Mode/Start +rRP EQU $ff56 ; CGB Mode Only - Infrared Communications Port +rBGPI EQU $ff68 ; CGB Mode Only - Background Palette Index +rBGPD EQU $ff69 ; CGB Mode Only - Background Palette Data +rOBPI EQU $ff6a ; CGB Mode Only - Sprite Palette Index +rOBPD EQU $ff6b ; CGB Mode Only - Sprite Palette Data +rUNKNOWN1 EQU $ff6c ; (FEh) Bit 0 (Read/Write) - CGB Mode Only +rSVBK EQU $ff70 ; CGB Mode Only - WRAM Bank +rUNKNOWN2 EQU $ff72 ; (00h) - Bit 0-7 (Read/Write) +rUNKNOWN3 EQU $ff73 ; (00h) - Bit 0-7 (Read/Write) +rUNKNOWN4 EQU $ff74 ; (00h) - Bit 0-7 (Read/Write) - CGB Mode Only +rUNKNOWN5 EQU $ff75 ; (8Fh) - Bit 4-6 (Read/Write) +rUNKNOWN6 EQU $ff76 ; (00h) - Always 00h (Read Only) +rUNKNOWN7 EQU $ff77 ; (00h) - Always 00h (Read Only) +rIE EQU $ffff ; Interrupt Enable (R/W) + diff --git a/pokemontools/data/pokecrystal/hram.asm b/pokemontools/data/pokecrystal/hram.asm new file mode 100644 index 0000000..051d418 --- /dev/null +++ b/pokemontools/data/pokecrystal/hram.asm @@ -0,0 +1,71 @@ +hPushOAM EQU $ff80 + +hBuffer EQU $ff8b + +hRTCDayHi EQU $ff8d +hRTCDayLo EQU $ff8e +hRTCHours EQU $ff8f +hRTCMinutes EQU $ff90 +hRTCSeconds EQU $ff91 + +hHours EQU $ff94 + +hMinutes EQU $ff96 + +hSeconds EQU $ff98 + +hROMBank EQU $ff9d + +hJoypadReleased EQU $ffa2 +hJoypadPressed EQU $ffa3 +hJoypadDown EQU $ffa4 +hJoypadSum EQU $ffa5 +hJoyReleased EQU $ffa6 +hJoyPressed EQU $ffa7 +hJoyDown EQU $ffa8 + +hConnectionStripLength EQU $ffaf +hConnectedMapWidth EQU $ffb0 + +hPastLeadingZeroes EQU $ffb3 + +hDividend EQU $ffb3 +hDivisor EQU $ffb7 +hQuotient EQU $ffb4 + +hMultiplicand EQU $ffb4 +hMultiplier EQU $ffb7 +hProduct EQU $ffb3 + +hMathBuffer EQU $ffb8 + +hLCDStatCustom EQU $ffc6 + +hSerialSend EQU $ffcd +hSerialReceive EQU $ffce + +hSCX EQU $ffcf +hSCY EQU $ffd0 +hWX EQU $ffd1 +hWY EQU $ffd2 + +hBGMapMode EQU $ffd4 +hBGMapThird EQU $ffd5 +hBGMapAddress EQU $ffd6 + +hOAMUpdate EQU $ffd8 +hSPBuffer EQU $ffd9 + +hBGMapUpdate EQU $ffdb + +hTileAnimFrame EQU $ffdf + +hRandomAdd EQU $ffe1 +hRandomSub EQU $ffe2 + +hBattleTurn EQU $ffe4 +hCGBPalUpdate EQU $ffe5 +hCGB EQU $ffe6 +hSGB EQU $ffe7 +hDMATransfer EQU $ffe8 + diff --git a/pokemontools/gfx.py b/pokemontools/gfx.py index 8397337..04ccac5 100644 --- a/pokemontools/gfx.py +++ b/pokemontools/gfx.py @@ -1012,7 +1012,7 @@ def get_uncompressed_gfx(start, num_tiles, filename): -def hex_to_rgb(word): +def bin_to_rgb(word): red = word & 0b11111 word >>= 5 green = word & 0b11111 @@ -1020,23 +1020,39 @@ def hex_to_rgb(word): blue = word & 0b11111 return (red, green, blue) -def grab_palettes(address, length=0x80): +def rgb_from_rom(address, length=0x80): + return convert_binary_pal_to_text(rom[address:address+length]) + +def convert_binary_pal_to_text_by_filename(filename): + with open(filename) as f: + pal = bytearray(f.read()) + return convert_binary_pal_to_text(pal) + +def convert_binary_pal_to_text(pal): output = '' - for word in range(length/2): - color = ord(rom[address+1])*0x100 + ord(rom[address]) - address += 2 - color = hex_to_rgb(color) - red = str(color[0]).zfill(2) - green = str(color[1]).zfill(2) - blue = str(color[2]).zfill(2) - output += '\tRGB '+red+', '+green+', '+blue + 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(): @@ -1147,6 +1163,9 @@ def to_lines(image, width): def dmg2rgb(word): + """ + For PNGs. + """ def shift(value): while True: yield value & (2**5 - 1) @@ -1159,27 +1178,49 @@ def dmg2rgb(word): def rgb_to_dmg(color): + """ + For PNGs. + """ word = (color['r'] / 8) word += (color['g'] / 8) << 5 word += (color['b'] / 8) << 10 return word -def png_pal(filename): - with open(filename, 'rb') as pal_data: - words = pal_data.read() - dmg_pals = [] - for word in range(len(words)/2): - dmg_pals.append(ord(words[word*2]) + ord(words[word*2+1])*0x100) +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) - for word in dmg_pals: palette += [dmg2rgb(word)] - if white not in dmg_pals and len(palette) < 4: palette = [white] + palette - if black not in dmg_pals and len(palette) < 4: palette += [black] + 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 export_2bpp_to_png(filein, fileout=None, pal_file=None, height=0, width=0): if fileout == None: fileout = os.path.splitext(filein)[0] + '.png' @@ -1234,7 +1275,7 @@ def convert_2bpp_to_png(image, width=0, height=0, pal_file=None): px_map = [[3 - pixel for pixel in line] for line in lines] else: # gbc color - palette = png_pal(pal_file) + palette = pal_to_png(pal_file) greyscale = False bitdepth = 8 px_map = [[pixel for pixel in line] for line in lines] @@ -1371,13 +1412,22 @@ def png_to_2bpp(filein): def export_palette(palette, filename): + """ + Export a palette from png to rgb macros in a .pal file. + """ + if os.path.exists(filename): - output = [] - for color in palette: - word = rgb_to_dmg(color) - output += [word & 0xff] - output += [word >> 8] - to_file(filename, output) + + # 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): @@ -1516,11 +1566,16 @@ def export_lz_to_png(filename): """ assert filename[-3:] == ".lz" lz_data = open(filename, "rb").read() + bpp = Decompressed(lz_data).output bpp_filename = filename.replace(".lz", ".2bpp") to_file(bpp_filename, bpp) + export_2bpp_to_png(bpp_filename) + # touch the lz file so it doesn't get remade + os.utime(filename, None) + def dump_tileset_pngs(): """ Convert .lz format tilesets into .png format tilesets. @@ -1566,6 +1621,8 @@ def expand_pic_palettes(): with open(filename, 'wb') as out: out.write(w + palette + b) + + if __name__ == "__main__": debug = False @@ -1627,6 +1684,9 @@ if __name__ == "__main__": elif argv[1] == 'png-to-1bpp': export_png_to_1bpp(argv[2]) + elif argv[1] == '1bpp-to-png': + export_1bpp_to_png(argv[2]) + elif argv[1] == '2bpp-to-lz': if argv[2] == '--vert': filein = argv[3] diff --git a/pokemontools/labels.py b/pokemontools/labels.py index 96e34b9..87e9990 100644 --- a/pokemontools/labels.py +++ b/pokemontools/labels.py @@ -8,33 +8,39 @@ import json import logging import pointers +import sym class Labels(object): """ Store all labels. """ - filename = "labels.json" - def __init__(self, config): + def __init__(self, config, filename="pokecrystal.map"): """ Setup the instance. """ self.config = config - self.path = os.path.join(self.config.path, Labels.filename) + self.filename = filename + self.path = os.path.join(self.config.path, self.filename) def initialize(self): """ Handle anything requiring file-loading and such. """ + # Look for a mapfile if it's not given if not os.path.exists(self.path): - logging.info( - "Running crystal.scan_for_predefined_labels to create \"{0}\". Trying.." - .format(Labels.filename) - ) - import crystal - crystal.scan_for_predefined_labels() + self.filename = find_mapfile_in_dir(self.config.path) + if self.filename == None: + raise Exception, "Couldn't find any mapfiles. Run rgblink -m to create a mapfile." + self.path = os.path.join(self.config.path, self.filename) - self.labels = json.read(open(self.path, "r").read()) + self.labels = sym.read_mapfile(self.path) + +def find_mapfile_in_dir(path): + for filename in os.listdir(path): + if os.path.splitext(filename)[1] == '.map': + return filename + return None def remove_quoted_text(line): """get rid of content inside quotes diff --git a/pokemontools/map_editor.py b/pokemontools/map_editor.py index 192d06c..47b7d95 100644 --- a/pokemontools/map_editor.py +++ b/pokemontools/map_editor.py @@ -15,6 +15,7 @@ from Tkinter import ( X, TclError, ) +import tkFileDialog from ttk import ( Frame, @@ -178,7 +179,8 @@ class Application(Frame): def new_map(self): self.map_name = None self.init_map() - self.map.blockdata = [self.paint_tile] * 20 * 20 + self.map.blockdata_filename = os.path.join(self.config.map_dir, 'newmap.blk') + self.map.blockdata = bytearray([self.paint_tile] * 20 * 20) self.map.width = 20 self.map.height = 20 self.draw_map() @@ -193,7 +195,8 @@ class Application(Frame): def save_map(self): if hasattr(self, 'map'): if self.map.blockdata_filename: - with open(self.map.blockdata_filename, 'wb') as save: + filename = tkFileDialog.asksaveasfilename(initialfile=self.map.blockdata_filename) + with open(filename, 'wb') as save: save.write(self.map.blockdata) self.log.info('blockdata saved as {}'.format(self.map.blockdata_filename)) else: @@ -445,7 +448,7 @@ class Tileset: def get_tiles(self): filename = self.get_tileset_gfx_filename() if not os.path.exists(filename): - gfx.export_2bpp_to_png(filename.replace('.png','.2bpp'), filename) + gfx.export_2bpp_to_png(filename.replace('.png','.2bpp')) self.img = Image.open(filename) self.img.width, self.img.height = self.img.size self.tiles = [] @@ -505,30 +508,9 @@ class Tileset: self.palettes = get_palettes(filename) def get_palettes(filename): - pals = bytearray(open(filename, 'rb').read()) - - num_colors = 4 - color_length = 2 - - palette_length = num_colors * color_length - - num_pals = len(pals) / palette_length - - palettes = [] - for pal in xrange(num_pals): - palettes += [[]] - - for color in xrange(num_colors): - i = pal * palette_length - i += color * color_length - word = pals[i] + pals[i+1] * 0x100 - palettes[pal] += [[ - c & 0x1f for c in [ - word >> 0, - word >> 5, - word >> 10, - ] - ]] + lines = open(filename, 'r').readlines() + colors = gfx.read_rgb_macros(lines) + palettes = [colors[i:i+4] for i in xrange(0, len(colors), 4)] return palettes def get_available_maps(config=config): diff --git a/pokemontools/map_gfx.py b/pokemontools/map_gfx.py new file mode 100644 index 0000000..b06f0df --- /dev/null +++ b/pokemontools/map_gfx.py @@ -0,0 +1,380 @@ +""" +Map-related graphic functions. +""" + +import os +import png +from io import BytesIO + +from PIL import ( + Image, + ImageDraw, +) + +import crystal +import gfx + +tile_width = 8 +tile_height = 8 +block_width = 4 +block_height = 4 + +WALKING_SPRITE = 1 +STANDING_SPRITE = 2 +STILL_SPRITE = 3 + +# use the same configuration +gfx.config = crystal.conf +config = gfx.config + +def add_pokecrystal_paths_to_configuration(config=config): + """ + Assumes that the current working directory is the pokecrystal project path. + """ + config.gfx_dir = os.path.join(os.path.abspath("."), "gfx/tilesets/") + config.block_dir = os.path.join(os.path.abspath("."), "tilesets/") + config.palmap_dir = config.block_dir + config.palette_dir = config.block_dir + config.sprites_dir = os.path.join(os.path.abspath("."), "gfx/overworld/") + +add_pokecrystal_paths_to_configuration(config=config) + +def read_map_blockdata(map_header): + """ + Reads out the list of bytes representing the blockdata for the current map. + """ + width = map_header.second_map_header.blockdata.width.byte + height = map_header.second_map_header.blockdata.height.byte + + start_address = map_header.second_map_header.blockdata.address + end_address = start_address + (width * height) + + blockdata = crystal.rom[start_address : end_address] + + return [ord(x) for x in blockdata] + +def load_png(filepath): + """ + Makes an image object from file. + """ + return Image.open(filepath) + +all_blocks = {} +def read_blocks(tileset_id, config=config): + """ + Makes a list of blocks, such that each block is a list of tiles by id, for + the given tileset. + """ + if tileset_id in all_blocks.keys(): + return all_blocks[tileset_id] + + blocks = [] + + block_width = 4 + block_height = 4 + block_length = block_width * block_height + + filename = "{id}{ext}".format(id=str(tileset_id).zfill(2), ext="_metatiles.bin") + filepath = os.path.join(config.block_dir, filename) + + blocksetdata = bytearray(open(filepath, "rb").read()) + + for blockbyte in xrange(len(blocksetdata) / block_length): + block_num = blockbyte * block_length + block = blocksetdata[block_num : block_num + block_length] + blocks += [block] + + all_blocks[tileset_id] = blocks + + return blocks + +def colorize_tile(tile, palette): + """ + Make the tile have colors. + """ + (width, height) = tile.size + tile = tile.convert("RGB") + px = tile.load() + + for y in xrange(height): + for x in xrange(width): + # assume greyscale + which_color = 3 - (px[x, y][0] / 0x55) + (r, g, b) = [v * 8 for v in palette[which_color]] + px[x, y] = (r, g, b) + + return tile + +pre_cropped = {} +def read_tiles(tileset_id, palette_map, palettes, config=config): + """ + Opens the tileset png file and reads bytes for each tile in the tileset. + """ + + if tileset_id not in pre_cropped.keys(): + pre_cropped[tileset_id] = {} + + tile_width = 8 + tile_height = 8 + + tiles = [] + + filename = "{id}.{ext}".format(id=str(tileset_id).zfill(2), ext="png") + filepath = os.path.join(config.gfx_dir, filename) + + image = load_png(filepath) + (image.width, image.height) = image.size + + cur_tile = 0 + + for y in xrange(0, image.height, tile_height): + for x in xrange(0, image.width, tile_width): + if (x, y) in pre_cropped[tileset_id].keys(): + tile = pre_cropped[tileset_id][(x, y)] + else: + tile = image.crop((x, y, x + tile_width, y + tile_height)) + pre_cropped[tileset_id][(x, y)] = tile + + # palette maps are padded to make vram mapping easier + pal = palette_map[cur_tile + 0x20 if cur_tile > 0x60 else cur_tile] & 0x7 + tile = colorize_tile(tile, palettes[pal]) + + tiles.append(tile) + + cur_tile += 1 + + return tiles + +all_palette_maps = {} +def read_palette_map(tileset_id, config=config): + """ + Loads a palette map. + """ + if tileset_id in all_palette_maps.keys(): + return all_palette_maps[tileset_id] + + filename = "{id}{ext}".format(id=str(tileset_id).zfill(2), ext="_palette_map.bin") + filepath = os.path.join(config.palmap_dir, filename) + + palette_map = [] + + palmap = bytearray(open(filepath, "rb").read()) + + for i in xrange(len(palmap)): + palette_map += [palmap[i] & 0xf] + palette_map += [(palmap[i] >> 4) & 0xf] + + all_palette_maps[tileset_id] = palette_map + + return palette_map + +def read_palettes(time_of_day=1, config=config): + """ + Loads up the .pal file? + """ + palettes = [] + + actual_time_of_day = ["morn", "day", "nite"][time_of_day] + filename = "{}.pal".format(actual_time_of_day) + filepath = os.path.join(config.palette_dir, filename) + + lines = open(filepath, "r").readlines() + colors = gfx.read_rgb_macros(lines) + palettes = [colors[i:i+4] for i in xrange(0, len(colors), 4)] + return palettes + +def load_sprite_image(address, config=config): + """ + Make standard file path. + """ + pal_file = os.path.join(config.block_dir, "day.pal") + + length = 0x40 + + image = crystal.rom[address:address + length] + width, height, palette, greyscale, bitdepth, px_map = gfx.convert_2bpp_to_png(image, width=16, height=16, pal_file=pal_file) + w = png.Writer(16, 16, palette=palette, compression=9, greyscale=greyscale, bitdepth=bitdepth) + some_buffer = BytesIO() + w.write(some_buffer, px_map) + some_buffer.seek(0) + + sprite_image = Image.open(some_buffer) + + return sprite_image + +sprites = {} +def load_all_sprite_images(config=config): + """ + Loads all images for each sprite in each direction. + """ + crystal.direct_load_rom() + + sprite_headers_address = 0x14736 + sprite_header_size = 6 + sprite_count = 102 + frame_size = 0x40 + + current_address = sprite_headers_address + + current_image_id = 0 + + for sprite_id in xrange(1, sprite_count): + rom_bytes = crystal.rom[current_address : current_address + sprite_header_size] + header = [ord(x) for x in rom_bytes] + + bank = header[3] + + lo = header[0] + hi = header[1] + sprite_address = (hi * 0x100) + lo - 0x4000 + sprite_address += 0x4000 * bank + + sprite_size = header[2] + sprite_type = header[4] + sprite_palette = header[5] + image_count = sprite_size / frame_size + + sprite = { + "size": sprite_size, + "image_count": image_count, + "type": sprite_type, + "palette": sprite_palette, + "images": {}, + } + + if sprite_type in [WALKING_SPRITE, STANDING_SPRITE]: + # down, up, left, move down, move up, move left + sprite["images"]["down"] = load_sprite_image(sprite_address, config=config) + sprite["images"]["up"] = load_sprite_image(sprite_address + 0x40, config=config) + sprite["images"]["left"] = load_sprite_image(sprite_address + (0x40 * 2), config=config) + + if sprite_type == WALKING_SPRITE: + current_image_id += image_count * 2 + elif sprite_type == STANDING_SPRITE: + current_image_id += image_count * 1 + elif sprite_type == STILL_SPRITE: + # just one image + sprite["images"]["still"] = load_sprite_image(sprite_address, config=config) + + current_image_id += image_count * 1 + + # store the actual metadata + sprites[sprite_id] = sprite + + current_address += sprite_header_size + + return sprites + +def draw_map_sprites(map_header, map_image, config=config): + """ + Show NPCs and items on the map. + """ + + events = map_header.second_map_header.event_header.people_events + + for event in events: + sprite_image_id = event.params[0].byte + y = (event.params[1].byte - 4) * 4 + x = (event.params[2].byte - 4) * 4 + facing = event.params[3].byte + movement = event.params[4].byte + sight_range = event.params[8].byte + some_pointer = event.params[9] + bit_table_bit_number = event.params[10] + + other_args = {} + + if sprite_image_id not in sprites.keys() or sprite_image_id > 0x66: + print "sprite_image_id {} is not in sprites".format(sprite_image_id) + + sprite_image = Image.new("RGBA", (16, 16)) + + draw = ImageDraw.Draw(sprite_image, "RGBA") + draw.rectangle([(0, 0), (16, 16)], fill=(0, 0, 0, 127)) + + other_args["mask"] = sprite_image + else: + sprite = sprites[sprite_image_id] + + # TODO: pick the correct direction based on "facing" + sprite_image = sprite["images"].values()[0] + + # TODO: figure out how to calculate the correct position + map_image.paste(sprite_image, (x * 4, y * 4), **other_args) + +def draw_map(map_group_id, map_id, palettes, show_sprites=True, config=config): + """ + Makes a picture of a map. + """ + # extract data from the ROM + crystal.cachably_parse_rom() + + map_header = crystal.map_names[map_group_id][map_id]["header_new"] + second_map_header = map_header.second_map_header + + width = second_map_header.blockdata.width.byte + height = second_map_header.blockdata.height.byte + + tileset_id = map_header.tileset.byte + blockdata = read_map_blockdata(map_header) + + palette_map = read_palette_map(tileset_id, config=config) + + tileset_blocks = read_blocks(tileset_id, config=config) + tileset_images = read_tiles(tileset_id, palette_map, palettes, config=config) + + map_image = Image.new("RGB", (width * tile_width * block_width, height * tile_height * block_height)) + + # draw each block on the map + for block_num in xrange(len(blockdata)): + block_x = block_num % width + block_y = block_num / width + + block = blockdata[block_y * width + block_x] + + for (tile_num, tile) in enumerate(tileset_blocks[block]): + # tile gfx are split in half to make vram mapping easier + if tile >= 0x80: + tile -= 0x20 + + tile_x = block_x * 32 + (tile_num % 4) * 8 + tile_y = block_y * 32 + (tile_num / 4) * 8 + + tile_image = tileset_images[tile] + + map_image.paste(tile_image, (tile_x, tile_y)) + + # draw each sprite on the map + draw_map_sprites(map_header, map_image, config=config) + + return map_image + +def save_map(map_group_id, map_id, savedir, show_sprites=True, config=config): + """ + Makes a map and saves it to a file in savedir. + """ + # this could be moved into a decorator + crystal.cachably_parse_rom() + + map_name = crystal.map_names[map_group_id][map_id]["label"] + filename = "{name}.{ext}".format(name=map_name, ext="png") + filepath = os.path.join(savedir, filename) + + palettes = read_palettes(config=config) + + print "Drawing {}".format(map_name) + map_image = draw_map(map_group_id, map_id, palettes, show_sprites=show_sprites, config=config) + map_image.save(filepath) + + return map_image + +def save_maps(savedir, show_sprites=True, config=config): + """ + Draw as many maps as possible. + """ + crystal.cachably_parse_rom() + + for map_group_id in crystal.map_names.keys(): + for map_id in crystal.map_names[map_group_id].keys(): + if isinstance(map_id, int): + image = save_map(map_group_id, map_id, savedir, show_sprites=show_sprites, config=config) diff --git a/pokemontools/preprocessor.py b/pokemontools/preprocessor.py index f4e92b6..954263a 100644 --- a/pokemontools/preprocessor.py +++ b/pokemontools/preprocessor.py @@ -483,22 +483,18 @@ class Preprocessor(object): for l in lines: self.read_line(l) - self.update_globals() - def update_globals(self): """ Add any labels not already in globals.asm. """ - # TODO: pokered needs to be fixed - try: - globes = open(os.path.join(self.config.path, 'globals.asm'), 'r+') + path = os.path.join(self.config.path, 'globals.asm') + if os.path.exists(path): + globes = open(path, 'r+') lines = globes.readlines() for globe in self.globes: line = 'GLOBAL ' + globe + '\n' if line not in lines: globes.write(line) - except Exception as exception: - pass # don't care if it's not there... def read_line(self, l): """ @@ -527,7 +523,7 @@ class Preprocessor(object): sys.stdout.write(asm) # convert text to bytes when a quote appears (not in a comment) - elif "\"" in asm: + elif "\"" in asm and "EQUS" not in asm: sys.stdout.write(quote_translator(asm)) # check against other preprocessor features @@ -553,40 +549,22 @@ class Preprocessor(object): original_line = line - # remove trailing newline - if line[-1] == "\n": - line = line[:-1] - - # remove first tab - has_tab = False - if line[0] == "\t": - has_tab = True - line = line[1:] + has_tab = line[0] == "\t" - # remove duplicate whitespace (also trailing) + # remove whitespace line = " ".join(line.split()) - params = [] - # check if the line has params if " " in line: # split the line into separate parameters params = line.replace(token, "").split(",") - - # check if there are no params (redundant) - if len(params) == 1 and params[0] == "": - raise exceptions.MacroException("macro has no params?") + else: + params = [] # write out a comment showing the original line if show_original_lines: sys.stdout.write("; original_line: " + original_line) - # rgbasm can handle "db" so no preprocessing is required, plus this wont be - # reached because of earlier checks in macro_test. - if macro.macro_name in ["db", "dw"]: - sys.stdout.write(original_line) - return - # rgbasm can handle other macros too if "is_rgbasm_macro" in dir(macro): if macro.is_rgbasm_macro: @@ -604,64 +582,18 @@ class Preprocessor(object): if do_macro_sanity_check: self.check_macro_sanity(params, macro, original_line) - # used for storetext - correction = 0 - output = "" - - index = 0 - while index < len(params): - param_type = macro.param_types[index - correction] + for index in xrange(len(params)): + param_type = macro.param_types[index] description = param_type["name"].strip() param_klass = param_type["class"] - byte_type = param_klass.byte_type # db or dw - size = param_klass.size + byte_type = param_klass.byte_type param = params[index].strip() - # param_klass.to_asm() won't work here because it doesn't - # include db/dw. - - # some parameters are really multiple types of bytes - if (byte_type == "dw" and size != 2) or \ - (byte_type == "db" and size != 1): - - output += ("; " + description + "\n") - - if size == 3 and is_based_on(param_klass, "PointerLabelBeforeBank"): - # write the bank first - output += ("db " + param + "\n") - # write the pointer second - output += ("dw " + params[index+1].strip() + "\n") - index += 2 - correction += 1 - elif size == 3 and is_based_on(param_klass, "PointerLabelAfterBank"): - # write the pointer first - output += ("dw " + param + "\n") - # write the bank second - output += ("db " + params[index+1].strip() + "\n") - index += 2 - correction += 1 - elif size == 3 and "from_asm" in dir(param_klass): - output += ("\t" + byte_type + " " + param_klass.from_asm(param) + "\n") - index += 1 - else: - raise exceptions.MacroException( - "dunno what to do with this macro param ({klass}) in line: {line}" - .format( - klass=param_klass, - line=original_line, - ) - ) - - elif "from_asm" in dir(param_klass): - output += ("\t" + byte_type + " " + param_klass.from_asm(param) + " ; " + description + "\n") - index += 1 - - # or just print out the byte - else: - output += ("\t" + byte_type + " " + param + " ; " + description + "\n") + if "from_asm" in dir(param_klass): + param = param_klass.from_asm(param) - index += 1 + output += ("\t" + byte_type + " " + param + " ; " + description + "\n") sys.stdout.write(output) diff --git a/pokemontools/scan_includes.py b/pokemontools/scan_includes.py new file mode 100644 index 0000000..7f34e92 --- /dev/null +++ b/pokemontools/scan_includes.py @@ -0,0 +1,36 @@ +# coding: utf-8 + +""" +Recursively scan an asm file for rgbasm INCLUDEs and INCBINs. +Used to generate dependencies for each rgbasm object. +""" + +import os +import sys + +import configuration +conf = configuration.Config() + +def recursive_scan(filename, includes = []): + if (filename[-4:] == '.asm' or filename[-3] == '.tx') and os.path.exists(filename): + lines = open(filename).readlines() + for line in lines: + for directive in ('INCLUDE', 'INCBIN'): + if directive in line: + line = line[:line.find(';')] + if directive in line: + include = line.split('"')[1] + if include not in includes: + includes += [include] + includes = recursive_scan(os.path.join(conf.path, include), includes) + break + return includes + +if __name__ == '__main__': + filenames = sys.argv[1:] + dependencies = [] + for filename in filenames: + dependencies += recursive_scan(os.path.join(conf.path, filename)) + dependencies = list(set(dependencies)) + sys.stdout.write(' '.join(dependencies)) + diff --git a/pokemontools/sym.py b/pokemontools/sym.py index ebd8532..b1e755f 100644 --- a/pokemontools/sym.py +++ b/pokemontools/sym.py @@ -4,7 +4,7 @@ import os import sys import json -def make_sym_from_json(filename = '../pokecrystal.sym', j = 'labels.json'): +def make_sym_from_json(filename = 'pokecrystal.sym', j = 'labels.json'): output = '' labels = json.load(open(j)) for label in labels: @@ -12,13 +12,13 @@ def make_sym_from_json(filename = '../pokecrystal.sym', j = 'labels.json'): with open(filename, 'w') as sym: sym.write(output) -def make_json_from_mapfile(filename='labels.json', mapfile='../pokecrystal.map'): +def make_json_from_mapfile(filename='labels.json', mapfile='pokecrystal.map'): output = [] labels = filter_wram_addresses(read_mapfile(mapfile)) with open(filename, 'w') as out: out.write(json.dumps(labels)) -def read_mapfile(filename='../pokecrystal.map'): +def read_mapfile(filename='pokecrystal.map'): """ Scrape label addresses from an rgbds mapfile. """ @@ -29,9 +29,15 @@ def read_mapfile(filename='../pokecrystal.map'): lines = mapfile.readlines() for line in lines: - # bank # - if 'Bank #' in line: - cur_bank = int(line.lstrip('Bank #').strip(';\n').strip(' (HOME)')) + if line[0].strip(): # section type def + section_type = line.split(' ')[0] + if section_type == 'Bank': # ROM + cur_bank = int(line.split(' ')[1].split(':')[0][1:]) + elif section_type in ['WRAM0', 'HRAM']: + cur_bank = 0 + elif section_type in ['WRAM, VRAM']: + cur_bank = int(line.split(' ')[2].split(':')[0][1:]) + cur_bank = int(line.split(' ')[2].split(':')[0][1:]) # label definition elif '=' in line: @@ -39,21 +45,10 @@ def read_mapfile(filename='../pokecrystal.map'): address = int(address.lstrip().replace('$', '0x'), 16) label = label.strip() - # rgbds doesn't support ram banks yet bank = cur_bank offset = address - - ranges = [ - 0x8000 <= address < 0xa000, - 0xa000 <= address < 0xc000, - 0xc000 <= address < 0xd000, - 0xd000 <= address < 0xe000, - ] - - if any(ranges): - bank = 0 - else: - offset += (bank * 0x4000 - 0x4000) if bank > 0 else 0 + if address < 0x8000 and bank: # ROM + offset += (bank - 1) * 0x4000 labels += [{ 'label': label, diff --git a/pokemontools/vba/path.py b/pokemontools/vba/path.py new file mode 100644 index 0000000..2e50d1b --- /dev/null +++ b/pokemontools/vba/path.py @@ -0,0 +1,664 @@ +""" +path finding implementation + +1) For each position on the map, create a node representing the position. +2) For each NPC/item, mark nearby nodes as members of that NPC's threat zone + (note that they can be members of multiple zones simultaneously). +""" + +import pokemontools.configuration +config = pokemontools.configuration.Config() + +import pokemontools.crystal +import pokemontools.map_gfx + +from PIL import ( + Image, + ImageDraw, +) + +PENALTIES = { + # The minimum cost for a step must be greater than zero or else the path + # finding implementation might take the player through elaborate routes + # through nowhere. + "NONE": 1, + + # for any area that might be near a trainer or moving object + "THREAT_ZONE": 50, + + # for any nodes that might be under active observation (sight) by a trainer + "SIGHT_RANGE": 80, + + # active sight range is where the trainer will definitely see the player + "ACTIVE_SIGHT_RANGE": 100, + + # This is impossible, but the pathfinder might have a bug, and it would be + # nice to know about such a bug very soon. + "COLLISION": -999999, +} + +DIRECTIONS = { + "UP": "UP", + "DOWN": "DOWN", + "LEFT": "LEFT", + "RIGHT": "RIGHT", +} + +class Node(object): + """ + A ``Node`` represents a position on the map. + """ + + def __init__(self, position, threat_zones=None, contents=None): + self.position = position + self.y = position[0] + self.x = position[1] + + # by default a node is not a member of any threat zones + self.threat_zones = threat_zones or set() + + # by default a node does not have any objects at this location + self.contents = contents or set() + + self.cost = self.calculate_cost() + + def calculate_cost(self, PENALTIES=PENALTIES): + """ + Calculates a cost associated with passing through this node. + """ + penalty = PENALTIES["NONE"] + + # 1) assign a penalty based on whether or not this object is passable, + # if it's a collision then return a priority immediately + if self.is_collision_by_map_data() or self.is_collision_by_map_obstacle(): + penalty += PENALTIES["COLLISION"] + return penalty + + # 2) assign a penalty based on whether or not this object is grass/water + + # 3) assign a penalty based on whether or not there is a map_obstacle here, + # check each of the contents to see if there are any objects that exist + # at this location, if anything exists here then return a priority immediately + + # 4) consider any additional penalties due to the presence of a threat + # zone. Only calculate detailed penalties about the threat zone if the + # player is within range. + for threat_zone in self.threat_zones: + # the player might be inside the threat zone or the player might be + # just on the boundary + player_y = get_player_y() + player_x = get_player_x() + if threat_zone.is_player_near(player_y, player_x): + consider_sight_range = True + else: + consider_sight_range = False + + penalty += threat_zone.calculate_node_cost(self.y, self.x, consider_sight_range=consider_sight_range, PENALTIES=PENALTIES) + + return penalty + + def is_collision_by_map_data(self): + """ + Checks if the player can walk on this location. + """ + raise NotImplementedError + + def is_collision_by_map_obstacle(self): + """ + Checks if there is a map_obstacle on the current position that prevents + the player walking here. + """ + for content in self.contents: + if self.content.y == self.y and self.content.x == self.x: + return True + else: + return False + +class MapObstacle(object): + """ + A ``MapObstacle`` represents an item, npc or trainer on the map. + """ + + def __init__(self, some_map, identifier, sight_range=None, movement=None, turn=None, simulation=False, facing_direction=DIRECTIONS["DOWN"]): + """ + :param some_map: a reference to the map that this object belongs to + :param identifier: which object on the map does this correspond to? + :param simulation: set to False to not read from RAM + """ + self.simulation = simulation + + self.some_map = some_map + self.identifier = identifier + + self._sight_range = sight_range + if self._sight_range is None: + self._sight_range = self._get_sight_range() + + self._movement = movement + if self._movement is None: + self._movement = self._get_movement() + + self._turn = turn + if self._turn is None: + self._turn = self._get_turn() + + self.facing_direction = facing_direction + if not self.facing_direction: + self.facing_direction = self.get_current_facing_direction() + + self.update_location() + + def update_location(self): + """ + Determines the (y, x) location of the given map_obstacle object, which + can be a reference to an item, npc or trainer npc. + """ + if self.simulation: + return (self.y, self.x) + else: + raise NotImplementedError + + self.y = new_y + self.x = new_x + + return (new_y, new_x) + + def _get_current_facing_direction(self, DIRECTIONS=DIRECTIONS): + """ + Get the current facing direction of the map_obstacle. + """ + raise NotImplementedError + + def get_current_facing_direction(self, DIRECTIONS=DIRECTIONS): + """ + Get the current facing direction of the map_obstacle. + """ + if not self.simulation: + self.facing_direction = self._get_current_facing_direction(DIRECTIONS=DIRECTIONS) + return self.facing_direction + + def _get_movement(self): + """ + Figures out the "movement" variable. Also, this converts from the + internal game's format into True or False for whether or not the object + is capable of moving. + """ + raise NotImplementedError + + @property + def movement(self): + if self._movement is None: + self._movement = self._get_movement() + return self._movement + + def can_move(self): + """ + Checks if this map_obstacle is capable of movement. + """ + return self.movement + + def _get_turn(self): + """ + Checks whether or not the map_obstacle can turn. This only matters for + trainers. + """ + raise NotImplementedError + + @property + def turn(self): + if self._turn is None: + self._turn = self._get_turn() + return self._turn + + def can_turn_without_moving(self): + """ + Checks whether or not the map_obstacle can turn. This only matters for + trainers. + """ + return self.turn + + def _get_sight_range(self): + """ + Figure out the sight range of this map_obstacle. + """ + raise NotImplementedError + + @property + def sight_range(self): + if self._sight_range is None: + self._sight_range = self._get_sight_range() + return self._sight_range + +class ThreatZone(object): + """ + A ``ThreatZone`` represents the area surrounding a moving or turning object + that the player can try to avoid. + """ + + def __init__(self, map_obstacle, main_graph): + """ + Constructs a ``ThreatZone`` based on a graph of a map and a particular + object on that map. + + :param map_obstacle: the subject based on which to build a threat zone + :param main_graph: a reference to the map's nodes + """ + + self.map_obstacle = map_obstacle + self.main_graph = main_graph + + self.sight_range = self.calculate_sight_range() + + self.top_left_y = None + self.top_left_x = None + self.bottom_right_y = None + self.bottom_right_x = None + self.height = None + self.width = None + self.size = self.calculate_size() + + # nodes specific to this threat zone + self.nodes = [] + + def calculate_size(self): + """ + Calculate the bounds of the threat zone based on the map obstacle. + Returns the top left corner (y, x) and the bottom right corner (y, x) + in the form of ((y, x), (y, x), height, width). + """ + top_left_y = 0 + top_left_x = 0 + + bottom_right_y = 1 + bottom_right_x = 1 + + # TODO: calculate the correct bounds of the threat zone. + + raise NotImplementedError + + # if there is a sight_range for this map_obstacle then increase the size of the zone. + if self.sight_range > 0: + top_left_y += self.sight_range + top_left_x += self.sight_range + bottom_right_y += self.sight_range + bottom_right_x += self.sight_range + + top_left = (top_left_y, top_left_x) + bottom_right = (bottom_right_y, bottom_right_x) + + height = bottom_right_y - top_left_y + width = bottom_right_x - top_left_x + + self.top_left_y = top_left_y + self.top_left_x = top_left_x + self.bottom_right_y = bottom_right_y + self.bottom_right_x = bottom_right_x + self.height = height + self.width = width + + return (top_left, bottom_right, height, width) + + def is_player_near(self, y, x): + """ + Applies a boundary of one around the threat zone, then checks if the + player is inside. This is how the threatzone activates to calculate an + updated graph or set of penalties for each step. + """ + y_condition = (self.top_left_y - 1) <= y < (self.bottom_right_y + 1) + x_condition = (self.top_left_x - 1) <= x < (self.bottom_right_x + 1) + return y_condition and x_condition + + def check_map_obstacle_has_sight(self): + """ + Determines if the map object has the sight feature. + """ + return self.map_obstacle.sight_range > 0 + + def calculate_sight_range(self): + """ + Calculates the range that the object is able to see. + """ + if not self.check_map_obstacle_has_sight(): + return 0 + else: + return self.map_obstacle.sight_range + + def get_current_facing_direction(self, DIRECTIONS=DIRECTIONS): + """ + Get the current facing direction of the map_obstacle. + """ + return self.map_obstacle.get_current_facing_direction(DIRECTIONS=DIRECTIONS) + + # this isn't used anywhere yet + def is_map_obstacle_in_screen_range(self): + """ + Determines if the map_obstacle is within the bounds of whatever is on + screen at the moment. If the object is of a type that is capable of + moving, and it is not on screen, then it is not moving. + """ + raise NotImplementedError + + def mark_nodes_as_members_of_threat_zone(self): + """ + Based on the nodes in this threat zone, mark each main graph's nodes as + members of this threat zone. + """ + + for y in range(self.top_left_y, self.top_left_y + self.height): + for x in range(self.top_left_x, self.top_left_x + self.width): + main_node = self.main_graph[y][x] + main_node.threat_zones.add(self) + + self.nodes.append(main_node) + + def update_obstacle_location(self): + """ + Updates which node has the obstacle. This does not recompute the graph + based on this new information. + + Each threat zone is responsible for updating its own map objects. So + there will never be a time when the current x value attached to the + map_obstacle does not represent the actual previous location. + """ + + # find the previous location of the obstacle + old_y = self.map_obstacle.y + old_x = self.map_obstacle.x + + # remove it from the main graph + self.main_graph[old_y][old_x].contents.remove(self.map_obstacle) + + # get the latest location + self.map_obstacle.update_location() + (new_y, new_x) = (self.map_obstacle.y, self.map_obstacle.x) + + # add it back into the main graph + self.main_graph[new_y][new_x].contents.add(self.map_obstacle) + + # update the map obstacle (not necessary, but it doesn't hurt) + self.map_obstacle.y = new_y + self.map_obstacle.x = new_x + + def is_node_in_threat_zone(self, y, x): + """ + Checks if the node is in the range of the threat zone. + """ + y_condition = self.top_left_y <= y < self.top_left_y + self.height + x_condition = self.top_left_x <= x < self.top_left_x + self.width + return y_condition and x_condition + + def is_node_in_sight_range(self, y, x, skip_range_check=False): + """ + Checks if the node is in the sight range of the threat. + """ + if not skip_range_check: + if not self.is_node_in_threat_zone(y, x): + return False + + if self.sight_range == 0: + return False + + # TODO: sight range can be blocked by collidable map objects. But this + # node wouldn't be in the threat zone anyway. + y_condition = self.map_obstacle.y == y + x_condition = self.map_obstacle.x == x + + # this probably only happens if the player warps to the exact spot + if y_condition and x_condition: + raise Exception( + "Don't know the meaning of being on top of the map_obstacle." + ) + + # check if y or x matches the map object + return y_condition or x_condition + + def is_node_in_active_sight_range(self, + y, + x, + skip_sight_range_check=False, + skip_range_check=False, + DIRECTIONS=DIRECTIONS): + """ + Checks if the node has active sight range lock. + """ + + if not skip_sight_range_check: + # can't be in active sight range if not in sight range + if not self.is_in_sight_range(y, x, skip_range_check=skip_range_check): + return False + + y_condition = self.map_obstacle.y == y + x_condition = self.map_obstacle.x == x + + # this probably only happens if the player warps to the exact spot + if y_condition and x_condition: + raise Exception( + "Don't know the meaning of being on top of the map_obstacle." + ) + + current_facing_direction = self.get_current_facing_direction(DIRECTIONS=DIRECTIONS) + + if current_facing_direction not in DIRECTIONS.keys(): + raise Exception( + "Invalid direction." + ) + + if current_facing_direction in [DIRECTIONS["UP"], DIRECTIONS["DOWN"]]: + # map_obstacle is looking up/down but player doesn't match y + if not y_condition: + return False + + if current_facing_direction == DIRECTIONS["UP"]: + return y < self.map_obstacle.y + elif current_facing_direction == DIRECTIONS["DOWN"]: + return y > self.map_obstacle.y + else: + # map_obstacle is looking left/right but player doesn't match x + if not x_condition: + return False + + if current_facing_direction == DIRECTIONS["LEFT"]: + return x < self.map_obstacle.x + elif current_facing_direction == DIRECTIONS["RIGHT"]: + return x > self.map_obstacle.x + + def calculate_node_cost(self, y, x, consider_sight_range=True, PENALTIES=PENALTIES): + """ + Calculates the cost of the node w.r.t this threat zone. Turn off + consider_sight_range when not in the threat zone. + """ + penalty = 0 + + # The node is probably in the threat zone because otherwise why would + # this cost function be called? Only the nodes that are members of the + # current threat zone would have a reference to this threat zone and + # this function. + if not self.is_node_in_threat_zone(y, x): + penalty += PENALTIES["NONE"] + + # Additionally, if htis codepath is ever hit, the other node cost + # function will have already used the "NONE" penalty, so this would + # really be doubling the penalty of the node.. + raise Exception( + "Didn't expect to calculate a non-threat-zone node's cost, " + "since this is a threat zone function." + ) + else: + penalty += PENALTIES["THREAT_ZONE"] + + if consider_sight_range: + if self.is_node_in_sight_range(y, x, skip_range_check=True): + penalty += PENALTIES["SIGHT_RANGE"] + + params = { + "skip_sight_range_check": True, + "skip_range_check": True, + } + + active_sight_range = self.is_node_in_active_sight_range(y, x, **params) + + if active_sight_range: + penalty += PENALTIES["ACTIVE_SIGHT_RANGE"] + + return penalty + +def create_graph(some_map): + """ + Creates the array of nodes representing the in-game map. + """ + + map_height = some_map.height + map_width = some_map.width + map_obstacles = some_map.obstacles + + nodes = [[None] * map_width] * map_height + + # create a node representing each position on the map + for y in range(0, map_height): + for x in range(0, map_width): + position = (y, x) + + # create a node describing this position + node = Node(position=position) + + # store it on the graph + nodes[y][x] = node + + # look through all moving characters, non-moving characters, and items + for map_obstacle in map_obstacles: + # all characters must start somewhere + node = nodes[map_obstacle.y][map_obstacle.x] + + # store the map_obstacle on this node. + node.contents.add(map_obstacle) + + # only create threat zones for moving/turning entities + if map_obstacle.can_move() or map_obstacle.can_turn_without_moving(): + threat_zone = ThreatZone(map_obstacle, nodes, some_map) + threat_zone.mark_nodes_as_members_of_threat_zone() + + some_map.nodes = nodes + + return nodes + +class Map(object): + """ + The ``Map`` class provides an interface for reading the currently loaded + map. + """ + + def __init__(self, cry, parsed_map, height, width, map_group_id, map_id, config=config): + """ + :param cry: pokemon crystal emulation interface + :type cry: crystal + """ + self.config = config + self.cry = cry + + self.threat_zones = set() + self.obstacles = set() + + self.parsed_map = parsed_map + self.map_group_id = map_group_id + self.map_id = map_id + self.height = height + self.width = width + + def travel_to(self, destination_location): + """ + Does path planning and figures out the quickest way to get to the + destination. + """ + raise NotImplementedError + + @staticmethod + def from_rom(cry, address): + """ + Loads a map from bytes in ROM at the given address. + + :param cry: pokemon crystal wrapper + """ + raise NotImplementedError + + @staticmethod + def from_wram(cry): + """ + Loads a map from bytes in WRAM. + + :param cry: pokemon crystal wrapper + """ + raise NotImplementedError + + def draw_path(self, path): + """ + Draws a path on an image of the current map. The path must be an + iterable of nodes to visit in (y, x) format. + """ + palettes = pokemontools.map_gfx.read_palettes(self.config) + map_image = pokemontools.map_gfx.draw_map(self.map_group_id, self.map_id, palettes, show_sprites=True, config=self.config) + + for coordinates in path: + y = coordinates[0] + x = coordinates[1] + + some_image = Image.new("RGBA", (32, 32)) + draw = ImageDraw.Draw(some_image, "RGBA") + draw.rectangle([(0, 0), (32, 32)], fill=(0, 0, 0, 127)) + + target = [(x * 4, y * 4), ((x + 32) * 4, (y + 32) * 4)] + + map_image.paste(some_image, target, mask=some_image) + + return map_image + +class PathPlanner(object): + """ + Generic path finding implementation. + """ + + def __init__(self, some_map, initial_location, target_location): + self.some_map = some_map + self.initial_location = initial_location + self.target_location = target_location + + def plan(self): + """ + Runs the path planner and returns a list of positions making up the + path. + """ + return [(0, 0), (1, 0), (1, 1), (1, 2), (1, 3)] + +def plan_and_draw_path_on(map_group_id=1, map_id=1, initial_location=(0, 0), final_location=(2, 2), config=config): + """ + An attempt at an entry point. This hasn't been sufficiently considered yet. + """ + initial_location = (0, 0) + final_location = (2, 2) + map_group_id = 1 + map_id = 1 + + pokemontools.crystal.cachably_parse_rom() + pokemontools.map_gfx.add_pokecrystal_paths_to_configuration(config) + + # get the map based on data from the rom + parsed_map = pokemontools.crystal.map_names[map_group_id][map_id]["header_new"] + + # convert this map into a different structure + current_map = Map(cry=None, parsed_map=parsed_map, height=parsed_map.height.byte, width=parsed_map.width.byte, map_group_id=map_group_id, map_id=map_id, config=config) + + # make a graph based on the map data + nodes = create_graph(current_map) + + # make an instance of the planner implementation + planner = PathPlanner(current_map, initial_location, final_location) + + # Make that planner do its planning based on the current configuration. The + # planner should be callable in the future and still have + # previously-calculated state, like cached pre-computed routes or + # something. + path = planner.plan() + + # show the path on the map + drawn = current_map.draw_path(path) + + return drawn diff --git a/pokemontools/wram.py b/pokemontools/wram.py index e1b9212..2133444 100644 --- a/pokemontools/wram.py +++ b/pokemontools/wram.py @@ -9,6 +9,11 @@ import os NUM_OBJECTS = 0x10 OBJECT_LENGTH = 0x10 + +def rgbasm_to_py(text): + return text.replace('$', '0x').replace('%', '0b') + + def make_wram_labels(wram_sections): wram_labels = {} for section in wram_sections: @@ -18,6 +23,9 @@ def make_wram_labels(wram_sections): wram_labels[label['address']] += [label['label']] return wram_labels +def bracket_value(string, i=0): + return string.split('[')[1 + i*2].split(']')[0] + def read_bss_sections(bss): sections = [] section = { @@ -30,10 +38,40 @@ def read_bss_sections(bss): if 'SECTION' in line: if section: sections.append(section) # last section - address = eval(line[line.find('[')+1:line.find(']')].replace('$','0x')) + comment_index = line.find(';') + line, comment = line[:comment_index].lstrip(), line[comment_index:] + + if 'SECTION' == line[:7]: + if section: # previous + sections += [section] + + section_def = line.split(',') + name = section_def[0].split('"')[1] + type_ = section_def[1].strip() + if len(section_def) > 2: + bank = bracket_value(section_def[2]) + else: + bank = None + + if '[' in type_: + address = int(rgbasm_to_py(bracket_value(type_)), 16) + else: + types = { + 'VRAM': 0x8000, + 'SRAM': 0xa000, + 'WRAM0': 0xc000, + 'WRAMX': 0xd000, + 'HRAM': 0xff80, + } + if address == None or bank != section['bank'] or section['type'] != type_: + if type_ in types.keys(): + address = types[type_] + # else: keep going from this address + section = { - 'name': line.split('"')[1], - #'type': line.split(',')[1].split('[')[0].strip(), + 'name': name, + 'type': type_, + 'bank': bank, 'start': address, 'labels': [], } @@ -49,7 +87,7 @@ def read_bss_sections(bss): }] elif line[:3] == 'ds ': - length = eval(line[3:line.find(';')].replace('$','0x')) + length = eval(rgbasm_to_py(line[3:])) address += length # adjacent labels use the same space for label in section['labels'][::-1]: @@ -68,7 +106,7 @@ def read_bss_sections(bss): return sections def constants_to_dict(constants): - return dict((eval(constant[constant.find('EQU')+3:constant.find(';')].replace('$','0x')), constant[:constant.find('EQU')].strip()) for constant in constants) + return dict((eval(rgbasm_to_py(constant[constant.find('EQU')+3:constant.find(';')])), constant[:constant.find('EQU')].strip()) for constant in constants) def scrape_constants(text): if type(text) is not list: @@ -79,10 +117,10 @@ def read_constants(filepath): """ Load lines from a file and call scrape_constants. """ - lines = None - - with open(filepath, "r") as file_handler: - lines = file_handler.readlines() + lines = [] + if os.path.exists(filepath): + with open(filepath, "r") as file_handler: + lines = file_handler.readlines() constants = scrape_constants(lines) return constants @@ -99,9 +137,21 @@ class WRAMProcessor(object): self.config = config self.paths = {} - self.paths["wram"] = os.path.join(self.config.path, "wram.asm") - self.paths["hram"] = os.path.join(self.config.path, "hram.asm") - self.paths["gbhw"] = os.path.join(self.config.path, "gbhw.asm") + + if hasattr(self.config, "wram"): + self.paths["wram"] = self.config.wram + else: + self.paths["wram"] = os.path.join(self.config.path, "wram.asm") + + if hasattr(self.config, "hram"): + self.paths["hram"] = self.config.hram + else: + self.paths["hram"] = os.path.join(self.config.path, "hram.asm") + + if hasattr(self.config, "gbhw"): + self.paths["gbhw"] = self.config.gbhw + else: + self.paths["gbhw"] = os.path.join(self.config.path, "gbhw.asm") def initialize(self): """ |