summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md46
-rw-r--r--pokemontools/__init__.py2
-rw-r--r--pokemontools/audio.py390
-rw-r--r--pokemontools/configuration.py9
-rw-r--r--pokemontools/cry_names.py4
-rw-r--r--pokemontools/crystal.py172
-rw-r--r--pokemontools/crystalparts/old_parsers.py2
-rw-r--r--pokemontools/data/__init__.py15
-rw-r--r--pokemontools/data/pokecrystal/wram.asm2293
-rw-r--r--pokemontools/exceptions.py5
-rw-r--r--pokemontools/gfx.py679
-rw-r--r--pokemontools/map_editor.py1289
-rw-r--r--pokemontools/preprocessor.py18
-rwxr-xr-xpokemontools/redmusicdisasm.py23
-rwxr-xr-xpokemontools/redsfxdisasm.py79
-rwxr-xr-xpokemontools/redsfxheaders.py13
-rw-r--r--pokemontools/sfx_names.py211
-rw-r--r--pokemontools/song_names.py107
-rw-r--r--pokemontools/vba/autoplayer.py820
-rw-r--r--pokemontools/vba/battle.py521
-rw-r--r--pokemontools/vba/vba.py1093
-rw-r--r--pokemontools/wram.py17
-rw-r--r--requirements.txt3
-rw-r--r--setup.py2
-rw-r--r--tests/bootstrapping.py54
-rw-r--r--tests/integration/tests.py5
-rw-r--r--tests/setup_vba.py4
-rw-r--r--tests/test_vba.py276
-rw-r--r--tests/test_vba_battle.py117
-rw-r--r--tests/tests.py5
30 files changed, 6545 insertions, 1729 deletions
diff --git a/README.md b/README.md
index 760731b..9c3ca92 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/setup.py b/setup.py
index 65c9842..19d4af9 100644
--- a/setup.py
+++ b/setup.py
@@ -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,