diff options
30 files changed, 6545 insertions, 1729 deletions
@@ -1,3 +1,49 @@ +pokemontools +============================== + +``pokemontools`` is a python module that provides various reverse engineering +components for various Pokémon games. This includes: + +* a utility to disassemble bytes from games into asm +* map editor +* python bindings for Pokémon games running in the vba-linux emulator +* in-game graphics converter (png, lz, 2bpp) +* preprocessor that dumps out rgbds-compatible asm +* stuff that parses and dumps data from ROMs + +# installing + +To install this python library in ``site-packages``: + +``` +pip install --upgrade pokemontools +``` + +And for local development work: + +``` +python setup.py develop +``` + +And of course local installation: + +``` +python setup.py install +``` + +# testing + +Run the tests with: + +``` +nosetests-2.7 +``` + +# see also + +* [Pokémon Crystal source code](https://github.com/kanzure/pokecrystal) +* [Pokémon Red source code](https://github.com/iimarckus/pokered) + Pokémon Crystal utilities and extras ============================== diff --git a/pokemontools/__init__.py b/pokemontools/__init__.py index 293e2f2..dc4346a 100644 --- a/pokemontools/__init__.py +++ b/pokemontools/__init__.py @@ -1,3 +1,5 @@ import configuration as config import crystal import preprocessor + +__version__ = "1.6.0" diff --git a/pokemontools/audio.py b/pokemontools/audio.py new file mode 100644 index 0000000..38fd65f --- /dev/null +++ b/pokemontools/audio.py @@ -0,0 +1,390 @@ +# coding: utf-8 + +import os + +from math import ceil + +from gbz80disasm import get_global_address, get_local_address + +import crystal +from crystal import music_classes as sound_classes + +from crystal import ( + Command, + SingleByteParam, + MultiByteParam, + load_rom, +) + +rom = load_rom() +rom = bytearray(rom) + +import configuration +conf = configuration.Config() + + +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)) + +class NybbleParam: + size = 0.5 + byte_type = 'dn' + which = None + + def __init__(self, address, name): + if self.which == None: + self.which = {0.0: 'lo', 0.5: 'hi'}[address % 1] + self.address = int(address) + self.name = name + self.parse() + + 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 + +class LoNybbleParam(NybbleParam): + which = 'lo' + def to_asm(self): + return '%d' % self.nybble + +class PitchParam(HiNybbleParam): + def to_asm(self): + """E and B cant be sharp""" + if self.nybble == 0: + pitch = '__' + else: + pitch = 'CCDDEFFGGAAB'[(self.nybble - 1)] + if self.nybble in [2, 4, 7, 9, 11]: + pitch += '#' + else: + pitch += '_' + return pitch + + +class Note(Command): + macro_name = "note" + size = 1 + end = False + param_types = { + 0: {"name": "pitch", "class": PitchParam}, + 1: {"name": "duration", "class": LoNybbleParam}, + } + allowed_lengths = [2] + override_byte_check = True + is_rgbasm_macro = True + + def parse(self): + self.params = [] + byte = rom[self.address] + current_address = self.address + for (key, param_type) in self.param_types.items(): + name = param_type["name"] + class_ = param_type["class"] + + # by making an instance, obj.parse() is called + obj = class_(address=int(current_address), name=name) + self.params += [obj] + + current_address += obj.size + + self.params = dict(enumerate(self.params)) + + # obj sizes were 0.5, but were working with ints + current_address = int(ceil(current_address)) + self.size = int(ceil(self.size)) + + self.last_address = current_address + return True + + +class Noise(Note): + macro_name = "noise" + size = 0 + end = False + param_types = { + 0: {"name": "duration", "class": LoNybbleParam}, + 1: {"name": "intensity", "class": SingleByteParam}, + 2: {"name": "frequency", "class": MultiByteParam}, + } + allowed_lengths = [2,3] + override_byte_check = True + is_rgbasm_macro = False + + + +class Channel: + """A sound channel data parser.""" + + def __init__(self, address, channel=1, base_label='Sound'): + self.start_address = address + self.address = address + self.channel = channel + self.base_label = base_label + self.output = [] + self.labels = [] + self.parse() + + def parse(self): + noise = False + done = False + while not done: + cmd = rom[self.address] + + class_ = self.get_sound_class(cmd)(address=self.address, channel=self.channel) + + # notetype loses the intensity param on channel 4 + if class_.macro_name == 'notetype': + if self.channel in [4, 8]: + class_.size -= 1 + del class_.params[class_.size - 1] + + # togglenoise only has a param when toggled on + elif class_.macro_name in ['togglenoise', 'sfxtogglenoise']: + if noise: + class_.size -= 1 + del class_.params[class_.size - 1] + noise = not noise + + asm = class_.to_asm() + + # label any jumps or calls + for key, param in class_.param_types.items(): + if param['class'] == crystal.PointerLabelParam: + label_address = class_.params[key].parsed_address + label = '%s_branch_%x' % ( + self.base_label, + label_address + ) + label_output = ( + label_address, + '\n%s: ; %x' % (label, label_address), + label_address + ) + self.labels += [label_output] + 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 == 'loopchannel': + if class_.params[0].byte == 0: + done = True + + # keep going past enders if theres more to parse + if any(self.address <= address for address, asm, last_address in self.output + self.labels): + if done: + self.output += [(self.address, '; %x' % self.address, self.address)] + done = False + + # dumb safety checks + if ( + self.address >= len(rom) or + self.address / 0x4000 != self.start_address / 0x4000 + ) and not done: + done = True + raise Exception, 'reached the end of the bank without finishing!' + + def to_asm(self): + output = sort_asms(self.output + self.labels) + text = '' + for i, (address, asm, last_address) in enumerate(output): + if ':' in asm: + # dont print labels for empty chunks + for (address_, asm_, last_address_) in output[i:]: + if ':' not in asm_: + text += '\n' + asm + '\n' + break + else: + text += asm + '\n' + text += '; %x' % (last_address) + '\n' + return text + + def get_sound_class(self, i): + for class_ in sound_classes: + if class_.id == i: + return class_ + if self.channel in [4, 8]: return Noise + return Note + + +class Sound: + """Interprets a sound data header.""" + + def __init__(self, address, name=''): + self.start_address = address + self.bank = address / 0x4000 + self.address = address + + self.name = name + self.base_label = 'Sound_%x' % self.start_address + if self.name != '': + self.base_label = self.name + + self.output = [] + self.labels = [] + self.asms = [] + self.parse() + + def parse(self): + self.num_channels = (rom[self.address] >> 6) + 1 + self.channels = [] + for ch in xrange(self.num_channels): + current_channel = (rom[self.address] & 0xf) + 1 + self.address += 1 + address = rom[self.address] + rom[self.address + 1] * 0x100 + address = self.bank * 0x4000 + address % 0x4000 + self.address += 2 + channel = Channel(address, current_channel, self.base_label) + self.channels += [(current_channel, channel)] + + self.labels += channel.labels + + label_text = '\n%s_Ch%d: ; %x' % ( + self.base_label, + current_channel, + channel.start_address + ) + label_output = (channel.start_address, label_text, channel.start_address) + self.labels += [label_output] + + asms = [] + + text = '%s: ; %x' % (self.base_label, self.start_address) + '\n' + for i, (num, channel) in enumerate(self.channels): + channel_id = num - 1 + if i == 0: + channel_id += (len(self.channels) - 1) << 6 + text += '\tdbw $%.2x, %s_Ch%d' % (channel_id, self.base_label, num) + '\n' + text += '; %x\n' % self.address + asms += [(self.start_address, text, self.start_address + len(self.channels) * 3)] + + for num, channel in self.channels: + asms += channel.output + + asms = sort_asms(asms) + self.last_address = asms[-1][2] + asms += [(self.last_address,'; %x' % self.last_address, self.last_address)] + + self.asms += asms + + def to_asm(self, labels=[]): + """insert outside labels here""" + asms = self.asms + + # incbins dont really count as parsed data + incbins = [] + for i, (address, asm, last_address) in enumerate(asms): + if i + 1 < len(asms): + next_address = asms[i + 1][0] + if last_address != next_address: + incbins += [(last_address, 'INCBIN "baserom.gbc", $%x, $%x - $%x' % (last_address, next_address, last_address), next_address)] + asms += incbins + for label in self.labels + labels: + if self.start_address <= label[0] < self.last_address: + asms += [label] + + return '\n'.join(asm for address, asm, last_address in sort_asms(asms)) + + +def read_bank_address_pointer(addr): + bank, address = rom[addr], rom[addr+1] + rom[addr+2] * 0x100 + return get_global_address(address, bank) + + +def dump_sounds(origin, names, base_label='Sound_'): + """Dump sound data from a pointer table.""" + + # first pass to grab labels and boundaries + labels = [] + addresses = [] + for i, name in enumerate(names): + sound_at = read_bank_address_pointer(origin + i * 3) + sound = Sound(sound_at, base_label + name) + labels += sound.labels + addresses += [(sound.start_address, sound.last_address)] + addresses = sorted(addresses) + + outputs = [] + for i, name in enumerate(names): + 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): + next_address = addresses[index + 1][0] + if 5 > next_address - sound.last_address > 0: + if next_address / 0x4000 == sound.last_address / 0x4000: + output += '\nINCBIN "baserom.gbc", $%x, $%x - $%x\n' % (sound.last_address, next_address, sound.last_address) + + filename = name.lower() + '.asm' + outputs += [(filename, output)] + return outputs + + +def export_sounds(origin, names, path, base_label='Sound_'): + for filename, output in dump_sounds(origin, names, base_label): + with open(os.path.join(path, filename), 'w') as out: + out.write(output) + + +def dump_sound_clump(origin, names, base_label='Sound_'): + """some sounds are grouped together and/or share most components. + these can't reasonably be split into files for each sound.""" + + output = [] + for i, name in enumerate(names): + sound_at = read_bank_address_pointer(origin + i * 3) + sound = Sound(sound_at, base_label + name) + output += sound.asms + sound.labels + output = sort_asms(output) + return output + + +def export_sound_clump(origin, names, path, base_label='Sound_'): + output = dump_sound_clump(origin, names, base_label) + with open(path, 'w') as out: + out.write('\n'.join(asm for address, asm, last_address in output)) + + +def dump_crystal_music(): + from song_names import song_names + export_sounds(0xe906e, song_names, os.path.join(conf.path, 'audio', 'music'), 'Music_') + +def generate_crystal_music_pointers(): + from song_names import song_names + return '\n'.join('\tdbw BANK({0}), {0}'.format('Music_' + label) for label in song_names) + +def dump_crystal_sfx(): + from sfx_names import sfx_names + export_sound_clump(0xe927c, sfx_names, os.path.join(conf.path, 'audio', 'sfx.asm'), 'Sfx_') + +def generate_crystal_sfx_pointers(): + from sfx_names import sfx_names + return '\n'.join('\tdbw BANK({0}), {0}'.format('Sfx_' + label) for label in sfx_names) + +def dump_crystal_cries(): + from cry_names import cry_names + export_sound_clump(0xe91b0, cry_names, os.path.join(conf.path, 'audio', 'cries.asm'), 'Cry_') + +def generate_crystal_cry_pointers(): + from cry_names import cry_names + return '\n'.join('\tdbw BANK({0}), {0}'.format('Cry_' + label) for label in cry_names) + + +if __name__ == '__main__': + dump_crystal_music() + dump_crystal_sfx() + diff --git a/pokemontools/configuration.py b/pokemontools/configuration.py index cbf230c..1592fe6 100644 --- a/pokemontools/configuration.py +++ b/pokemontools/configuration.py @@ -4,7 +4,10 @@ Configuration import os -import exceptions +class ConfigException(Exception): + """ + Configuration error. Maybe a missing config variable. + """ class Config(object): """ @@ -23,7 +26,7 @@ class Config(object): if key not in self.__dict__: self._config[key] = value else: - raise exceptions.ConfigException( + raise ConfigException( "Can't store \"{0}\" in configuration because the key conflicts with an existing property." .format(key) ) @@ -49,6 +52,6 @@ class Config(object): elif key in self._config: return self._config[key] else: - raise exceptions.ConfigException( + raise ConfigException( "no config found for \"{0}\"".format(key) ) diff --git a/pokemontools/cry_names.py b/pokemontools/cry_names.py new file mode 100644 index 0000000..af08fe1 --- /dev/null +++ b/pokemontools/cry_names.py @@ -0,0 +1,4 @@ +# coding: utf-8 + +cry_names = ['%.2X' % x for x in xrange(0x44)] + diff --git a/pokemontools/crystal.py b/pokemontools/crystal.py index 5d602c9..cdab01f 100644 --- a/pokemontools/crystal.py +++ b/pokemontools/crystal.py @@ -174,7 +174,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, rom=rom) data = helpers.grouper(data) for pointer_parts in data: pointer = pointer_parts[0] + (pointer_parts[1] << 8) @@ -548,7 +548,7 @@ def parse_text_from_bytes(bytes, debug=True, japanese=False): def parse_text_at(address, count=10, debug=True): """returns a list of bytes from an address see parse_text_at2 for pretty printing""" - return parse_text_from_bytes(rom_interval(address, count, strings=False), debug=debug) + return parse_text_from_bytes(rom.interval(address, count, strings=False), debug=debug) def parse_text_at2(address, count=10, debug=True, japanese=False): """returns a string of text from an address @@ -569,7 +569,7 @@ def parse_text_at3(address, map_group=None, map_id=None, debug=False): def rom_text_at(address, count=10): """prints out raw text from the ROM like for 0x112110""" - return "".join([chr(x) for x in rom_interval(address, count, strings=False)]) + return "".join([chr(x) for x in rom.interval(address, count, strings=False)]) def get_map_constant_label(map_group=None, map_id=None, map_internal_ids=None): """returns PALLET_TOWN for some map group/id pair""" @@ -764,6 +764,10 @@ class SingleByteParam(): else: return str(self.byte) + @staticmethod + def from_asm(value): + return value + class DollarSignByte(SingleByteParam): def to_asm(self): return hex(self.byte).replace("0x", "$") @@ -803,7 +807,7 @@ class MultiByteParam(): self.parse() def parse(self): - self.bytes = rom_interval(self.address, self.size, strings=False) + self.bytes = rom.interval(self.address, self.size, strings=False) self.parsed_number = self.bytes[0] + (self.bytes[1] << 8) if hasattr(self, "bank"): self.parsed_address = calculate_pointer_from_bytes_at(self.address, bank=self.bank) @@ -822,6 +826,10 @@ class MultiByteParam(): decimal = int("0x"+"".join([("%.2x")%x for x in reversed(self.bytes)]), 16) return str(decimal) + @staticmethod + def from_asm(value): + return value + class PointerLabelParam(MultiByteParam): # default size is 2 bytes @@ -1000,6 +1008,7 @@ class RAMAddressParam(MultiByteParam): class MoneyByteParam(MultiByteParam): size = 3 + byte_type = "db" max_value = 0x0F423F should_be_decimal = True def parse(self): @@ -1236,6 +1245,7 @@ class Command: # use this when the "byte id" doesn't matter # .. for example, a non-script command doesn't use the "byte id" override_byte_check = False + is_rgbasm_macro = False base_label = "UnseenLabel_" def __init__(self, address=None, *pargs, **kwargs): @@ -1723,6 +1733,7 @@ class TextCommand(Command): #def get_dependencies(self, recompute=False, global_dependencies=set()): # return [] + # this is a regular command in a TextScript for writing text # but unlike other macros that preprocessor.py handles, # the preprocessor-parser is custom and MainText is not @@ -1769,7 +1780,7 @@ class MainText(TextCommand): # read the text bytes into a structure # skip the first offset byte because that's the command byte - self.bytes = rom_interval(offset, jump, strings=False) + self.bytes = rom.interval(offset, jump, strings=False) # include the original command in the size calculation self.size = jump @@ -2372,41 +2383,70 @@ def create_command_classes(debug=False): command_classes = create_command_classes() +class BigEndianParam: + """big-endian word""" + size = 2 + should_be_decimal = False + byte_type = "bigdw" + + def __init__(self, *args, **kwargs): + self.prefix = '$' + for (key, value) in kwargs.items(): + setattr(self, key, value) + self.parse() + + def parse(self): + self.bytes = rom.interval(self.address, 2, strings=False) + self.parsed_number = self.bytes[0] * 0x100 + self.bytes[1] + + def to_asm(self): + if not self.should_be_decimal: + return self.prefix+"".join([("%.2x")%x for x in self.bytes]) + elif self.should_be_decimal: + decimal = int("0x"+"".join([("%.2x")%x for x in self.bytes]), 16) + return str(decimal) + + @staticmethod + def from_asm(value): + return value + +class DecimalBigEndianParam(BigEndianParam): + should_be_decimal = True -music_commands_new = { - 0xD0: ["octave8"], - 0xD1: ["octave7"], - 0xD2: ["octave6"], - 0xD3: ["octave5"], - 0xD4: ["octave4"], - 0xD5: ["octave3"], - 0xD6: ["octave2"], - 0xD7: ["octave1"], - 0xD8: ["notetype", ["note_length", SingleByteParam], ["intensity", SingleByteParam]], # only 1 param on ch3 +music_commands = { + 0xD0: ["octave 8"], + 0xD1: ["octave 7"], + 0xD2: ["octave 6"], + 0xD3: ["octave 5"], + 0xD4: ["octave 4"], + 0xD5: ["octave 3"], + 0xD6: ["octave 2"], + 0xD7: ["octave 1"], + 0xD8: ["notetype", ["note_length", SingleByteParam], ["intensity", SingleByteParam]], # no intensity on channel 4/8 0xD9: ["forceoctave", ["octave", SingleByteParam]], - 0xDA: ["tempo", ["tempo", MultiByteParam]], + 0xDA: ["tempo", ["tempo", DecimalBigEndianParam]], 0xDB: ["dutycycle", ["duty_cycle", SingleByteParam]], 0xDC: ["intensity", ["intensity", SingleByteParam]], 0xDD: ["soundinput", ["input", SingleByteParam]], - 0xDE: ["unknownmusic0xde", ["unknown", SingleByteParam]], # also updates duty cycle + 0xDE: ["unknownmusic0xde", ["unknown", SingleByteParam]], 0xDF: ["unknownmusic0xdf"], 0xE0: ["unknownmusic0xe0", ["unknown", SingleByteParam], ["unknown", SingleByteParam]], 0xE1: ["vibrato", ["delay", SingleByteParam], ["extent", SingleByteParam]], 0xE2: ["unknownmusic0xe2", ["unknown", SingleByteParam]], - 0xE3: ["togglenoise", ["id", SingleByteParam]], # this can have 0-1 params! + 0xE3: ["togglenoise", ["id", SingleByteParam]], # no parameters on toggle off 0xE4: ["panning", ["tracks", SingleByteParam]], 0xE5: ["volume", ["volume", SingleByteParam]], - 0xE6: ["tone", ["tone", MultiByteParam]], # big endian + 0xE6: ["tone", ["tone", BigEndianParam]], 0xE7: ["unknownmusic0xe7", ["unknown", SingleByteParam]], 0xE8: ["unknownmusic0xe8", ["unknown", SingleByteParam]], - 0xE9: ["globaltempo", ["value", MultiByteParam]], + 0xE9: ["globaltempo", ["value", DecimalBigEndianParam]], 0xEA: ["restartchannel", ["address", PointerLabelParam]], - 0xEB: ["newsong", ["id", MultiByteParam]], + 0xEB: ["newsong", ["id", DecimalBigEndianParam]], 0xEC: ["sfxpriorityon"], 0xED: ["sfxpriorityoff"], 0xEE: ["unknownmusic0xee", ["address", PointerLabelParam]], 0xEF: ["stereopanning", ["tracks", SingleByteParam]], - 0xF0: ["sfxtogglenoise", ["id", SingleByteParam]], # 0-1 params + 0xF0: ["sfxtogglenoise", ["id", SingleByteParam]], # no parameters on toggle off 0xF1: ["music0xf1"], # nothing 0xF2: ["music0xf2"], # nothing 0xF3: ["music0xf3"], # nothing @@ -2419,19 +2459,29 @@ music_commands_new = { 0xFA: ["setcondition", ["condition", SingleByteParam]], 0xFB: ["jumpif", ["condition", SingleByteParam], ["address", PointerLabelParam]], 0xFC: ["jumpchannel", ["address", PointerLabelParam]], - 0xFD: ["loopchannel", ["count", SingleByteParam], ["address", PointerLabelParam]], + 0xFD: ["loopchannel", ["count", DecimalParam], ["address", PointerLabelParam]], 0xFE: ["callchannel", ["address", PointerLabelParam]], 0xFF: ["endchannel"], } -music_command_enders = [0xEA, 0xEB, 0xEE, 0xFC, 0xFF,] -# special case for 0xFD (if loopchannel.count = 0, break) +music_command_enders = [ + "restartchannel", + "newsong", + "unknownmusic0xee", + "jumpchannel", + "endchannel", +] def create_music_command_classes(debug=False): klasses = [] - for (byte, cmd) in music_commands_new.items(): + for (byte, cmd) in music_commands.items(): cmd_name = cmd[0].replace(" ", "_") - params = {"id": byte, "size": 1, "end": byte in music_command_enders, "macro_name": cmd_name} + params = { + "id": byte, + "size": 1, + "end": cmd[0] in music_command_enders, + "macro_name": cmd[0] + } params["param_types"] = {} if len(cmd) > 1: param_types = cmd[1:] @@ -2451,24 +2501,54 @@ def create_music_command_classes(debug=False): klasses.append(klass) # later an individual klass will be instantiated to handle something return klasses + music_classes = create_music_command_classes() +class OctaveParam(DecimalParam): + @staticmethod + def from_asm(value): + value = int(value) + return hex(0xd8 - value).replace("0x", "$") + +class OctaveCommand(Command): + macro_name = "octave" + size = 0 + end = False + param_types = { + 0: {"name": "octave", "class": OctaveParam}, + } + allowed_lengths = [1] + override_byte_check = True + +class ChannelCommand(Command): + macro_name = "channel" + size = 3 + override_byte_check = True + param_types = { + 0: {"name": "id", "class": DecimalParam}, + 1: {"name": "address", "class": PointerLabelParam}, + } + + +# pokered + class callchannel(Command): - id = 0xFD - macro_name = "callchannel" - size = 3 - param_types = { - 0: {"name": "address", "class": PointerLabelParam}, - } + id = 0xFD + macro_name = "callchannel" + size = 3 + param_types = { + 0: {"name": "address", "class": PointerLabelParam}, + } class loopchannel(Command): - id = 0xFE - macro_name = "loopchannel" - size = 4 - param_types = { - 0: {"name": "count", "class": SingleByteParam}, - 1: {"name": "address", "class": PointerLabelParam}, - } + id = 0xFE + macro_name = "loopchannel" + size = 4 + param_types = { + 0: {"name": "count", "class": SingleByteParam}, + 1: {"name": "address", "class": PointerLabelParam}, + } + effect_commands = { 0x1: ['checkturn'], @@ -4272,7 +4352,7 @@ class Signpost(Command): address = self.address bank = self.bank self.last_address = self.address + self.size - bytes = rom_interval(self.address, self.size) #, signpost_byte_size) + bytes = rom.interval(self.address, self.size) #, signpost_byte_size) self.y = int(bytes[0], 16) self.x = int(bytes[1], 16) @@ -4526,7 +4606,7 @@ class SecondMapHeader: def parse(self): address = self.address - bytes = rom_interval(address, second_map_header_byte_size, strings=False) + bytes = rom.interval(address, second_map_header_byte_size, strings=False) size = second_map_header_byte_size # for later @@ -5339,7 +5419,7 @@ class MapBlockData: os.mkdir(self.maps_path) if not os.path.exists(map_path): # dump to file - #bytes = rom_interval(self.address, self.width.byte*self.height.byte, strings=True) + #bytes = rom.interval(self.address, self.width.byte*self.height.byte, strings=True) bytes = rom[self.address : self.address + self.width.byte*self.height.byte] file_handler = open(map_path, "w") file_handler.write(bytes) @@ -5407,7 +5487,7 @@ class MapEventHeader: # signposts signpost_count = ord(rom[after_triggers]) signpost_byte_count = signpost_byte_size * signpost_count - # signposts = rom_interval(after_triggers+1, signpost_byte_count) + # signposts = rom.interval(after_triggers+1, signpost_byte_count) signposts = parse_signposts(after_triggers+1, signpost_count, bank=bank, map_group=map_group, map_id=map_id, debug=debug) after_signposts = after_triggers + 1 + signpost_byte_count self.signpost_count = signpost_count @@ -5416,7 +5496,7 @@ class MapEventHeader: # people events people_event_count = ord(rom[after_signposts]) people_event_byte_count = people_event_byte_size * people_event_count - # people_events_bytes = rom_interval(after_signposts+1, people_event_byte_count) + # people_events_bytes = rom.interval(after_signposts+1, people_event_byte_count) # people_events = parse_people_event_bytes(people_events_bytes, address=after_signposts+1, map_group=map_group, map_id=map_id) people_events = parse_people_events(after_signposts+1, people_event_count, bank=pointers.calculate_bank(after_signposts+2), map_group=map_group, map_id=map_id, debug=debug) self.people_event_count = people_event_count @@ -5577,7 +5657,7 @@ class MapScriptHeader: self.trigger_count = ord(rom[address]) self.triggers = [] ptr_line_size = 4 - groups = helpers.grouper(rom_interval(address+1, self.trigger_count * ptr_line_size, strings=False), count=ptr_line_size) + groups = helpers.grouper(rom.interval(address+1, self.trigger_count * ptr_line_size, strings=False), count=ptr_line_size) current_address = address+1 for (index, trigger_bytes) in enumerate(groups): logging.debug( diff --git a/pokemontools/crystalparts/old_parsers.py b/pokemontools/crystalparts/old_parsers.py index e07082d..2c1d2b2 100644 --- a/pokemontools/crystalparts/old_parsers.py +++ b/pokemontools/crystalparts/old_parsers.py @@ -2,7 +2,7 @@ Some old methods rescued from crystal.py """ -import pointers +import pokemontools.pointers as pointers map_header_byte_size = 9 diff --git a/pokemontools/data/__init__.py b/pokemontools/data/__init__.py new file mode 100644 index 0000000..fcc59e9 --- /dev/null +++ b/pokemontools/data/__init__.py @@ -0,0 +1,15 @@ +""" +Access to data files. +""" + +# hide the os import +import os as _os + +# path to where these files are located +path = _os.path.abspath(_os.path.dirname(__file__)) + +def join(filename, path=path): + """ + Construct the absolute path to the file. + """ + return _os.path.join(path, filename) diff --git a/pokemontools/data/pokecrystal/wram.asm b/pokemontools/data/pokecrystal/wram.asm new file mode 100644 index 0000000..3796969 --- /dev/null +++ b/pokemontools/data/pokecrystal/wram.asm @@ -0,0 +1,2293 @@ +SECTION "tiles0",VRAM[$8000],BANK[0] +VTiles0: +SECTION "tiles1",VRAM[$8800],BANK[0] +VTiles1: +SECTION "tiles2",VRAM[$9000],BANK[0] +VTiles2: +SECTION "bgmap0",VRAM[$9800],BANK[0] +VBGMap0: +SECTION "bgmap1",VRAM[$9C00],BANK[0] +VBGMap1: + + + +SECTION "WRAMBank0",WRAM0[$c000] + +SECTION "stack",WRAM0[$c0ff] +Stack: ; c0ff + ds -$100 + + +SECTION "audio",WRAM0[$c100] +MusicPlaying: ; c100 +; nonzero if playing + ds 1 + +Channels: +Channel1: +Channel1MusicID: ; c101 + ds 2 +Channel1MusicBank: ; c103 + ds 1 +Channel1Flags: ; c104 +; 0: on/off +; 1: subroutine +; 2: +; 3: +; 4: noise sampling on/off +; 5: +; 6: +; 7: + ds 1 +Channel1Flags2: ; c105 +; 0: vibrato on/off +; 1: +; 2: duty cycle on/off +; 3: +; 4: +; 5: +; 6: +; 7: + ds 1 +Channel1Flags3: ; c106 +; 0: vibrato up/down +; 1: +; 2: +; 3: +; 4: +; 5: +; 6: +; 7: + ds 1 +Channel1MusicAddress: ; c107 + ds 2 +Channel1LastMusicAddress: ; c109 + ds 2 +; could have been meant as a third-level address + ds 2 +Channel1NoteFlags: ; c10d +; 0: +; 1: +; 2: +; 3: +; 4: +; 5: rest +; 6: +; 7: + ds 1 +Channel1Condition: ; c10e +; used for conditional jumps + ds 1 +Channel1DutyCycle: ; c10f +; uses top 2 bits only +; 0: 12.5% +; 1: 25% +; 2: 50% +; 3: 75% + ds 1 +Channel1Intensity: ; c110 +; hi: pressure +; lo: velocity + ds 1 +Channel1Frequency: +; 11 bits +Channel1FrequencyLo: ; c111 + ds 1 +Channel1FrequencyHi: ; c112 + ds 1 +Channel1Pitch: ; c113 +; 0: rest +; 1: C +; 2: C# +; 3: D +; 4: D# +; 5: E +; 6: F +; 7: F# +; 8: G +; 9: G# +; a: A +; b: A# +; c: B + ds 1 +Channel1Octave: ; c114 +; 0: highest +; 7: lowest + ds 1 +Channel1StartingOctave: ; c115 +; raises existing octaves by this value +; used for repeating phrases in a higher octave to save space + ds 1 +Channel1NoteDuration: ; c116 +; number of frames remaining in the current note + ds 1 +; c117 + ds 1 +; c118 + ds 1 +Channel1LoopCount: ; c119 + ds 1 +Channel1Tempo: ; c11a + ds 2 +Channel1Tracks: ; c11c +; hi: l +; lo: r + ds 1 +; c11d + ds 1 + +Channel1VibratoDelayCount: ; c11e +; initialized at the value in VibratoDelay +; decrements each frame +; at 0, vibrato starts + ds 1 +Channel1VibratoDelay: ; c11f +; number of frames a note plays until vibrato starts + ds 1 +Channel1VibratoExtent: ; c120 +; difference in + ds 1 +Channel1VibratoRate: ; c121 +; counts down from a max of 15 frames +; over which the pitch is alternated +; hi: init frames +; lo: frame count + ds 1 + +; c122 + ds 1 +; c123 + ds 1 +; c124 + ds 1 +; c125 + ds 1 +; c126 + ds 1 +; c127 + ds 1 +Channel1CryPitch: ; c128 + ds 1 +Channel1CryEcho: ; c129 + ds 1 + ds 4 +Channel1NoteLength: ; c12e +; # frames per 16th note + ds 1 +; c12f + ds 1 +; c130 + ds 1 +; c131 + ds 1 +; c132 + ds 1 +; end + +Channel2: ; c133 + ds 50 +Channel3: ; c165 + ds 50 +Channel4: ; c197 + ds 50 + +SFXChannels: +Channel5: ; c1c9 + ds 50 +Channel6: ; c1fb + ds 50 +Channel7: ; c22d + ds 50 +Channel8: ; c25f + ds 50 + +; c291 + ds 1 +; c292 + ds 1 +; c293 + ds 1 +; c294 + ds 1 +; c295 + ds 1 +; c296 + ds 1 +; c297 + ds 1 + +CurMusicByte: ; c298 + ds 1 +CurChannel: ; c299 + ds 1 +Volume: ; c29a +; corresponds to $ff24 +; Channel control / ON-OFF / Volume (R/W) +; bit 7 - Vin->SO2 ON/OFF +; bit 6-4 - SO2 output level (volume) (# 0-7) +; bit 3 - Vin->SO1 ON/OFF +; bit 2-0 - SO1 output level (volume) (# 0-7) + ds 1 +SoundOutput: ; c29b +; corresponds to $ff25 +; bit 4-7: ch1-4 so2 on/off +; bit 0-3: ch1-4 so1 on/off + ds 1 +SoundInput: ; c29c +; corresponds to $ff26 +; bit 7: global on/off +; bit 0: ch1 on/off +; bit 1: ch2 on/off +; bit 2: ch3 on/off +; bit 3: ch4 on/off + ds 1 + +MusicID: +MusicIDLo: ; c29d + ds 1 +MusicIDHi: ; c29e + ds 1 +MusicBank: ; c29f + ds 1 +NoiseSampleAddress: +NoiseSampleAddressLo: ; c2a0 + ds 1 +NoiseSampleAddressHi: ; c2a1 + ds 1 +; noise delay? ; c2a2 + ds 1 +; c2a3 + ds 1 +MusicNoiseSampleSet: ; c2a4 + ds 1 +SFXNoiseSampleSet: ; c2a5 + ds 1 +Danger: ; c2a6 +; bit 7: on/off +; bit 4: pitch +; bit 0-3: counter + ds 1 +MusicFade: ; c2a7 +; fades volume over x frames +; bit 7: fade in/out +; bit 0-5: number of frames for each volume level +; $00 = none (default) + ds 1 +MusicFadeCount: ; c2a8 + ds 1 +MusicFadeID: +MusicFadeIDLo: ; c2a9 + ds 1 +MusicFadeIDHi: ; c2aa + ds 1 + ds 5 +CryPitch: ; c2b0 + ds 1 +CryEcho: ; c2b1 + ds 1 +CryLength: ; c2b2 + ds 2 +LastVolume: ; c2b4 + ds 1 + ds 1 +SFXPriority: ; c2b6 +; if nonzero, turn off music when playing sfx + ds 1 + ds 6 +CryTracks: ; c2bd +; plays only in left or right track depending on what side the monster is on +; both tracks active outside of battle + ds 1 + ds 1 +CurSFX: ; c2bf +; id of sfx currently playing + ds 1 +CurMusic: ; c2c0 +; id of music currently playing + ds 1 + +SECTION "auto",WRAM0[$c2c7] +InputType: ; c2c7 + ds 1 +AutoInputAddress: ; c2c8 + ds 2 +AutoInputBank: ; c2ca + ds 1 +AutoInputLength: ; c2cb + ds 1 + +SECTION "linkbattle",WRAM0[$c2dc] +InLinkBattle: ; c2dc +; 0 not in link battle +; 1 link battle +; 4 mobile battle + ds 1 + +SECTION "scriptengine",WRAM0[$c2dd] +ScriptVar: ; c2dd + ds 1 + + +SECTION "tiles",WRAM0[$c2fa] +TileDown: ; c2fa + ds 1 +TileUp: ; c2fb + ds 1 +TileLeft: ; c2fc + ds 1 +TileRight: ; c2fd + ds 1 + +TilePermissions: ; c2fe +; set if tile behavior prevents +; you from walking in that direction +; bit 3: down +; bit 2: up +; bit 1: left +; bit 0: right + ds 1 + +SECTION "icons",WRAM0[$c3b6] + +CurIcon: ; c3b6 + ds 1 + +SECTION "gfx",WRAM0[$c400] + +Sprites: ; c400 +; 4 bytes per sprite +; 40 sprites +; struct: +; y in pixels +; x in pixels +; tile id +; attributes: +; bit 7: priority +; bit 6: y flip +; bit 5: x flip +; bit 4: pal # (non-cgb) +; bit 3: vram bank (cgb only) +; bit 2-0: pal # (cgb only) + ds 160 +SpritesEnd: + +TileMap: ; c4a0 +; 20x18 grid of 8x8 tiles + ds 360 + + +SECTION "BattleMons",WRAM0[$c608] + +EnemyMoveStruct: +EnemyMoveAnimation: ; c608 + ds 1 +EnemyMoveEffect: ; c609 + ds 1 +EnemyMovePower: ; c60a + ds 1 +EnemyMoveType: ; c60b + ds 1 +EnemyMoveAccuracy: ; c60c + ds 1 +EnemyMovePP: ; c60d + ds 1 +EnemyMoveEffectChance: ; c60e + ds 1 + +PlayerMoveStruct: +PlayerMoveAnimation: ; c60f + ds 1 +PlayerMoveEffect: ; c610 + ds 1 +PlayerMovePower: ; c611 + ds 1 +PlayerMoveType: ; c612 + ds 1 +PlayerMoveAccuracy: ; c613 + ds 1 +PlayerMovePP: ; c614 + ds 1 +PlayerMoveEffectChance: ; c615 + ds 1 + +EnemyMonNick: ; c616 + ds 11 +BattleMonNick: ; c621 + ds 11 + + +BattleMonSpecies: ; c62c + ds 1 +BattleMonItem: ; c62d + ds 1 + +BattleMonMoves: +BattleMonMove1: ; c62e + ds 1 +BattleMonMove2: ; c62f + ds 1 +BattleMonMove3: ; c630 + ds 1 +BattleMonMove4: ; c631 + ds 1 + +BattleMonDVs: +BattleMonAtkDefDV: ; c632 + ds 1 +BattleMonSpdSpclDV: ; c633 + ds 1 + +BattleMonPP: +BattleMonPPMove1: ; c634 + ds 1 +BattleMonPPMove2: ; c635 + ds 1 +BattleMonPPMove3: ; c636 + ds 1 +BattleMonPPMove4: ; c637 + ds 1 + +BattleMonHappiness: ; c638 + ds 1 +BattleMonLevel: ; c639 + ds 1 + +BattleMonStatus: ; c63a + ds 2 + +BattleMonHP: ; c63c + ds 2 +BattleMonMaxHP: ; c63e + ds 2 + +BattleMonAtk: ; c640 + ds 2 +BattleMonDef: ; c642 + ds 2 +BattleMonSpd: ; c644 + ds 2 +BattleMonSpclAtk: ; c646 + ds 2 +BattleMonSpclDef: ; c648 + ds 2 + +BattleMonType1: ; c64a + ds 1 +BattleMonType2: ; c64b + ds 1 + + ds 10 + +OTName: ; c656 + ds 13 + +CurOTMon: ; c663 + ds 1 + + ds 1 + +TypeModifier: ; c665 +; >10: super-effective +; 10: normal +; <10: not very effective + +; bit 7: stab + ds 1 + +CriticalHit: ; c666 +; nonzero for a critical hit + ds 1 + +AttackMissed: ; c667 +; nonzero for a miss + ds 1 + +PlayerSubStatus1: ; c668 +; bit +; 7 attract +; 6 encore +; 5 endure +; 4 perish song +; 3 identified +; 2 protect +; 1 curse +; 0 nightmare + ds 1 +PlayerSubStatus2: ; c669 +; bit +; 7 +; 6 +; 5 +; 4 +; 3 +; 2 +; 1 +; 0 curled + ds 1 +PlayerSubStatus3: ; c66a +; bit +; 7 confused +; 6 flying +; 5 underground +; 4 charged +; 3 flinch +; 2 +; 1 rollout +; 0 bide + ds 1 +PlayerSubStatus4: ; c66b +; bit +; 7 leech seed +; 6 rage +; 5 recharge +; 4 substitute +; 3 +; 2 focus energy +; 1 mist +; 0 bide: unleashed energy + ds 1 +PlayerSubStatus5: ; c66c +; bit +; 7 cant run +; 6 destiny bond +; 5 lock-on +; 4 +; 3 +; 2 +; 1 +; 0 toxic + ds 1 + +EnemySubStatus1: ; c66d +; see PlayerSubStatus1 + ds 1 +EnemySubStatus2: ; c66e +; see PlayerSubStatus2 + ds 1 +EnemySubStatus3: ; c66f +; see PlayerSubStatus3 + ds 1 +EnemySubStatus4: ; c670 +; see PlayerSubStatus4 + ds 1 +EnemySubStatus5: ; c671 +; see PlayerSubStatus5 + ds 1 + +PlayerRolloutCount: ; c672 + ds 1 +PlayerConfuseCount: ; c673 + ds 1 + ds 1 +PlayerDisableCount: ; c675 + ds 1 +PlayerEncoreCount: ; c676 + ds 1 +PlayerPerishCount: ; c677 + ds 1 +PlayerFuryCutterCount: ; c678 + ds 1 +PlayerProtectCount: ; c679 + ds 1 + +EnemyRolloutCount: ; c67a + ds 1 +EnemyConfuseCount: ; c67b + ds 1 + ds 1 +EnemyDisableCount: ; c67d + ds 1 +EnemyEncoreCount: ; c67e + ds 1 +EnemyPerishCount: ; c67f + ds 1 +EnemyFuryCutterCount: ; c680 + ds 1 +EnemyProtectCount: ; c681 + ds 1 + +PlayerDamageTaken: ; c682 + ds 2 +EnemyDamageTaken: ; c684 + ds 2 + + ds 3 + + ds 1 + +BattleScriptBuffer: ; c68a + ds 40 + +BattleScriptBufferLoc: ; c6b2 + ds 2 + + ds 2 + +PlayerStats: ; c6b6 + ds 10 + ds 1 +EnemyStats: ; c6c1 + ds 10 + ds 1 + +PlayerStatLevels: ; c6cc +; 07 neutral +PlayerAtkLevel: ; c6cc + ds 1 +PlayerDefLevel: ; c6cd + ds 1 +PlayerSpdLevel: ; c6ce + ds 1 +PlayerSAtkLevel: ; c6cf + ds 1 +PlayerSDefLevel: ; c6d0 + ds 1 +PlayerAccLevel: ; c6d1 + ds 1 +PlayerEvaLevel: ; c6d2 + ds 1 +; c6d3 + ds 1 +PlayerStatLevelsEnd: + +EnemyStatLevels: ; c6d4 +; 07 neutral +EnemyAtkLevel: ; c6d4 + ds 1 +EnemyDefLevel: ; c6d5 + ds 1 +EnemySpdLevel: ; c6d6 + ds 1 +EnemySAtkLevel: ; c6d7 + ds 1 +EnemySDefLevel: ; c6d8 + ds 1 +EnemyAccLevel: ; c6d9 + ds 1 +EnemyEvaLevel: ; c6da + ds 1 +; c6db + ds 1 + +EnemyTurnsTaken: ; c6dc + ds 1 +PlayerTurnsTaken: ; c6dd + ds 1 + + ds 5 + +CurPlayerMove: ; c6e3 + ds 1 +CurEnemyMove: ; c6e4 + ds 1 + +LinkBattleRNCount: ; c6e5 +; how far through the prng stream + ds 1 + + ds 3 + +CurEnemyMoveNum: ; c6e9 + ds 1 + + ds 10 + +AlreadyDisobeyed: ; c6f4 + ds 1 + +DisabledMove: ; c6f5 + ds 1 +EnemyDisabledMove: ; c6f6 + ds 1 + ds 1 + +; exists so you can't counter on switch +LastEnemyCounterMove: ; c6f8 + ds 1 +LastPlayerCounterMove: ; c6f9 + ds 1 + + ds 1 + +AlreadyFailed: ; c6fb + ds 1 + + ds 3 + +PlayerScreens: ; c6ff +; bit +; 4 reflect +; 3 light screen +; 2 safeguard +; 0 spikes + ds 1 + +EnemyScreens: ; c700 +; see PlayerScreens + ds 1 + + ds 1 + +PlayerLightScreenCount: ; c702 + ds 1 +PlayerReflectCount: ; c703 + ds 1 + + ds 2 + +EnemyLightScreenCount: ; c706 + ds 1 +EnemyReflectCount: ; c707 + ds 1 + + ds 2 + +Weather: ; c70a +; 00 normal +; 01 rain +; 02 sun +; 03 sandstorm +; 04 rain stopped +; 05 sunliight faded +; 06 sandstorm subsided + ds 1 + +WeatherCount: ; c70b +; # turns remaining + ds 1 + +LoweredStat: ; c70c + ds 1 +EffectFailed: ; c70d + ds 1 +FailedMessage: ; c70e + ds 1 + + ds 3 + +PlayerUsedMoves: ; c712 +; add a move that has been used once by the player +; added in order of use + ds 4 + + ds 5 + +LastPlayerMove: ; c71b + ds 1 +LastEnemyMove: ; c71c + ds 1 + + +SECTION "battle",WRAM0[$c734] +BattleEnded: ; c734 + ds 1 + + +SECTION "overworldmap",WRAM0[$c800] +OverworldMap: ; c800 + ds 1300 +OverworldMapEnd: + + ds 12 + +SECTION "gfx2",WRAM0[$cd20] +CreditsPos: +BGMapBuffer: ; cd20 + ds 2 +CreditsTimer: ; cd22 + ds 1 + ds 37 + +BGMapPalBuffer: ; cd48 + ds 40 + +BGMapBufferPtrs: ; cd70 +; 20 bg map addresses (16x8 tiles) + ds 40 + +SGBPredef: ; cd98 + ds 1 +PlayerHPPal: ; cd99 + ds 1 +EnemyHPPal: ; cd9a + ds 1 + + ds 62 + +AttrMap: ; cdd9 +; 20x18 grid of palettes for 8x8 tiles +; read horizontally from the top row +; bit 3: vram bank +; bit 0-2: palette id + ds 360 + + ds 30 + +MonType: ; cf5f + ds 1 + +CurSpecies: ; cf60 + ds 1 + + ds 6 + +Requested2bpp: ; cf67 + ds 1 +Requested2bppSource: ; cf68 + ds 2 +Requested2bppDest: ; cf6a + ds 2 + +Requested1bpp: ; cf6c + ds 1 +Requested1bppSource: ; cf6d + ds 2 +Requested1bppDest: ; cf6f + ds 2 + + ds 3 + +MenuSelection:; cf74 + ds 1 + + + +SECTION "VBlank",WRAM0[$cfb1] +OverworldDelay: ; cfb1 + ds 1 +TextDelayFrames: ; cfb2 + ds 1 +VBlankOccurred: ; cfb3 + ds 1 + +PredefID: ; cfb4 + ds 1 +PredefTemp: ; cfb5 + ds 2 +PredefAddress: ; cfb7 + ds 2 + + ds 3 + +GameTimerPause: ; cfbc +; bit 0 + ds 1 + +SECTION "Engine",WRAM0[$cfc2] +FXAnimID: +FXAnimIDLo: ; cfc2 + ds 1 +FXAnimIDHi: ; cfc3 + ds 1 + + ds 2 + +TileAnimationTimer: ; cfc6 + ds 1 + + ds 5 + +Options: ; cfcc +; bit 0-2: number of frames to delay when printing text +; fast 1; mid 3; slow 5 +; bit 3: ? +; bit 4: no text delay +; bit 5: stereo off/on +; bit 6: battle style shift/set +; bit 7: battle scene off/on + ds 1 + + ds 1 + +TextBoxFrame: ; cfce +; bits 0-2: textbox frame 0-7 + ds 1 + + ds 1 + +GBPrinter: ; cfd0 +; bit 0-6: brightness +; lightest: $00 +; lighter: $20 +; normal: $40 (default) +; darker: $60 +; darkest: $7F + ds 1 + +Options2: ; cfd1 +; bit 1: menu account off/on + ds 1 + + ds 46 + + +SECTION "WRAMBank1",WRAMX[$d000],BANK[1] + + ds 2 + +DefaultFlypoint: ; d002 + ds 1 +; d003 + ds 1 +; d004 + ds 1 +StartFlypoint: ; d005 + ds 1 +EndFlypoint: ; d006 + ds 1 + +MovementBuffer: ; d007 + + ds 55 + +MenuItemsList: +CurFruitTree: +CurInput: +EngineBuffer1: ; d03e + ds 1 +CurFruit: ; d03f + ds 1 + +MartPointer: ; d040 + ds 2 + +MovementAnimation: ; d042 + ds 1 + +WalkingDirection: ; d043 + ds 1 + +FacingDirection: ; d044 + ds 1 + +WalkingX: ; d045 + ds 1 +WalkingY: ; d046 + ds 1 +WalkingTile: ; d047 + ds 1 + + ds 43 + +StringBuffer1: ; d073 + ds 19 +StringBuffer2: ; d086 + ds 19 +StringBuffer3: ; d099 + ds 19 +StringBuffer4: ; d0ac + ds 19 + + ds 21 + +CurBattleMon: ; d0d4 + ds 1 +CurMoveNum: ; d0d5 + ds 1 + + ds 23 + +VramState: ; d0ed +; bit 0: overworld sprite updating on/off +; bit 6: something to do with text +; bit 7: on when surf initiates +; flickers when climbing waterfall + ds 1 + + ds 2 + +CurMart: ; d0f0 + ds 16 +CurMartEnd: + + ds 6 + +CurItem: ; d106 + ds 1 + + ds 1 + +CurPartySpecies: ; d108 + ds 1 + +CurPartyMon: ; d109 +; contains which monster in a party +; is being dealt with at the moment +; 0-5 + ds 1 + + ds 4 + +TempMon: +TempMonSpecies: ; d10e + ds 1 +TempMonItem: ; d10f + ds 1 +TempMonMoves: ; d110 +TempMonMove1: ; d110 + ds 1 +TempMonMove2: ; d111 + ds 1 +TempMonMove3: ; d112 + ds 1 +TempMonMove4: ; d113 + ds 1 +TempMonID: ; d114 + ds 2 +TempMonExp: ; d116 + ds 3 +TempMonHPExp: ; d119 + ds 2 +TempMonAtkExp: ; d11b + ds 2 +TempMonDefExp: ; d11d + ds 2 +TempMonSpdExp: ; d11f + ds 2 +TempMonSpclExp: ; d121 + ds 2 +TempMonDVs: ; d123 +; hp = %1000 for each dv + ds 1 ; atk/def + ds 1 ; spd/spc +TempMonPP: ; d125 + ds 4 +TempMonHappiness: ; d129 + ds 1 +TempMonPokerusStatus: ; d12a + ds 1 +TempMonCaughtData: ; d12b +TempMonCaughtTime: ; d12b +TempMonCaughtLevel: ; d12b + ds 1 +TempMonCaughtGender: ; d12c +TempMonCaughtLocation: ; d12c + ds 1 +TempMonLevel: ; d12d + ds 1 +TempMonStatus: ; d12e + ds 1 +; d12f + ds 1 +TempMonCurHP: ; d130 + ds 2 +TempMonMaxHP: ; d132 + ds 2 +TempMonAtk: ; d134 + ds 2 +TempMonDef: ; d136 + ds 2 +TempMonSpd: ; d138 + ds 2 +TempMonSpclAtk: ; d13a + ds 2 +TempMonSpclDef: ; d13c + ds 2 +TempMonEnd: ; d13e + + ds 3 + +PartyMenuActionText: ; d141 + ds 1 + ds 1 + +CurPartyLevel: ; d143 + ds 1 + + +SECTION "UsedSprites",WRAMX[$d154],BANK[1] +UsedSprites: ; d154 + ds 32 + +SECTION "map",WRAMX[$d19d],BANK[1] + +; both are in blocks (2x2 walkable tiles, 4x4 graphics tiles) +MapHeader: ; d19d +MapBorderBlock: ; d19d + ds 1 +MapHeight: ; d19e + ds 1 +MapWidth: ; d19f + ds 1 +MapBlockDataBank: ; d1a0 + ds 1 +MapBlockDataPointer: ; d1a1 + ds 2 +MapScriptHeaderBank: ; d1a3 + ds 1 +MapScriptHeaderPointer: ; d1a4 + ds 2 +MapEventHeaderPointer: ; d1a6 + ds 2 +; bit set +MapConnections: ; d1a8 + ds 1 +NorthMapConnection: ; d1a9 +NorthConnectedMapGroup: ; d1a9 + ds 1 +NorthConnectedMapNumber: ; d1aa + ds 1 +NorthConnectionStripPointer: ; d1ab + ds 2 +NorthConnectionStripLocation: ; d1ad + ds 2 +NorthConnectionStripLength: ; d1af + ds 1 +NorthConnectedMapWidth: ; d1b0 + ds 1 +NorthConnectionStripYOffset: ; d1b1 + ds 1 +NorthConnectionStripXOffset: ; d1b2 + ds 1 +NorthConnectionWindow: ; d1b3 + ds 2 + +SouthMapConnection: ; d1b5 +SouthConnectedMapGroup: ; d1b5 + ds 1 +SouthConnectedMapNumber: ; d1b6 + ds 1 +SouthConnectionStripPointer: ; d1b7 + ds 2 +SouthConnectionStripLocation: ; d1b9 + ds 2 +SouthConnectionStripLength: ; d1bb + ds 1 +SouthConnectedMapWidth: ; d1bc + ds 1 +SouthConnectionStripYOffset: ; d1bd + ds 1 +SouthConnectionStripXOffset: ; d1be + ds 1 +SouthConnectionWindow: ; d1bf + ds 2 + +WestMapConnection: ; d1c1 +WestConnectedMapGroup: ; d1c1 + ds 1 +WestConnectedMapNumber: ; d1c2 + ds 1 +WestConnectionStripPointer: ; d1c3 + ds 2 +WestConnectionStripLocation: ; d1c5 + ds 2 +WestConnectionStripLength: ; d1c7 + ds 1 +WestConnectedMapWidth: ; d1c8 + ds 1 +WestConnectionStripYOffset: ; d1c9 + ds 1 +WestConnectionStripXOffset: ; d1ca + ds 1 +WestConnectionWindow: ; d1cb + ds 2 + +EastMapConnection: ; d1cd +EastConnectedMapGroup: ; d1cd + ds 1 +EastConnectedMapNumber: ; d1ce + ds 1 +EastConnectionStripPointer: ; d1cf + ds 2 +EastConnectionStripLocation: ; d1d1 + ds 2 +EastConnectionStripLength: ; d1d3 + ds 1 +EastConnectedMapWidth: ; d1d4 + ds 1 +EastConnectionStripYOffset: ; d1d5 + ds 1 +EastConnectionStripXOffset: ; d1d6 + ds 1 +EastConnectionWindow: ; d1d7 + ds 2 + + +TilesetHeader: +TilesetBank: ; d1d9 + ds 1 +TilesetAddress: ; d1da + ds 2 +TilesetBlocksBank: ; d1dc + ds 1 +TilesetBlocksAddress: ; d1dd + ds 2 +TilesetCollisionBank: ; d1df + ds 1 +TilesetCollisionAddress: ; d1e0 + ds 2 +TilesetAnim: ; d1e2 +; bank 3f + ds 2 +; unused ; d1e4 + ds 2 +TilesetPalettes: ; d1e6 +; bank 3f + ds 2 + +EvolvableFlags: ; d1e8 + ds 1 + + ds 1 + +MagikarpLength: +Buffer1: ; d1ea + ds 1 +MovementType: +Buffer2: ; d1eb + ds 1 + +SECTION "BattleMons2",WRAMX[$d1fa],BANK[1] +LinkBattleRNs: ; d1fa + ds 10 + +TempEnemyMonSpecies: ; d204 + ds 1 +TempBattleMonSpecies: ; d205 + ds 1 + +EnemyMon: +EnemyMonSpecies: ; d206 + ds 1 +EnemyMonItem: ; d207 + ds 1 + +EnemyMonMoves: +EnemyMonMove1: ; d208 + ds 1 +EnemyMonMove2: ; d209 + ds 1 +EnemyMonMove3: ; d20a + ds 1 +EnemyMonMove4: ; d20b + ds 1 +EnemyMonMovesEnd: + +EnemyMonDVs: +EnemyMonAtkDefDV: ; d20c + ds 1 +EnemyMonSpdSpclDV: ; d20d + ds 1 + +EnemyMonPP: +EnemyMonPPMove1: ; d20e + ds 1 +EnemyMonPPMove2: ; d20f + ds 1 +EnemyMonPPMove3: ; d210 + ds 1 +EnemyMonPPMove4: ; d211 + ds 1 + +EnemyMonHappiness: ; d212 + ds 1 +EnemyMonLevel: ; d213 + ds 1 + +EnemyMonStatus: ; d214 + ds 2 + +EnemyMonHP: +EnemyMonHPHi: ; d216 + ds 1 +EnemyMonHPLo: ; d217 + ds 1 + +EnemyMonMaxHP: +EnemyMonMaxHPHi: ; d218 + ds 1 +EnemyMonMaxHPLo: ; d219 + ds 1 + +EnemyMonStats: +EnemyMonAtk: ; d21a + ds 2 +EnemyMonDef: ; d21c + ds 2 +EnemyMonSpd: ; d21e + ds 2 +EnemyMonSpclAtk: ; d220 + ds 2 +EnemyMonSpclDef: ; d222 + ds 2 +EnemyMonStatsEnd: + +EnemyMonType1: ; d224 + ds 1 +EnemyMonType2: ; d225 + ds 1 + +EnemyMonBaseStats: ; d226 + ds 5 + +EnemyMonCatchRate: ; d22b + ds 1 +EnemyMonBaseExp: ; d22c + ds 1 + +EnemyMonEnd: + + +IsInBattle: ; d22d +; 0: overworld +; 1: wild battle +; 2: trainer battle + ds 1 + + ds 1 + +OtherTrainerClass: ; d22f +; class (Youngster, Bug Catcher, etc.) of opposing trainer +; 0 if opponent is a wild Pokémon, not a trainer + ds 1 + +BattleType: ; d230 +; $00 normal +; $01 +; $02 +; $03 dude +; $04 fishing +; $05 roaming +; $06 +; $07 shiny +; $08 headbutt/rock smash +; $09 +; $0a force Item1 +; $0b +; $0c suicune + ds 1 + +OtherTrainerID: ; d231 +; which trainer of the class that you're fighting +; (Joey, Mikey, Albert, etc.) + ds 1 + + ds 1 + +TrainerClass: ; d233 + ds 1 + +UnownLetter: ; d234 + ds 1 + + ds 1 + +CurBaseData: ; d236 +BaseDexNo: ; d236 + ds 1 +BaseStats: ; d237 +BaseHP: ; d237 + ds 1 +BaseAttack: ; d238 + ds 1 +BaseDefense: ; d239 + ds 1 +BaseSpeed: ; d23a + ds 1 +BaseSpecialAttack: ; d23b + ds 1 +BaseSpecialDefense: ; d23c + ds 1 +BaseType: ; d23d +BaseType1: ; d23d + ds 1 +BaseType2: ; d23e + ds 1 +BaseCatchRate: ; d23f + ds 1 +BaseExp: ; d240 + ds 1 +BaseItems: ; d241 + ds 2 +BaseGender: ; d243 + ds 1 +BaseUnknown1: ; d244 + ds 1 +BaseEggSteps: ; d245 + ds 1 +BaseUnknown2: ; d246 + ds 1 +BasePicSize: ; d247 + ds 1 +BasePadding: ; d248 + ds 4 +BaseGrowthRate: ; d24c + ds 1 +BaseEggGroups: ; d24d + ds 1 +BaseTMHM: ; d24e + ds 8 + + +CurDamage: ; d256 + ds 2 + + +SECTION "TimeOfDay",WRAMX[$d269],BANK[1] + +TimeOfDay: ; d269 + ds 1 + +SECTION "OTParty",WRAMX[$d280],BANK[1] + +OTPartyCount: ; d280 + ds 1 ; number of Pokémon in party +OTPartySpecies: ; d281 + ds 6 ; species of each Pokémon in party +; d287 + ds 1 ; any empty slots including the 7th must be FF + ; or the routine will keep going + +OTPartyMon1: +OTPartyMon1Species: ; d288 + ds 1 +OTPartyMon1Item: ; d289 + ds 1 + +OTPartyMon1Moves: ; d28a +OTPartyMon1Move1: ; d28a + ds 1 +OTPartyMon1Move2: ; d28b + ds 1 +OTPartyMon1Move3: ; d28c + ds 1 +OTPartyMon1Move4: ; d28d + ds 1 + +OTPartyMon1ID: ; d28e + ds 2 +OTPartyMon1Exp: ; d290 + ds 3 +OTPartyMon1HPExp: ; d293 + ds 2 +OTPartyMon1AtkExp: ; d295 + ds 2 +OTPartyMon1DefExp: ; d297 + ds 2 +OTPartyMon1SpdExp: ; d299 + ds 2 +OTPartyMon1SpclExp: ; d29b + ds 2 + +OTPartyMon1DVs: ; d29d +OTPartyMon1AtkDefDV: ; d29d + ds 1 +OTPartyMon1SpdSpclDV: ; d29e + ds 1 + +OTPartyMon1PP: ; d29f +OTPartyMon1PPMove1: ; d29f + ds 1 +OTPartyMon1PPMove2: ; d2a0 + ds 1 +OTPartyMon1PPMove3: ; d2a1 + ds 1 +OTPartyMon1PPMove4: ; d2a2 + ds 1 + +OTPartyMon1Happiness: ; d2a3 + ds 1 +OTPartyMon1PokerusStatus: ; d2a4 + ds 1 + +OTPartyMon1CaughtData: ; d2a5 +OTPartyMon1CaughtGender: ; d2a5 +OTPartyMon1CaughtLocation: ; d2a5 + ds 1 +OTPartyMon1CaughtTime: ; d2a6 + ds 1 +OTPartyMon1Level: ; d2a7 + ds 1 + +OTPartyMon1Status: ; d2a8 + ds 1 +OTPartyMon1Unused: ; d2a9 + ds 1 +OTPartyMon1CurHP: ; d2aa + ds 2 +OTPartyMon1MaxHP: ; d2ac + ds 2 +OTPartyMon1Atk: ; d2ae + ds 2 +OTPartyMon1Def: ; d2b0 + ds 2 +OTPartyMon1Spd: ; d2b2 + ds 2 +OTPartyMon1SpclAtk: ; d2b4 + ds 2 +OTPartyMon1SpclDef: ; d2b6 + ds 2 + +OTPartyMon2: ; d2b8 + ds 48 +OTPartyMon3: ; d2e8 + ds 48 +OTPartyMon4: ; d318 + ds 48 +OTPartyMon5: ; d348 + ds 48 +OTPartyMon6: ; d378 + ds 48 + + +OTPartyMonOT: +OTPartyMon1OT: ; d3a8 + ds 11 +OTPartyMon2OT: ; d3b3 + ds 11 +OTPartyMon3OT: ; d3be + ds 11 +OTPartyMon4OT: ; d3c9 + ds 11 +OTPartyMon5OT: ; d3d4 + ds 11 +OTPartyMon6OT: ; d3df + ds 11 + +OTPartyMonNicknames: +OTPartyMon1Nickname: ; d3ea + ds 11 +OTPartyMon2Nickname: ; d3f5 + ds 11 +OTPartyMon3Nickname: ; d400 + ds 11 +OTPartyMon4Nickname: ; d40b + ds 11 +OTPartyMon5Nickname: ; d416 + ds 11 +OTPartyMon6Nickname: ; d421 + ds 11 + +SECTION "Scripting",WRAMX[$d434],BANK[1] +ScriptFlags: ; d434 + ds 1 +ScriptFlags2: ; d435 + ds 1 +ScriptFlags3: ; d436 + ds 1 + +ScriptMode: ; d437 + ds 1 +ScriptRunning: ; d438 + ds 1 +ScriptBank: ; d439 + ds 1 +ScriptPos: ; d43a + ds 2 + + ds 17 + +ScriptDelay: ; d44d + ds 1 + +SECTION "Player",WRAMX[$d472],BANK[1] +PlayerGender: ; d472 +; bit 0: +; 0 male +; 1 female + ds 1 + ds 8 +PlayerID: ; d47b + ds 2 + +PlayerName: ; d47d + ds 11 +MomsName: ; d488 + ds 11 +RivalName: ; d493 + ds 11 +RedsName: ; d49e + ds 11 +GreensName: ; d4a9 + ds 11 + + ds 2 + +; init time set at newgame +StartDay: ; d4b6 + ds 1 +StartHour: ; d4b7 + ds 1 +StartMinute: ; d4b8 + ds 1 +StartSecond: ; d4b9 + ds 1 + + ds 9 + +GameTimeCap: ; d4c3 + ds 1 +GameTimeHours: ; d4c4 + ds 2 +GameTimeMinutes: ; d4c6 + ds 1 +GameTimeSeconds: ; d4c7 + ds 1 +GameTimeFrames: ; d4c8 + ds 1 + + ds 2 + +CurDay: ; d4cb + ds 1 + + ds 10 + + ds 2 + +PlayerSprite: ; d4d8 + ds 1 + + ds 3 + +PlayerPalette: ; d4dc + ds 1 + + ds 1 + +PlayerDirection: ; d4de +; uses bits 2 and 3 / $0c / %00001100 +; %00 down +; %01 up +; %10 left +; $11 right + ds 1 + + ds 2 + +PlayerAction: ; d4e1 +; 1 standing +; 2 walking +; 4 spinning +; 6 fishing + ds 1 + + ds 2 + +StandingTile: ; d4e4 + ds 1 +StandingTile2: ; d4e5 + ds 1 + +; relative to the map struct including borders +MapX: ; d4e6 + ds 1 +MapY: ; d4e7 + ds 1 +MapX2: ; d4e8 + ds 1 +MapY2: ; d4e9 + ds 1 + + ds 3 + +; relative to the bg map, in px +PlayerSpriteX: ; d4ed + ds 1 +PlayerSpriteY: ; d4ee + ds 1 + + +SECTION "Objects",WRAMX[$d71e],BANK[1] +MapObjects: ; d71e + ds OBJECT_LENGTH * NUM_OBJECTS + + +SECTION "VariableSprites",WRAMX[$d82e],BANK[1] +VariableSprites: ; d82e + ds $10 + + +SECTION "Status",WRAMX[$d841],BANK[1] +TimeOfDayPal: ; d841 + ds 1 + ds 4 +; d846 + ds 1 + ds 1 +CurTimeOfDay: ; d848 + ds 1 + + ds 3 + +StatusFlags: ; d84c + ds 1 +StatusFlags2: ; d84d + ds 1 + +Money: ; d84e + ds 3 + + ds 4 + +Coins: ; d855 + ds 2 + +Badges: +JohtoBadges: ; d857 + ds 1 +KantoBadges: ; d858 + ds 1 + +SECTION "Items",WRAMX[$d859],BANK[1] +TMsHMs: ; d859 + ds 57 +TMsHMsEnd: + +NumItems: ; d892 + ds 1 +Items: ; d893 + ds 41 +ItemsEnd: + +NumKeyItems: ; d8bc + ds 1 +KeyItems: ; d8bd + ds 26 +KeyItemsEnd: + +NumBalls: ; d8d7 + ds 1 +Balls: ; d8d8 + ds 25 +BallsEnd: + +PCItems: ; d8f1 + ds 101 +PCItemsEnd: + + +SECTION "overworld",WRAMX[$d95b],BANK[1] +WhichRegisteredItem: ; d95b + ds 1 +RegisteredItem: ; d95c + ds 1 + +PlayerState: ; d95d + ds 1 + +SECTION "scriptram",WRAMX[$d962],BANK[1] +MooMooBerries: ; d962 + ds 1 ; how many berries fed to MooMoo +UndergroundSwitchPositions: ; d963 + ds 1 ; which positions the switches are in +FarfetchdPosition: ; d964 + ds 1 ; which position the ilex farfetch'd is in + +SECTION "Events",WRAMX[$da72],BANK[1] + +EventFlags: ; da72 +;RoomDecorations: ; dac6 +;TeamRocketAzaleaTownAttackEvent: ; db51 +;PoliceAtElmsLabEvent: ; db52 +;SalesmanMahoganyTownEvent: ; db5c +;RedGyaradosEvent: ; db5c + ds 250 +; db6c + +SECTION "BoxNames",WRAMX[$db75],BANK[1] +; 8 chars + $50 +Box1Name: ; db75 + ds 9 +Box2Name: ; db7e + ds 9 +Box3Name: ; db87 + ds 9 +Box4Name: ; db90 + ds 9 +Box5Name: ; db99 + ds 9 +Box6Name: ; dba2 + ds 9 +Box7Name: ; dbab + ds 9 +Box8Name: ; dbb4 + ds 9 +Box9Name: ; dbbd + ds 9 +Box10Name: ; dbc6 + ds 9 +Box11Name: ; dbcf + ds 9 +Box12Name: ; dbd8 + ds 9 +Box13Name: ; dbe1 + ds 9 +Box14Name: ; dbea + ds 9 + +SECTION "bike", WRAMX[$dbf5],BANK[1] +BikeFlags: ; dbf5 +; bit 1: always on bike +; bit 2: downhill + ds 1 + +SECTION "decorations", WRAMX[$dc0f],BANK[1] +; Sprite id of each decoration +Bed: ; dc0f + ds 1 +Carpet: ; dc10 + ds 1 +Plant: ; dc11 + ds 1 +Poster: ; dc12 + ds 1 +Console: ; dc13 + ds 1 +LeftOrnament: ; dc14 + ds 1 +RightOrnament: ; dc15 + ds 1 +BigDoll: ; dc16 + ds 1 + +SECTION "fruittrees", WRAMX[$dc27],BANK[1] +FruitTreeFlags: ; dc27 + ds 1 + +SECTION "steps", WRAMX[$dc73],BANK[1] +StepCount: ; dc73 + ds 1 +PoisonStepCount: ; dc74 + ds 1 + +SECTION "FlypointPermissions", WRAMX[$dca5],BANK[1] +FlypointPerms: ; dca5 + ds 4 + +SECTION "BackupMapInfo", WRAMX[$dcad],BANK[1] + +; used on maps like second floor pokécenter, which are reused, so we know which +; map to return to +BackupMapGroup: ; dcad + ds 1 +BackupMapNumber: ; dcae + ds 1 + +SECTION "PlayerMapInfo", WRAMX[$dcb4],BANK[1] + +WarpNumber: ; dcb4 + ds 1 +MapGroup: ; dcb5 + ds 1 ; map group of current map +MapNumber: ; dcb6 + ds 1 ; map number of current map +YCoord: ; dcb7 + ds 1 ; current y coordinate relative to top-left corner of current map +XCoord: ; dcb8 + ds 1 ; current x coordinate relative to top-left corner of current map + +SECTION "PlayerParty",WRAMX[$dcd7],BANK[1] + +PartyCount: ; dcd7 + ds 1 ; number of Pokémon in party +PartySpecies: ; dcd8 + ds 6 ; species of each Pokémon in party +PartyEnd: ; dcde + ds 1 ; legacy functions don't check PartyCount + +PartyMons: +PartyMon1: +PartyMon1Species: ; dcdf + ds 1 +PartyMon1Item: ; dce0 + ds 1 + +PartyMon1Moves: ; dce1 +PartyMon1Move1: ; dce1 + ds 1 +PartyMon1Move2: ; dce2 + ds 1 +PartyMon1Move3: ; dce3 + ds 1 +PartyMon1Move4: ; dce4 + ds 1 + +PartyMon1ID: ; dce5 + ds 2 +PartyMon1Exp: ; dce7 + ds 3 + +PartyMon1HPExp: ; dcea + ds 2 +PartyMon1AtkExp: ; dcec + ds 2 +PartyMon1DefExp: ; dcee + ds 2 +PartyMon1SpdExp: ; dcf0 + ds 2 +PartyMon1SpclExp: ; dcf2 + ds 2 + +PartyMon1DVs: ; dcf4 +; hp = %1000 for each dv + ds 1 ; atk/def + ds 1 ; spd/spc +PartyMon1PP: ; dcf6 + ds 4 +PartyMon1Happiness: ; dcfa + ds 1 +PartyMon1PokerusStatus: ; dcfb + ds 1 +PartyMon1CaughtData: ; dcfc +PartyMon1CaughtTime: ; dcfc +PartyMon1CaughtLevel: ; dcfc + ds 1 +PartyMon1CaughtGender: ; dcfd +PartyMon1CaughtLocation: ; dcfd + ds 1 +PartyMon1Level: ; dcfe + ds 1 +PartyMon1Status: ; dcff + ds 1 +; dd00 unused + ds 1 +PartyMon1CurHP: ; dd01 + ds 2 +PartyMon1MaxHP: ; dd03 + ds 2 +PartyMon1Atk: ; dd05 + ds 2 +PartyMon1Def: ; dd07 + ds 2 +PartyMon1Spd: ; dd09 + ds 2 +PartyMon1SpclAtk: ; dd0b + ds 2 +PartyMon1SpclDef: ; dd0d + ds 2 + + +PartyMon2: ; dd0f + ds 48 +PartyMon3: ; dd3f + ds 48 +PartyMon4: ; dd6f + ds 48 +PartyMon5: ; dd9f + ds 48 +PartyMon6: ; ddcf + ds 48 + +PartyMonOT: +PartyMon1OT: ; ddff + ds 11 +PartyMon2OT: ; de0a + ds 11 +PartyMon3OT: ; de15 + ds 11 +PartyMon4OT: ; de20 + ds 11 +PartyMon5OT: ; de2b + ds 11 +PartyMon6OT: ; de36 + ds 11 + +PartyMonNicknames: +PartyMon1Nickname: ; de41 + ds 11 +PartyMon2Nickname: ; de4c + ds 11 +PartyMon3Nickname: ; de57 + ds 11 +PartyMon4Nickname: ; de62 + ds 11 +PartyMon5Nickname: ; de6d + ds 11 +PartyMon6Nickname: ; de78 + ds 11 +PartyMonNicknamesEnd: + +SECTION "Pokedex",WRAMX[$de99],BANK[1] +PokedexCaught: ; de99 + ds 32 +EndPokedexCaught: +PokedexSeen: ; deb9 + ds 32 +EndPokedexSeen: +UnownDex: ; ded9 + ds 26 +UnlockedUnowns: ; def3 + ds 1 + +SECTION "Breeding",WRAMX[$def5],BANK[1] +DaycareMan: ; def5 +; bit 7: active +; bit 6: monsters are compatible +; bit 5: egg ready +; bit 0: monster 1 in daycare + ds 1 + +BreedMon1: +BreedMon1Nick: ; def6 + ds 11 +BreedMon1OT: ; df01 + ds 11 +BreedMon1Stats: +BreedMon1Species: ; df0c + ds 1 + ds 31 + +DaycareLady: ; df2c +; bit 7: active +; bit 0: monster 2 in daycare + ds 1 + +StepsToEgg: ; df2d + ds 1 +DittoInDaycare: ; df2e +; z: yes +; nz: no + ds 1 + +BreedMon2: +BreedMon2Nick: ; df2f + ds 11 +BreedMon2OT: ; df3a + ds 11 +BreedMon2Stats: +BreedMon2Species: ; df45 + ds 1 + ds 31 + +EggNick: ; df65 +; EGG@ + ds 11 +EggOT: ; df70 + ds 11 +EggStats: +EggSpecies: ; df7b + ds 1 + ds 31 + +SECTION "RoamMons",WRAMX[$dfcf],BANK[1] +RoamMon1Species: ; dfcf + ds 1 +RoamMon1Level: ; dfd0 + ds 1 +RoamMon1MapGroup: ; dfd1 + ds 1 +RoamMon1MapNumber: ; dfd2 + ds 1 +RoamMon1CurHP: ; dfd3 + ds 1 +RoamMon1DVs: ; dfd4 + ds 2 + +RoamMon2Species: ; dfd6 + ds 1 +RoamMon2Level: ; dfd7 + ds 1 +RoamMon2MapGroup: ; dfd8 + ds 1 +RoamMon2MapNumber: ; dfd9 + ds 1 +RoamMon2CurHP: ; dfda + ds 1 +RoamMon2DVs: ; dfdb + ds 2 + +RoamMon3Species: ; dfdd + ds 1 +RoamMon3Level: ; dfde + ds 1 +RoamMon3MapGroup: ; dfdf + ds 1 +RoamMon3MapNumber: ; dfe0 + ds 1 +RoamMon3CurHP: ; dfe1 + ds 1 +RoamMon3DVs: ; dfe2 + ds 2 + + + +SECTION "WRAMBank5",WRAMX[$d000],BANK[5] + +; 8 4-color palettes +Unkn1Pals: ; d000 + ds $40 +Unkn2Pals: ; d040 + ds $40 +BGPals: ; d080 + ds $40 +OBPals: ; d0c0 + ds $40 + +LYOverrides: ; d100 + ds 144 +LYOverridesEnd: + + +SECTION "SRAMBank1",SRAM,BANK[1] + +SECTION "BoxMons",SRAM[$ad10],BANK[1] +BoxCount: ; ad10 + ds 1 +BoxSpecies: ; ad11 + ds 20 + ds 1 +BoxMons: +BoxMon1: +BoxMon1Species: ; ad26 + ds 1 +BoxMon1Item: ; ad27 + ds 1 +BoxMon1Moves: ; ad28 + ds 4 +BoxMon1ID: ; ad2c + ds 2 +BoxMon1Exp: ; ad2e + ds 3 +BoxMon1HPExp: ; ad31 + ds 2 +BoxMon1AtkExp: ; ad33 + ds 2 +BoxMon1DefExp: ; ad35 + ds 2 +BoxMon1SpdExp: ; ad37 + ds 2 +BoxMon1SpcExp: ; ad39 + ds 2 +BoxMon1DVs: ; ad3b + ds 2 +BoxMon1PP: ; ad3d + ds 4 +BoxMon1Happiness: ; ad41 + ds 1 +BoxMon1PokerusStatus: ; ad42 + ds 1 +BoxMon1CaughtData: +BoxMon1CaughtTime: +BoxMon1CaughtLevel: ; ad43 + ds 1 +BoxMon1CaughtGender: +BoxMon1CaughtLocation: ; ad44 + ds 1 +BoxMon1Level: ; ad45 + ds 1 + +BoxMon2: ; ad46 + ds 32 +BoxMon3: ; ad66 + ds 32 +BoxMon4: ; ad86 + ds 32 +BoxMon5: ; ada6 + ds 32 +BoxMon6: ; adc6 + ds 32 +BoxMon7: ; ade6 + ds 32 +BoxMon8: ; ae06 + ds 32 +BoxMon9: ; ae26 + ds 32 +BoxMon10: ; ae46 + ds 32 +BoxMon11: ; ae66 + ds 32 +BoxMon12: ; ae86 + ds 32 +BoxMon13: ; aea6 + ds 32 +BoxMon14: ; aec6 + ds 32 +BoxMon15: ; aee6 + ds 32 +BoxMon16: ; af06 + ds 32 +BoxMon17: ; af26 + ds 32 +BoxMon18: ; af46 + ds 32 +BoxMon19: ; af66 + ds 32 +BoxMon20: ; af86 + ds 32 + +BoxMonOT: +BoxMon1OT: ; afa6 + ds 11 +BoxMon2OT: ; afb1 + ds 11 +BoxMon3OT: ; afbc + ds 11 +BoxMon4OT: ; afc7 + ds 11 +BoxMon5OT: ; afd2 + ds 11 +BoxMon6OT: ; afdd + ds 11 +BoxMon7OT: ; afe8 + ds 11 +BoxMon8OT: ; aff3 + ds 11 +BoxMon9OT: ; affe + ds 11 +BoxMon10OT: ; b009 + ds 11 +BoxMon11OT: ; b014 + ds 11 +BoxMon12OT: ; b01f + ds 11 +BoxMon13OT: ; b02a + ds 11 +BoxMon14OT: ; b035 + ds 11 +BoxMon15OT: ; b040 + ds 11 +BoxMon16OT: ; b04b + ds 11 +BoxMon17OT: ; b056 + ds 11 +BoxMon18OT: ; b061 + ds 11 +BoxMon19OT: ; b06c + ds 11 +BoxMon20OT: ; b077 + ds 11 + +BoxMonNicknames: +BoxMon1Nickname: ; b082 + ds 11 +BoxMon2Nickname: ; b08d + ds 11 +BoxMon3Nickname: ; b098 + ds 11 +BoxMon4Nickname: ; b0a3 + ds 11 +BoxMon5Nickname: ; b0ae + ds 11 +BoxMon6Nickname: ; b0b9 + ds 11 +BoxMon7Nickname: ; b0c4 + ds 11 +BoxMon8Nickname: ; b0cf + ds 11 +BoxMon9Nickname: ; b0da + ds 11 +BoxMon10Nickname: ; b0e5 + ds 11 +BoxMon11Nickname: ; b0f0 + ds 11 +BoxMon12Nickname: ; b0fb + ds 11 +BoxMon13Nickname: ; b106 + ds 11 +BoxMon14Nickname: ; b111 + ds 11 +BoxMon15Nickname: ; b11c + ds 11 +BoxMon16Nickname: ; b127 + ds 11 +BoxMon17Nickname: ; b132 + ds 11 +BoxMon18Nickname: ; b13d + ds 11 +BoxMon19Nickname: ; b148 + ds 11 +BoxMon20Nickname: ; b153 + ds 11 +BoxMonNicknamesEnd: + diff --git a/pokemontools/exceptions.py b/pokemontools/exceptions.py index 4de62eb..e583b17 100644 --- a/pokemontools/exceptions.py +++ b/pokemontools/exceptions.py @@ -12,11 +12,6 @@ class TextScriptException(Exception): TextScript encountered an inconsistency or problem. """ -class ConfigException(Exception): - """ - Configuration error. Maybe a missing config variable. - """ - class PreprocessorException(Exception): """ There was a problem in the preprocessor. diff --git a/pokemontools/gfx.py b/pokemontools/gfx.py index c7c6bec..8397337 100644 --- a/pokemontools/gfx.py +++ b/pokemontools/gfx.py @@ -5,121 +5,70 @@ import sys import png from math import sqrt, floor, ceil -import crystal +import configuration +config = configuration.Config() + import pokemon_constants import trainers +import romstr if __name__ != "__main__": - rom = crystal.load_rom() + rom = romstr.RomStr.load(filename=config.rom_path) + -def hex_dump(input, debug=True): +def split(list_, interval): """ - Display hex dump in rows of 16 bytes. + Split a list by length. """ + for i in xrange(0, len(list_), interval): + j = min(i + interval, len(list_)) + yield list_[i:j] - dump = '' - output = '' - stream = '' - address = 0x00 - margin = 2 + len(hex(len(input))[2:]) - - # dump - for byte in input: - cool = hex(byte)[2:].zfill(2) - dump += cool + ' ' - if debug: stream += cool - - # convenient for testing quick edits in bgb - if debug: output += stream + '\n' - - # get dump info - bytes_per_line = 16 - chars_per_byte = 3 # '__ ' - chars_per_line = bytes_per_line * chars_per_byte - num_lines = int(ceil(float(len(dump)) / float(chars_per_line))) - - # top - # margin - for char in range(margin): - output += ' ' - - for byte in range(bytes_per_line): - output += hex(byte)[2:].zfill(2) + ' ' - output = output[:-1] # last space - - # print hex - for line in range(num_lines): - # address - output += '\n' + hex(address)[2:].zfill(margin - 2) + ': ' - # contents - start = line * chars_per_line - end = chars_per_line + start - 1 # ignore last space - output += dump[start:end] - address += 0x10 - return output +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. """ - tiles = [] - tile = [] - bytes_per_tile = 16 - - cur_byte = 0 - for byte in image: - # build tile - tile.append(byte) - cur_byte += 1 - # done building? - if cur_byte >= bytes_per_tile: - # push completed tile - tiles.append(tile) - tile = [] - cur_byte = 0 - return tiles - + return list(split(image, 0x10)) def connect(tiles): """ Combine 8x8 tiles into a 2bpp image. """ - out = [] - for tile in tiles: - for byte in tile: - out.append(byte) - return out - + return [byte for tile in tiles for byte in tile] -def transpose(tiles): +def transpose(tiles, width=None): """ - Transpose a tile arrangement along line y=x. + 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 """ - - # horizontal <-> vertical - # 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 - # etc - - flipped = [] - t = 0 # which tile we're on - w = int(sqrt(len(tiles))) # assume square image - for tile in tiles: - flipped.append(tiles[t]) - t += w - # end of row? - if t >= w*w: - # wrap around - t -= w*w - # next row - t += 1 - return flipped + 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 to_file(filename, data): @@ -130,54 +79,45 @@ def to_file(filename, data): - -# basic rundown of crystal's compression scheme: - -# a control command consists of -# the command (bits 5-7) -# and the count (bits 0-4) -# followed by additional params - -lz_lit = 0 -# print literal for [count] bytes - -lz_iter = 1 -# print one byte [count] times - -lz_alt = 2 -# print alternating bytes (2 params) for [count] bytes - -lz_zeros = 3 -# print 00 for [count] bytes - -# repeater control commands have a signed parameter used to determine the start point -# wraparound is simulated -# positive values are added to the start address of the decompressed data -# and negative values are subtracted from the current position - -lz_repeat = 4 -# print [count] bytes from decompressed data - -lz_flip = 5 -# print [count] bytes from decompressed data in bit order 01234567 - -lz_reverse = 6 -# print [count] bytes from decompressed data backwards - -lz_hi = 7 -# -used when the count exceeds 5 bits. uses a 10-bit count instead -# -bits 2-4 now contain the control code, bits 0-1 are bits 8-9 of the count -# -the following byte contains bits 0-7 of the count - -lz_end = 0xff -# if 0xff is encountered the decompression ends - -# since frontpics have animation tiles lumped onto them, -# sizes must be grabbed from base stats to know when to stop reading them - +""" +A rundown of Pokemon Crystal's compression scheme: + +Control commands occupy bits 5-7. +Bits 0-4 serve as the first parameter <n> 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 <s> 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: @@ -293,10 +233,10 @@ class Compressed: def doLiterals(self): if len(self.literals) > lowmax: - self.output.append( (lz_hi << 5) | (lz_lit << 2) | ((len(self.literals) - 1) >> 8) ) + self.output.append( (lz_commands['long'] << 5) | (lz_commands['literal'] << 2) | ((len(self.literals) - 1) >> 8) ) self.output.append( (len(self.literals) - 1) & 0xff ) elif len(self.literals) > 0: - self.output.append( (lz_lit << 5) | (len(self.literals) - 1) ) + self.output.append( (lz_commands['literal'] << 5) | (len(self.literals) - 1) ) for byte in self.literals: self.output.append(byte) self.literals = [] @@ -311,8 +251,8 @@ class Compressed: """ Works, but doesn't do flipped/reversed streams yet. - This takes up most of the compress time and only saves a few bytes - it might be more feasible to exclude it entirely. + This takes up most of the compress time and only saves a few bytes. + It might be more effective to exclude it entirely. """ self.repeats = [] @@ -417,14 +357,14 @@ class Compressed: # decide which side we're copying from if (self.address - repeat[1]) <= 0x80: self.doLiterals() - self.stream.append( (lz_repeat << 5) | length - 1 ) + self.stream.append( (lz_commands['repeat'] << 5) | length - 1 ) # wrong? self.stream.append( (((self.address - repeat[1])^0xff)+1)&0xff ) else: self.doLiterals() - self.stream.append( (lz_repeat << 5) | length - 1 ) + self.stream.append( (lz_commands['repeat'] << 5) | length - 1 ) # wrong? self.stream.append(repeat[1]>>8) @@ -454,10 +394,10 @@ class Compressed: def doWhitespace(self): if (len(self.zeros) + 1) >= lowmax: - self.stream.append( (lz_hi << 5) | (lz_zeros << 2) | ((len(self.zeros) - 1) >> 8) ) + self.stream.append( (lz_commands['long'] << 5) | (lz_commands['blank'] << 2) | ((len(self.zeros) - 1) >> 8) ) self.stream.append( (len(self.zeros) - 1) & 0xff ) elif len(self.zeros) > 1: - self.stream.append( lz_zeros << 5 | (len(self.zeros) - 1) ) + self.stream.append( lz_commands['blank'] << 5 | (len(self.zeros) - 1) ) else: raise Exception, "checkWhitespace() should prevent this from happening" @@ -510,12 +450,12 @@ class Compressed: num_alts = len(self.iters) + 1 if num_alts > lowmax: - self.stream.append( (lz_hi << 5) | (lz_alt << 2) | ((num_alts - 1) >> 8) ) + self.stream.append( (lz_commands['long'] << 5) | (lz_commands['alternate'] << 2) | ((num_alts - 1) >> 8) ) self.stream.append( num_alts & 0xff ) self.stream.append( self.alts[0] ) self.stream.append( self.alts[1] ) elif num_alts > 2: - self.stream.append( (lz_alt << 5) | (num_alts - 1) ) + self.stream.append( (lz_commands['alternate'] << 5) | (num_alts - 1) ) self.stream.append( self.alts[0] ) self.stream.append( self.alts[1] ) else: @@ -552,22 +492,19 @@ class Compressed: self.next() if (len(self.iters) - 1) >= lowmax: - self.stream.append( (lz_hi << 5) | (lz_iter << 2) | ((len(self.iters)-1) >> 8) ) + self.stream.append( (lz_commands['long'] << 5) | (lz_commands['iterate'] << 2) | ((len(self.iters)-1) >> 8) ) self.stream.append( (len(self.iters) - 1) & 0xff ) self.stream.append( iter ) elif len(self.iters) > 3: # 3 or fewer isn't worth the trouble and actually longer # if part of a larger literal set - self.stream.append( (lz_iter << 5) | (len(self.iters) - 1) ) + self.stream.append( (lz_commands['iterate'] << 5) | (len(self.iters) - 1) ) self.stream.append( iter ) else: self.address = original_address raise Exception, "checkIter() should prevent this from happening" - - - class Decompressed: """ Parse compressed 2bpp data. @@ -633,7 +570,7 @@ class Decompressed: self.cmd = (self.byte & 0b11100000) >> 5 - if self.cmd == lz_hi: # 10-bit param + if self.cmd == lz_commands['long']: # 10-bit param self.cmd = (self.byte & 0b00011100) >> 2 self.length = (self.byte & 0b00000011) << 8 self.next() @@ -642,13 +579,13 @@ class Decompressed: self.length = (self.byte & 0b00011111) + 1 # literals - if self.cmd == lz_lit: + if self.cmd == lz_commands['literal']: self.doLiteral() - elif self.cmd == lz_iter: + elif self.cmd == lz_commands['iterate']: self.doIter() - elif self.cmd == lz_alt: + elif self.cmd == lz_commands['alternate']: self.doAlt() - elif self.cmd == lz_zeros: + elif self.cmd == lz_commands['blank']: self.doZeros() else: # repeaters @@ -661,11 +598,11 @@ class Decompressed: self.next() self.displacement += self.byte - if self.cmd == lz_flip: + if self.cmd == lz_commands['flip']: self.doFlip() - elif self.cmd == lz_reverse: + elif self.cmd == lz_commands['reverse']: self.doReverse() - else: # lz_repeat + else: # lz_commands['repeat'] self.doRepeat() self.address += 1 @@ -1171,13 +1108,16 @@ def flatten(planar): Flatten planar 2bpp image data into a quaternary pixel map. """ strips = [] - for pair in range(len(planar)/2): - bottom = ord(planar[(pair*2) ]) - top = ord(planar[(pair*2)+1]) - strip = [] - for i in range(7,-1,-1): - color = ((bottom >> i) & 1) + (((top >> i-1) if i > 0 else (top << 1-i)) & 2) - strip.append(color) + for bottom, top in split(planar, 2): + bottom = ord(bottom) + top = ord(top) + strip = [] + for i in xrange(7,-1,-1): + color = ( + (bottom >> i & 1) + + (top *2 >> i & 2) + ) + strip += [color] strips += strip return strips @@ -1186,47 +1126,52 @@ def to_lines(image, width): """ Convert a tiled quaternary pixel map to lines of quaternary pixels. """ - - tile = 8 * 8 - - # so we know how many strips of 8px we're putting into a line - num_columns = width / 8 - # number of lines + tile_width = 8 + tile_height = 8 + num_columns = width / tile_width height = len(image) / width lines = [] - for cur_line in range(height): - tile_row = int(cur_line / 8) + for cur_line in xrange(height): + tile_row = cur_line / tile_height line = [] - for column in range(num_columns): - anchor = num_columns*tile_row*tile + column*tile + (cur_line%8)*8 - line += image[anchor:anchor+8] - lines.append(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): - red = word & 0b11111 - word >>= 5 - green = word & 0b11111 - word >>= 5 - blue = word & 0b11111 + 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<<3)+0b100, (green<<3)+0b100, (blue<<3)+0b100, alpha) + return (red, green, blue, alpha) + def rgb_to_dmg(color): word = (color['r'] / 8) - word += (color['g'] / 8) << 5 + word += (color['g'] / 8) << 5 word += (color['b'] / 8) << 10 return word def png_pal(filename): - palette = [] 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) + dmg_pals = [] + for word in range(len(words)/2): + dmg_pals.append(ord(words[word*2]) + ord(words[word*2+1])*0x100) + palette = [] white = (255,255,255,255) black = (000,000,000,255) for word in dmg_pals: palette += [dmg2rgb(word)] @@ -1235,224 +1180,259 @@ def png_pal(filename): return palette -def to_png(filein, fileout=None, pal_file=None, height=None, width=None): - """ - Take a planar 2bpp graphics file and converts it to png. - """ - - if fileout == None: fileout = '.'.join(filein.split('.')[:-1]) + '.png' - +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' image = open(filein, 'rb').read() - num_pixels = len(image) * 4 + if pal_file == None: + if os.path.exists(os.path.splitext(fileout)[0]+'.pal'): + pal_file = os.path.splitext(fileout)[0]+'.pal' - if num_pixels == 0: return 'empty image!' + width, height, palette, greyscale, bitdepth, px_map = convert_2bpp_to_png(image, width=width, height=height, pal_file=pal_file) + w = png.Writer(width, height, palette=palette, compression=9, greyscale=greyscale, bitdepth=bitdepth) + with open(fileout, 'wb') as f: + w.write(f, px_map) - # unless the pic is square, at least one dimension should be given - if width == None and height == None: - width = int(sqrt(num_pixels)) - height = width +def convert_2bpp_to_png(image, width=0, height=0, pal_file=None): + """ + Convert a planar 2bpp graphic to png. + """ + num_pixels = len(image) * 4 + assert num_pixels > 0, 'empty image!' - elif height == None: + # at least one dimension should be given + if height == 0 and width != 0: height = num_pixels / width - - elif width == None: - width = num_pixels / height - - - # but try to see if it can be made rectangular + elif width == 0 and height != 0: + width = num_pixels / height if width * height != num_pixels: - # look for possible combos of width/height that would form a rectangle matches = [] - - # this is pretty inefficient, and there is probably a simpler way - for width in range(8,256+1,8): # we only want dimensions that fit in tiles - height = num_pixels / width - if height % 8 == 0: - matches.append((width, height)) - + for w in range(8, num_pixels / 2 + 1, 8): + h = num_pixels / w + if w * h == num_pixels and h % 8 == 0: + matches += [(w, h)] # go for the most square image - width, height = sorted(matches, key=lambda (x,y): x+y)[0] # favors height - - - # if it can't, the only option is a width of 1 tile - - if width * height != num_pixels: - width = 8 - height = num_pixels / width - + if len(matches): + width, height = sorted(matches, key= lambda (w, h): w + h)[0] # favor height - # if this still isn't rectangular, then the image isn't made of tiles - - # for now we'll just spit out a warning + # if it still isn't rectangular then the image isn't made of tiles if width * height != num_pixels: - print 'Warning! ' + fileout + ' is ' + width + 'x' + height + '(' + width*height + ' pixels),\n' +\ - 'but ' + filein + ' is ' + num_pixels + ' pixels!' - - - # map it out + raise Exception, 'Image can\'t be divided into tiles (%d px)!' % (num_pixels) + # convert tiles to lines lines = to_lines(flatten(image), width) if pal_file == None: - if os.path.exists(os.path.splitext(fileout)[0]+'.pal'): - pal_file = os.path.splitext(fileout)[0]+'.pal' - - if pal_file == None: palette = None greyscale = True bitdepth = 2 - inverse = { 0:3, 1:2, 2:1, 3:0 } - map = [[inverse[pixel] for pixel in line] for line in lines] + px_map = [[3 - pixel for pixel in line] for line in lines] else: # gbc color palette = png_pal(pal_file) greyscale = False bitdepth = 8 - map = [[pixel for pixel in line] for line in lines] - - - w = png.Writer(width, height, palette=palette, compression = 9, greyscale = greyscale, bitdepth = bitdepth) - with open(fileout, 'wb') as file: - w.write(file, map) - + px_map = [[pixel for pixel in line] for line in lines] + return width, height, palette, greyscale, bitdepth, px_map -def to_2bpp(filein, fileout=None, palout=None): - """ - Take a png and converts it to planar 2bpp. - """ +def export_png_to_2bpp(filein, fileout=None, palout=None): + image, palette = png_to_2bpp(filein) - if fileout == None: fileout = '.'.join(filein.split('.')[:-1]) + '.2bpp' + if fileout == None: + fileout = os.path.splitext(filein)[0] + '.2bpp' + to_file(fileout, image) - with open(filein, 'rb') as file: + if palout == None: + palout = os.path.splitext(fileout)[0] + '.pal' + export_palette(palette, palout) - r = png.Reader(file) - info = r.asRGBA8() - width = info[0] - height = info[1] +def get_image_padding(width, height, wstep=8, hstep=8): - rgba = list(info[2]) - greyscale = info[3]['greyscale'] + padding = { + 'left': 0, + 'right': 0, + 'top': 0, + 'bottom': 0, + } + if width % wstep: + pad = float(width % wstep) / 2 + padding['left'] = int(ceil(pad)) + padding['right'] = int(floor(pad)) - padding = { 'left': 0, - 'right': 0, - 'top': 0, - 'bottom': 0, } - #if width % 8 != 0: - # padding['left'] = int(ceil((width / 8 + 8 - width) / 2)) - # padding['right'] = int(floor((width / 8 + 8 - width) / 2)) - #if height % 8 != 0: - # padding['top'] = int(ceil((height / 8 + 8 - height) / 2)) - # padding['bottom'] = int(floor((height / 8 + 8 - height) / 2)) + if height % hstep: + pad = float(height % hstep) / 2 + padding['top'] = int(ceil(pad)) + padding['bottom'] = int(floor(pad)) + return padding - # turn the flat values into something more workable - pixel_length = 4 # rgba - image = [] +def png_to_2bpp(filein): + """ + Convert a png image to planar 2bpp. + """ - # while we're at it, let's size up the palette + with open(filein, 'rb') as data: + width, height, rgba, info = png.Reader(data).asRGBA8() + rgba = list(rgba) + greyscale = info['greyscale'] + # png.Reader returns flat pixel data. Nested is easier to work with + len_px = 4 # rgba + image = [] palette = [] - for line in rgba: newline = [] - for pixel in range(len(line)/pixel_length): - i = pixel * pixel_length - color = { 'r': line[i ], - 'g': line[i+1], - 'b': line[i+2], - 'a': line[i+3], } + for px in xrange(0, len(line), len_px): + color = { 'r': line[px ], + 'g': line[px+1], + 'b': line[px+2], + 'a': line[px+3], } newline += [color] - if color not in palette: palette += [color] - image.append(newline) + if color not in palette: + palette += [color] + image += [newline] + + assert len(palette) <= 4, 'Palette should be 4 colors, is really %d' % len(palette) - # pad out any small palettes + # Pad out smaller palettes with greyscale colors hues = { 'white': { 'r': 0xff, 'g': 0xff, 'b': 0xff, 'a': 0xff }, '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 }, } - while len(palette) < 4: - for hue in hues.values(): - if not any(color is hue for color in palette): - palette += [hue] - if len(palette) >= 4: break + for hue in hues.values(): + if len(palette) >= 4: + break + if hue not in palette: + palette += [hue] - assert len(palette) <= 4, 'Palette should be 4 colors, is really ' + str(len(palette)) - - # sort by luminance + # Sort palettes by luminance def luminance(color): - # this is actually in reverse, thanks to dmg/cgb palette ordering rough = { 'r': 4.7, 'g': 1.4, 'b': 13.8, } - return sum(color[key] * -rough[key] for key in rough.keys()) - palette = sorted(palette, key=luminance) + return sum(color[key] * rough[key] for key in rough.keys()) + palette.sort(key=luminance) - # spit out a new .pal file - # disable this if it causes problems with paletteless images - if palout == None: - if os.path.exists(os.path.splitext(fileout)[0]+'.pal'): - palout = os.path.splitext(fileout)[0]+'.pal' - if palout != None: - output = [] - for color in palette: - word = rgb_to_dmg(color) - output += [word & 0xff] - output += [word >> 8] - to_file(palout, output) + # 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 = [0] - # create a new map of quaternary color ids - map = [] - if padding['top']: map += [0] * (width + padding['left'] + padding['right']) * padding['top'] + qmap = [] + qmap += pad * width * padding['top'] for line in image: - if padding['left']: map += [0] * padding['left'] + qmap += pad * padding['left'] for color in line: - map.append(palette.index(color)) - if padding['right']: map += [0] * padding['right'] - if padding['bottom']: map += [0] * (width + padding['left'] + padding['right']) * padding['bottom'] - - # split it into strips of 8, and make them planar - num_columns = width / 8 - num_rows = height / 8 - tile = 8 * 8 + 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 = width / tile_width + num_rows = height / tile_height image = [] - for row in range(num_rows): - for column in range(num_columns): - for strip in range(tile / 8): - anchor = row*num_columns*tile + column*tile/8 + strip*width - line = map[anchor:anchor+8] - bottom = 0 - top = 0 + + 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(tile_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.append(bottom) - image.append(top) + bottom += (quad & 1) << (7 - bit) + top += (quad /2 & 1) << (7 - bit) + image += [bottom, top] - to_file(fileout, image) + return image, palette + + +def export_palette(palette, filename): + 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) def png_to_lz(filein): name = os.path.splitext(filein)[0] - to_2bpp(filein) + export_png_to_2bpp(filein) image = open(name+'.2bpp', 'rb').read() to_file(name+'.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_1bpp_to_png(filename, fileout=None): + + if fileout == None: + fileout = os.path.splitext(filename)[0] + '.png' + + image = open(filename, 'rb').read() + image = convert_1bpp_to_2bpp(image) + + width, height, palette, greyscale, bitdepth, px_map = convert_2bpp_to_png(image) + + 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): + image = png_to_1bpp(filename) + + if fileout == None: + fileout = os.path.splitext(filename)[0] + '.1bpp' + + to_file(fileout, image) + +def png_to_1bpp(filename): + image, palette = png_to_2bpp(filename) + return convert_2bpp_to_1bpp(image) + def mass_to_png(debug=False): # greyscale @@ -1460,7 +1440,7 @@ def mass_to_png(debug=False): for name in files: if debug: print os.path.splitext(name), os.path.join(root, name) if os.path.splitext(name)[1] == '.2bpp': - to_png(os.path.join(root, name)) + export_2bpp_to_png(os.path.join(root, name)) def mass_to_colored_png(debug=False): # greyscale, unless a palette is detected @@ -1469,7 +1449,10 @@ def mass_to_colored_png(debug=False): for name in files: if debug: print os.path.splitext(name), os.path.join(root, name) if os.path.splitext(name)[1] == '.2bpp': - to_png(os.path.join(root, name)) + export_2bpp_to_png(os.path.join(root, name)) + os.utime(os.path.join(root, name), None) + elif os.path.splitext(name)[1] == '.1bpp': + export_1bpp_to_png(os.path.join(root, name)) os.utime(os.path.join(root, name), None) # only monster and trainer pics for now @@ -1478,16 +1461,16 @@ def mass_to_colored_png(debug=False): if debug: print os.path.splitext(name), os.path.join(root, name) if os.path.splitext(name)[1] == '.2bpp': if 'normal.pal' in files: - to_png(os.path.join(root, name), None, os.path.join(root, 'normal.pal')) + export_2bpp_to_png(os.path.join(root, name), None, os.path.join(root, 'normal.pal')) else: - to_png(os.path.join(root, name)) + export_2bpp_to_png(os.path.join(root, name)) os.utime(os.path.join(root, name), None) for root, dirs, files in os.walk('./gfx/trainers/'): for name in files: if debug: print os.path.splitext(name), os.path.join(root, name) if os.path.splitext(name)[1] == '.2bpp': - to_png(os.path.join(root, name)) + export_2bpp_to_png(os.path.join(root, name)) os.utime(os.path.join(root, name), None) @@ -1527,7 +1510,7 @@ def append_terminator_to_lzs(directory): new.write(data) new.close() -def lz_to_png_by_file(filename): +def export_lz_to_png(filename): """ Convert a lz file to png. Dump a 2bpp file too. """ @@ -1536,7 +1519,7 @@ def lz_to_png_by_file(filename): bpp = Decompressed(lz_data).output bpp_filename = filename.replace(".lz", ".2bpp") to_file(bpp_filename, bpp) - to_png(bpp_filename) + export_2bpp_to_png(bpp_filename) def dump_tileset_pngs(): """ @@ -1546,7 +1529,7 @@ def dump_tileset_pngs(): """ for tileset_id in range(37): tileset_filename = "./gfx/tilesets/" + str(tileset_id).zfill(2) + ".lz" - lz_to_png_by_file(tileset_filename) + export_lz_to_png(tileset_filename) def decompress_frontpic(lz_file): """ @@ -1615,10 +1598,9 @@ if __name__ == "__main__": name = os.path.splitext(argv[3])[0] lz = open(name+'.lz', 'rb').read() to_file(name+'.2bpp', Decompressed(lz, 'vert').output) - pic = open(name+'.2bpp', 'rb').read() - to_file(name+'.png', to_png(pic)) + export_2bpp_to_png(name+'.2bpp') else: - lz_to_png_by_file(argv[2]) + export_lz_to_png(argv[2]) elif argv[1] == 'png-to-lz': # python gfx.py png-to-lz [--front anim(2bpp) | --vert] [png] @@ -1626,21 +1608,24 @@ if __name__ == "__main__": # front.2bpp and tiles.2bpp are combined before compression, # so we have to pass in the anim file and pic size name = os.path.splitext(argv[4])[0] - to_2bpp(name+'.png', name+'.2bpp') + export_png_to_2bpp(name+'.png', name+'.2bpp') pic = open(name+'.2bpp', 'rb').read() anim = open(argv[3], 'rb').read() size = int(sqrt(len(pic)/16)) # assume square pic to_file(name+'.lz', Compressed(pic + anim, 'vert', size).output) elif argv[2] == '--vert': name = os.path.splitext(argv[3])[0] - to_2bpp(name+'.png', name+'.2bpp') + export_png_to_2bpp(name+'.png', name+'.2bpp') pic = open(name+'.2bpp', 'rb').read() to_file(name+'.lz', Compressed(pic, 'vert').output) else: png_to_lz(argv[2]) elif argv[1] == 'png-to-2bpp': - to_2bpp(argv[2]) + export_png_to_2bpp(argv[2]) + + elif argv[1] == 'png-to-1bpp': + export_png_to_1bpp(argv[2]) elif argv[1] == '2bpp-to-lz': if argv[2] == '--vert': @@ -1653,4 +1638,4 @@ if __name__ == "__main__": compress_file(filein, fileout) elif argv[1] == '2bpp-to-png': - to_png(argv[2]) + export_2bpp_to_png(argv[2]) diff --git a/pokemontools/map_editor.py b/pokemontools/map_editor.py index 4c0bec5..43042cb 100644 --- a/pokemontools/map_editor.py +++ b/pokemontools/map_editor.py @@ -1,657 +1,713 @@ import os +import sys +import logging + +from Tkinter import ( + Tk, + Button, + Canvas, + Scrollbar, + VERTICAL, + HORIZONTAL, + RIGHT, + LEFT, + Y, + X, + TclError, +) + +from ttk import ( + Frame, + Style, + Combobox, +) + +# This is why requirements.txt says to install pillow instead of the original +# PIL. +from PIL import ( + Image, + ImageTk, +) + +import gfx +import preprocessor +import configuration +config = configuration.Config() + +def setup_logging(): + """ + Temporary function that configures logging to go straight to console. + """ + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + console = logging.StreamHandler(sys.stdout) + console.setLevel(logging.DEBUG) + console.setFormatter(formatter) + root = logging.getLogger() + root.addHandler(console) + root.setLevel(logging.DEBUG) + +def configure_for_pokered(config=config): + """ + Sets default configuration values for pokered. These should eventually be + moved into the configuration module. + """ + attrs = { + "version": "red", -from Tkinter import * -import ttk -from ttk import Frame, Style -import PIL -from PIL import Image, ImageTk - -import config -conf = config.Config() - - -#version = 'crystal' -version = 'red' - -if version == 'crystal': - map_dir = os.path.join(conf.path, 'maps/') - gfx_dir = os.path.join(conf.path, 'gfx/tilesets/') - to_gfx_name = lambda x : '%.2d' % x - block_dir = os.path.join(conf.path, 'tilesets/') - block_ext = '_metatiles.bin' + "map_dir": os.path.join(config.path, 'maps/'), + "gfx_dir": os.path.join(config.path, 'gfx/tilesets/'), + "to_gfx_name": lambda x : '%.2x' % x, + "block_dir": os.path.join(config.path, 'gfx/blocksets/'), + "block_ext": '.bst', - palettes_on = True - palmap_dir = os.path.join(conf.path, 'tilesets/') - palette_dir = os.path.join(conf.path, 'tilesets/') + "palettes_on": False, - asm_dir = os.path.join(conf.path, 'maps/') + "asm_path": os.path.join(config.path, 'main.asm'), - constants_dir = os.path.join(conf.path, 'constants/') - constants_filename = os.path.join(constants_dir, 'map_constants.asm') + "constants_filename": os.path.join(config.path, 'constants.asm'), - header_dir = os.path.join(conf.path, 'maps/') + "header_path": os.path.join(config.path, 'main.asm'), -elif version == 'red': - map_dir = os.path.join(conf.path, 'maps/') - gfx_dir = os.path.join(conf.path, 'gfx/tilesets/') - to_gfx_name = lambda x : '%.2x' % x - block_dir = os.path.join(conf.path, 'gfx/blocksets/') - block_ext = '.bst' + "time_of_day": 1, + } + return attrs - palettes_on = False +def configure_for_pokecrystal(config=config): + """ + Sets default configuration values for pokecrystal. These should eventually + be moved into the configuration module. + """ + attrs = { + "version": "crystal", - asm_path = os.path.join(conf.path, 'main.asm') + "map_dir": os.path.join(config.path, 'maps/'), + "gfx_dir": os.path.join(config.path, 'gfx/tilesets/'), + "to_gfx_name": lambda x : '%.2d' % x, + "block_dir": os.path.join(config.path, 'tilesets/'), + "block_ext": '_metatiles.bin', - constants_filename = os.path.join(conf.path, 'constants.asm') + "palettes_on": True, + "palmap_dir": os.path.join(config.path, 'tilesets/'), + "palette_dir": os.path.join(config.path, 'tilesets/'), - header_path = os.path.join(conf.path, 'main.asm') + "asm_dir": os.path.join(config.path, 'maps/'), -else: - raise Exception, 'version must be "crystal" or "red"' + "constants_filename": os.path.join(os.path.join(config.path, "constants/"), 'map_constants.asm'), + "header_dir": os.path.join(config.path, 'maps/'), -def get_constants(): - lines = open(constants_filename, 'r').readlines() - for line in lines: - if ' EQU ' in line: - name, value = [s.strip() for s in line.split(' EQU ')] - globals()[name] = eval(value.split(';')[0].replace('$','0x').replace('%','0b')) -get_constants() + "time_of_day": 1, + } + return attrs +def configure_for_version(version, config=config): + """ + Overrides default values from the configuration with additional attributes. + """ + if version == "red": + attrs = configure_for_pokered(config) + elif version == "crystal": + attrs = configure_for_pokecrystal(config) + else: + # TODO: pick a better exception + raise Exception( + "Can't configure for this version." + ) + + for (key, value) in attrs.iteritems(): + setattr(config, key, value) + + # not really needed since it's modifying the same object + return config + +def get_constants(config=config): + constants = {} + lines = open(config.constants_filename, 'r').readlines() + for line in lines: + if ' EQU ' in line: + name, value = [s.strip() for s in line.split(' EQU ')] + constants[name] = eval(value.split(';')[0].replace('$','0x').replace('%','0b')) + config.constants = constants + return constants class Application(Frame): - def __init__(self, master=None): - self.display_connections = False - Frame.__init__(self, master) - self.grid() - Style().configure("TFrame", background="#444") - self.paint_tile = 1 - self.init_ui() - - def init_ui(self): - self.connections = {} - self.button_frame = Frame(self) - self.button_frame.grid(row=0, column=0, columnspan=2) - self.map_frame = Frame(self) - self.map_frame.grid(row=1, column=0, padx=5, pady=5) - self.picker_frame = Frame(self) - self.picker_frame.grid(row=1, column=1) - - self.new = Button(self.button_frame) - self.new["text"] = "New" - self.new["command"] = self.new_map - self.new.grid(row=0, column=0, padx=2) - - self.open = Button(self.button_frame) - self.open["text"] = "Open" - self.open["command"] = self.open_map - self.open.grid(row=0, column=1, padx=2) - - self.save = Button(self.button_frame) - self.save["text"] = "Save" - self.save["command"] = self.save_map - self.save.grid(row=0, column=2, padx=2) - - self.get_map_list() - self.map_list.grid(row=0, column=3, padx=2) - - - def get_map_list(self): - self.available_maps = sorted(m for m in get_available_maps()) - self.map_list = ttk.Combobox(self.button_frame, height=24, width=24, values=self.available_maps) - if len(self.available_maps): - self.map_list.set(self.available_maps[0]) - - def new_map(self): - self.map_name = None - self.init_map() - self.map.blockdata = [self.paint_tile] * 20 * 20 - self.map.width = 20 - self.map.height = 20 - self.draw_map() - self.init_picker() - - def open_map(self): - self.map_name = self.map_list.get() - self.init_map() - self.draw_map() - self.init_picker() - - def save_map(self): - if hasattr(self, 'map'): - if self.map.blockdata_filename: - with open(self.map.blockdata_filename, 'wb') as save: - save.write(self.map.blockdata) - print 'blockdata saved as %s' % self.map.blockdata_filename - else: - print 'dunno how to save this' - else: - print 'nothing to save' - - def init_map(self): - if hasattr(self, 'map'): - self.map.kill_canvas() - self.map = Map(self.map_frame, self.map_name) - self.init_map_connections() - - def draw_map(self): - self.map.init_canvas(self.map_frame) - self.map.canvas.pack() #.grid(row=1,column=1) - self.map.draw() - self.map.canvas.bind('<Button-1>', self.paint) - self.map.canvas.bind('<B1-Motion>', self.paint) - - def init_picker(self): - - self.current_tile = Map(self.button_frame, tileset_id=self.map.tileset_id) - self.current_tile.blockdata = [self.paint_tile] - self.current_tile.width = 1 - self.current_tile.height = 1 - self.current_tile.init_canvas() - self.current_tile.draw() - self.current_tile.canvas.grid(row=0, column=4, padx=4) - - if hasattr(self, 'picker'): - self.picker.kill_canvas() - self.picker = Map(self, tileset_id=self.map.tileset_id) - self.picker.blockdata = range(len(self.picker.tileset.blocks)) - self.picker.width = 4 - self.picker.height = len(self.picker.blockdata) / self.picker.width - self.picker.init_canvas(self.picker_frame) - - if hasattr(self.picker_frame, 'vbar'): - self.picker_frame.vbar.destroy() - self.picker_frame.vbar = Scrollbar(self.picker_frame, orient=VERTICAL) - self.picker_frame.vbar.pack(side=RIGHT, fill=Y) - self.picker_frame.vbar.config(command=self.picker.canvas.yview) - - - self.picker.canvas.config(scrollregion=(0,0,self.picker.canvas_width, self.picker.canvas_height)) - self.map_frame.update() - self.picker.canvas.config(height=self.map_frame.winfo_height()) - self.picker.canvas.config(yscrollcommand=self.picker_frame.vbar.set) - self.picker.canvas.pack(side=LEFT, expand=True) - - self.picker.canvas.bind('<4>', lambda event : self.scroll_picker(event)) - self.picker.canvas.bind('<5>', lambda event : self.scroll_picker(event)) - self.picker_frame.vbar.bind('<4>', lambda event : self.scroll_picker(event)) - self.picker_frame.vbar.bind('<5>', lambda event : self.scroll_picker(event)) - - self.picker.draw() - self.picker.canvas.bind('<Button-1>', self.pick_block) - - def scroll_picker(self, event): - if event.num == 4: - self.picker.canvas.yview('scroll', -1, 'units') - elif event.num == 5: - self.picker.canvas.yview('scroll', 1, 'units') - - - def pick_block(self, event): - block_x = int(self.picker.canvas.canvasx(event.x)) / (self.picker.tileset.block_width * self.picker.tileset.tile_width) - block_y = int(self.picker.canvas.canvasy(event.y)) / (self.picker.tileset.block_height * self.picker.tileset.tile_height) - i = block_y * self.picker.width + block_x - self.paint_tile = self.picker.blockdata[i] - - self.current_tile.blockdata = [self.paint_tile] - self.current_tile.draw() - - def paint(self, event): - block_x = event.x / (self.map.tileset.block_width * self.map.tileset.tile_width) - block_y = event.y / (self.map.tileset.block_height * self.map.tileset.tile_height) - i = block_y * self.map.width + block_x - if 0 <= i < len(self.map.blockdata): - self.map.blockdata[i] = self.paint_tile - self.map.draw_block(block_x, block_y) - - def init_map_connections(self): - if not display_connections: - return - for direction in self.map.connections.keys(): - if direction in self.connections.keys(): - if hasattr(self.connections[direction], 'canvas'): - self.connections[direction].kill_canvas() - if self.map.connections[direction] == {}: - self.connections[direction] = {} - continue - self.connections[direction] = Map(self, self.map.connections[direction]['map_name']) - - if direction in ['north', 'south']: - x1 = 0 - y1 = 0 - x2 = x1 + eval(self.map.connections[direction]['strip_length']) - y2 = y1 + 3 - else: # east, west - x1 = 0 - y1 = 0 - x2 = x1 + 3 - y2 = y1 + eval(self.map.connections[direction]['strip_length']) - - self.connections[direction].crop(x1, y1, x2, y2) - self.connections[direction].init_canvas(self.map_frame) - self.connections[direction].canvas.pack(side={'west':LEFT,'east':RIGHT}[direction]) - self.connections[direction].draw() + def __init__(self, master=None, config=config): + self.config = config + self.log = logging.getLogger("{0}.{1}".format(self.__class__.__name__, id(self))) + self.display_connections = False + Frame.__init__(self, master) + self.grid() + Style().configure("TFrame", background="#444") + self.paint_tile = 1 + self.init_ui() + + def init_ui(self): + self.connections = {} + self.button_frame = Frame(self) + self.button_frame.grid(row=0, column=0, columnspan=2) + self.map_frame = Frame(self) + self.map_frame.grid(row=1, column=0, padx=5, pady=5) + self.picker_frame = Frame(self) + self.picker_frame.grid(row=1, column=1) + + self.button_new = Button(self.button_frame) + self.button_new["text"] = "New" + self.button_new["command"] = self.new_map + self.button_new.grid(row=0, column=0, padx=2) + + self.open = Button(self.button_frame) + self.open["text"] = "Open" + self.open["command"] = self.open_map + self.open.grid(row=0, column=1, padx=2) + + self.save = Button(self.button_frame) + self.save["text"] = "Save" + self.save["command"] = self.save_map + self.save.grid(row=0, column=2, padx=2) + + self.get_map_list() + self.map_list.grid(row=0, column=3, padx=2) + + + def get_map_list(self): + self.available_maps = sorted(m for m in get_available_maps(config=self.config)) + self.map_list = Combobox(self.button_frame, height=24, width=24, values=self.available_maps) + if len(self.available_maps): + self.map_list.set(self.available_maps[0]) + + def new_map(self): + self.map_name = None + self.init_map() + self.map.blockdata = [self.paint_tile] * 20 * 20 + self.map.width = 20 + self.map.height = 20 + self.draw_map() + self.init_picker() + + def open_map(self): + self.map_name = self.map_list.get() + self.init_map() + self.draw_map() + self.init_picker() + + def save_map(self): + if hasattr(self, 'map'): + if self.map.blockdata_filename: + with open(self.map.blockdata_filename, 'wb') as save: + save.write(self.map.blockdata) + self.log.info('blockdata saved as {}'.format(self.map.blockdata_filename)) + else: + self.log.info('dunno how to save this') + else: + self.log.info('nothing to save') + + def init_map(self): + if hasattr(self, 'map'): + self.map.kill_canvas() + self.map = Map(self.map_frame, self.map_name, config=self.config) + self.init_map_connections() + + def draw_map(self): + self.map.init_canvas(self.map_frame) + self.map.canvas.pack() #.grid(row=1,column=1) + self.map.draw() + self.map.canvas.bind('<Button-1>', self.paint) + self.map.canvas.bind('<B1-Motion>', self.paint) + + def init_picker(self): + self.current_tile = Map(self.button_frame, tileset_id=self.map.tileset_id, config=self.config) + self.current_tile.blockdata = [self.paint_tile] + self.current_tile.width = 1 + self.current_tile.height = 1 + self.current_tile.init_canvas() + self.current_tile.draw() + self.current_tile.canvas.grid(row=0, column=4, padx=4) + + if hasattr(self, 'picker'): + self.picker.kill_canvas() + self.picker = Map(self, tileset_id=self.map.tileset_id, config=self.config) + self.picker.blockdata = range(len(self.picker.tileset.blocks)) + self.picker.width = 4 + self.picker.height = len(self.picker.blockdata) / self.picker.width + self.picker.init_canvas(self.picker_frame) + + if hasattr(self.picker_frame, 'vbar'): + self.picker_frame.vbar.destroy() + self.picker_frame.vbar = Scrollbar(self.picker_frame, orient=VERTICAL) + self.picker_frame.vbar.pack(side=RIGHT, fill=Y) + self.picker_frame.vbar.config(command=self.picker.canvas.yview) + + self.picker.canvas.config(scrollregion=(0,0,self.picker.canvas_width, self.picker.canvas_height)) + self.map_frame.update() + self.picker.canvas.config(height=self.map_frame.winfo_height()) + self.picker.canvas.config(yscrollcommand=self.picker_frame.vbar.set) + self.picker.canvas.pack(side=LEFT, expand=True) + + self.picker.canvas.bind('<4>', lambda event : self.scroll_picker(event)) + self.picker.canvas.bind('<5>', lambda event : self.scroll_picker(event)) + self.picker_frame.vbar.bind('<4>', lambda event : self.scroll_picker(event)) + self.picker_frame.vbar.bind('<5>', lambda event : self.scroll_picker(event)) + + self.picker.draw() + self.picker.canvas.bind('<Button-1>', self.pick_block) + + def scroll_picker(self, event): + if event.num == 4: + self.picker.canvas.yview('scroll', -1, 'units') + elif event.num == 5: + self.picker.canvas.yview('scroll', 1, 'units') + + + def pick_block(self, event): + block_x = int(self.picker.canvas.canvasx(event.x)) / (self.picker.tileset.block_width * self.picker.tileset.tile_width) + block_y = int(self.picker.canvas.canvasy(event.y)) / (self.picker.tileset.block_height * self.picker.tileset.tile_height) + i = block_y * self.picker.width + block_x + self.paint_tile = self.picker.blockdata[i] + + self.current_tile.blockdata = [self.paint_tile] + self.current_tile.draw() + + def paint(self, event): + block_x = event.x / (self.map.tileset.block_width * self.map.tileset.tile_width) + block_y = event.y / (self.map.tileset.block_height * self.map.tileset.tile_height) + i = block_y * self.map.width + block_x + if 0 <= i < len(self.map.blockdata): + self.map.blockdata[i] = self.paint_tile + self.map.draw_block(block_x, block_y) + + def init_map_connections(self): + if not self.display_connections: + return + for direction in self.map.connections.keys(): + if direction in self.connections.keys(): + if hasattr(self.connections[direction], 'canvas'): + self.connections[direction].kill_canvas() + if self.map.connections[direction] == {}: + self.connections[direction] = {} + continue + self.connections[direction] = Map(self, self.map.connections[direction]['map_name'], config=self.config) + + if direction in ['north', 'south']: + x1 = 0 + y1 = 0 + x2 = x1 + eval(self.map.connections[direction]['strip_length'], self.config.constants) + y2 = y1 + 3 + else: # east, west + x1 = 0 + y1 = 0 + x2 = x1 + 3 + y2 = y1 + eval(self.map.connections[direction]['strip_length'], self.config.constants) + + self.connections[direction].crop(x1, y1, x2, y2) + self.connections[direction].init_canvas(self.map_frame) + self.connections[direction].canvas.pack(side={'west':LEFT,'east':RIGHT}[direction]) + self.connections[direction].draw() class Map: - def __init__(self, parent, name=None, width=20, height=20, tileset_id=2, blockdata_filename=None): - self.parent = parent - - self.name = name - - self.blockdata_filename = blockdata_filename - if not self.blockdata_filename and self.name: - self.blockdata_filename = os.path.join(map_dir, self.name + '.blk') - elif not self.blockdata_filename: - self.blockdata_filename = '' - - asm_filename = '' - if self.name: - if 'asm_dir' in globals().keys(): - asm_filename = os.path.join(asm_dir, self.name + '.asm') - elif 'asm_path' in globals().keys(): - asm_filename = asm_path - - if os.path.exists(asm_filename): - for props in [map_header(self.name), second_map_header(self.name)]: - self.__dict__.update(props) - self.asm = open(asm_filename, 'r').read() - self.events = event_header(self.asm, self.name) - self.scripts = script_header(self.asm, self.name) - - self.tileset_id = eval(self.tileset_id) - - self.width = eval(self.width) - self.height = eval(self.height) - - else: - self.width = width - self.height = height - self.tileset_id = tileset_id - - if self.blockdata_filename: - self.blockdata = bytearray(open(self.blockdata_filename, 'rb').read()) - else: - self.blockdata = [] - - self.tileset = Tileset(self.tileset_id) - - def init_canvas(self, parent=None): - if parent == None: - parent = self.parent - if not hasattr(self, 'canvas'): - self.canvas_width = self.width * 32 - self.canvas_height = self.height * 32 - self.canvas = Canvas(parent, width=self.canvas_width, height=self.canvas_height) - self.canvas.xview_moveto(0) - self.canvas.yview_moveto(0) - - def kill_canvas(self): - if hasattr(self, 'canvas'): - self.canvas.destroy() - - def crop(self, x1, y1, x2, y2): - blockdata = self.blockdata - start = y1 * self.width + x1 - width = x2 - x1 - height = y2 - y1 - self.blockdata = [] - for y in xrange(height): - for x in xrange(width): - self.blockdata += [blockdata[start + y * self.width + x]] - self.blockdata = bytearray(self.blockdata) - self.width = width - self.height = height - - def draw(self): - for i in xrange(len(self.blockdata)): - block_x = i % self.width - block_y = i / self.width - self.draw_block(block_x, block_y) - - def draw_block(self, block_x, block_y): - # the canvas starts at 4, 4 for some reason - # probably something to do with a border - index, indey = 4, 4 - - # Draw one block (4x4 tiles) - block = self.blockdata[block_y * self.width + block_x] - for j, tile in enumerate(self.tileset.blocks[block]): - try: - # Tile gfx are split in half to make vram mapping easier - if tile >= 0x80: - tile -= 0x20 - tile_x = block_x * 32 + (j % 4) * 8 - tile_y = block_y * 32 + (j / 4) * 8 - self.canvas.create_image(index + tile_x, indey + tile_y, image=self.tileset.tiles[tile]) - except: - pass + def __init__(self, parent, name=None, width=20, height=20, tileset_id=2, blockdata_filename=None, config=config): + self.parent = parent + + self.name = name + + self.config = config + self.log = logging.getLogger("{0}.{1}".format(self.__class__.__name__, id(self))) + + self.blockdata_filename = blockdata_filename + if not self.blockdata_filename and self.name: + self.blockdata_filename = os.path.join(self.config.map_dir, self.name + '.blk') + elif not self.blockdata_filename: + self.blockdata_filename = '' + + asm_filename = '' + if self.name: + if self.config.asm_dir is not None: + asm_filename = os.path.join(self.config.asm_dir, self.name + '.asm') + elif self.config.asm_path is not None: + asm_filename = self.config.asm_path + + if os.path.exists(asm_filename): + for props in [map_header(self.name, config=self.config), second_map_header(self.name, config=self.config)]: + self.__dict__.update(props) + self.asm = open(asm_filename, 'r').read() + self.events = event_header(self.asm, self.name) + self.scripts = script_header(self.asm, self.name) + + self.tileset_id = eval(self.tileset_id, self.config.constants) + + self.width = eval(self.width, self.config.constants) + self.height = eval(self.height, self.config.constants) + + else: + self.width = width + self.height = height + self.tileset_id = tileset_id + + if self.blockdata_filename: + self.blockdata = bytearray(open(self.blockdata_filename, 'rb').read()) + else: + self.blockdata = [] + + self.tileset = Tileset(self.tileset_id, config=self.config) + + def init_canvas(self, parent=None): + if parent == None: + parent = self.parent + if not hasattr(self, 'canvas'): + self.canvas_width = self.width * 32 + self.canvas_height = self.height * 32 + self.canvas = Canvas(parent, width=self.canvas_width, height=self.canvas_height) + self.canvas.xview_moveto(0) + self.canvas.yview_moveto(0) + + def kill_canvas(self): + if hasattr(self, 'canvas'): + self.canvas.destroy() + + def crop(self, x1, y1, x2, y2): + blockdata = self.blockdata + start = y1 * self.width + x1 + width = x2 - x1 + height = y2 - y1 + self.blockdata = [] + for y in xrange(height): + for x in xrange(width): + self.blockdata += [blockdata[start + y * self.width + x]] + self.blockdata = bytearray(self.blockdata) + self.width = width + self.height = height + + def draw(self): + for i in xrange(len(self.blockdata)): + block_x = i % self.width + block_y = i / self.width + self.draw_block(block_x, block_y) + + def draw_block(self, block_x, block_y): + # the canvas starts at 4, 4 for some reason + # probably something to do with a border + index, indey = 4, 4 + + # Draw one block (4x4 tiles) + block = self.blockdata[block_y * self.width + block_x] + for j, tile in enumerate(self.tileset.blocks[block]): + try: + # Tile gfx are split in half to make vram mapping easier + if tile >= 0x80: + tile -= 0x20 + tile_x = block_x * 32 + (j % 4) * 8 + tile_y = block_y * 32 + (j / 4) * 8 + self.canvas.create_image(index + tile_x, indey + tile_y, image=self.tileset.tiles[tile]) + except: + pass class Tileset: - def __init__(self, tileset_id=0): - self.id = tileset_id - - self.tile_width = 8 - self.tile_height = 8 - self.block_width = 4 - self.block_height = 4 - - self.alpha = 255 - - if palettes_on: - self.get_palettes() - self.get_palette_map() - - self.get_blocks() - self.get_tiles() - - def get_tileset_gfx_filename(self): - filename = None - - if version == 'red': - tileset_defs = open(os.path.join(conf.path, 'main.asm'), 'r').read() - incbin = asm_at_label(tileset_defs, 'Tset%.2X_GFX' % self.id) - print incbin - filename = read_header_macros(incbin, ['filename'], ['INCBIN'])[0][0].replace('"','').replace('.2bpp','.png') - filename = os.path.join(conf.path, filename) - print filename - - if not filename: - filename = os.path.join( - gfx_dir, - to_gfx_name(self.id) + '.png' - ) - - return filename - - def get_tiles(self): - filename = self.get_tileset_gfx_filename() - if not os.path.exists(filename): - import gfx - gfx.to_png(filename.replace('.png','.2bpp'), filename) - self.img = Image.open(filename) - self.img.width, self.img.height = self.img.size - self.tiles = [] - cur_tile = 0 - for y in xrange(0, self.img.height, self.tile_height): - for x in xrange(0, self.img.width, self.tile_width): - tile = self.img.crop((x, y, x + self.tile_width, y + self.tile_height)) - - if hasattr(self, 'palette_map') and hasattr(self, 'palettes'): - # Palette maps are padded to make vram mapping easier. - pal = self.palette_map[cur_tile + 0x20 if cur_tile >= 0x60 else cur_tile] & 0x7 - tile = self.colorize_tile(tile, self.palettes[pal]) - - self.tiles += [ImageTk.PhotoImage(tile)] - cur_tile += 1 - - def colorize_tile(self, tile, palette): - 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 - - def get_blocks(self): - filename = os.path.join( - block_dir, - to_gfx_name(self.id) + block_ext - ) - self.blocks = [] - block_length = self.block_width * self.block_height - blocks = bytearray(open(filename, 'rb').read()) - for block in xrange(len(blocks) / (block_length)): - i = block * block_length - self.blocks += [blocks[i : i + block_length]] - - def get_palette_map(self): - filename = os.path.join( - palmap_dir, - str(self.id).zfill(2) + '_palette_map.bin' - ) - self.palette_map = [] - palmap = bytearray(open(filename, 'rb').read()) - for i in xrange(len(palmap)): - self.palette_map += [palmap[i] & 0xf] - self.palette_map += [(palmap[i] >> 4) & 0xf] - - def get_palettes(self): - filename = os.path.join( - palette_dir, - ['morn', 'day', 'nite'][time_of_day] + '.pal' - ) - self.palettes = get_palettes(filename) - - -time_of_day = 1 - + def __init__(self, tileset_id=0, config=config): + self.config = config + self.log = logging.getLogger("{0}.{1}".format(self.__class__.__name__, id(self))) + + self.id = tileset_id + + self.tile_width = 8 + self.tile_height = 8 + self.block_width = 4 + self.block_height = 4 + + self.alpha = 255 + + if self.config.palettes_on: + self.get_palettes() + self.get_palette_map() + + self.get_blocks() + self.get_tiles() + + def get_tileset_gfx_filename(self): + filename = None + + if self.config.version == 'red': + tileset_defs = open(os.path.join(self.config.path, 'main.asm'), 'r').read() + incbin = asm_at_label(tileset_defs, 'Tset%.2X_GFX' % self.id) + self.log.debug(incbin) + filename = read_header_macros(incbin, ['filename'], ['INCBIN'])[0][0].replace('"','').replace('.2bpp','.png') + filename = os.path.join(self.config.path, filename) + self.log.debug(filename) + + if not filename: + filename = os.path.join( + self.config.gfx_dir, + self.config.to_gfx_name(self.id) + '.png' + ) + + return filename + + def get_tiles(self): + filename = self.get_tileset_gfx_filename() + if not os.path.exists(filename): + gfx.to_png(filename.replace('.png','.2bpp'), filename) + self.img = Image.open(filename) + self.img.width, self.img.height = self.img.size + self.tiles = [] + cur_tile = 0 + for y in xrange(0, self.img.height, self.tile_height): + for x in xrange(0, self.img.width, self.tile_width): + tile = self.img.crop((x, y, x + self.tile_width, y + self.tile_height)) + + if hasattr(self, 'palette_map') and hasattr(self, 'palettes'): + # Palette maps are padded to make vram mapping easier. + pal = self.palette_map[cur_tile + 0x20 if cur_tile >= 0x60 else cur_tile] & 0x7 + tile = self.colorize_tile(tile, self.palettes[pal]) + + self.tiles += [ImageTk.PhotoImage(tile)] + cur_tile += 1 + + def colorize_tile(self, tile, palette): + 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 + + def get_blocks(self): + filename = os.path.join( + self.config.block_dir, + self.config.to_gfx_name(self.id) + self.config.block_ext + ) + self.blocks = [] + block_length = self.block_width * self.block_height + blocks = bytearray(open(filename, 'rb').read()) + for block in xrange(len(blocks) / (block_length)): + i = block * block_length + self.blocks += [blocks[i : i + block_length]] + + def get_palette_map(self): + filename = os.path.join( + self.config.palmap_dir, + str(self.id).zfill(2) + '_palette_map.bin' + ) + self.palette_map = [] + palmap = bytearray(open(filename, 'rb').read()) + for i in xrange(len(palmap)): + self.palette_map += [palmap[i] & 0xf] + self.palette_map += [(palmap[i] >> 4) & 0xf] + + def get_palettes(self): + filename = os.path.join( + self.config.palette_dir, + ['morn', 'day', 'nite'][self.config.time_of_day] + '.pal' + ) + 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, - ] - ]] - return palettes - - - -def get_available_maps(): - for root, dirs, files in os.walk(map_dir): - for filename in files: - base_name, ext = os.path.splitext(filename) - if ext == '.blk': - yield base_name - - -def map_header(name): - if version == 'crystal': - headers = open(os.path.join(header_dir, 'map_headers.asm'), 'r').read() - label = name + '_MapHeader' - header = asm_at_label(headers, label) - macros = [ 'db', 'db', 'db', 'dw', 'db', 'db', 'db', 'db' ] - attributes = [ - 'bank', - 'tileset_id', - 'permission', - 'second_map_header', - 'world_map_location', - 'music', - 'time_of_day', - 'fishing_group', - ] - values, l = read_header_macros(header, attributes, macros) - attrs = dict(zip(attributes, values)) - return attrs - - elif version == 'red': - headers = open(header_path, 'r').read() - - # there has to be a better way to do this - lower_label = name + '_h' - i = headers.lower().find(lower_label) - if i == -1: - return {} - label = headers[i:i+len(lower_label)] - - header = asm_at_label(headers, label) - macros = [ 'db', 'db', 'db', 'dw', 'dw', 'dw', 'db' ] - attributes = [ - 'tileset_id', - 'height', - 'width', - 'blockdata_label', - 'text_label', - 'script_label', - 'which_connections', - ] - values, l = read_header_macros(header, attributes, macros) - - attrs = dict(zip(attributes, values)) - attrs['connections'], l = connections(attrs['which_connections'], header, l) - - macros = [ 'dw' ] - attributes = [ - 'object_label', - ] - values, l = read_header_macros(header[l:], attributes, macros) - attrs.update(dict(zip(attributes, values))) - - return attrs - - return {} - -def second_map_header(name): - if version == 'crystal': - headers = open(os.path.join(header_dir, 'second_map_headers.asm'), 'r').read() - label = name + '_SecondMapHeader' - header = asm_at_label(headers, label) - macros = [ 'db', 'db', 'db', 'db', 'dw', 'db', 'dw', 'dw', 'db' ] - attributes = [ - 'border_block', - 'height', - 'width', - 'blockdata_bank', - 'blockdata_label', - 'script_header_bank', - 'script_header_label', - 'map_event_header_label', - 'which_connections', - ] - - values, l = read_header_macros(header, attributes, macros) - attrs = dict(zip(attributes, values)) - attrs['connections'], l = connections(attrs['which_connections'], header, l) - return attrs - - return {} - -def connections(which_connections, header, l=0): - directions = { 'north': {}, 'south': {}, 'west': {}, 'east': {} } - - if version == 'crystal': - macros = [ 'db', 'db' ] - attributes = [ - 'map_group', - 'map_no', - ] - - elif version == 'red': - macros = [ 'db' ] - attributes = [ - 'map_id', - ] - - macros += [ 'dw', 'dw', 'db', 'db', 'db', 'db', 'dw' ] - attributes += [ - 'strip_pointer', - 'strip_destination', - 'strip_length', - 'map_width', - 'y_offset', - 'x_offset', - 'window', - ] - for d in directions.keys(): - if d.upper() in which_connections: - values, l = read_header_macros(header, attributes, macros) - header = header[l:] - directions[d] = dict(zip(attributes, values)) - if version == 'crystal': - directions[d]['map_name'] = directions[d]['map_group'].replace('GROUP_', '').title().replace('_','') - elif version == 'red': - directions[d]['map_name'] = directions[d]['map_id'].title().replace('_','') - return directions, l + 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, + ] + ]] + return palettes + +def get_available_maps(config=config): + for root, dirs, files in os.walk(config.map_dir): + for filename in files: + base_name, ext = os.path.splitext(filename) + if ext == '.blk': + yield base_name + +def map_header(name, config=config): + if config.version == 'crystal': + headers = open(os.path.join(config.header_dir, 'map_headers.asm'), 'r').read() + label = name + '_MapHeader' + header = asm_at_label(headers, label) + macros = [ 'db', 'db', 'db', 'dw', 'db', 'db', 'db', 'db' ] + attributes = [ + 'bank', + 'tileset_id', + 'permission', + 'second_map_header', + 'world_map_location', + 'music', + 'time_of_day', + 'fishing_group', + ] + values, l = read_header_macros(header, attributes, macros) + attrs = dict(zip(attributes, values)) + return attrs + + elif config.version == 'red': + headers = open(config.header_path, 'r').read() + + # there has to be a better way to do this + lower_label = name + '_h' + i = headers.lower().find(lower_label) + if i == -1: + return {} + label = headers[i:i+len(lower_label)] + + header = asm_at_label(headers, label) + macros = [ 'db', 'db', 'db', 'dw', 'dw', 'dw', 'db' ] + attributes = [ + 'tileset_id', + 'height', + 'width', + 'blockdata_label', + 'text_label', + 'script_label', + 'which_connections', + ] + values, l = read_header_macros(header, attributes, macros) + + attrs = dict(zip(attributes, values)) + attrs['connections'], l = connections(attrs['which_connections'], header, l, config=config) + + macros = [ 'dw' ] + attributes = [ + 'object_label', + ] + values, l = read_header_macros(header[l:], attributes, macros) + attrs.update(dict(zip(attributes, values))) + + return attrs + + return {} + +def second_map_header(name, config=config): + if config.version == 'crystal': + headers = open(os.path.join(config.header_dir, 'second_map_headers.asm'), 'r').read() + label = name + '_SecondMapHeader' + header = asm_at_label(headers, label) + macros = [ 'db', 'db', 'db', 'db', 'dw', 'db', 'dw', 'dw', 'db' ] + attributes = [ + 'border_block', + 'height', + 'width', + 'blockdata_bank', + 'blockdata_label', + 'script_header_bank', + 'script_header_label', + 'map_event_header_label', + 'which_connections', + ] + + values, l = read_header_macros(header, attributes, macros) + attrs = dict(zip(attributes, values)) + attrs['connections'], l = connections(attrs['which_connections'], header, l) + return attrs + + return {} + +def connections(which_connections, header, l=0, config=config): + directions = { 'north': {}, 'south': {}, 'west': {}, 'east': {} } + + if config.version == 'crystal': + macros = [ 'db', 'db' ] + attributes = [ + 'map_group', + 'map_no', + ] + + elif config.version == 'red': + macros = [ 'db' ] + attributes = [ + 'map_id', + ] + + macros += [ 'dw', 'dw', 'db', 'db', 'db', 'db', 'dw' ] + attributes += [ + 'strip_pointer', + 'strip_destination', + 'strip_length', + 'map_width', + 'y_offset', + 'x_offset', + 'window', + ] + for d in directions.keys(): + if d.upper() in which_connections: + values, l = read_header_macros(header, attributes, macros) + header = header[l:] + directions[d] = dict(zip(attributes, values)) + if config.version == 'crystal': + directions[d]['map_name'] = directions[d]['map_group'].replace('GROUP_', '').title().replace('_','') + elif config.version == 'red': + directions[d]['map_name'] = directions[d]['map_id'].title().replace('_','') + return directions, l def read_header_macros(header, attributes, macros): - values = [] - i = 0 - l = 0 - for l, (asm, comment) in enumerate(header): - if asm.strip() != '': - mvalues = macro_values(asm, macros[i]) - values += mvalues - i += len(mvalues) - if len(values) >= len(attributes): - l += 1 - break - return values, l - + values = [] + i = 0 + l = 0 + for l, (asm, comment) in enumerate(header): + if asm.strip() != '': + mvalues = macro_values(asm, macros[i]) + values += mvalues + i += len(mvalues) + if len(values) >= len(attributes): + l += 1 + break + return values, l def event_header(asm, name): - return {} + return {} def script_header(asm, name): - return {} - + return {} def macro_values(line, macro): - values = line[line.find(macro) + len(macro):].split(',') - values = [v.replace('$','0x').strip() for v in values] - if values[0] == 'w': # dbw - values = values[1:] - return values - -def db_value(line): - macro = 'db' - return macro_values(line, macro) - -def db_values(line): - macro = 'db' - return macro_values(line, macro) - - -from preprocessor import separate_comment + values = line[line.find(macro) + len(macro):].split(',') + values = [v.replace('$','0x').strip() for v in values] + if values[0] == 'w': # dbw + values = values[1:] + return values def asm_at_label(asm, label): - label_def = label + ':' - lines = asm.split('\n') - for line in lines: - if line.startswith(label_def): - lines = lines[lines.index(line):] - lines[0] = lines[0][len(label_def):] - break - # go until the next label - content = [] - for line in lines: - l, comment = separate_comment(line + '\n') - if ':' in l: - break - content += [[l, comment]] - return content - -def main(): + label_def = label + ':' + lines = asm.split('\n') + for line in lines: + if line.startswith(label_def): + lines = lines[lines.index(line):] + lines[0] = lines[0][len(label_def):] + break + # go until the next label + content = [] + for line in lines: + l, comment = preprocessor.separate_comment(line + '\n') + if ':' in l: + break + content += [[l, comment]] + return content + +def main(config=config): """ Launches the map editor. """ root = Tk() root.wm_title("MAP EDITOR") - app = Application(master=root) + app = Application(master=root, config=config) try: app.mainloop() @@ -664,4 +720,7 @@ def main(): pass if __name__ == "__main__": - main() + setup_logging() + config = configure_for_version("crystal", config) + get_constants(config=config) + main(config=config) diff --git a/pokemontools/preprocessor.py b/pokemontools/preprocessor.py index 24a74e1..bde5f70 100644 --- a/pokemontools/preprocessor.py +++ b/pokemontools/preprocessor.py @@ -552,8 +552,6 @@ class Preprocessor(object): # remove trailing newline if line[-1] == "\n": line = line[:-1] - else: - original_line += "\n" # remove first tab has_tab = False @@ -585,6 +583,12 @@ class Preprocessor(object): sys.stdout.write(original_line) return + # rgbasm can handle other macros too + if "is_rgbasm_macro" in dir(macro): + if macro.is_rgbasm_macro: + sys.stdout.write(original_line) + return + # certain macros don't need an initial byte written # do: all scripting macros # don't: signpost, warp_def, person_event, xy_trigger @@ -604,7 +608,7 @@ class Preprocessor(object): index = 0 while index < len(params): param_type = macro.param_types[index - correction] - description = param_type["name"] + description = param_type["name"].strip() param_klass = param_type["class"] byte_type = param_klass.byte_type # db or dw size = param_klass.size @@ -634,7 +638,7 @@ class Preprocessor(object): index += 2 correction += 1 elif size == 3 and "from_asm" in dir(param_klass): - output += ("db " + param_klass.from_asm(param) + "\n") + output += ("\t" + byte_type + " " + param_klass.from_asm(param) + "\n") index += 1 else: raise exceptions.MacroException( @@ -645,9 +649,13 @@ class Preprocessor(object): ) ) + 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 += (byte_type + " " + param + " ; " + description + "\n") + output += ("\t" + byte_type + " " + param + " ; " + description + "\n") index += 1 diff --git a/pokemontools/redmusicdisasm.py b/pokemontools/redmusicdisasm.py index ad370cd..3ed5a4d 100755 --- a/pokemontools/redmusicdisasm.py +++ b/pokemontools/redmusicdisasm.py @@ -1,5 +1,5 @@ -import config
-config = config.Config()
+import configuration
+config = configuration.Config()
rom = bytearray(open(config.rom_path, "r").read())
songs = [
@@ -49,22 +49,26 @@ songs = [ "MeetFemaleTrainer",
"MeetMaleTrainer",
"UnusedSong",
- #"SurfingPikachu",
- #"MeetJessieJames",
- #"YellowUnusedSong",
]
-
+"""
+songs = [
+ "YellowIntro",
+ "SurfingPikachu",
+ "MeetJessieJames",
+ "YellowUnusedSong",
+ ]
+"""
music_commands = {
0xd0: ["notetype", {"type": "nibble"}, 2],
0xe0: ["octave", 1],
- 0xe8: ["unknownmusic0xe8", 1],
+ 0xe8: ["togglecall", 1],
0xea: ["vibrato", {"type": "byte"}, {"type": "nibble"}, 3],
0xeb: ["pitchbend", {"type": "byte"}, {"type": "byte"}, 3],
0xec: ["duty", {"type": "byte"}, 2],
0xed: ["tempo", {"type": "byte"}, {"type": "byte"}, 3],
0xee: ["unknownmusic0xee", {"type": "byte"}, 2],
0xf0: ["stereopanning", {"type": "byte"}, 2],
- 0xf8: ["unknownmusic0xf8", 1],
+ 0xf8: ["executemusic", 1],
0xfc: ["dutycycle", {"type": "byte"}, 2],
0xfd: ["callchannel", {"type": "label"}, 3],
0xfe: ["loopchannel", {"type": "byte"}, {"type": "label"}, 4],
@@ -189,6 +193,7 @@ for i, songname in enumerate(songs): if songname == "PalletTown": header = 0x822e
if songname == "GymLeaderBattle": header = 0x202be
if songname == "TitleScreen": header = 0x7c249
+ if songname == "YellowIntro": header = 0x7c294
if songname == "SurfingPikachu": header = 0x801cb
bank = header / 0x4000
startingaddress = rom[header + 2] * 0x100 + rom[header + 1] - 0x4000 + (0x4000 * bank)
@@ -212,6 +217,8 @@ for i, songname in enumerate(songs): if curchannel == 1:
labels.append(0x719b)
labelsleft.append(0x719b)
+ labels.append(0x71a2)
+ labelsleft.append(0x71a2)
if curchannel == 2:
labels.append(0x721d)
labelsleft.append(0x721d)
diff --git a/pokemontools/redsfxdisasm.py b/pokemontools/redsfxdisasm.py index b84becb..9e9f01b 100755 --- a/pokemontools/redsfxdisasm.py +++ b/pokemontools/redsfxdisasm.py @@ -1,5 +1,5 @@ -import config
-config = config.Config()
+import configuration
+config = configuration.Config()
rom = bytearray(open(config.rom_path, "r").read())
banks = {
@@ -11,28 +11,13 @@ banks = { music_commands = {
0xd0: ["notetype", {"type": "nibble"}, 2],
0xe0: ["octave", 1],
- 0xe8: ["unknownmusic0xe8", 1],
- 0xe9: ["unknownmusic0xe9", 1],
+ 0xe8: ["togglecall", 1],
0xea: ["vibrato", {"type": "byte"}, {"type": "nibble"}, 3],
- 0xeb: ["pitchbend", {"type": "byte"}, {"type": "byte"}, 3],
0xec: ["duty", {"type": "byte"}, 2],
0xed: ["tempo", {"type": "byte"}, {"type": "byte"}, 3],
- 0xee: ["unknownmusic0xee", {"type": "byte"}, 2],
- 0xef: ["unknownmusic0xef", 1],
0xf0: ["stereopanning", {"type": "byte"}, 2],
- 0xf1: ["unknownmusic0xf1", 1],
- 0xf2: ["unknownmusic0xf2", 1],
- 0xf3: ["unknownmusic0xf3", 1],
- 0xf4: ["unknownmusic0xf4", 1],
- 0xf5: ["unknownmusic0xf5", 1],
- 0xf6: ["unknownmusic0xf6", 1],
- 0xf7: ["unknownmusic0xf7", 1],
- 0xf8: ["unknownmusic0xf8", 1],
- 0xf9: ["unknownmusic0xf9", 1],
- 0xfa: ["unknownmusic0xfa", 1],
- #0xfb: ["unknownmusic0xfb", 1],
+ 0xf8: ["executemusic", 1],
0xfc: ["dutycycle", {"type": "byte"}, 2],
- 0xfd: ["callchannel", {"type": "label"}, 3],
0xfe: ["loopchannel", {"type": "byte"}, {"type": "label"}, 4],
0xff: ["endchannel", 1],
}
@@ -62,45 +47,35 @@ for bank in banks: header = bank * 0x4000 + 3
for sfx in range(1,banks[bank]):
sfxname = "SFX_{:02x}_{:02x}".format(bank, sfx)
- sfxfile = open("music/sfx/" + sfxname.lower() + ".asm", 'a')
+ sfxfile = open("music/sfx/" + sfxname.lower() + ".asm", 'w')
startingaddress = rom[header + 2] * 0x100 + rom[header + 1] + (0x4000 * (bank - 1))
+ end = 0
curchannel = 1
lastchannel = (rom[header] >> 6) + 1
+ channelnumber = rom[header] % 0x10
output = ''
while 1:
- # pass 1, build a list of all addresses pointed to by calls and loops
- address = startingaddress
- labels = []
- labelsleft = []
- while 1:
- byte = rom[address]
- if byte < 0xd0:
- command_length = 1
- elif byte < 0xe0:
- command_length = 2
- elif byte < 0xe8:
- command_length = 1
- else:
- command_length = music_commands[byte][-1]
- if byte == 0xfd or byte == 0xfe:
- label = rom[address + command_length - 1] * 0x100 + rom[address + command_length - 2]
- labels.append(label)
- if label > address % 0x4000 + 0x4000: labelsleft.append(label)
- address += command_length
- if len(labelsleft) == 0 and (byte == 0xfe and rom[address - command_length + 1] == 0 and rom[address - 1] * 0x100 + rom[address - 2] < address % 0x4000 + 0x4000 or byte == 0xff): break
- while address % 0x4000 + 0x4000 in labelsleft: labelsleft.remove(address % 0x4000 + 0x4000)
- # once the loop breaks, start over from first address
- end = address
- if curchannel != lastchannel: end = rom[header + 5] * 0x100 + rom[header + 4] + (0x4000 * (bank - 1))
address = startingaddress
+ if curchannel != lastchannel:
+ end = rom[header + 5] * 0x100 + rom[header + 4] + (0x4000 * (bank - 1))
byte = rom[address]
- # pass 2, print commands and labels for addresses that are in labels
- while address != end:
- if address == startingaddress:
- output += "{}_Ch{}: ; {:02x} ({:0x}:{:02x})\n".format(sfxname, curchannel, address, bank, address % 0x4000 + 0x4000)
- elif address % 0x4000 + 0x4000 in labels:
+ if byte == 0xf8 or (bank == 2 and sfx == 0x5e): executemusic = True
+ else: executemusic = False
+ output += "{}_Ch{}: ; {:02x} ({:0x}:{:02x})\n".format(sfxname, curchannel, address, bank, address % 0x4000 + 0x4000)
+ while 1:
+ if address == 0x2062a or address == 0x2063d or address == 0x20930:
output += "\n{}_branch_{:02x}:\n".format(sfxname, address)
- if byte < 0xc0:
+ if byte == 0x10 and not executemusic:
+ output += "\tunknownsfx0x{:02x} {}".format(byte, rom[address + 1])
+ command_length = 2
+ elif byte < 0x30 and not executemusic:
+ if channelnumber == 7:
+ output += "\tunknownnoise0x20 {}, {}, {}".format(byte % 0x10, rom[address + 1], rom[address + 2])
+ command_length = 3
+ else:
+ output += "\tunknownsfx0x20 {}, {}, {}, {}".format(byte % 0x10, rom[address + 1], rom[address + 2], rom[address + 3])
+ command_length = 4
+ elif byte < 0xc0:
output += "\tnote {}, {}".format(music_notes[byte >> 4], byte % 0x10 + 1)
command_length = 1
elif byte < 0xd0:
@@ -138,12 +113,14 @@ for bank in banks: if params != len(music_commands[byte]) - 1: output += ","
output += "\n"
address += command_length
+ if byte == 0xff or address == end: break
byte = rom[address]
header += 3
+ channelnumber = rom[header]
if curchannel == lastchannel:
output += "; {}".format(hex(address))
sfxfile.write(output)
break
output += "\n\n"
- startingaddress = end
+ startingaddress = address
curchannel += 1
\ No newline at end of file diff --git a/pokemontools/redsfxheaders.py b/pokemontools/redsfxheaders.py index 7b04701..c854c20 100755 --- a/pokemontools/redsfxheaders.py +++ b/pokemontools/redsfxheaders.py @@ -1,5 +1,5 @@ -import config
-config = config.Config()
+import configuration
+config = configuration.Config()
rom = bytearray(open(config.rom_path, "r").read())
headerlist = (
@@ -8,13 +8,6 @@ headerlist = ( ["sfxheaders1f.asm", 0x7c003, 0x7c249],
)
-numberofchannels = {
- 0x0: 1,
- 0x4: 2,
- 0x8: 3,
- 0xC: 4,
- }
-
def printsfxheaders(filename, address, end):
file = open(filename, 'w')
bank = address / 0x4000
@@ -24,7 +17,7 @@ def printsfxheaders(filename, address, end): file.write("SFX_Headers_{:02x}:\n".format(bank))
file.write("\tdb $ff, $ff, $ff ; padding\n")
while address != end:
- left = numberofchannels[byte >> 4]
+ left = (byte >> 6) + 1
file.write("\nSFX_{:02x}_{:02x}: ; {:02x} ({:0x}:{:02x})\n".format(bank, sfx, address, bank, address % 0x4000 + 0x4000))
while left != 0:
pointer = rom[address + 2] * 0x100 + rom[address + 1]
diff --git a/pokemontools/sfx_names.py b/pokemontools/sfx_names.py new file mode 100644 index 0000000..f7da967 --- /dev/null +++ b/pokemontools/sfx_names.py @@ -0,0 +1,211 @@ +# coding: utf-8 + +sfx_names = [ + 'DexFanfare5079', + 'Item', + 'CaughtMon', + 'PokeballsPlacedOnTable', + 'Potion', + 'FullHeal', + 'Menu', + 'ReadText', + 'ReadText2', + 'DexFanfare2049', + 'DexFanfare80109', + 'Poison', + 'GotSafariBalls', + 'BootPc', + 'ShutDownPc', + 'ChoosePcOption', + 'EscapeRope', + 'PushButton', + 'SecondPartOfItemfinder', + 'WarpTo', + 'WarpFrom', + 'ChangeDexMode', + 'JumpOverLedge', + 'GrassRustle', + 'Fly', + 'Wrong', + 'Squeak', + 'Strength', + 'Boat', + 'WallOpen', + 'PlacePuzzlePieceDown', + 'EnterDoor', + 'SwitchPokemon', + 'Tally', + 'Transaction', + 'ExitBuilding', + 'Bump', + 'Save', + 'Pokeflute', + 'ElevatorEnd', + 'ThrowBall', + 'BallPoof', + 'Unknown3A', + 'Run', + 'SlotMachineStart', + 'Fanfare', + 'Peck', + 'Kinesis', + 'Lick', + 'Pound', + 'MovePuzzlePiece', + 'CometPunch', + 'MegaPunch', + 'Scratch', + 'Vicegrip', + 'RazorWind', + 'Cut', + 'WingAttack', + 'Whirlwind', + 'Bind', + 'VineWhip', + 'DoubleKick', + 'MegaKick', + 'Headbutt', + 'HornAttack', + 'Tackle', + 'PoisonSting', + 'Powder', + 'Doubleslap', + 'Bite', + 'JumpKick', + 'Stomp', + 'TailWhip', + 'KarateChop', + 'Submission', + 'WaterGun', + 'SwordsDance', + 'Thunder', + 'Supersonic', + 'Leer', + 'Ember', + 'Bubblebeam', + 'HydroPump', + 'Surf', + 'Psybeam', + 'Charge', + 'Thundershock', + 'Psychic', + 'Screech', + 'BoneClub', + 'Sharpen', + 'EggBomb', + 'Sing', + 'HyperBeam', + 'Shine', + 'Unknown5F', + 'Unknown60', + 'Unknown61', + 'Unknown62', + 'Unknown63', + 'Burn', + 'TitleScreenEntrance', + 'Unknown66', + 'GetCoinFromSlots', + 'PayDay', + 'Metronome', + 'Call', + 'HangUp', + 'NoSignal', + 'Sandstorm', + 'Elevator', + 'Protect', + 'Sketch', + 'RainDance', + 'Aeroblast', + 'Spark', + 'Curse', + 'Rage', + 'Thief', + 'Thief2', + 'SpiderWeb', + 'MindReader', + 'Nightmare', + 'Snore', + 'SweetKiss', + 'SweetKiss2', + 'BellyDrum', + 'Unknown7F', + 'SludgeBomb', + 'Foresight', + 'Spite', + 'Outrage', + 'PerishSong', + 'GigaDrain', + 'Attract', + 'Kinesis2', + 'ZapCannon', + 'MeanLook', + 'HealBell', + 'Return', + 'ExpBar', + 'MilkDrink', + 'Present', + 'MorningSun', + 'LevelUp', + 'KeyItem', + 'Fanfare2', + 'RegisterPhoneNumber', + '3RdPlace', + 'GetEggFromDaycareMan', + 'GetEggFromDaycareLady', + 'MoveDeleted', + '2NdPlace', + '1StPlace', + 'ChooseACard', + 'GetTm', + 'GetBadge', + 'QuitSlots', + 'EggCrack', + 'DexFanfareLessThan20', + 'DexFanfare140169', + 'DexFanfare170199', + 'DexFanfare200229', + 'DexFanfare230Plus', + 'Evolved', + 'MasterBall', + 'EggHatch', + 'GsIntroCharizardFireball', + 'GsIntroPokemonAppears', + 'Flash', + 'GameFreakLogoGs', + 'NotVeryEffective', + 'Damage', + 'SuperEffective', + 'BallBounce', + 'Moonlight', + 'Encore', + 'BeatUp', + 'BatonPass', + 'BallWiggle', + 'SweetScent', + 'SweetScent2', + 'HitEndOfExpBar', + 'GiveTrademon', + 'GetTrademon', + 'TrainArrived', + 'StopSlot', + '2Boops', + 'GlassTing', + 'GlassTing2', + 'IntroUnown1', + 'IntroUnown2', + 'IntroUnown3', + 'DittoPopUp', + 'DittoTransform', + 'IntroSuicune1', + 'IntroPichu', + 'IntroSuicune2', + 'IntroSuicune3', + 'DittoBounce', + 'IntroSuicune4', + 'GameFreakPresents', + 'Tingle', + 'UnknownCb', + 'TwoPcBeeps', + '4NoteDitty', + 'Twinkle', +] diff --git a/pokemontools/song_names.py b/pokemontools/song_names.py new file mode 100644 index 0000000..1e48ca7 --- /dev/null +++ b/pokemontools/song_names.py @@ -0,0 +1,107 @@ +# coding: utf-8 + +song_names = [ + 'Nothing', + 'TitleScreen', + 'Route1', + 'Route3', + 'Route12', + 'MagnetTrain', + 'KantoGymBattle', + 'KantoTrainerBattle', + 'KantoWildBattle', + 'PokemonCenter', + 'LookHiker', + 'LookLass', + 'LookOfficer', + 'HealPokemon', + 'LavenderTown', + 'Route2', + 'MtMoon', + 'ShowMeAround', + 'GameCorner', + 'Bicycle', + 'HallOfFame', + 'ViridianCity', + 'CeladonCity', + 'TrainerVictory', + 'WildPokemonVictory', + 'GymLeaderVictory', + 'MtMoonSquare', + 'Gym', + 'PalletTown', + 'ProfOaksPokemonTalk', + 'ProfOak', + 'LookRival', + 'AfterTheRivalFight', + 'Surf', + 'Evolution', + 'NationalPark', + 'Credits', + 'AzaleaTown', + 'CherrygroveCity', + 'LookKimonoGirl', + 'UnionCave', + 'JohtoWildBattle', + 'JohtoTrainerBattle', + 'Route30', + 'EcruteakCity', + 'VioletCity', + 'JohtoGymBattle', + 'ChampionBattle', + 'RivalBattle', + 'RocketBattle', + 'ElmsLab', + 'DarkCave', + 'Route29', + 'Route36', + 'SSAqua', + 'LookYoungster', + 'LookBeauty', + 'LookRocket', + 'LookPokemaniac', + 'LookSage', + 'NewBarkTown', + 'GoldenrodCity', + 'VermilionCity', + 'PokemonChannel', + 'PokeFluteChannel', + 'TinTower', + 'SproutTower', + 'BurnedTower', + 'Lighthouse', + 'LakeOfRage', + 'IndigoPlateau', + 'Route37', + 'RocketHideout', + 'DragonsDen', + 'JohtoWildBattleNight', + 'RuinsOfAlphRadio', + 'SuccessfulCapture', + 'Route26', + 'Mom', + 'VictoryRoad', + 'PokemonLullaby', + 'PokemonMarch', + 'GoldSilverOpening', + 'GoldSilverOpening2', + 'MainMenu', + 'RuinsOfAlphInterior', + 'RocketTheme', + 'DancingHall', + 'ContestResults', + 'BugCatchingContest', + 'LakeOfRageRocketRadio', + 'Printer', + 'PostCredits', + 'Clair', + 'MobileAdapterMenu', + 'MobileAdapter', + 'BuenasPassword', + 'LookMysticalMan', + 'CrystalOpening', + 'BattleTowerTheme', + 'SuicuneBattle', + 'BattleTowerLobby', + 'MobileCenter', +] diff --git a/pokemontools/vba/autoplayer.py b/pokemontools/vba/autoplayer.py index 9aa8f4a..af14d47 100644 --- a/pokemontools/vba/autoplayer.py +++ b/pokemontools/vba/autoplayer.py @@ -4,492 +4,580 @@ Programmatic speedrun of Pokémon Crystal """ import os -# bring in the emulator and basic tools -import vba - -def main(): - """ - Start the game. - """ - vba.load_rom() +import pokemontools.configuration as configuration - # get past the opening sequence - skip_intro() - - # walk to mom and handle her text - handle_mom() - - # walk outside into new bark town - walk_into_new_bark_town() - - # walk to elm and do whatever he wants - handle_elm("totodile") - - new_bark_level_grind(10, skip=False) +# bring in the emulator and basic tools +import vba as _vba def skippable(func): """ Makes a function skippable. - Saves the state before and after the function runs. - Pass "skip=True" to the function to load the previous save - state from when the function finished. + Saves the state before and after the function runs. Pass "skip=True" to the + function to load the previous save state from when the function finished. """ def wrapped_function(*args, **kwargs): + self = args[0] skip = True + override = True if "skip" in kwargs.keys(): skip = kwargs["skip"] del kwargs["skip"] + if "override" in kwargs.keys(): + override = kwargs["override"] + del kwargs["override"] + # override skip if there's no save if skip: full_name = func.__name__ + "-end.sav" - if not os.path.exists(os.path.join(vba.save_state_path, full_name)): + if not os.path.exists(os.path.join(self.config.save_state_path, full_name)): skip = False return_value = None if not skip: - vba.save_state(func.__name__ + "-start", override=True) + if override: + self.cry.save_state(func.__name__ + "-start", override=override) + return_value = func(*args, **kwargs) - vba.save_state(func.__name__ + "-end", override=True) + + if override: + self.cry.save_state(func.__name__ + "-end", override=override) elif skip: - vba.set_state(vba.load_state(func.__name__ + "-end")) + self.cry.vba.state = self.cry.load_state(func.__name__ + "-end") return return_value return wrapped_function -@skippable -def skip_intro(): +class Runner(object): """ - Skip the game boot intro sequence. + ``Runner`` is used to represent a set of functions that control an instance + of the emulator. This allows for automated runs of games. """ + pass - # copyright sequence - vba.nstep(400) +class SpeedRunner(Runner): + def __init__(self, cry=None, config=None): + super(SpeedRunner, self).__init__() - # skip the ditto sequence - vba.press("a") - vba.nstep(100) + self.cry = cry - # skip the start screen - vba.press("start") - vba.nstep(100) + if not config: + config = configuration.Config() - # click "new game" - vba.press("a", holdsteps=50, aftersteps=1) + self.config = config - # skip text up to "Are you a boy? Or are you a girl?" - vba.crystal.text_wait() + def setup(self): + """ + Configure this ``Runner`` instance to contain a reference to an active + emulator session. + """ + if not self.cry: + self.cry = _vba.crystal(config=self.config) - # select "Boy" - vba.press("a", holdsteps=50, aftersteps=1) + def main(self): + """ + Main entry point for complete control of the game as the main player. + """ + # get past the opening sequence + self.skip_intro(skip=True) - # text until "What time is it?" - vba.crystal.text_wait() + # walk to mom and handle her text + self.handle_mom(skip=True) - # select 10 o'clock - vba.press("a", holdsteps=50, aftersteps=1) + # walk outside into new bark town + self.walk_into_new_bark_town(skip=True) - # yes i mean it - vba.press("a", holdsteps=50, aftersteps=1) + # walk to elm and do whatever he wants + self.handle_elm("totodile", skip=True) - # "How many minutes?" 0 min. - vba.press("a", holdsteps=50, aftersteps=1) + self.new_bark_level_grind(17, skip=False) - # "Who! 0 min.?" yes/no select yes - vba.press("a", holdsteps=50, aftersteps=1) + @skippable + def skip_intro(self, stop_at_name_selection=False): + """ + Skip the game boot intro sequence. + """ - # read text until name selection - vba.crystal.text_wait() + # copyright sequence + self.cry.nstep(400) - # select "Chris" - vba.press("d", holdsteps=10, aftersteps=1) - vba.press("a", holdsteps=50, aftersteps=1) + # skip the ditto sequence + self.cry.vba.press("a") + self.cry.nstep(100) - def overworldcheck(): - """ - A basic check for when the game starts. - """ - return vba.get_memory_at(0xcfb1) != 0 + # skip the start screen + self.cry.vba.press("start") + self.cry.nstep(100) - # go until the introduction is done - vba.crystal.text_wait(callback=overworldcheck) + # click "new game" + self.cry.vba.press("a", hold=50, after=1) - return + # skip text up to "Are you a boy? Or are you a girl?" + self.cry.text_wait() -@skippable -def handle_mom(): - """ - Walk to mom. Handle her speech and questions. - """ + # select "Boy" + self.cry.vba.press("a", hold=50, after=1) - vba.crystal.move("r") - vba.crystal.move("r") - vba.crystal.move("r") - vba.crystal.move("r") + # text until "What time is it?" + self.cry.text_wait() - vba.crystal.move("u") - vba.crystal.move("u") - vba.crystal.move("u") + # select 10 o'clock + self.cry.vba.press("a", hold=50, after=1) - vba.crystal.move("d") - vba.crystal.move("d") + # yes i mean it + self.cry.vba.press("a", hold=50, after=1) - # move into mom's line of sight - vba.crystal.move("d") + # "How many minutes?" 0 min. + self.cry.vba.press("a", hold=50, after=1) - # let mom talk until "What day is it?" - vba.crystal.text_wait() + # "Who! 0 min.?" yes/no select yes + self.cry.vba.press("a", hold=50, after=1) - # "What day is it?" Sunday - vba.press("a", holdsteps=10) # Sunday + # read text until name selection + self.cry.text_wait() - vba.crystal.text_wait() + if stop_at_name_selection: + return - # "SUNDAY, is it?" yes/no - vba.press("a", holdsteps=10) # yes + # select "Chris" + self.cry.vba.press("d", hold=10, after=1) + self.cry.vba.press("a", hold=50, after=1) - vba.crystal.text_wait() + def overworldcheck(): + """ + A basic check for when the game starts. + """ + return self.cry.vba.memory[0xcfb1] != 0 - # "Is it Daylight Saving Time now?" yes/no - vba.press("a", holdsteps=10) # yes + # go until the introduction is done + self.cry.text_wait(callback=overworldcheck) - vba.crystal.text_wait() + return - # "AM DST, is that OK?" yes/no - vba.press("a", holdsteps=10) # yes + @skippable + def handle_mom(self): + """ + Walk to mom. Handle her speech and questions. + """ - # text until "know how to use the PHONE?" yes/no - vba.crystal.text_wait() + self.cry.move("r") + self.cry.move("r") + self.cry.move("r") + self.cry.move("r") - # press yes - vba.press("a", holdsteps=10) + self.cry.move("u") + self.cry.move("u") + self.cry.move("u") - # wait until mom is done talking - vba.crystal.text_wait() + self.cry.move("d") + self.cry.move("d") - # wait until the script is done running - vba.crystal.wait_for_script_running() + # move into mom's line of sight + self.cry.move("d") - return + # let mom talk until "What day is it?" + self.cry.text_wait() -@skippable -def walk_into_new_bark_town(): - """ - Walk outside after talking with mom. - """ + # "What day is it?" Sunday + self.cry.vba.press("a", hold=10) # Sunday - vba.crystal.move("d") - vba.crystal.move("d") - vba.crystal.move("d") - vba.crystal.move("l") - vba.crystal.move("l") + self.cry.text_wait() - # walk outside - vba.crystal.move("d") + # "SUNDAY, is it?" yes/no + self.cry.vba.press("a", hold=10) # yes -@skippable -def handle_elm(starter_choice): - """ - Walk to Elm's Lab and get a starter. - """ + self.cry.text_wait() - # walk to the lab - vba.crystal.move("l") - vba.crystal.move("l") - vba.crystal.move("l") - vba.crystal.move("l") - vba.crystal.move("l") - vba.crystal.move("l") - vba.crystal.move("l") - vba.crystal.move("u") - vba.crystal.move("u") + # "Is it Daylight Saving Time now?" yes/no + self.cry.vba.press("a", hold=10) # yes - # walk into the lab - vba.crystal.move("u") + self.cry.text_wait() - # talk to elm - vba.crystal.text_wait() + # "AM DST, is that OK?" yes/no + self.cry.vba.press("a", hold=10) # yes - # "that I recently caught." yes/no - vba.press("a", holdsteps=10) # yes + # text until "know how to use the PHONE?" yes/no + self.cry.text_wait() - # talk to elm some more - vba.crystal.text_wait() + # press yes + self.cry.vba.press("a", hold=10) - # talking isn't done yet.. - vba.crystal.text_wait() - vba.crystal.text_wait() - vba.crystal.text_wait() + # wait until mom is done talking + self.cry.text_wait() - # wait until the script is done running - vba.crystal.wait_for_script_running() + # wait until the script is done running + self.cry.wait_for_script_running() - # move toward the pokeballs - vba.crystal.move("r") + return - # move to cyndaquil - vba.crystal.move("r") + @skippable + def walk_into_new_bark_town(self): + """ + Walk outside after talking with mom. + """ - moves = 0 + self.cry.move("d") + self.cry.move("d") + self.cry.move("d") + self.cry.move("l") + self.cry.move("l") + + # walk outside + self.cry.move("d") + + @skippable + def handle_elm(self, starter_choice): + """ + Walk to Elm's Lab and get a starter. + """ + + # walk to the lab + self.cry.move("l") + self.cry.move("l") + self.cry.move("l") + self.cry.move("l") + self.cry.move("l") + self.cry.move("l") + self.cry.move("l") + self.cry.move("u") + self.cry.move("u") + + # walk into the lab + self.cry.move("u") + + # talk to elm + self.cry.text_wait() + + # "that I recently caught." yes/no + self.cry.vba.press("a", hold=10) # yes + + # talk to elm some more + self.cry.text_wait() + + # talking isn't done yet.. + self.cry.text_wait() + self.cry.text_wait() + self.cry.text_wait() + + # wait until the script is done running + self.cry.wait_for_script_running() + + # move toward the pokeballs + self.cry.move("r") + + # move to cyndaquil + self.cry.move("r") - if starter_choice.lower() == "cyndaquil": moves = 0 - if starter_choice.lower() == "totodile": - moves = 1 - else: - moves = 2 - for each in range(0, moves): - vba.crystal.move("r") + if starter_choice.lower() == "cyndaquil": + moves = 0 + elif starter_choice.lower() == "totodile": + moves = 1 + else: + moves = 2 - # face the pokeball - vba.crystal.move("u") + for each in range(0, moves): + self.cry.move("r") - # select it - vba.press("a", holdsteps=10, aftersteps=0) + # face the pokeball + self.cry.move("u") - # wait for the image to pop up - vba.crystal.text_wait() + # select it + self.cry.vba.press("a", hold=10, after=0) - # wait for the image to close - vba.crystal.text_wait() + # wait for the image to pop up + self.cry.text_wait() - # wait for the yes/no box - vba.crystal.text_wait() + # wait for the image to close + self.cry.text_wait() - # press yes - vba.press("a", holdsteps=10, aftersteps=0) + # wait for the yes/no box + self.cry.text_wait() - # wait for elm to talk a bit - vba.crystal.text_wait() + # press yes + self.cry.vba.press("a", hold=10, after=0) - # TODO: why didn't that finish his talking? - vba.crystal.text_wait() + # wait for elm to talk a bit + self.cry.text_wait() - # give a nickname? yes/no - vba.press("d", holdsteps=10, aftersteps=0) # move to "no" - vba.press("a", holdsteps=10, aftersteps=0) # no + # TODO: why didn't that finish his talking? + self.cry.text_wait() - # TODO: why didn't this wait until he was completely done? - vba.crystal.text_wait() - vba.crystal.text_wait() + # give a nickname? yes/no + self.cry.vba.press("d", hold=10, after=0) # move to "no" + self.cry.vba.press("a", hold=10, after=0) # no - # get the phone number - vba.crystal.text_wait() + # TODO: why didn't this wait until he was completely done? + self.cry.text_wait() + self.cry.text_wait() - # talk with elm a bit more - vba.crystal.text_wait() + # get the phone number + self.cry.text_wait() - # TODO: and again.. wtf? - vba.crystal.text_wait() + # talk with elm a bit more + self.cry.text_wait() - # wait until the script is done running - vba.crystal.wait_for_script_running() + # wait until the script is done running + self.cry.wait_for_script_running() - # move down - vba.crystal.move("d") - vba.crystal.move("d") - vba.crystal.move("d") - vba.crystal.move("d") + # move down + self.cry.move("d") + self.cry.move("d") + self.cry.move("d") + self.cry.move("d") - # move into the researcher's line of sight - vba.crystal.move("d") + # move into the researcher's line of sight + self.cry.move("d") - # get the potion from the person - vba.crystal.text_wait() - vba.crystal.text_wait() + # get the potion from the person + self.cry.text_wait() + self.cry.text_wait() - # wait for the script to end - vba.crystal.wait_for_script_running() + # wait for the script to end + self.cry.wait_for_script_running() - vba.crystal.move("d") - vba.crystal.move("d") - vba.crystal.move("d") + self.cry.move("d") + self.cry.move("d") + self.cry.move("d") - # go outside - vba.crystal.move("d") + # go outside + self.cry.move("d") - return + return -@skippable -def new_bark_level_grind(level): - """ - Do level grinding in New Bark. + @skippable + def new_bark_level_grind(self, level, walk_to_grass=True): + """ + Do level grinding in New Bark. - Starting just outside of Elm's Lab, do some level grinding until the first - partymon level is equal to the given value.. - """ + Starting just outside of Elm's Lab, do some level grinding until the + first partymon level is equal to the given value.. + """ - # walk to the grass area - new_bark_level_grind_walk_to_grass(skip=False) + # walk to the grass area + if walk_to_grass: + self.new_bark_level_grind_walk_to_grass(skip=False) - # TODO: walk around in grass, handle battles - walk = ["d", "d", "u", "d", "u", "d"] - for direction in walk: - vba.crystal.move(direction) + last_direction = "u" - # wait for wild battle to completely start - vba.crystal.text_wait() + # walk around in the grass until a battle happens + while self.cry.vba.memory[0xd22d] == 0: + if last_direction == "u": + direction = "d" + else: + direction = "u" - attacks = 5 + self.cry.move(direction) - while attacks > 0: - # FIGHT - vba.press("a", holdsteps=10, aftersteps=1) + last_direction = direction - # wait to select a move - vba.crystal.text_wait() + # wait for wild battle to completely start + self.cry.text_wait() - # SCRATCH - vba.press("a", holdsteps=10, aftersteps=1) + attacks = 5 - # wait for the move to be over - vba.crystal.text_wait() + while attacks > 0: + # FIGHT + self.cry.vba.press("a", hold=10, after=1) - hp = ((vba.get_memory_at(0xd218) << 8) | vba.get_memory_at(0xd217)) - print "enemy hp is: " + str(hp) + # wait to select a move + self.cry.text_wait() - if hp == 0: - print "enemy hp is zero, exiting" - break - else: + # SCRATCH + self.cry.vba.press("a", hold=10, after=1) + + # wait for the move to be over + self.cry.text_wait() + + hp = self.cry.get_enemy_hp() print "enemy hp is: " + str(hp) - attacks = attacks - 1 - - while vba.get_memory_at(0xd22d) != 0: - vba.press("a", holdsteps=10, aftersteps=1) - - # wait for the map to finish loading - vba.nstep(50) - - print "okay, back in the overworld" - - # move up - vba.crystal.move("u") - vba.crystal.move("u") - vba.crystal.move("u") - vba.crystal.move("u") - - # move into new bark town - vba.crystal.move("r") - vba.crystal.move("r") - vba.crystal.move("r") - vba.crystal.move("r") - vba.crystal.move("r") - vba.crystal.move("r") - vba.crystal.move("r") - vba.crystal.move("r") - vba.crystal.move("r") - vba.crystal.move("r") - - # move up - vba.crystal.move("u") - vba.crystal.move("u") - vba.crystal.move("u") - vba.crystal.move("u") - vba.crystal.move("u") - - # move to the door - vba.crystal.move("r") - vba.crystal.move("r") - vba.crystal.move("r") - - # walk in - vba.crystal.move("u") - - # move up to the healing thing - vba.crystal.move("u") - vba.crystal.move("u") - vba.crystal.move("u") - vba.crystal.move("u") - vba.crystal.move("u") - vba.crystal.move("u") - vba.crystal.move("u") - vba.crystal.move("u") - vba.crystal.move("u") - vba.crystal.move("l") - vba.crystal.move("l") - - # face it - vba.crystal.move("u") - - # interact - vba.press("a", holdsteps=10, aftersteps=1) - - # wait for yes/no box - vba.crystal.text_wait() - - # press yes - vba.press("a", holdsteps=10, aftersteps=1) - - # TODO: when is healing done? - - # wait until the script is done running - vba.crystal.wait_for_script_running() - - # wait for it to be really really done - vba.nstep(50) - - vba.crystal.move("r") - vba.crystal.move("r") - - # move to the door - vba.crystal.move("d") - vba.crystal.move("d") - vba.crystal.move("d") - vba.crystal.move("d") - vba.crystal.move("d") - vba.crystal.move("d") - vba.crystal.move("d") - vba.crystal.move("d") - vba.crystal.move("d") - - # walk out - vba.crystal.move("d") - - # check partymon1 level - if vba.get_memory_at(0xdcfe) < level: - new_bark_level_grind(level, skip=False) - else: - return + if hp == 0: + print "enemy hp is zero, exiting" + break + else: + print "enemy hp is: " + str(hp) + + attacks = attacks - 1 + + while self.cry.vba.memory[0xd22d] != 0: + self.cry.vba.press("a", hold=10, after=1) + + # wait for the map to finish loading + self.cry.vba.step(count=50) + + # This is used to handle any additional textbox that might be up on the + # screen. The debug parameter is set to True so that max_wait is + # enabled. This might be a textbox that is still waiting around because + # of some faint during the battle. I am not completely sure why this + # happens. + self.cry.text_wait(max_wait=30, debug=True) + + print "okay, back in the overworld" + + cur_hp = ((self.cry.vba.memory[0xdd01] << 8) | self.cry.vba.memory[0xdd02]) + move_pp = self.cry.vba.memory[0xdcf6] # move 1 pp + + # if pokemon health is >20, just continue + # if move 1 PP is 0, just continue + if cur_hp > 20 and move_pp > 5 and self.cry.vba.memory[0xdcfe] < level: + self.cry.move("u") + return self.new_bark_level_grind(level, walk_to_grass=False, skip=False) + + # move up + self.cry.move("u") + self.cry.move("u") + self.cry.move("u") + self.cry.move("u") + + # move into new bark town + self.cry.move("r") + self.cry.move("r") + self.cry.move("r") + self.cry.move("r") + self.cry.move("r") + self.cry.move("r") + self.cry.move("r") + self.cry.move("r") + self.cry.move("r") + self.cry.move("r") + + # move up + self.cry.move("u") + self.cry.move("u") + self.cry.move("u") + self.cry.move("u") + self.cry.move("u") + + # move to the door + self.cry.move("r") + self.cry.move("r") + self.cry.move("r") + + # walk in + self.cry.move("u") + + # move up to the healing thing + self.cry.move("u") + self.cry.move("u") + self.cry.move("u") + self.cry.move("u") + self.cry.move("u") + self.cry.move("u") + self.cry.move("u") + self.cry.move("u") + self.cry.move("u") + self.cry.move("l") + self.cry.move("l") + + # face it + self.cry.move("u") + + # interact + self.cry.vba.press("a", hold=10, after=1) + + # wait for yes/no box + self.cry.text_wait() + + # press yes + self.cry.vba.press("a", hold=10, after=1) + + # TODO: when is healing done? + + # wait until the script is done running + self.cry.wait_for_script_running() + + # wait for it to be really really done + self.cry.vba.step(count=50) + + self.cry.move("r") + self.cry.move("r") + + # move to the door + self.cry.move("d") + self.cry.move("d") + self.cry.move("d") + self.cry.move("d") + self.cry.move("d") + self.cry.move("d") + self.cry.move("d") + self.cry.move("d") + self.cry.move("d") + + # walk out + self.cry.move("d") + + # check partymon1 level + if self.cry.vba.memory[0xdcfe] < level: + self.new_bark_level_grind(level, skip=False) + else: + return + + @skippable + def new_bark_level_grind_walk_to_grass(self): + """ + Move to just above the grass from outside Elm's lab. + """ + + self.cry.move("d") + self.cry.move("d") + + self.cry.move("l") + self.cry.move("l") + + self.cry.move("d") + self.cry.move("d") + + self.cry.move("l") + self.cry.move("l") -@skippable -def new_bark_level_grind_walk_to_grass(): + # move to route 29 past the trees + self.cry.move("l") + self.cry.move("l") + self.cry.move("l") + self.cry.move("l") + self.cry.move("l") + self.cry.move("l") + self.cry.move("l") + self.cry.move("l") + self.cry.move("l") + + # move to just above the grass + self.cry.move("d") + self.cry.move("d") + self.cry.move("d") + +def bootstrap(runner=None, cry=None): """ - Move to just above the grass from outside Elm's lab. + Setup the initial game and return the state. This skips the intro and + performs some other actions to get the game to a reasonable starting state. """ + if not runner: + runner = SpeedRunner(cry=cry) + runner.setup() + + # skip=False means always run the skip_intro function regardless of the + # presence of a saved after state. + runner.skip_intro(skip=True) + + # keep a reference of the current state + state = runner.cry.vba.state + + runner.cry.vba.shutdown() - vba.crystal.move("d") - vba.crystal.move("d") - - vba.crystal.move("l") - vba.crystal.move("l") - - vba.crystal.move("d") - vba.crystal.move("d") - - vba.crystal.move("l") - vba.crystal.move("l") - - # move to route 29 past the trees - vba.crystal.move("l") - vba.crystal.move("l") - vba.crystal.move("l") - vba.crystal.move("l") - vba.crystal.move("l") - vba.crystal.move("l") - vba.crystal.move("l") - vba.crystal.move("l") - vba.crystal.move("l") - - # move to just above the grass - vba.crystal.move("d") - vba.crystal.move("d") - vba.crystal.move("d") + return state + +def main(): + """ + Setup a basic ``SpeedRunner`` instance and then run the runner. + """ + runner = SpeedRunner() + runner.setup() + return runner.main() if __name__ == "__main__": main() diff --git a/pokemontools/vba/battle.py b/pokemontools/vba/battle.py new file mode 100644 index 0000000..39d7047 --- /dev/null +++ b/pokemontools/vba/battle.py @@ -0,0 +1,521 @@ +""" +Code that attempts to model a battle. +""" + +from pokemontools.vba.vba import crystal as emulator +import pokemontools.vba.vba as vba + +class BattleException(Exception): + """ + Something went terribly wrong in a battle. + """ + +class EmulatorController(object): + """ + Controls the emulator. I don't have a good reason for this. + """ + +class Battle(EmulatorController): + """ + Wrapper around the battle routine inside of the game. This object controls + the emulator and provides a sanitized interface for interacting with a + battle through python. + """ + + def __init__(self, emulator=None): + """ + Setup the battle. + """ + self.emulator = emulator + + def is_in_battle(self): + """ + @rtype: bool + """ + return self.emulator.is_in_battle() + + def is_input_required(self): + """ + Detects if the battle is waiting for player input. + """ + return self.is_player_turn() or self.is_mandatory_switch() or self.is_switch_prompt() or self.is_levelup_screen() or self.is_make_room_for_move_prompt() + + def is_fight_pack_run_menu(self): + """ + Attempts to detect if the current menu is fight-pack-run. This is only + for whether or not the player needs to choose what to do next. + """ + signs = ["FIGHT", "PACK", "RUN"] + screentext = self.emulator.get_text() + return all([sign in screentext for sign in signs]) + + def select_battle_menu_action(self, action, execute=True): + """ + Moves the cursor to the requested action and selects it. + + :param action: fight, pkmn, pack, run + """ + if not self.is_fight_pack_run_menu(): + raise Exception( + "This isn't the fight-pack-run menu." + ) + + action = action.lower() + + action_map = { + "fight": (1, 1), + "pkmn": (1, 2), + "pack": (2, 1), + "run": (2, 2), + } + + if action not in action_map.keys(): + raise Exception( + "Unexpected requested action {0}".format(action) + ) + + current_row = self.emulator.vba.read_memory_at(0xcfa9) + current_column = self.emulator.vba.read_memory_at(0xcfaa) + + direction = None + if current_row != action_map[action][0]: + if current_row > action_map[action][0]: + direction = "u" + elif current_row < action_map[action][0]: + direction = "d" + + self.emulator.vba.press(direction, hold=5, after=10) + + direction = None + if current_column != action_map[action][1]: + if current_column > action_map[action][1]: + direction = "l" + elif current_column < action_map[action][1]: + direction = "r" + + self.emulator.vba.press(direction, hold=5, after=10) + + # now select the action + if execute: + self.emulator.vba.press("a", hold=5, after=100) + + def select_attack(self, move_number=1, hold=5, after=10): + """ + Moves the cursor to the correct attack in the menu and presses the + button. + + :param move_number: the attack number on the FIGHT menu. Note that this + starts from 1. + :param hold: how many frames to hold each button press + :param after: how many frames to wait after each button press + """ + # TODO: detect fight menu and make sure it's detected here. + + pp_address = 0xc634 + (move_number - 1) + pp = self.emulator.vba.read_memory_at(pp_address) + + # detect zero pp because i don't want to write a way to inform the + # caller that there was no more pp. Just check the pp yourself. + if pp == 0: + raise BattleException( + "Move {num} has no more PP.".format( + num=move_number, + ) + ) + + valid_selection_states = (1, 2, 3, 4) + + selection = self.emulator.vba.read_memory_at(0xcfa9) + + while selection != move_number: + if selection not in valid_selection_states: + raise BattleException( + "The current selected attack is out of bounds: {num}".format( + num=selection, + ) + ) + + direction = None + + if selection > move_number: + direction = "d" + elif selection < move_number: + direction = "u" + else: + # probably never happens + raise BattleException( + "Not sure what to do here." + ) + + # press the arrow button + self.emulator.vba.press(direction, hold=hold, after=after) + + # let's see what the current selection is + selection = self.emulator.vba.read_memory_at(0xcfa9) + + # press to choose the attack + self.emulator.vba.press("a", hold=hold, after=after) + + def fight(self, move_number): + """ + Select FIGHT from the flight-pack-run menu and select the move + identified by move_number. + """ + # make sure the menu is detected + if not self.is_fight_pack_run_menu(): + raise BattleException( + "Wrong menu. Can't press FIGHT here." + ) + + # select FIGHT + self.select_battle_menu_action("fight") + + # select the requested attack + self.select_attack(move_number) + + def is_player_turn(self): + """ + Detects if the battle is waiting for the player to choose an attack. + """ + return self.is_fight_pack_run_menu() + + def is_trainer_switch_prompt(self): + """ + Detects if the battle is waiting for the player to choose whether or + not to switch pokemon. This is the prompt that asks yes/no for whether + to switch pokemon, like if the trainer is switching pokemon at the end + of a turn set. + """ + return self.emulator.is_trainer_switch_prompt() + + def is_wild_switch_prompt(self): + """ + Detects if the battle is waiting for the player to choose whether or + not to continue to fight the wild pokemon. + """ + return self.emulator.is_wild_switch_prompt() + + def is_switch_prompt(self): + """ + Detects both trainer and wild switch prompts (for prompting whether to + switch pokemon). This is a yes/no box and not the actual pokemon + selection menu. + """ + return self.is_trainer_switch_prompt() or self.is_wild_switch_prompt() + + def is_mandatory_switch(self): + """ + Detects if the battle is waiting for the player to choose a next + pokemon. + """ + # TODO: test when "no" fails to escape for wild battles. + # trainer battles: menu asks to select the next mon + # wild battles: yes/no box first + # The following conditions are probably sufficient: + # 1) current pokemon hp is 0 + # 2) game is polling for input + + if "CANCEL Which ?" in self.emulator.get_text(): + return True + else: + return False + + def is_levelup_screen(self): + """ + Detects the levelup stats screen. + """ + # This is implemented as reading some text on the screen instead of + # using get_text() because checking every loop is really slow. + + address = 0xc50f + values = [146, 143, 130, 139] + + for (index, value) in enumerate(values): + if self.emulator.vba.read_memory_at(address + index) != value: + return False + else: + return True + + def is_evolution_screen(self): + """ + What? MEW is evolving! + """ + address = 0xc5e4 + + values = [164, 181, 174, 171, 181, 168, 173, 166, 231] + + for (index, value) in enumerate(values): + if self.emulator.vba.read_memory_at(address + index) != value: + return False + else: + # also check "What?" + what_address = 0xc5b9 + what_values = [150, 167, 160, 179, 230] + for (index, value) in enumerate(what_values): + if self.emulator.vba.read_memory_at(what_address + index) != value: + return False + else: + return True + + def is_evolved_screen(self): + """ + Checks if the screen is the "evolved into METAPOD!" screen. Note that + this only works inside of a battle. This is because there may be other + text boxes that have the same text when outside of battle. But within a + battle, this is probably the only time the text "evolved into ... !" is + seen. + """ + if not self.is_in_battle(): + return False + + address = 0x4bb1 + values = [164, 181, 174, 171, 181, 164, 163, 127, 168, 173, 179, 174, 79] + + for (index, value) in enumerate(values): + if self.emulator.vba.read_memory_at(address + index) != value: + return False + else: + return True + + def is_make_room_for_move_prompt(self): + """ + Detects the prompt that asks whether to make room for a move. + """ + if not self.is_in_battle(): + return False + + address = 0xc5b9 + values = [172, 174, 181, 164, 127, 179, 174, 127, 172, 160, 170, 164, 127, 177, 174, 174, 172] + + for (index, value) in enumerate(values): + if self.emulator.vba.read_memory_at(address + index) != value: + return False + else: + return True + + def skip_start_text(self, max_loops=20): + """ + Skip any initial conversation until the player can select an action. + This includes skipping any text that appears on a map from an NPC as + well as text that appears prior to the first time the action selection + menu appears. + """ + if not self.is_in_battle(): + while not self.is_in_battle() and max_loops > 0: + self.emulator.text_wait() + max_loops -= 1 + + if max_loops <= 0: + raise Exception("Couldn't start the battle.") + else: + self.emulator.text_wait() + + def skip_end_text(self, loops=20): + """ + Skip through any text that appears after the final attack. + """ + if not self.is_in_battle(): + # TODO: keep talking until the character can move? A battle can be + # triggered inside of a script, and after the battle is ver the + # player may not be able to move until the script is done. The + # script might only finish after other player input is given, so + # using "text_wait() until the player can move" is a bad idea here. + self.emulator.text_wait() + else: + while self.is_in_battle() and loops > 0: + self.emulator.text_wait() + loops -= 1 + + if loops <= 0: + raise Exception("Couldn't get out of the battle.") + + def skip_until_input_required(self): + """ + Waits until the battle needs player input. + """ + # callback causes text_wait to exit when the callback returns True + def is_in_battle_checker(): + result = (self.emulator.vba.read_memory_at(0xd22d) == 0) and (self.emulator.vba.read_memory_at(0xc734) != 0) + + # but also, jump out if it's the stats screen + result = result or self.is_levelup_screen() + + # jump out if it's the "make room for a new move" screen + result = result or self.is_make_room_for_move_prompt() + + # stay in text_wait if it's the evolution screen + result = result and not self.is_evolution_screen() + + return result + + while not self.is_input_required() and self.is_in_battle(): + self.emulator.text_wait(callback=is_in_battle_checker) + + # let the text draw so that the state is more obvious + self.emulator.vba.step(count=10) + + def run(self): + """ + Step through the entire battle. + """ + # Advance to the battle from either of these states: + # 1) the player is talking with an npc + # 2) the battle has already started but there's initial text + # xyz wants to battle, a wild foobar appeared + self.skip_start_text() + + # skip a few hundred frames + self.emulator.vba.step(count=100) + + wild = (self.emulator.vba.read_memory_at(0xd22d) == 1) + + while self.is_in_battle(): + self.skip_until_input_required() + + if not self.is_in_battle(): + continue + + if self.is_player_turn(): + # battle hook provides input to handle this situation + self.handle_turn() + elif self.is_trainer_switch_prompt(): + self.handle_trainer_switch_prompt() + elif self.is_wild_switch_prompt(): + self.handle_wild_switch_prompt() + elif self.is_mandatory_switch(): + # battle hook provides input to handle this situation too + self.handle_mandatory_switch() + elif self.is_levelup_screen(): + self.emulator.vba.press("a", hold=5, after=30) + elif self.is_evolved_screen(): + self.emulator.vba.step(count=30) + elif self.is_make_room_for_move_prompt(): + self.handle_make_room_for_move() + else: + raise BattleException("unknown state, aborting") + + # "how did i lose? wah" + # TODO: this doesn't happen for wild battles + if not wild: + self.skip_end_text() + + # TODO: return should indicate win/loss (blackout) + + def handle_mandatory_switch(self): + """ + Something fainted, pick the next mon. + """ + raise NotImplementedError + + def handle_trainer_switch_prompt(self): + """ + The trainer is switching pokemon. The game asks yes/no for whether or + not the player would like to switch. + """ + raise NotImplementedError + + def handle_wild_switch_prompt(self): + """ + The wild pokemon defeated the party pokemon. This is the yes/no box for + whether to switch pokemon or not. + """ + raise NotImplementedError + + def handle_turn(self): + """ + Take actions inside of a battle based on the game state. + """ + raise NotImplementedError + +class BattleStrategy(Battle): + """ + This class shows the relevant methods to make a battle handler. + """ + + def handle_mandatory_switch(self): + """ + Something fainted, pick the next mon. + """ + raise NotImplementedError + + def handle_trainer_switch_prompt(self): + """ + The trainer is switching pokemon. The game asks yes/no for whether or + not the player would like to switch. + """ + raise NotImplementedError + + def handle_wild_switch_prompt(self): + """ + The wild pokemon defeated the party pokemon. This is the yes/no box for + whether to switch pokemon or not. + """ + raise NotImplementedError + + def handle_turn(self): + """ + Take actions inside of a battle based on the game state. + """ + raise NotImplementedError + + def handle_make_room_for_move(self): + """ + Choose yes/no then handle learning the move. + """ + raise NotImplementedError + +class SpamBattleStrategy(BattleStrategy): + """ + A really simple battle strategy that always picks the first move of the + first pokemon to attack the enemy. + """ + + def handle_turn(self): + """ + Always picks the first move of the current pokemon. + """ + self.fight(1) + + def handle_trainer_switch_prompt(self): + """ + The trainer is switching pokemon. The game asks yes/no for whether or + not the player would like to switch. + """ + # decline + self.emulator.vba.press(["b"], hold=5, after=10) + + def handle_wild_switch_prompt(self): + """ + The wild pokemon defeated the party pokemon. This is the yes/no box for + whether to switch pokemon or not. + """ + # why not just make a battle strategy that doesn't lose? + # TODO: Note that the longer "after" value is required here. + self.emulator.vba.press("a", hold=5, after=30) + + self.handle_mandatory_switch() + + def handle_mandatory_switch(self): + """ + Something fainted, pick the next mon. + """ + + # TODO: make a better selector for which pokemon. + + # now scroll down + self.emulator.vba.press("d", hold=5, after=10) + + # select this mon + self.emulator.vba.press("a", hold=5, after=30) + + def handle_make_room_for_move(self): + """ + Choose yes/no then handle learning the move. + """ + # make room? no + self.emulator.vba.press("b", hold=5, after=100) + + # stop learning? yes + self.emulator.vba.press("a", hold=5, after=20) + + self.emulator.text_wait() diff --git a/pokemontools/vba/vba.py b/pokemontools/vba/vba.py index d7fdf1d..204e102 100644 --- a/pokemontools/vba/vba.py +++ b/pokemontools/vba/vba.py @@ -9,18 +9,20 @@ import re import string from copy import copy -import unittest - # for converting bytes to readable text -from pokemontools.chars import chars +from pokemontools.chars import ( + chars, +) -from pokemontools.map_names import map_names +from pokemontools.map_names import ( + map_names, +) import keyboard # just use a default config for now until the globals are removed completely -import pokemontools.config as conf -config = conf.Config() +import pokemontools.configuration as configuration +config = configuration.Config() project_path = config.path save_state_path = config.save_state_path rom_path = config.rom_path @@ -30,94 +32,379 @@ if not os.path.exists(rom_path): import vba_wrapper -vba = vba_wrapper.VBA(rom_path) -registers = vba_wrapper.core.registers.Registers(vba) - button_masks = vba_wrapper.core.VBA.button_masks button_combiner = vba_wrapper.core.VBA.button_combine +def calculate_bank(address): + """ + Which bank does this address exist in? + """ + return address / 0x4000 + +def calculate_address(address): + """ + Gives the relative address once the bank is loaded. + + This is not the same as the calculate_pointer in the + pokemontools.crystal.pointers module. + """ + return (address % 0x4000) + 0x4000 + def translate_chars(charz): + """ + Translate a string from the in-game format to readable form. This is + accomplished through the same lookup table that the preprocessors use. + """ result = "" for each in charz: result += chars[each] return result -def press(buttons, holdsteps=1, aftersteps=1): +def translate_text(text, chars=chars): """ - Press a button. - - Use steplimit to say for how many steps you want to press - the button (try leaving it at the default, 1). - """ - if hasattr(buttons, "__len__"): - number = button_combiner(buttons) - elif isinstance(buttons, int): - number = buttons - else: - number = buttons - for step_counter in range(0, holdsteps): - Gb.step(number) - - # clear the button press - if aftersteps > 0: - for step_counter in range(0, aftersteps): - Gb.step(0) - -def call(bank, address): + Converts text to the in-game byte coding. """ - Jumps into a function at a certain address. + output = [] + for given_char in text: + for (byte, char) in chars.iteritems(): + if char == given_char: + output.append(byte) + break + else: + raise Exception( + "no match for {0}".format(given_char) + ) + return output - Go into the start menu, pause the game and try call(1, 0x1078) to see a - string printed to the screen. +class crystal(object): """ - push = [ - registers.pc, - registers.hl, - registers.de, - registers.bc, - registers.af, - 0x3bb7, - ] - - for value in push: - registers.sp -= 2 - set_memory_at(registers.sp + 1, value >> 8) - set_memory_at(registers.sp, value & 0xFF) - if get_memory_range(registers.sp, 2) != [value & 0xFF, value >> 8]: - print "desired memory values: " + str([value & 0xFF, value >> 8] ) - print "actual memory values: " + str(get_memory_range(registers.sp , 2)) - print "wrong value at " + hex(registers.sp) + " expected " + hex(value) + " but got " + hex(get_memory_at(registers.sp)) - - if bank != 0: - registers["af"] = (bank << 8) | (registers["af"] & 0xFF) - registers["hl"] = address - registers["pc"] = 0x2d63 # FarJump - else: - registers["pc"] = address - -def get_stack(): - """ - Return a list of functions on the stack. + Just a simple namespace to store a bunch of functions for Pokémon Crystal. + There can only be one running instance of the emulator per process because + it's a poorly written shared library. """ - addresses = [] - sp = registers.sp - for x in range(0, 11): - sp = sp - (2 * x) - hi = get_memory_at(sp + 1) - lo = get_memory_at(sp) - address = ((hi << 8) | lo) - addresses.append(address) + def __init__(self, config=None): + """ + Launch the VBA controller. + """ + if not config: + config = configuration.Config() - return addresses + self.config = config -class crystal: - """ - Just a simple namespace to store a bunch of functions for Pokémon Crystal. - """ + self.vba = vba_wrapper.VBA(self.config.rom_path) + self.registers = vba_wrapper.core.registers.Registers(self.vba) + + if not os.path.exists(self.config.rom_path): + raise Exception("rom_path is not configured properly; edit vba_config.py? " + str(rom_path)) + + def shutdown(self): + """ + Reset the emulator. + """ + self.vba.shutdown() + + def save_state(self, name, state=None, override=False): + """ + Saves the given state to save_state_path. + + The file format must be ".sav", and this will be appended to your + string if necessary. + """ + if state == None: + state = self.vba.state + + if len(name) < 4 or name[-4:] != ".sav": + name += ".sav" + + save_path = os.path.join(self.config.save_state_path, name) + + if not override and os.path.exists(save_path): + raise Exception("oops, save state path already exists: {0}".format(save_path)) + + with open(save_path, "wb") as file_handler: + file_handler.write(state) + + def load_state(self, name, loadit=True): + """ + Read a state from file based on the name of the state. + + Looks in save_state_path for a file with this name (".sav" is + optional). + + @param loadit: whether or not to set the emulator to this state + """ + save_path = os.path.join(self.config.save_state_path, name) + + if not os.path.exists(save_path): + if len(name) < 4 or name[-4:] != ".sav": + name += ".sav" + save_path = os.path.join(self.config.save_state_path, name) + + with open(save_path, "rb") as file_handler: + state = file_handler.read() + + if loadit: + self.vba.state = state + + return state + + def call(self, address, bank=None): + """ + Jumps into a function at a certain address. + + Go into the start menu, pause the game and try call(1, 0x1078) to see a + string printed to the screen. + """ + if bank is None: + bank = calculate_bank(address) + + push = [ + self.registers.pc, + self.registers.hl, + self.registers.de, + self.registers.bc, + self.registers.af, + 0x3bb7, + ] + + self.push_stack(push) + + if bank != 0: + self.registers["af"] = (bank << 8) | (self.registers["af"] & 0xFF) + self.registers["hl"] = address + self.registers["pc"] = 0x2d63 # FarJump + else: + self.registers["pc"] = address + + def push_stack(self, push): + for value in push: + self.registers["sp"] -= 2 + self.vba.write_memory_at(self.registers.sp + 1, value >> 8) + self.vba.write_memory_at(self.registers.sp, value & 0xFF) + if list(self.vba.memory[self.registers.sp : self.registers.sp + 2]) != [value & 0xFF, value >> 8]: + print "desired memory values: " + str([value & 0xFF, value >> 8] ) + print "actual memory values: " + str(list(self.vba.memory[self.registers.sp : self.registers.sp + 2])) + print "wrong value at " + hex(self.registers.sp) + " expected " + hex(value) + " but got " + hex(self.vba.read_memory_at(self.registers.sp)) + + def get_stack(self): + """ + Return a list of functions on the stack. + """ + addresses = [] + sp = self.registers.sp + + for x in range(0, 11): + sp = sp - (2 * x) + hi = self.vba.read_memory_at(sp + 1) + lo = self.vba.read_memory_at(sp) + address = ((hi << 8) | lo) + addresses.append(address) + + return addresses + + def inject_asm_into_rom(self, asm=[], address=0x75 * 0x4000, has_finished_address=0xdb75): + """ + Writes asm to the loaded ROM. Calls the asm. + + :param address: ROM address for where to store the injected asm script. + The default value is an address in pokecrystal that isn't used for + anything. - @staticmethod - def text_wait(step_size=1, max_wait=200, sfx_limit=0, debug=False, callback=None): + :param has_finished_address: address for where to store whether the + script executed or not. This value is restored when the script has been + confirmed to work. It's conceivable that some injected asm might need + to change that address if the asm needs to access the original wram + value itself. + """ + if len(asm) > 0x4000: + raise Exception("too much asm") + + # temporarily use wram + cached_wram_value = self.vba.memory[has_finished_address] + + # set the value at has_finished_address to 0 + reset_wram_mem = list(self.vba.memory) + reset_wram_mem[has_finished_address] = 0 + self.vba.memory = reset_wram_mem + + # set a value to indicate that the script has executed + set_has_finished = [ + # push af + 0xf5, + + # ld a, 1 + 0x3e, 1, + + # ld [has_finished], a + 0xea, has_finished_address & 0xff, has_finished_address >> 8, + + # pop af + 0xf1, + + # ret + 0xc9, + ] + + # TODO: check if asm ends with a byte that causes a return or call or + # other "ender". Raise an exception if it already returns on its own. + + # combine the given asm with the setter bytes + total_asm = asm + set_has_finished + + # get a copy of the current rom + rom = list(self.vba.rom) + + # inject the asm + rom[address : address + len(total_asm)] = total_asm + + # set the rom with the injected asm + self.vba.rom = rom + + # call the injected asm + self.call(calculate_address(address), bank=calculate_bank(address)) + + # make the emulator step forward + self.vba.step(count=20) + + # check if the script has executed (see below) + current_mem = self.vba.memory + + # reset the wram value to its original value + another_mem = list(self.vba.memory) + another_mem[has_finished_address] = cached_wram_value + self.vba.memory = another_mem + + # check if the script has actually executed + # TODO: should this raise an exception if the script didn't finish? + if current_mem[has_finished_address] == 0: + return False + elif current_mem[has_finished_address] == 1: + return True + else: + raise Exception( + "has_finished_address at {has_finished_address} was overwritten with an unexpected value {value}".format( + has_finished_address=hex(has_finished_address), + value=current_mem[has_finished_address], + ) + ) + + def inject_asm_into_wram(self, asm=[], address=0xdfcf): + """ + Writes asm to memory. Makes the emulator run the asm. + + This function will append "ret" to the list of bytes. Before returning, + it updates the value at the first byte to indicate that the function + has executed. + + The first byte at the given address is reserved for whether the asm has + finished executing. + """ + memory = list(self.vba.memory) + + # the first byte is reserved for whether the script has finished + has_finished = address + memory[has_finished] = 0 + + # the second byte is where the script will be stored + script_address = address + 1 + + # TODO: error checking; make sure the last byte doesn't already return. + # Use some functions from gbz80disasm to perform this check. + + # set a value to indicate that the script has executed + set_has_finished = [ + # push af + 0xf5, + + # ld a, 1 + 0x3e, 1, + + # ld [has_finished], a + 0xea, has_finished & 0xff, has_finished >> 8, + + # pop af + 0xf1, + + # ret + 0xc9, + ] + + # append the last opcodes to the script + asm = bytearray(asm) + bytearray(set_has_finished) + + memory[script_address : script_address + len(asm)] = asm + self.vba.memory = memory + + # make the emulator call the script + self.call(script_address, bank=0) + + # make the emulator step forward + self.vba.step(count=50) + + # check if the script has executed + # TODO: should this raise an exception if the script didn't finish? + if self.vba.memory[has_finished] == 0: + return False + elif self.vba.memory[has_finished] == 1: + return True + else: + raise Exception( + "has_finished at {has_finished} was overwritten with an unexpected value {value}".format( + has_finished=hex(has_finished), + value=self.vba.memory[has_finished], + ) + ) + + def call_script(self, address, bank=None, wram=False, force=False): + """ + Sets wram values so that the engine plays a script. + + :param address: address of the map script + :param bank: override for bank calculation (based on address) + :param wram: force bank to 0 + :param force: override an already-running script + """ + + ScriptFlags = 0xd434 + ScriptMode = 0xd437 + ScriptRunning = 0xd438 + ScriptBank = 0xd439 + ScriptPos = 0xd43a + NumScriptParents = 0xd43c + ScriptParents = 0xd43d + + num_possible_parents = 4 + len_parent = 3 + + mem = list(self.vba.memory) + + if mem[ScriptRunning] == 0xff: + if force: + # wipe the parent routine array + mem[NumScriptParents] = 0 + for i in xrange(num_possible_parents * len_parent): + mem[ScriptParents + i] = 0 + else: + raise Exception("a script is already running, use force=True") + + if wram: + bank = 0 + elif not bank: + bank = calculate_bank(address) + address = address % 0x4000 + 0x4000 * bool(bank) + + mem[ScriptFlags] |= 4 + mem[ScriptMode] = 1 + mem[ScriptRunning] = 0xff + + mem[ScriptBank] = bank + mem[ScriptPos] = address % 0x100 + mem[ScriptPos+1] = address / 0x100 + + self.vba.memory = mem + + def text_wait(self, step_size=1, max_wait=200, sfx_limit=0, debug=False, callback=None): """ Presses the "A" button when text is done being drawn to screen. @@ -134,22 +421,27 @@ class crystal: :param max_wait: number of wait loops to perform """ while max_wait > 0: - hi = get_memory_at(registers.sp + 1) - lo = get_memory_at(registers.sp) + hi = self.vba.read_memory_at(self.registers.sp + 1) + lo = self.vba.read_memory_at(self.registers.sp) address = ((hi << 8) | lo) if address in range(0xa1b, 0xa46) + range(0xaaf, 0xaf5): # 0xaef: print "pressing, then breaking.. address is: " + str(hex(address)) # set CurSFX - set_memory_at(0xc2bf, 0) + self.vba.write_memory_at(0xc2bf, 0) - press("a", holdsteps=10, aftersteps=1) + self.vba.press("a", hold=10, after=50) # check if CurSFX is SFX_READ_TEXT_2 - if get_memory_at(0xc2bf) == 0x8: - print "cursfx is set to SFX_READ_TEXT_2, looping.." - return crystal.text_wait(step_size=step_size, max_wait=max_wait, debug=debug, callback=callback, sfx_limit=sfx_limit) + if self.vba.read_memory_at(0xc2bf) == 0x8: + if "CANCEL Which" in self.get_text(): + print "probably the 'switch pokemon' menu" + return + else: + print "cursfx is set to SFX_READ_TEXT_2, looping.." + print self.get_text() + return self.text_wait(step_size=step_size, max_wait=max_wait, debug=debug, callback=callback, sfx_limit=sfx_limit) else: if sfx_limit > 0: sfx_limit = sfx_limit - 1 @@ -160,7 +452,7 @@ class crystal: break else: - stack = get_stack() + stack = self.get_stack() # yes/no box or the name selection box if address in range(0xa46, 0xaaf): @@ -170,23 +462,23 @@ class crystal: # date/time box (day choice) # 0x47ab is the one from the intro, 0x49ab is the one from mom. elif 0x47ab in stack or 0x49ab in stack: # was any([x in stack for x in range(0x46EE, 0x47AB)]) - print "probably at a date/time box ? exiting." - break + if not self.is_in_battle(): + print "probably at a date/time box ? exiting." + break # "How many minutes?" selection box elif 0x4826 in stack: print "probably at a \"How many minutes?\" box ? exiting." break - else: - nstep(step_size) + self.vba.step(count=step_size) # if there is a callback, then call the callback and exit when the # callback returns True. This is especially useful during the # OakSpeech intro where textboxes are running constantly, and then # suddenly the player can move around. One way to detect that is to # set callback to a function that returns - # "vba.get_memory_at(0xcfb1) != 0". + # "vba.read_memory_at(0xcfb1) != 0". if callback != None: result = callback() if result == True: @@ -202,17 +494,15 @@ class crystal: if max_wait == 0: print "max_wait was hit" - @staticmethod - def walk_through_walls_slow(): - memory = get_memory() + def walk_through_walls_slow(self): + memory = self.vba.memory memory[0xC2FA] = 0 memory[0xC2FB] = 0 memory[0xC2FC] = 0 memory[0xC2FD] = 0 - set_memory(memory) + self.vba.memory = memory - @staticmethod - def walk_through_walls(): + def walk_through_walls(self): """ Lets the player walk all over the map. @@ -221,73 +511,75 @@ class crystal: to be executed each step/tick if continuous walk-through-walls is desired. """ - set_memory_at(0xC2FA, 0) - set_memory_at(0xC2FB, 0) - set_memory_at(0xC2FC, 0) - set_memory_at(0xC2FD, 0) + self.vba.write_memory_at(0xC2FA, 0) + self.vba.write_memory_at(0xC2FB, 0) + self.vba.write_memory_at(0xC2FC, 0) + self.vba.write_memory_at(0xC2FD, 0) - #@staticmethod - #def set_enemy_level(level): - # set_memory_at(0xd213, level) + def lower_enemy_hp(self): + """ + Dramatically lower the enemy's HP. + """ + self.vba.write_memory_at(0xd216, 0) + self.vba.write_memory_at(0xd217, 1) - @staticmethod - def nstep(steplimit=500): + def set_battle_mon_hp(self, hp): + """ + Set the BattleMonHP variable to the given hp. + """ + self.vba.write_memory_at(0xc63c, hp / 0x100) + self.vba.write_memory_at(0xc63c + 1, hp % 0x100) + + def nstep(self, steplimit=500): """ Steps the CPU forward and calls some functions in between each step. (For example, to manipulate memory.) This is pretty slow. """ for step_counter in range(0, steplimit): - crystal.walk_through_walls() - #call(0x1, 0x1078) - step() + self.walk_through_walls() + #call(0x1078) + self.vba.step() - @staticmethod - def disable_triggers(): - set_memory_at(0x23c4, 0xAF) - set_memory_at(0x23d0, 0xAF); + def disable_triggers(self): + self.vba.write_memory_at(0x23c4, 0xAF) + self.vba.write_memory_at(0x23d0, 0xAF); - @staticmethod - def disable_callbacks(): - set_memory_at(0x23f2, 0xAF) - set_memory_at(0x23fe, 0xAF) + def disable_callbacks(self): + self.vba.write_memory_at(0x23f2, 0xAF) + self.vba.write_memory_at(0x23fe, 0xAF) - @staticmethod - def get_map_group_id(): + def get_map_group_id(self): """ Returns the current map group. """ - return get_memory_at(0xdcb5) + return self.vba.read_memory_at(0xdcb5) - @staticmethod - def get_map_id(): + def get_map_id(self): """ Returns the map number of the current map. """ - return get_memory_at(0xdcb6) + return self.vba.read_memory_at(0xdcb6) - @staticmethod - def get_map_name(): + def get_map_name(self, map_names=map_names): """ Figures out the current map name. """ - map_group_id = crystal.get_map_group_id() - map_id = crystal.get_map_id() + map_group_id = self.get_map_group_id() + map_id = self.get_map_id() return map_names[map_group_id][map_id]["name"] - @staticmethod - def get_xy(): + def get_xy(self): """ (x, y) coordinates of player on map. Relative to top-left corner of map. """ - x = get_memory_at(0xdcb8) - y = get_memory_at(0xdcb7) + x = self.vba.read_memory_at(0xdcb8) + y = self.vba.read_memory_at(0xdcb7) return (x, y) - @staticmethod - def menu_select(id=1): + def menu_select(self, id=1): """ Sets the cursor to the given pokemon in the player's party. @@ -296,38 +588,84 @@ class crystal: This probably works on other menus. """ - set_memory_at(0xcfa9, id) + self.vba.write_memory_at(0xcfa9, id) - @staticmethod - def is_in_battle(): + def is_in_battle(self): """ Checks whether or not we're in a battle. """ - return (get_memory_at(0xd22d) != 0) or crystal.is_in_link_battle() + return (self.vba.read_memory_at(0xd22d) != 0) or self.is_in_link_battle() + + def is_in_link_battle(self): + return self.vba.read_memory_at(0xc2dc) != 0 + + def is_trainer_switch_prompt(self): + """ + Checks if the game is currently displaying the yes/no prompt for + whether or not to switch pokemon. This happens when the trainer is + switching pokemon out. + """ + # TODO: this method should return False if the game options have been + # set to not use the battle switching style. + + # get on-screen text + text = self.get_text() + + requirements = [ + "YES", + "NO", + "Will ", + "change POKMON?", + ] - @staticmethod - def is_in_link_battle(): - return get_memory_at(0xc2dc) != 0 + return all([requirement in text for requirement in requirements]) - @staticmethod - def unlock_flypoints(): + def is_wild_switch_prompt(self): + """ + Detects if the battle is waiting for the player to choose whether or + not to continue to fight the wild pokemon. + """ + # get on-screen text + screen_text = self.get_text() + + requirements = [ + "YES", + "NO", + "Use next POKMON?", + ] + + return all([requirement in screen_text for requirement in requirements]) + + def is_switch_prompt(self): + """ + Detects both the trainer-style switch prompt and the wild-style switch + prompt. This is the yes/no prompt for whether to switch pokemon. + """ + return self.is_trainer_switch_prompt() or self.is_wild_switch_prompt() + + def unlock_flypoints(self): """ Unlocks different destinations for flying. Note: this might start at 0xDCA4 (minus one on all addresses), but not sure. """ - set_memory_at(0xDCA5, 0xFF) - set_memory_at(0xDCA6, 0xFF) - set_memory_at(0xDCA7, 0xFF) - set_memory_at(0xDCA8, 0xFF) + self.vba.write_memory_at(0xDCA5, 0xFF) + self.vba.write_memory_at(0xDCA6, 0xFF) + self.vba.write_memory_at(0xDCA7, 0xFF) + self.vba.write_memory_at(0xDCA8, 0xFF) - @staticmethod - def get_gender(): + def set_battle_type(self, battle_type): + """ + Changes the battle type value. + """ + self.vba.write_memory_at(0xd230, battle_type) + + def get_gender(self): """ Returns 'male' or 'female'. """ - gender = get_memory_at(0xD472) + gender = self.vba.read_memory_at(0xD472) if gender == 0: return "male" elif gender == 1: @@ -335,54 +673,59 @@ class crystal: else: return gender - @staticmethod - def get_player_name(): + def get_player_name(self): """ Returns the 7 characters making up the player's name. """ - bytez = get_memory_range(0xD47D, 7) + bytez = self.vba.memory[0xD47D:0xD47D + 7] name = translate_chars(bytez) return name - @staticmethod - def warp(map_group_id, map_id, x, y): - set_memory_at(0xdcb5, map_group_id) - set_memory_at(0xdcb6, map_id) - set_memory_at(0xdcb7, y) - set_memory_at(0xdcb8, x) - set_memory_at(0xd001, 0xFF) - set_memory_at(0xff9f, 0xF1) - set_memory_at(0xd432, 1) - set_memory_at(0xd434, 0 & 251) - - @staticmethod - def warp_pokecenter(): - crystal.warp(1, 1, 3, 3) - crystal.nstep(200) - - @staticmethod - def masterballs(): + def warp(self, map_group_id, map_id, x, y): + """ + Warp into another map. + """ + self.vba.write_memory_at(0xdcb5, map_group_id) + self.vba.write_memory_at(0xdcb6, map_id) + self.vba.write_memory_at(0xdcb7, y) + self.vba.write_memory_at(0xdcb8, x) + self.vba.write_memory_at(0xd001, 0xFF) + self.vba.write_memory_at(0xff9f, 0xF1) + self.vba.write_memory_at(0xd432, 1) + self.vba.write_memory_at(0xd434, 0 & 251) + + def warp_pokecenter(self): + """ + Warp straight into a pokecenter. + """ + self.warp(1, 1, 3, 3) + self.nstep(200) + + def masterballs(self): + """ + Deposit some pokeballs into the first few slots of the pack. This + overrides whatever items were previously there. + """ # masterball - set_memory_at(0xd8d8, 1) - set_memory_at(0xd8d9, 99) + self.vba.write_memory_at(0xd8d8, 1) + self.vba.write_memory_at(0xd8d9, 99) # ultraball - set_memory_at(0xd8da, 2) - set_memory_at(0xd8db, 99) + self.vba.write_memory_at(0xd8da, 2) + self.vba.write_memory_at(0xd8db, 99) # pokeballs - set_memory_at(0xd8dc, 5) - set_memory_at(0xd8dd, 99) + self.vba.write_memory_at(0xd8dc, 5) + self.vba.write_memory_at(0xd8dd, 99) - @staticmethod - def get_text(): + def get_text(self, chars=chars, offset=0, bounds=1000): """ Returns alphanumeric text on the screen. Other characters will not be shown. """ output = "" - tiles = get_memory_range(0xc4a0, 1000) + tiles = self.vba.memory[0xc4a0 + offset:0xc4a0 + offset + bounds] for each in tiles: if each in chars.keys(): thing = chars[each] @@ -405,32 +748,69 @@ class crystal: return output - @staticmethod - def keyboard_apply(button_sequence): + def is_showing_stats_screen(self): + """ + This is meant to detect whether or not the stats screen is showing. + This is the menu that pops up after leveling up. + """ + # These words must be on the screen if the stats screen is currently + # displayed. + parts = [ + "ATTACK", + "DEFENSE", + "SPCL.ATK", + "SPCL.DEF", + "SPEED", + ] + + # get the current text on the screen + text = self.get_text() + + if all([part in text for part in parts]): + return True + else: + return False + + def handle_stats_screen(self, force=False): + """ + Attempts to bypass a stats screen. Set force=True if you want to make + the attempt regardless of whether or not the system thinks a stats + screen is showing. + """ + if self.is_showing_stats_screen() or force: + self.vba.press("a") + self.vba.step(count=20) + + def keyboard_apply(self, button_sequence): """ Applies a sequence of buttons to the on-screen keyboard. """ for buttons in button_sequence: - press(buttons) - nstep(2) - press([]) + self.vba.press(buttons) + + if buttons == "select": + self.vba.step(count=5) + else: + self.vba.step(count=2) + + self.vba.press([]) - @staticmethod - def write(something="TrAiNeR"): + def write(self, something="TrAiNeR"): """ Types out a word. Uses a planning algorithm to do this in the most efficient way possible. """ button_sequence = keyboard.plan_typing(something) - crystal.keyboard_apply([[x] for x in button_sequence]) + self.vba.step(count=10) + self.keyboard_apply([[x] for x in button_sequence]) + return button_sequence - @staticmethod - def set_partymon2(): + def set_partymon2(self): """ This causes corruption, so it's not working yet. """ - memory = get_memory() + memory = self.vba.memory memory[0xdcd7] = 2 memory[0xdcd9] = 0x7 @@ -464,19 +844,18 @@ class crystal: memory[0xdd33] = 0x10 memory[0xdd34] = 0x40 - set_memory(memory) + self.vba.memory = memory - @staticmethod - def wait_for_script_running(debug=False, limit=1000): + def wait_for_script_running(self, debug=False, limit=1000): """ Wait until ScriptRunning isn't -1. """ while limit > 0: - if get_memory_at(0xd438) != 255: + if self.vba.read_memory_at(0xd438) != 255: print "script is done executing" return else: - step() + self.vba.step() if debug: limit = limit - 1 @@ -484,44 +863,316 @@ class crystal: if limit == 0: print "limit ran out" - @staticmethod - def move(cmd): + def move(self, cmd): """ Attempt to move the player. """ - press(cmd, holdsteps=10, aftersteps=0) - press([]) + if isinstance(cmd, list): + for command in cmd: + self.move(command) + else: + self.vba.press(cmd, hold=10, after=0) + self.vba.press([]) + + memory = self.vba.memory + #while memory[0xd4e1] == 2 and memory[0xd042] != 0x3e: + while memory[0xd043] in [0, 1, 2, 3]: + #while memory[0xd043] in [0, 1, 2, 3] or memory[0xd042] != 0x3e: + self.vba.step(count=10) + memory = self.vba.memory + + def get_enemy_hp(self): + """ + Returns the HP of the current enemy. + """ + hp = ((self.vba.memory[0xd218] << 8) | self.vba.memory[0xd217]) + return hp - memory = get_memory() - #while memory[0xd4e1] == 2 and memory[0xd042] != 0x3e: - while memory[0xd043] in [0, 1, 2, 3]: - #while memory[0xd043] in [0, 1, 2, 3] or memory[0xd042] != 0x3e: - nstep(10) - memory = get_memory() + def start_trainer_battle_lamely(self, map_group=0x1, map_id=0xc, x=6, y=8, direction="l", loop_limit=10): + """ + Starts a trainer battle by warping into a map at the designated + coordinates, pressing the direction button for a full walk step (which + ideally should be blocked, this is mainly to establish direction), and + then pressing "a" to initiate the trainer battle. + + Consider using start_trainer_battle instead. + """ + self.warp(map_group, map_id, x, y) -class TestEmulator(unittest.TestCase): - def test_PlaceString(self): - call(0, 0x1078) + # finish loading the map, might not be necessary? + self.nstep(100) - # where to draw the text - registers["hl"] = 0xc4a0 + # face towards the trainer (or whatever direction was specified). If + # this direction is blocked, then this will only change which direction + # the character is facing. However, if this direction is not blocked by + # the map or by an npc, then this will cause an entire step to be + # taken. + self.vba.press([direction]) + + # talk to the trainer, don't assume line of sight will be triggered + self.vba.press(["a"]) + self.vba.press([]) + + # trainer might talk, skip any text until the player can choose moves + while not self.is_in_battle() and loop_limit > 0: + self.text_wait() + loop_limit -= 1 + + def start_trainer_battle(self, trainer_group=0x1, trainer_id=0x1, text_win="YOU WIN", text_address=0xdb90): + """ + Start a trainer battle with the trainer located by trainer_group and + trainer_id. + + :param trainer_group: trainer group id + :param trainer_id: trainer id within the group + :param text_win: text to show if player wins + :param text_address: where to store text_win in wram + """ + # where the script will be written + rom_address = 0x75 * 0x4000 - # what text to read from - registers["de"] = 0x1276 + # battle win message + translated_text = translate_text(text_win) - nstep(10) + # also include the first and last bytes needed for text + translated_text = [0] + translated_text + [0x57] - text = crystal.get_text() + mem = self.vba.memory - self.assertTrue("TRAINER" in text) + # create a backup of the current data + wram_backup = mem[text_address : text_address + len(translated_text)] -class TestWriter(unittest.TestCase): - def test_very_basic(self): - button_sequence = keyboard.plan_typing("an") - expected_result = ["select", "a", "d", "r", "r", "r", "r", "a"] + # manipulate the memory + mem[text_address : text_address + len(translated_text)] = translated_text + self.vba.memory = mem - self.assertEqual(len(expected_result), len(button_sequence)) - self.assertEqual(expected_result, button_sequence) + text_pointer_hi = text_address / 0x100 + text_pointer_lo = text_address % 0x100 -if __name__ == "__main__": - unittest.main() + script = [ + # loadfont + #0x47, + + # winlosstext address, address + 0x64, text_pointer_lo, text_pointer_hi, 0, 0, + + # loadtrainer group, id + 0x5e, trainer_group, trainer_id, + + # startbattle + 0x5f, + + # returnafterbattle + 0x60, + + # reloadmapmusic + 0x83, + + # reloadmap + 0x7B, + ] + + # Now make the script restore wram at the end (after the text has been + # used). The assumption here is that this particular subset of wram + # data would not be needed during the bulk of the script. + address = text_address + for byte in wram_backup: + address_hi = address / 0x100 + address_lo = address % 0x100 + + script += [ + # loadvar + 0x1b, address_lo, address_hi, byte, + ] + + address += 1 + + script += [ + # end + 0x91, + ] + + # Use a different wram address because the default is something related + # to trainers. + # use a higher loop limit because otherwise it doesn't start fast enough? + self.inject_script_into_rom(asm=script, rom_address=rom_address, wram_address=0xdb75, limit=1000) + + def set_script(self, address): + """ + Sets the current script in wram to whatever address. + """ + ScriptBank = 0xd439 + ScriptPos = 0xd43a + + memory = self.vba.memory + memory[ScriptBank] = calculate_bank(address) + pointer = calculate_address(address) + memory[ScriptPos] = (calculate_address(address) & 0xff00) >> 8 + memory[ScriptPos] = calculate_address(address) & 0xff + + # TODO: determine if this is necessary + #memory[ScriptRunning] = 0xff + + self.vba.memory = memory + + def inject_script_into_rom(self, asm=[0x91], rom_address=0x75 * 0x4000, wram_address=0xd280, limit=50): + """ + Writes a script to the ROM in a blank location. Calls call_script to + make the game engine aware of the script. Then executes the script and + looks for confirmation thta the script has started to run. + + The script must end itself. + + :param asm: scripting command bytes + :param rom_address: rom location to write asm to + :param wram_address: temporary storage for indicating if the script has + started yet + :param limit: number of frames to emulate before giving up on the start + script + """ + execution_pending = 0 + execution_started = 1 + valid_execution_states = (execution_pending, execution_started) + + # location for byte for whether script has started executing + execution_indicator_address = wram_address + + # backup whatever exists at the current wram location + backup_wram = self.vba.read_memory_at(execution_indicator_address) + + # .. and set it to "pending" + self.vba.write_memory_at(execution_indicator_address, execution_pending) + + # initial script that runs first to tell python that execution started + execution_indicator_script = [ + # loadvar address, value + 0x1b, execution_indicator_address & 0xff, execution_indicator_address >> 8, execution_started, + ] + + # make the indicator script run before the user script + full_script = execution_indicator_script + asm + + # inject the asm + rom = list(self.vba.rom) + rom[rom_address : rom_address + len(full_script)] = full_script + + # set the rom with the injected bytes + self.vba.rom = rom + + # setup the script for execution + self.call_script(rom_address) + + status = execution_pending + while status != execution_started and limit > 0: + # emulator time travel + self.vba.step(count=1) + + # get latest wram + status = self.vba.read_memory_at(execution_indicator_address) + if status not in valid_execution_states: + raise Exception( + "The execution indicator at {addr} has invalid state {value}".format( + addr=hex(execution_indicator_address), + value=status, + ) + ) + elif status == execution_started: + break # hooray + + limit -= 1 + + if status == execution_pending and limit == 0: + raise Exception( + "Emulation timeout while waiting for script to start." + ) + + # The script has started so it's okay to reset wram back to whatever it + # was. + self.vba.write_memory_at(execution_indicator_address, backup_wram) + + return True + + def givepoke(self, pokemon_id, level, nickname=None, wram=False): + """ + Give the player a pokemon. + """ + if isinstance(nickname, str): + if len(nickname) == 0: + raise Exception("invalid nickname") + elif len(nickname) > 11: + raise Exception("nickname too long") + else: + if not nickname: + nickname = False + else: + raise Exception("nickname needs to be a string, False or None") + + # script to inject into wram + script = [ + 0x47, # loadfont + #0x55, # keeptextopen + + # givepoke pokemon_id, level, 0, 0 + 0x2d, pokemon_id, level, 0, 0, + + #0x54, # closetext + 0x49, # loadmovesprites + 0x91, # end + ] + + # picked this region of wram because it looks like it's probably unused + # in situations where givepoke will work. + #address = 0xd073 + #address = 0xc000 + #address = 0xd8f1 + address = 0xd280 + + if not wram: + self.inject_script_into_rom(asm=script, wram_address=address) + else: + # TODO: move this into a separate function. Maybe use a context + # manager to restore wram at the end. + mem = list(self.vba.memory) + backup_wram = mem[address : address + len(script)] + mem[address : address + len(script)] = script + self.vba.memory = mem + + self.call_script(address, wram=True) + + # "would you like to give it a nickname?" + self.text_wait() + + if nickname: + # yes + self.vba.press("a", hold=10) + + # wait for the keyboard to appear + # TODO: this wait should be moved into write() + self.vba.step(count=20) + + # type the requested nicknameb + self.write(nickname) + + self.vba.press("start", hold=5, after=10) + self.vba.press("a", hold=5, after=50) + else: + # no nickname + self.vba.press("b", hold=10, after=20) + + if wram: + # Wait for the script to end in the engine before copying the original + # wram values back in. + self.vba.step(count=100) + + # reset whatever was in wram before this script was called + mem = list(self.vba.memory) + mem[address : address + len(script)] = backup_wram + self.vba.memory = mem + + def start_random_battle_by_rocksmash_battle_script(self): + """ + Initiates a wild battle using the same function that using rocksmash + would call. + """ + RockSmashBattleScript_address = 0x97cf9 + self.call_script(RockSmashBattleScript_address) diff --git a/pokemontools/wram.py b/pokemontools/wram.py index 7bc017d..8affd26 100644 --- a/pokemontools/wram.py +++ b/pokemontools/wram.py @@ -5,6 +5,10 @@ RGBDS BSS section and constant parsing. import os +# TODO: parse these constants from constants.asm +NUM_OBJECTS = 0x10 +OBJECT_LENGTH = 0x10 + def make_wram_labels(wram_sections): wram_labels = {} for section in wram_sections: @@ -142,6 +146,8 @@ class WRAMProcessor(object): self.setup_hram_constants() self.setup_gbhw_constants() + self.reformat_wram_labels() + def read_wram_sections(self): """ Opens the wram file and calls read_bss_sections. @@ -196,3 +202,14 @@ class WRAMProcessor(object): """ self.gbhw_constants = self.read_gbhw_constants() return self.gbhw_constants + + def reformat_wram_labels(self): + """ + Flips the wram_labels dictionary the other way around to access + addresses by label. + """ + self.wram = {} + + for (address, labels) in self.wram_labels.iteritems(): + for label in labels: + self.wram[label] = address diff --git a/requirements.txt b/requirements.txt index ab6f202..c3d403f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,8 @@ -e git://github.com/drj11/pypng.git@master#egg=pypng +# for the map editor, pillow instead of PIL +pillow + # testing mock @@ -24,7 +24,7 @@ requires = [ setup( name="pokemontools", - version="1.4.1", + version="1.6.0", description="Tools for compiling and disassembling Pokémon Red and Pokémon Crystal.", long_description=open("README.md", "r").read(), license="BSD", diff --git a/tests/bootstrapping.py b/tests/bootstrapping.py new file mode 100644 index 0000000..b71c19a --- /dev/null +++ b/tests/bootstrapping.py @@ -0,0 +1,54 @@ +""" +Functions to bootstrap the emulator state +""" + +from setup_vba import ( + vba, + autoplayer, +) + +def bootstrap(): + """ + Every test needs to be run against a certain minimum context. That context + is constructed by this function. + """ + + cry = vba.crystal(config=None) + runner = autoplayer.SpeedRunner(cry=cry) + + # skip=False means run the skip_intro function instead of just skipping to + # a saved state. + runner.skip_intro(skip=True) + + state = cry.vba.state + + # clean everything up again + cry.vba.shutdown() + + return state + +def bootstrap_trainer_battle(): + """ + Start a trainer battle. + """ + # setup + cry = vba.crystal(config=None) + runner = autoplayer.SpeedRunner(cry=cry) + + runner.skip_intro(skip=True) + runner.handle_mom(skip=True) + runner.walk_into_new_bark_town(skip=True) + runner.handle_elm("totodile", skip=True) + + # levelgrind a pokemon + # TODO: make new_bark_level_grind able to figure out how to construct its + # initial state if none is provided. + runner.new_bark_level_grind(17, skip=True) + + cry.givepoke(64, 31, "kAdAbRa") + cry.givepoke(224, 60, "OcTiLlErY") + cry.givepoke(126, 87, "magmar") + + cry.start_trainer_battle() + + return runner.cry.vba.state diff --git a/tests/integration/tests.py b/tests/integration/tests.py index 40933e5..4f96699 100644 --- a/tests/integration/tests.py +++ b/tests/integration/tests.py @@ -42,6 +42,10 @@ from pokemontools.helpers import ( index, ) +from pokemontools.crystalparts.old_parsers import ( + old_parse_map_header_at, +) + from pokemontools.crystal import ( rom, load_rom, @@ -65,7 +69,6 @@ from pokemontools.crystal import ( all_labels, write_all_labels, parse_map_header_at, - old_parse_map_header_at, process_00_subcommands, parse_all_map_headers, translate_command_byte, diff --git a/tests/setup_vba.py b/tests/setup_vba.py new file mode 100644 index 0000000..6e615e2 --- /dev/null +++ b/tests/setup_vba.py @@ -0,0 +1,4 @@ +import pokemontools.vba.vba as vba +import pokemontools.vba.keyboard as keyboard +import pokemontools.vba.autoplayer as autoplayer +autoplayer.vba = vba diff --git a/tests/test_vba.py b/tests/test_vba.py index 56a71e3..caa1867 100644 --- a/tests/test_vba.py +++ b/tests/test_vba.py @@ -4,81 +4,96 @@ Tests for VBA automation tools import unittest -import pokemontools.vba.vba as vba +from setup_vba import ( + vba, + autoplayer, + keyboard, +) -try: - import pokemontools.vba.vba_autoplayer -except ImportError: - import pokemontools.vba.autoplayer as vba_autoplayer - -vba_autoplayer.vba = vba +from bootstrapping import ( + bootstrap, + bootstrap_trainer_battle, +) def setup_wram(): """ Loads up some default addresses. Should eventually be replaced with the actual wram parser. """ + # TODO: this should just be parsed straight out of wram.asm wram = {} wram["PlayerDirection"] = 0xd4de wram["PlayerAction"] = 0xd4e1 wram["MapX"] = 0xd4e6 wram["MapY"] = 0xd4e7 + + wram["WarpNumber"] = 0xdcb4 + wram["MapGroup"] = 0xdcb5 + wram["MapNumber"] = 0xdcb6 + wram["YCoord"] = 0xdcb7 + wram["XCoord"] = 0xdcb8 + return wram -def bootstrap(): - """ - Every test needs to be run against a certain minimum context. That context - is constructed by this function. - """ +class OtherVbaTests(unittest.TestCase): + def test_keyboard_planner(self): + button_sequence = keyboard.plan_typing("an") + expected_result = ["select", "a", "d", "r", "r", "r", "r", "a"] - # reset the rom - vba.shutdown() - vba.load_rom() + self.assertEqual(len(expected_result), len(button_sequence)) + self.assertEqual(expected_result, button_sequence) - # skip=False means run the skip_intro function instead of just skipping to - # a saved state. - vba_autoplayer.skip_intro() +class VbaTests(unittest.TestCase): + cry = None + wram = None - state = vba.get_state() + @classmethod + def setUpClass(cls): + cls.bootstrap_state = bootstrap() - # clean everything up again - vba.shutdown() + cls.wram = setup_wram() - return state + cls.cry = vba.crystal() + cls.vba = cls.cry.vba -class VbaTests(unittest.TestCase): - # unittest in jython2.5 doesn't seem to have setUpClass ?? Man, why am I on - # jython2.5? This is ancient. - #@classmethod - #def setUpClass(cls): - # # get a good game state - # cls.state = bootstrap() - # - # # figure out addresses - # cls.wram = setup_wram() - - # FIXME: work around jython2.5 unittest - state = bootstrap() - wram = setup_wram() + cls.vba.state = cls.bootstrap_state - def get_wram_value(self, name): - return vba.get_memory_at(self.wram[name]) + @classmethod + def tearDownClass(cls): + cls.vba.shutdown() def setUp(self): - # clean the state - vba.shutdown() - vba.load_rom() - # reset to whatever the bootstrapper created - vba.set_state(self.state) + self.vba.state = self.bootstrap_state + + def get_wram_value(self, name): + return self.vba.memory[self.wram[name]] + + def check_movement(self, direction="d"): + """ + Check if (y, x) before attempting to move and (y, x) after attempting + to move are the same. + """ + start = (self.get_wram_value("MapY"), self.get_wram_value("MapX")) + self.cry.move(direction) + end = (self.get_wram_value("MapY"), self.get_wram_value("MapX")) + return start != end + + def bootstrap_name_prompt(self): + runner = autoplayer.SpeedRunner(cry=None) + runner.setup() + runner.skip_intro(stop_at_name_selection=True, skip=False, override=False) + + self.cry.vba.press("a", hold=20) - def tearDown(self): - vba.shutdown() + # wait for "Your name?" to show up + while "YOUR NAME?" not in self.cry.get_text(): + self.cry.step(count=50) def test_movement_changes_player_direction(self): player_direction = self.get_wram_value("PlayerDirection") - vba.crystal.move("u") + self.cry.move("u") # direction should have changed self.assertNotEqual(player_direction, self.get_wram_value("PlayerDirection")) @@ -86,7 +101,7 @@ class VbaTests(unittest.TestCase): def test_movement_changes_y_coord(self): first_map_y = self.get_wram_value("MapY") - vba.crystal.move("u") + self.cry.move("u") # y location should be different second_map_y = self.get_wram_value("MapY") @@ -96,11 +111,176 @@ class VbaTests(unittest.TestCase): # should start with standing self.assertEqual(self.get_wram_value("PlayerAction"), 1) - vba.crystal.move("l") + self.cry.move("l") # should be standing player_action = self.get_wram_value("PlayerAction") self.assertEqual(player_action, 1) # 1 = standing + def test_PlaceString(self): + self.cry.call(0, 0x1078) + + # where to draw the text + self.cry.registers["hl"] = 0xc4a0 + + # what text to read from + self.cry.registers["de"] = 0x1276 + + self.cry.vba.step(count=10) + + text = self.cry.get_text() + + self.assertTrue("TRAINER" in text) + + def test_speedrunner_constructor(self): + runner = autoplayer.SpeedRunner(cry=self.cry) + + def test_speedrunner_handle_mom(self): + # TODO: why can't i pass in the current state of the emulator? + runner = autoplayer.SpeedRunner(cry=None) + runner.setup() + runner.skip_intro(skip=True) + runner.handle_mom(skip=False) + + # confirm that handle_mom is done by attempting to move on the map + self.assertTrue(self.check_movement("d")) + + def test_speedrunner_walk_into_new_bark_town(self): + runner = autoplayer.SpeedRunner(cry=None) + runner.setup() + runner.skip_intro(skip=True) + runner.handle_mom(skip=True) + runner.walk_into_new_bark_town(skip=False) + + # test that the game is in a state such that the player can walk + self.assertTrue(self.check_movement("d")) + + # check that the map is correct + self.assertEqual(self.get_wram_value("MapGroup"), 24) + self.assertEqual(self.get_wram_value("MapNumber"), 4) + + def test_speedrunner_handle_elm(self): + runner = autoplayer.SpeedRunner(cry=None) + runner.setup() + runner.skip_intro(skip=True) + runner.handle_mom(skip=True) + runner.walk_into_new_bark_town(skip=False) + + # go through the Elm's Lab sequence + runner.handle_elm("cyndaquil", skip=False) + + # test again if the game is in a state where the player can walk + self.assertTrue(self.check_movement("u")) + + # check that the map is correct + self.assertEqual(self.get_wram_value("MapGroup"), 24) + self.assertEqual(self.get_wram_value("MapNumber"), 5) + + def test_moving_back_and_forth(self): + runner = autoplayer.SpeedRunner(cry=None) + runner.setup() + runner.skip_intro(skip=True) + runner.handle_mom(skip=True) + runner.walk_into_new_bark_town(skip=False) + + # must be in New Bark Town + self.assertEqual(self.get_wram_value("MapGroup"), 24) + self.assertEqual(self.get_wram_value("MapNumber"), 4) + + runner.cry.move("l") + runner.cry.move("l") + runner.cry.move("l") + runner.cry.move("d") + runner.cry.move("d") + + for x in range(0, 10): + runner.cry.move("l") + runner.cry.move("d") + runner.cry.move("r") + runner.cry.move("u") + + # must still be in New Bark Town + self.assertEqual(self.get_wram_value("MapGroup"), 24) + self.assertEqual(self.get_wram_value("MapNumber"), 4) + + def test_crystal_move_list(self): + runner = autoplayer.SpeedRunner(cry=None) + runner.setup() + runner.skip_intro(skip=True) + runner.handle_mom(skip=True) + runner.walk_into_new_bark_town(skip=False) + + # must be in New Bark Town + self.assertEqual(self.get_wram_value("MapGroup"), 24) + self.assertEqual(self.get_wram_value("MapNumber"), 4) + + first_map_x = self.get_wram_value("MapX") + + runner.cry.move(["l", "l", "l"]) + + # x location should be different + second_map_x = self.get_wram_value("MapX") + self.assertNotEqual(first_map_x, second_map_x) + + # must still be in New Bark Town + self.assertEqual(self.get_wram_value("MapGroup"), 24) + self.assertEqual(self.get_wram_value("MapNumber"), 4) + + def test_keyboard_typing_dumb_name(self): + self.bootstrap_name_prompt() + + name = "tRaInEr" + self.cry.write(name) + + # save this selection + self.cry.vba.press("a", hold=20) + + self.assertEqual(name, self.cry.get_player_name()) + + def test_keyboard_typing_cap_name(self): + names = [ + "trainer", + "TRAINER", + "TrAiNeR", + "tRaInEr", + "ExAmPlE", + "Chris", + "Kris", + "beepaaa", + "chris", + "CHRIS", + "Python", + "pYthon", + "pyThon", + "pytHon", + "pythOn", + "pythoN", + "python", + "PyThOn", + "Zot", + "Death", + "Hiro", + "HIRO", + ] + + self.bootstrap_name_prompt() + start_state = self.cry.vba.state + + for name in names: + print "Writing name: " + name + + self.cry.vba.state = start_state + + sequence = self.cry.write(name) + + print "sequence is: " + str(sequence) + + # save this selection + self.cry.vba.press("start", hold=20) + self.cry.vba.press("a", hold=20) + + pname = self.cry.get_player_name().replace("@", "") + self.assertEqual(name, pname) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_vba_battle.py b/tests/test_vba_battle.py new file mode 100644 index 0000000..c6debc3 --- /dev/null +++ b/tests/test_vba_battle.py @@ -0,0 +1,117 @@ +""" +Tests for the battle controller +""" + +import unittest + +from setup_vba import ( + vba, + autoplayer, +) + +from pokemontools.vba.battle import ( + Battle, + BattleException, +) + +from bootstrapping import ( + bootstrap, + bootstrap_trainer_battle, +) + +class BattleTests(unittest.TestCase): + cry = None + vba = None + bootstrap_state = None + + @classmethod + def setUpClass(cls): + cls.cry = vba.crystal() + cls.vba = cls.cry.vba + + cls.bootstrap_state = bootstrap_trainer_battle() + cls.vba.state = cls.bootstrap_state + + @classmethod + def tearDownClass(cls): + cls.vba.shutdown() + + def setUp(self): + # reset to whatever the bootstrapper created + self.vba.state = self.bootstrap_state + self.battle = Battle(emulator=self.cry) + self.battle.skip_start_text() + + def test_is_in_battle(self): + self.assertTrue(self.battle.is_in_battle()) + + def test_is_player_turn(self): + self.battle.skip_start_text() + self.battle.skip_until_input_required() + + # the initial state should be the player's turn + self.assertTrue(self.battle.is_player_turn()) + + def test_is_mandatory_switch_initial(self): + # should not be asking for a switch so soon in the battle + self.assertFalse(self.battle.is_mandatory_switch()) + + def test_is_mandatory_switch(self): + self.battle.skip_start_text() + self.battle.skip_until_input_required() + + # press "FIGHT" + self.vba.press(["a"], after=20) + + # press the first move ("SCRATCH") + self.vba.press(["a"], after=20) + + # set partymon1 hp to very low + self.cry.set_battle_mon_hp(1) + + # let the enemy attack and kill the pokemon + self.battle.skip_until_input_required() + + self.assertTrue(self.battle.is_mandatory_switch()) + + def test_attack_loop(self): + self.battle.skip_start_text() + self.battle.skip_until_input_required() + + # press "FIGHT" + self.vba.press(["a"], after=20) + + # press the first move ("SCRATCH") + self.vba.press(["a"], after=20) + + self.battle.skip_until_input_required() + + self.assertTrue(self.battle.is_player_turn()) + + def test_is_battle_switch_prompt(self): + self.battle.skip_start_text() + self.battle.skip_until_input_required() + + # press "FIGHT" + self.vba.press(["a"], after=20) + + # press the first move ("SCRATCH") + self.vba.press(["a"], after=20) + + # set enemy hp to very low + self.cry.lower_enemy_hp() + + # attack the enemy and kill it + self.battle.skip_until_input_required() + + # yes/no menu is present, should be detected + self.assertTrue(self.battle.is_trainer_switch_prompt()) + + # and input should be required + self.assertTrue(self.battle.is_input_required()) + + # but it's not mandatory + self.assertFalse(self.battle.is_mandatory_switch()) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tests.py b/tests/tests.py index 7919a66..4398f03 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -38,6 +38,10 @@ from pokemontools.labels import ( find_labels_without_addresses, ) +from pokemontools.crystalparts.old_parsers import ( + old_parse_map_header_at, +) + from pokemontools.helpers import ( grouper, index, @@ -66,7 +70,6 @@ from pokemontools.crystal import ( all_labels, write_all_labels, parse_map_header_at, - old_parse_map_header_at, process_00_subcommands, parse_all_map_headers, translate_command_byte, |