diff options
-rw-r--r-- | pokemontools/crystal.py | 71 | ||||
-rw-r--r-- | pokemontools/data/pokecrystal/gbhw.asm | 102 | ||||
-rw-r--r-- | pokemontools/data/pokecrystal/hram.asm | 71 | ||||
-rw-r--r-- | pokemontools/map_gfx.py | 400 | ||||
-rw-r--r-- | pokemontools/vba/path.py | 664 | ||||
-rw-r--r-- | pokemontools/wram.py | 18 |
6 files changed, 1309 insertions, 17 deletions
diff --git a/pokemontools/crystal.py b/pokemontools/crystal.py index cdab01f..eb88b6b 100644 --- a/pokemontools/crystal.py +++ b/pokemontools/crystal.py @@ -70,6 +70,11 @@ OldTextScript = old_text_script import configuration conf = configuration.Config() +data_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), "data/pokecrystal/") +conf.wram = os.path.join(data_path, "wram.asm") +conf.gbhw = os.path.join(data_path, "gbhw.asm") +conf.hram = os.path.join(data_path, "hram.asm") + from map_names import map_names # ---- script_parse_table explanation ---- @@ -174,7 +179,7 @@ def how_many_until(byte, starting, rom): def load_map_group_offsets(map_group_pointer_table, map_group_count, rom=None): """reads the map group table for the list of pointers""" map_group_offsets = [] # otherwise this method can only be used once - data = rom.interval(map_group_pointer_table, map_group_count*2, strings=False, rom=rom) + data = rom.interval(map_group_pointer_table, map_group_count*2, strings=False) data = helpers.grouper(data) for pointer_parts in data: pointer = pointer_parts[0] + (pointer_parts[1] << 8) @@ -249,7 +254,10 @@ class TextScript: see: http://hax.iimarck.us/files/scriptingcodes_eng.htm#InText """ base_label = "UnknownText_" - def __init__(self, address, map_group=None, map_id=None, debug=False, label=None, force=False, show=None): + def __init__(self, address, map_group=None, map_id=None, debug=False, label=None, force=False, show=None, script_parse_table=None, text_command_classes=None): + self.text_command_classes = text_command_classes + self.script_parse_table = script_parse_table + self.address = address # $91, $84, $82, $54, $8c # 0x19768c is a a weird problem? @@ -425,7 +433,7 @@ def parse_text_engine_script_at(address, map_group=None, map_id=None, debug=True """ if is_script_already_parsed_at(address) and not force: return script_parse_table[address] - return TextScript(address, map_group=map_group, map_id=map_id, debug=debug, show=show, force=force) + return TextScript(address, map_group=map_group, map_id=map_id, debug=debug, show=show, force=force, script_parse_table=script_parse_table, text_command_classes=text_command_classes) def find_text_addresses(): """returns a list of text pointers @@ -560,7 +568,7 @@ def parse_text_at3(address, map_group=None, map_id=None, debug=False): if deh: return deh else: - text = TextScript(address, map_group=map_group, map_id=map_id, debug=debug) + text = TextScript(address, map_group=map_group, map_id=map_id, debug=debug, script_parse_table=script_parse_table, text_command_classes=text_command_classes) if text.is_valid(): return text else: @@ -775,7 +783,7 @@ HexByte=DollarSignByte class ItemLabelByte(DollarSignByte): def to_asm(self): - label = item_constants.item_constants.find_item_label_by_id(self.byte) + label = item_constants.find_item_label_by_id(self.byte) if label: return label elif not label: @@ -2925,7 +2933,7 @@ class Script: if start_address in stop_points and force == False: if debug: logging.debug( - "script parsing is stopping at stop_point={address} at map_group={map_group} map_id={map_id}" + "script parsing is stopping at stop_point={stop_point} at map_group={map_group} map_id={map_id}" .format( stop_point=hex(start_address), map_group=str(map_group), @@ -6596,7 +6604,7 @@ def list_texts_in_bank(bank): Narrows down the list of objects that you will be inserting into Asm. """ if len(all_texts) == 0: - raise Exception("all_texts is blank.. main() will populate it") + raise Exception("all_texts is blank.. parse_rom() will populate it") assert bank != None, "list_texts_in_banks must be given a particular bank" @@ -6614,7 +6622,7 @@ def list_movements_in_bank(bank, all_movements): Narrows down the list of objects to speed up Asm insertion. """ if len(all_movements) == 0: - raise Exception("all_movements is blank.. main() will populate it") + raise Exception("all_movements is blank.. parse_rom() will populate it") assert bank != None, "list_movements_in_bank must be given a particular bank" assert 0 <= bank < 0x80, "bank doesn't exist in the ROM (out of bounds)" @@ -6633,7 +6641,7 @@ def dump_asm_for_texts_in_bank(bank, start=50, end=100, rom=None): # load and parse the ROM if necessary if rom == None or len(rom) <= 4: rom = load_rom() - main() + parse_rom() # get all texts # first 100 look okay? @@ -6653,7 +6661,7 @@ def dump_asm_for_texts_in_bank(bank, start=50, end=100, rom=None): def dump_asm_for_movements_in_bank(bank, start=0, end=100, all_movements=None): if rom == None or len(rom) <= 4: rom = load_rom() - main() + parse_rom() movements = list_movements_in_bank(bank, all_movements)[start:end] @@ -6669,7 +6677,7 @@ def dump_things_in_bank(bank, start=50, end=100): # load and parse the ROM if necessary if rom == None or len(rom) <= 4: rom = load_rom() - main() + parse_rom() things = list_things_in_bank(bank)[start:end] @@ -6706,6 +6714,14 @@ def write_all_labels(all_labels, filename="labels.json"): fh.close() return True +def setup_wram_labels(config=conf): + """ + Get all wram labels and store it on the module. + """ + wramproc = wram.WRAMProcessor(config=config) + wramproc.initialize() + wram.wram_labels = wramproc.wram_labels + def get_ram_label(address): """ returns a label assigned to a particular ram address @@ -6953,16 +6969,28 @@ Command.trainer_group_maximums = trainer_group_maximums SingleByteParam.map_internal_ids = map_internal_ids MultiByteParam.map_internal_ids = map_internal_ids -def main(rom=None): +def add_map_offsets_into_map_names(map_group_offsets, map_names=None): + """ + Add the offsets for each map into the map_names variable. + """ + # add the offsets into our map structure, why not (johto maps only) + return [map_names[map_group_id+1].update({"offset": offset}) for map_group_id, offset in enumerate(map_group_offsets)] + +rom_parsed = False + +def parse_rom(rom=None): if not rom: # read the rom and figure out the offsets for maps rom = direct_load_rom() + # make wram.wram_labels available + setup_wram_labels() + # figure out the map offsets map_group_offsets = load_map_group_offsets(map_group_pointer_table=map_group_pointer_table, map_group_count=map_group_count, rom=rom) - # add the offsets into our map structure, why not (johto maps only) - [map_names[map_group_id+1].update({"offset": offset}) for map_group_id, offset in enumerate(map_group_offsets)] + # populate the map_names structure with the offsets + add_map_offsets_into_map_names(map_group_offsets, map_names=map_names) # parse map header bytes for each map parse_all_map_headers(map_names, all_map_headers=all_map_headers) @@ -6978,5 +7006,20 @@ def main(rom=None): # improve duplicate trainer names make_trainer_group_name_trainer_ids(trainer_group_table) + global rom_parsed + rom_parsed = True + + return map_names + +def cachably_parse_rom(rom=None): + """ + Calls parse_rom if it hasn't been called and completed yet. + """ + global rom_parsed + if not rom_parsed: + return parse_rom(rom=rom) + else: + return map_names + if __name__ == "crystal": pass diff --git a/pokemontools/data/pokecrystal/gbhw.asm b/pokemontools/data/pokecrystal/gbhw.asm new file mode 100644 index 0000000..0f12e48 --- /dev/null +++ b/pokemontools/data/pokecrystal/gbhw.asm @@ -0,0 +1,102 @@ +; Graciously aped from http://nocash.emubase.de/pandocs.htm . + +; MBC3 +MBC3SRamEnable EQU $0000 +MBC3RomBank EQU $2000 +MBC3SRamBank EQU $4000 +MBC3LatchClock EQU $6000 +MBC3RTC EQU $a000 + +SRAM_DISABLE EQU $00 +SRAM_ENABLE EQU $0a + +NUM_SRAM_BANKS EQU 4 + +RTC_S EQU $08 ; Seconds 0-59 (0-3Bh) +RTC_M EQU $09 ; Minutes 0-59 (0-3Bh) +RTC_H EQU $0a ; Hours 0-23 (0-17h) +RTC_DL EQU $0b ; Lower 8 bits of Day Counter (0-FFh) +RTC_DH EQU $0c ; Upper 1 bit of Day Counter, Carry Bit, Halt Flag + ; Bit 0 Most significant bit of Day Counter (Bit 8) + ; Bit 6 Halt (0=Active, 1=Stop Timer) + ; Bit 7 Day Counter Carry Bit (1=Counter Overflow) + +; interrupt flags +VBLANK EQU 0 +LCD_STAT EQU 1 +TIMER EQU 2 +SERIAL EQU 3 +JOYPAD EQU 4 + +; Hardware registers +rJOYP EQU $ff00 ; Joypad (R/W) +rSB EQU $ff01 ; Serial transfer data (R/W) +rSC EQU $ff02 ; Serial Transfer Control (R/W) +rSC_ON EQU 7 +rSC_CGB EQU 1 +rSC_CLOCK EQU 0 +rDIV EQU $ff04 ; Divider Register (R/W) +rTIMA EQU $ff05 ; Timer counter (R/W) +rTMA EQU $ff06 ; Timer Modulo (R/W) +rTAC EQU $ff07 ; Timer Control (R/W) +rTAC_ON EQU 2 +rTAC_4096_HZ EQU 0 +rTAC_262144_HZ EQU 1 +rTAC_65536_HZ EQU 2 +rTAC_16384_HZ EQU 3 +rIF EQU $ff0f ; Interrupt Flag (R/W) +rNR10 EQU $ff10 ; Channel 1 Sweep register (R/W) +rNR11 EQU $ff11 ; Channel 1 Sound length/Wave pattern duty (R/W) +rNR12 EQU $ff12 ; Channel 1 Volume Envelope (R/W) +rNR13 EQU $ff13 ; Channel 1 Frequency lo (Write Only) +rNR14 EQU $ff14 ; Channel 1 Frequency hi (R/W) +rNR21 EQU $ff16 ; Channel 2 Sound Length/Wave Pattern Duty (R/W) +rNR22 EQU $ff17 ; Channel 2 Volume Envelope (R/W) +rNR23 EQU $ff18 ; Channel 2 Frequency lo data (W) +rNR24 EQU $ff19 ; Channel 2 Frequency hi data (R/W) +rNR30 EQU $ff1a ; Channel 3 Sound on/off (R/W) +rNR31 EQU $ff1b ; Channel 3 Sound Length +rNR32 EQU $ff1c ; Channel 3 Select output level (R/W) +rNR33 EQU $ff1d ; Channel 3 Frequency's lower data (W) +rNR34 EQU $ff1e ; Channel 3 Frequency's higher data (R/W) +rNR41 EQU $ff20 ; Channel 4 Sound Length (R/W) +rNR42 EQU $ff21 ; Channel 4 Volume Envelope (R/W) +rNR43 EQU $ff22 ; Channel 4 Polynomial Counter (R/W) +rNR44 EQU $ff23 ; Channel 4 Counter/consecutive; Inital (R/W) +rNR50 EQU $ff24 ; Channel control / ON-OFF / Volume (R/W) +rNR51 EQU $ff25 ; Selection of Sound output terminal (R/W) +rNR52 EQU $ff26 ; Sound on/off +rLCDC EQU $ff40 ; LCD Control (R/W) +rSTAT EQU $ff41 ; LCDC Status (R/W) +rSCY EQU $ff42 ; Scroll Y (R/W) +rSCX EQU $ff43 ; Scroll X (R/W) +rLY EQU $ff44 ; LCDC Y-Coordinate (R) +rLYC EQU $ff45 ; LY Compare (R/W) +rDMA EQU $ff46 ; DMA Transfer and Start Address (W) +rBGP EQU $ff47 ; BG Palette Data (R/W) - Non CGB Mode Only +rOBP0 EQU $ff48 ; Object Palette 0 Data (R/W) - Non CGB Mode Only +rOBP1 EQU $ff49 ; Object Palette 1 Data (R/W) - Non CGB Mode Only +rWY EQU $ff4a ; Window Y Position (R/W) +rWX EQU $ff4b ; Window X Position minus 7 (R/W) +rKEY1 EQU $ff4d ; CGB Mode Only - Prepare Speed Switch +rVBK EQU $ff4f ; CGB Mode Only - VRAM Bank +rHDMA1 EQU $ff51 ; CGB Mode Only - New DMA Source, High +rHDMA2 EQU $ff52 ; CGB Mode Only - New DMA Source, Low +rHDMA3 EQU $ff53 ; CGB Mode Only - New DMA Destination, High +rHDMA4 EQU $ff54 ; CGB Mode Only - New DMA Destination, Low +rHDMA5 EQU $ff55 ; CGB Mode Only - New DMA Length/Mode/Start +rRP EQU $ff56 ; CGB Mode Only - Infrared Communications Port +rBGPI EQU $ff68 ; CGB Mode Only - Background Palette Index +rBGPD EQU $ff69 ; CGB Mode Only - Background Palette Data +rOBPI EQU $ff6a ; CGB Mode Only - Sprite Palette Index +rOBPD EQU $ff6b ; CGB Mode Only - Sprite Palette Data +rUNKNOWN1 EQU $ff6c ; (FEh) Bit 0 (Read/Write) - CGB Mode Only +rSVBK EQU $ff70 ; CGB Mode Only - WRAM Bank +rUNKNOWN2 EQU $ff72 ; (00h) - Bit 0-7 (Read/Write) +rUNKNOWN3 EQU $ff73 ; (00h) - Bit 0-7 (Read/Write) +rUNKNOWN4 EQU $ff74 ; (00h) - Bit 0-7 (Read/Write) - CGB Mode Only +rUNKNOWN5 EQU $ff75 ; (8Fh) - Bit 4-6 (Read/Write) +rUNKNOWN6 EQU $ff76 ; (00h) - Always 00h (Read Only) +rUNKNOWN7 EQU $ff77 ; (00h) - Always 00h (Read Only) +rIE EQU $ffff ; Interrupt Enable (R/W) + diff --git a/pokemontools/data/pokecrystal/hram.asm b/pokemontools/data/pokecrystal/hram.asm new file mode 100644 index 0000000..051d418 --- /dev/null +++ b/pokemontools/data/pokecrystal/hram.asm @@ -0,0 +1,71 @@ +hPushOAM EQU $ff80 + +hBuffer EQU $ff8b + +hRTCDayHi EQU $ff8d +hRTCDayLo EQU $ff8e +hRTCHours EQU $ff8f +hRTCMinutes EQU $ff90 +hRTCSeconds EQU $ff91 + +hHours EQU $ff94 + +hMinutes EQU $ff96 + +hSeconds EQU $ff98 + +hROMBank EQU $ff9d + +hJoypadReleased EQU $ffa2 +hJoypadPressed EQU $ffa3 +hJoypadDown EQU $ffa4 +hJoypadSum EQU $ffa5 +hJoyReleased EQU $ffa6 +hJoyPressed EQU $ffa7 +hJoyDown EQU $ffa8 + +hConnectionStripLength EQU $ffaf +hConnectedMapWidth EQU $ffb0 + +hPastLeadingZeroes EQU $ffb3 + +hDividend EQU $ffb3 +hDivisor EQU $ffb7 +hQuotient EQU $ffb4 + +hMultiplicand EQU $ffb4 +hMultiplier EQU $ffb7 +hProduct EQU $ffb3 + +hMathBuffer EQU $ffb8 + +hLCDStatCustom EQU $ffc6 + +hSerialSend EQU $ffcd +hSerialReceive EQU $ffce + +hSCX EQU $ffcf +hSCY EQU $ffd0 +hWX EQU $ffd1 +hWY EQU $ffd2 + +hBGMapMode EQU $ffd4 +hBGMapThird EQU $ffd5 +hBGMapAddress EQU $ffd6 + +hOAMUpdate EQU $ffd8 +hSPBuffer EQU $ffd9 + +hBGMapUpdate EQU $ffdb + +hTileAnimFrame EQU $ffdf + +hRandomAdd EQU $ffe1 +hRandomSub EQU $ffe2 + +hBattleTurn EQU $ffe4 +hCGBPalUpdate EQU $ffe5 +hCGB EQU $ffe6 +hSGB EQU $ffe7 +hDMATransfer EQU $ffe8 + diff --git a/pokemontools/map_gfx.py b/pokemontools/map_gfx.py new file mode 100644 index 0000000..77e7d56 --- /dev/null +++ b/pokemontools/map_gfx.py @@ -0,0 +1,400 @@ +""" +Map-related graphic functions. +""" + +import os +import png +from io import BytesIO + +from PIL import ( + Image, + ImageDraw, +) + +import crystal +import gfx + +tile_width = 8 +tile_height = 8 +block_width = 4 +block_height = 4 + +WALKING_SPRITE = 1 +STANDING_SPRITE = 2 +STILL_SPRITE = 3 + +# use the same configuration +gfx.config = crystal.conf +config = gfx.config + +def add_pokecrystal_paths_to_configuration(config=config): + """ + Assumes that the current working directory is the pokecrystal project path. + """ + config.gfx_dir = os.path.join(os.path.abspath("."), "gfx/tilesets/") + config.block_dir = os.path.join(os.path.abspath("."), "tilesets/") + config.palmap_dir = config.block_dir + config.palette_dir = config.block_dir + config.sprites_dir = os.path.join(os.path.abspath("."), "gfx/overworld/") + +add_pokecrystal_paths_to_configuration(config=config) + +def read_map_blockdata(map_header): + """ + Reads out the list of bytes representing the blockdata for the current map. + """ + width = map_header.second_map_header.blockdata.width.byte + height = map_header.second_map_header.blockdata.height.byte + + start_address = map_header.second_map_header.blockdata.address + end_address = start_address + (width * height) + + blockdata = crystal.rom[start_address : end_address] + + return [ord(x) for x in blockdata] + +def load_png(filepath): + """ + Makes an image object from file. + """ + return Image.open(filepath) + +all_blocks = {} +def read_blocks(tileset_id, config=config): + """ + Makes a list of blocks, such that each block is a list of tiles by id, for + the given tileset. + """ + if tileset_id in all_blocks.keys(): + return all_blocks[tileset_id] + + blocks = [] + + block_width = 4 + block_height = 4 + block_length = block_width * block_height + + filename = "{id}{ext}".format(id=str(tileset_id).zfill(2), ext="_metatiles.bin") + filepath = os.path.join(config.block_dir, filename) + + blocksetdata = bytearray(open(filepath, "rb").read()) + + for blockbyte in xrange(len(blocksetdata) / block_length): + block_num = blockbyte * block_length + block = blocksetdata[block_num : block_num + block_length] + blocks += [block] + + all_blocks[tileset_id] = blocks + + return blocks + +def colorize_tile(tile, palette): + """ + Make the tile have colors. + """ + (width, height) = tile.size + tile = tile.convert("RGB") + px = tile.load() + + for y in xrange(height): + for x in xrange(width): + # assume greyscale + which_color = 3 - (px[x, y][0] / 0x55) + (r, g, b) = [v * 8 for v in palette[which_color]] + px[x, y] = (r, g, b) + + return tile + +pre_cropped = {} +def read_tiles(tileset_id, palette_map, palettes, config=config): + """ + Opens the tileset png file and reads bytes for each tile in the tileset. + """ + + if tileset_id not in pre_cropped.keys(): + pre_cropped[tileset_id] = {} + + tile_width = 8 + tile_height = 8 + + tiles = [] + + filename = "{id}.{ext}".format(id=str(tileset_id).zfill(2), ext="png") + filepath = os.path.join(config.gfx_dir, filename) + + image = load_png(filepath) + (image.width, image.height) = image.size + + cur_tile = 0 + + for y in xrange(0, image.height, tile_height): + for x in xrange(0, image.width, tile_width): + if (x, y) in pre_cropped[tileset_id].keys(): + tile = pre_cropped[tileset_id][(x, y)] + else: + tile = image.crop((x, y, x + tile_width, y + tile_height)) + pre_cropped[tileset_id][(x, y)] = tile + + # palette maps are padded to make vram mapping easier + pal = palette_map[cur_tile + 0x20 if cur_tile > 0x60 else cur_tile] & 0x7 + tile = colorize_tile(tile, palettes[pal]) + + tiles.append(tile) + + cur_tile += 1 + + return tiles + +all_palette_maps = {} +def read_palette_map(tileset_id, config=config): + """ + Loads a palette map. + """ + if tileset_id in all_palette_maps.keys(): + return all_palette_maps[tileset_id] + + filename = "{id}{ext}".format(id=str(tileset_id).zfill(2), ext="_palette_map.bin") + filepath = os.path.join(config.palmap_dir, filename) + + palette_map = [] + + palmap = bytearray(open(filepath, "rb").read()) + + for i in xrange(len(palmap)): + palette_map += [palmap[i] & 0xf] + palette_map += [(palmap[i] >> 4) & 0xf] + + all_palette_maps[tileset_id] = palette_map + + return palette_map + +def read_palettes(time_of_day=1, config=config): + """ + Loads up the .pal file? + """ + palettes = [] + + actual_time_of_day = ["morn", "day", "nite"][time_of_day] + filename = "{}.pal".format(actual_time_of_day) + filepath = os.path.join(config.palette_dir, filename) + + num_colors = 4 + color_length = 2 + palette_length = num_colors * color_length + + pals = bytearray(open(filepath, "rb").read()) + num_pals = len(pals) / palette_length + + 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 load_sprite_image(address, config=config): + """ + Make standard file path. + """ + pal_file = os.path.join(config.block_dir, "day.pal") + + length = 0x40 + + image = crystal.rom[address:address + length] + width, height, palette, greyscale, bitdepth, px_map = gfx.convert_2bpp_to_png(image, width=16, height=16, pal_file=pal_file) + w = png.Writer(16, 16, palette=palette, compression=9, greyscale=greyscale, bitdepth=bitdepth) + some_buffer = BytesIO() + w.write(some_buffer, px_map) + some_buffer.seek(0) + + sprite_image = Image.open(some_buffer) + + return sprite_image + +sprites = {} +def load_all_sprite_images(config=config): + """ + Loads all images for each sprite in each direction. + """ + crystal.direct_load_rom() + + sprite_headers_address = 0x14736 + sprite_header_size = 6 + sprite_count = 102 + frame_size = 0x40 + + current_address = sprite_headers_address + + current_image_id = 0 + + for sprite_id in xrange(1, sprite_count): + rom_bytes = crystal.rom[current_address : current_address + sprite_header_size] + header = [ord(x) for x in rom_bytes] + + bank = header[3] + + lo = header[0] + hi = header[1] + sprite_address = (hi * 0x100) + lo - 0x4000 + sprite_address += 0x4000 * bank + + sprite_size = header[2] + sprite_type = header[4] + sprite_palette = header[5] + image_count = sprite_size / frame_size + + sprite = { + "size": sprite_size, + "image_count": image_count, + "type": sprite_type, + "palette": sprite_palette, + "images": {}, + } + + if sprite_type in [WALKING_SPRITE, STANDING_SPRITE]: + # down, up, left, move down, move up, move left + sprite["images"]["down"] = load_sprite_image(sprite_address, config=config) + sprite["images"]["up"] = load_sprite_image(sprite_address + 0x40, config=config) + sprite["images"]["left"] = load_sprite_image(sprite_address + (0x40 * 2), config=config) + + if sprite_type == WALKING_SPRITE: + current_image_id += image_count * 2 + elif sprite_type == STANDING_SPRITE: + current_image_id += image_count * 1 + elif sprite_type == STILL_SPRITE: + # just one image + sprite["images"]["still"] = load_sprite_image(sprite_address, config=config) + + current_image_id += image_count * 1 + + # store the actual metadata + sprites[sprite_id] = sprite + + current_address += sprite_header_size + + return sprites + +def draw_map_sprites(map_header, map_image, config=config): + """ + Show NPCs and items on the map. + """ + + events = map_header.second_map_header.event_header.people_events + + for event in events: + sprite_image_id = event.params[0].byte + y = (event.params[1].byte - 4) * 4 + x = (event.params[2].byte - 4) * 4 + facing = event.params[3].byte + movement = event.params[4].byte + sight_range = event.params[8].byte + some_pointer = event.params[9] + bit_table_bit_number = event.params[10] + + other_args = {} + + if sprite_image_id not in sprites.keys() or sprite_image_id > 0x66: + print "sprite_image_id {} is not in sprites".format(sprite_image_id) + + sprite_image = Image.new("RGBA", (16, 16)) + + draw = ImageDraw.Draw(sprite_image, "RGBA") + draw.rectangle([(0, 0), (16, 16)], fill=(0, 0, 0, 127)) + + other_args["mask"] = sprite_image + else: + sprite = sprites[sprite_image_id] + + # TODO: pick the correct direction based on "facing" + sprite_image = sprite["images"].values()[0] + + # TODO: figure out how to calculate the correct position + map_image.paste(sprite_image, (x * 4, y * 4), **other_args) + +def draw_map(map_group_id, map_id, palettes, show_sprites=True, config=config): + """ + Makes a picture of a map. + """ + # extract data from the ROM + crystal.cachably_parse_rom() + + map_header = crystal.map_names[map_group_id][map_id]["header_new"] + second_map_header = map_header.second_map_header + + width = second_map_header.blockdata.width.byte + height = second_map_header.blockdata.height.byte + + tileset_id = map_header.tileset.byte + blockdata = read_map_blockdata(map_header) + + palette_map = read_palette_map(tileset_id, config=config) + + tileset_blocks = read_blocks(tileset_id, config=config) + tileset_images = read_tiles(tileset_id, palette_map, palettes, config=config) + + map_image = Image.new("RGB", (width * tile_width * block_width, height * tile_height * block_height)) + + # draw each block on the map + for block_num in xrange(len(blockdata)): + block_x = block_num % width + block_y = block_num / width + + block = blockdata[block_y * width + block_x] + + for (tile_num, tile) in enumerate(tileset_blocks[block]): + # tile gfx are split in half to make vram mapping easier + if tile >= 0x80: + tile -= 0x20 + + tile_x = block_x * 32 + (tile_num % 4) * 8 + tile_y = block_y * 32 + (tile_num / 4) * 8 + + tile_image = tileset_images[tile] + + map_image.paste(tile_image, (tile_x, tile_y)) + + # draw each sprite on the map + draw_map_sprites(map_header, map_image, config=config) + + return map_image + +def save_map(map_group_id, map_id, savedir, show_sprites=True, config=config): + """ + Makes a map and saves it to a file in savedir. + """ + # this could be moved into a decorator + crystal.cachably_parse_rom() + + map_name = crystal.map_names[map_group_id][map_id]["label"] + filename = "{name}.{ext}".format(name=map_name, ext="png") + filepath = os.path.join(savedir, filename) + + palettes = read_palettes(config=config) + + print "Drawing {}".format(map_name) + map_image = draw_map(map_group_id, map_id, palettes, show_sprites=show_sprites, config=config) + map_image.save(filepath) + + return map_image + +def save_maps(savedir, show_sprites=True, config=config): + """ + Draw as many maps as possible. + """ + crystal.cachably_parse_rom() + + for map_group_id in crystal.map_names.keys(): + for map_id in crystal.map_names[map_group_id].keys(): + if isinstance(map_id, int): + image = save_map(map_group_id, map_id, savedir, show_sprites=show_sprites, config=config) diff --git a/pokemontools/vba/path.py b/pokemontools/vba/path.py new file mode 100644 index 0000000..2e50d1b --- /dev/null +++ b/pokemontools/vba/path.py @@ -0,0 +1,664 @@ +""" +path finding implementation + +1) For each position on the map, create a node representing the position. +2) For each NPC/item, mark nearby nodes as members of that NPC's threat zone + (note that they can be members of multiple zones simultaneously). +""" + +import pokemontools.configuration +config = pokemontools.configuration.Config() + +import pokemontools.crystal +import pokemontools.map_gfx + +from PIL import ( + Image, + ImageDraw, +) + +PENALTIES = { + # The minimum cost for a step must be greater than zero or else the path + # finding implementation might take the player through elaborate routes + # through nowhere. + "NONE": 1, + + # for any area that might be near a trainer or moving object + "THREAT_ZONE": 50, + + # for any nodes that might be under active observation (sight) by a trainer + "SIGHT_RANGE": 80, + + # active sight range is where the trainer will definitely see the player + "ACTIVE_SIGHT_RANGE": 100, + + # This is impossible, but the pathfinder might have a bug, and it would be + # nice to know about such a bug very soon. + "COLLISION": -999999, +} + +DIRECTIONS = { + "UP": "UP", + "DOWN": "DOWN", + "LEFT": "LEFT", + "RIGHT": "RIGHT", +} + +class Node(object): + """ + A ``Node`` represents a position on the map. + """ + + def __init__(self, position, threat_zones=None, contents=None): + self.position = position + self.y = position[0] + self.x = position[1] + + # by default a node is not a member of any threat zones + self.threat_zones = threat_zones or set() + + # by default a node does not have any objects at this location + self.contents = contents or set() + + self.cost = self.calculate_cost() + + def calculate_cost(self, PENALTIES=PENALTIES): + """ + Calculates a cost associated with passing through this node. + """ + penalty = PENALTIES["NONE"] + + # 1) assign a penalty based on whether or not this object is passable, + # if it's a collision then return a priority immediately + if self.is_collision_by_map_data() or self.is_collision_by_map_obstacle(): + penalty += PENALTIES["COLLISION"] + return penalty + + # 2) assign a penalty based on whether or not this object is grass/water + + # 3) assign a penalty based on whether or not there is a map_obstacle here, + # check each of the contents to see if there are any objects that exist + # at this location, if anything exists here then return a priority immediately + + # 4) consider any additional penalties due to the presence of a threat + # zone. Only calculate detailed penalties about the threat zone if the + # player is within range. + for threat_zone in self.threat_zones: + # the player might be inside the threat zone or the player might be + # just on the boundary + player_y = get_player_y() + player_x = get_player_x() + if threat_zone.is_player_near(player_y, player_x): + consider_sight_range = True + else: + consider_sight_range = False + + penalty += threat_zone.calculate_node_cost(self.y, self.x, consider_sight_range=consider_sight_range, PENALTIES=PENALTIES) + + return penalty + + def is_collision_by_map_data(self): + """ + Checks if the player can walk on this location. + """ + raise NotImplementedError + + def is_collision_by_map_obstacle(self): + """ + Checks if there is a map_obstacle on the current position that prevents + the player walking here. + """ + for content in self.contents: + if self.content.y == self.y and self.content.x == self.x: + return True + else: + return False + +class MapObstacle(object): + """ + A ``MapObstacle`` represents an item, npc or trainer on the map. + """ + + def __init__(self, some_map, identifier, sight_range=None, movement=None, turn=None, simulation=False, facing_direction=DIRECTIONS["DOWN"]): + """ + :param some_map: a reference to the map that this object belongs to + :param identifier: which object on the map does this correspond to? + :param simulation: set to False to not read from RAM + """ + self.simulation = simulation + + self.some_map = some_map + self.identifier = identifier + + self._sight_range = sight_range + if self._sight_range is None: + self._sight_range = self._get_sight_range() + + self._movement = movement + if self._movement is None: + self._movement = self._get_movement() + + self._turn = turn + if self._turn is None: + self._turn = self._get_turn() + + self.facing_direction = facing_direction + if not self.facing_direction: + self.facing_direction = self.get_current_facing_direction() + + self.update_location() + + def update_location(self): + """ + Determines the (y, x) location of the given map_obstacle object, which + can be a reference to an item, npc or trainer npc. + """ + if self.simulation: + return (self.y, self.x) + else: + raise NotImplementedError + + self.y = new_y + self.x = new_x + + return (new_y, new_x) + + def _get_current_facing_direction(self, DIRECTIONS=DIRECTIONS): + """ + Get the current facing direction of the map_obstacle. + """ + raise NotImplementedError + + def get_current_facing_direction(self, DIRECTIONS=DIRECTIONS): + """ + Get the current facing direction of the map_obstacle. + """ + if not self.simulation: + self.facing_direction = self._get_current_facing_direction(DIRECTIONS=DIRECTIONS) + return self.facing_direction + + def _get_movement(self): + """ + Figures out the "movement" variable. Also, this converts from the + internal game's format into True or False for whether or not the object + is capable of moving. + """ + raise NotImplementedError + + @property + def movement(self): + if self._movement is None: + self._movement = self._get_movement() + return self._movement + + def can_move(self): + """ + Checks if this map_obstacle is capable of movement. + """ + return self.movement + + def _get_turn(self): + """ + Checks whether or not the map_obstacle can turn. This only matters for + trainers. + """ + raise NotImplementedError + + @property + def turn(self): + if self._turn is None: + self._turn = self._get_turn() + return self._turn + + def can_turn_without_moving(self): + """ + Checks whether or not the map_obstacle can turn. This only matters for + trainers. + """ + return self.turn + + def _get_sight_range(self): + """ + Figure out the sight range of this map_obstacle. + """ + raise NotImplementedError + + @property + def sight_range(self): + if self._sight_range is None: + self._sight_range = self._get_sight_range() + return self._sight_range + +class ThreatZone(object): + """ + A ``ThreatZone`` represents the area surrounding a moving or turning object + that the player can try to avoid. + """ + + def __init__(self, map_obstacle, main_graph): + """ + Constructs a ``ThreatZone`` based on a graph of a map and a particular + object on that map. + + :param map_obstacle: the subject based on which to build a threat zone + :param main_graph: a reference to the map's nodes + """ + + self.map_obstacle = map_obstacle + self.main_graph = main_graph + + self.sight_range = self.calculate_sight_range() + + self.top_left_y = None + self.top_left_x = None + self.bottom_right_y = None + self.bottom_right_x = None + self.height = None + self.width = None + self.size = self.calculate_size() + + # nodes specific to this threat zone + self.nodes = [] + + def calculate_size(self): + """ + Calculate the bounds of the threat zone based on the map obstacle. + Returns the top left corner (y, x) and the bottom right corner (y, x) + in the form of ((y, x), (y, x), height, width). + """ + top_left_y = 0 + top_left_x = 0 + + bottom_right_y = 1 + bottom_right_x = 1 + + # TODO: calculate the correct bounds of the threat zone. + + raise NotImplementedError + + # if there is a sight_range for this map_obstacle then increase the size of the zone. + if self.sight_range > 0: + top_left_y += self.sight_range + top_left_x += self.sight_range + bottom_right_y += self.sight_range + bottom_right_x += self.sight_range + + top_left = (top_left_y, top_left_x) + bottom_right = (bottom_right_y, bottom_right_x) + + height = bottom_right_y - top_left_y + width = bottom_right_x - top_left_x + + self.top_left_y = top_left_y + self.top_left_x = top_left_x + self.bottom_right_y = bottom_right_y + self.bottom_right_x = bottom_right_x + self.height = height + self.width = width + + return (top_left, bottom_right, height, width) + + def is_player_near(self, y, x): + """ + Applies a boundary of one around the threat zone, then checks if the + player is inside. This is how the threatzone activates to calculate an + updated graph or set of penalties for each step. + """ + y_condition = (self.top_left_y - 1) <= y < (self.bottom_right_y + 1) + x_condition = (self.top_left_x - 1) <= x < (self.bottom_right_x + 1) + return y_condition and x_condition + + def check_map_obstacle_has_sight(self): + """ + Determines if the map object has the sight feature. + """ + return self.map_obstacle.sight_range > 0 + + def calculate_sight_range(self): + """ + Calculates the range that the object is able to see. + """ + if not self.check_map_obstacle_has_sight(): + return 0 + else: + return self.map_obstacle.sight_range + + def get_current_facing_direction(self, DIRECTIONS=DIRECTIONS): + """ + Get the current facing direction of the map_obstacle. + """ + return self.map_obstacle.get_current_facing_direction(DIRECTIONS=DIRECTIONS) + + # this isn't used anywhere yet + def is_map_obstacle_in_screen_range(self): + """ + Determines if the map_obstacle is within the bounds of whatever is on + screen at the moment. If the object is of a type that is capable of + moving, and it is not on screen, then it is not moving. + """ + raise NotImplementedError + + def mark_nodes_as_members_of_threat_zone(self): + """ + Based on the nodes in this threat zone, mark each main graph's nodes as + members of this threat zone. + """ + + for y in range(self.top_left_y, self.top_left_y + self.height): + for x in range(self.top_left_x, self.top_left_x + self.width): + main_node = self.main_graph[y][x] + main_node.threat_zones.add(self) + + self.nodes.append(main_node) + + def update_obstacle_location(self): + """ + Updates which node has the obstacle. This does not recompute the graph + based on this new information. + + Each threat zone is responsible for updating its own map objects. So + there will never be a time when the current x value attached to the + map_obstacle does not represent the actual previous location. + """ + + # find the previous location of the obstacle + old_y = self.map_obstacle.y + old_x = self.map_obstacle.x + + # remove it from the main graph + self.main_graph[old_y][old_x].contents.remove(self.map_obstacle) + + # get the latest location + self.map_obstacle.update_location() + (new_y, new_x) = (self.map_obstacle.y, self.map_obstacle.x) + + # add it back into the main graph + self.main_graph[new_y][new_x].contents.add(self.map_obstacle) + + # update the map obstacle (not necessary, but it doesn't hurt) + self.map_obstacle.y = new_y + self.map_obstacle.x = new_x + + def is_node_in_threat_zone(self, y, x): + """ + Checks if the node is in the range of the threat zone. + """ + y_condition = self.top_left_y <= y < self.top_left_y + self.height + x_condition = self.top_left_x <= x < self.top_left_x + self.width + return y_condition and x_condition + + def is_node_in_sight_range(self, y, x, skip_range_check=False): + """ + Checks if the node is in the sight range of the threat. + """ + if not skip_range_check: + if not self.is_node_in_threat_zone(y, x): + return False + + if self.sight_range == 0: + return False + + # TODO: sight range can be blocked by collidable map objects. But this + # node wouldn't be in the threat zone anyway. + y_condition = self.map_obstacle.y == y + x_condition = self.map_obstacle.x == x + + # this probably only happens if the player warps to the exact spot + if y_condition and x_condition: + raise Exception( + "Don't know the meaning of being on top of the map_obstacle." + ) + + # check if y or x matches the map object + return y_condition or x_condition + + def is_node_in_active_sight_range(self, + y, + x, + skip_sight_range_check=False, + skip_range_check=False, + DIRECTIONS=DIRECTIONS): + """ + Checks if the node has active sight range lock. + """ + + if not skip_sight_range_check: + # can't be in active sight range if not in sight range + if not self.is_in_sight_range(y, x, skip_range_check=skip_range_check): + return False + + y_condition = self.map_obstacle.y == y + x_condition = self.map_obstacle.x == x + + # this probably only happens if the player warps to the exact spot + if y_condition and x_condition: + raise Exception( + "Don't know the meaning of being on top of the map_obstacle." + ) + + current_facing_direction = self.get_current_facing_direction(DIRECTIONS=DIRECTIONS) + + if current_facing_direction not in DIRECTIONS.keys(): + raise Exception( + "Invalid direction." + ) + + if current_facing_direction in [DIRECTIONS["UP"], DIRECTIONS["DOWN"]]: + # map_obstacle is looking up/down but player doesn't match y + if not y_condition: + return False + + if current_facing_direction == DIRECTIONS["UP"]: + return y < self.map_obstacle.y + elif current_facing_direction == DIRECTIONS["DOWN"]: + return y > self.map_obstacle.y + else: + # map_obstacle is looking left/right but player doesn't match x + if not x_condition: + return False + + if current_facing_direction == DIRECTIONS["LEFT"]: + return x < self.map_obstacle.x + elif current_facing_direction == DIRECTIONS["RIGHT"]: + return x > self.map_obstacle.x + + def calculate_node_cost(self, y, x, consider_sight_range=True, PENALTIES=PENALTIES): + """ + Calculates the cost of the node w.r.t this threat zone. Turn off + consider_sight_range when not in the threat zone. + """ + penalty = 0 + + # The node is probably in the threat zone because otherwise why would + # this cost function be called? Only the nodes that are members of the + # current threat zone would have a reference to this threat zone and + # this function. + if not self.is_node_in_threat_zone(y, x): + penalty += PENALTIES["NONE"] + + # Additionally, if htis codepath is ever hit, the other node cost + # function will have already used the "NONE" penalty, so this would + # really be doubling the penalty of the node.. + raise Exception( + "Didn't expect to calculate a non-threat-zone node's cost, " + "since this is a threat zone function." + ) + else: + penalty += PENALTIES["THREAT_ZONE"] + + if consider_sight_range: + if self.is_node_in_sight_range(y, x, skip_range_check=True): + penalty += PENALTIES["SIGHT_RANGE"] + + params = { + "skip_sight_range_check": True, + "skip_range_check": True, + } + + active_sight_range = self.is_node_in_active_sight_range(y, x, **params) + + if active_sight_range: + penalty += PENALTIES["ACTIVE_SIGHT_RANGE"] + + return penalty + +def create_graph(some_map): + """ + Creates the array of nodes representing the in-game map. + """ + + map_height = some_map.height + map_width = some_map.width + map_obstacles = some_map.obstacles + + nodes = [[None] * map_width] * map_height + + # create a node representing each position on the map + for y in range(0, map_height): + for x in range(0, map_width): + position = (y, x) + + # create a node describing this position + node = Node(position=position) + + # store it on the graph + nodes[y][x] = node + + # look through all moving characters, non-moving characters, and items + for map_obstacle in map_obstacles: + # all characters must start somewhere + node = nodes[map_obstacle.y][map_obstacle.x] + + # store the map_obstacle on this node. + node.contents.add(map_obstacle) + + # only create threat zones for moving/turning entities + if map_obstacle.can_move() or map_obstacle.can_turn_without_moving(): + threat_zone = ThreatZone(map_obstacle, nodes, some_map) + threat_zone.mark_nodes_as_members_of_threat_zone() + + some_map.nodes = nodes + + return nodes + +class Map(object): + """ + The ``Map`` class provides an interface for reading the currently loaded + map. + """ + + def __init__(self, cry, parsed_map, height, width, map_group_id, map_id, config=config): + """ + :param cry: pokemon crystal emulation interface + :type cry: crystal + """ + self.config = config + self.cry = cry + + self.threat_zones = set() + self.obstacles = set() + + self.parsed_map = parsed_map + self.map_group_id = map_group_id + self.map_id = map_id + self.height = height + self.width = width + + def travel_to(self, destination_location): + """ + Does path planning and figures out the quickest way to get to the + destination. + """ + raise NotImplementedError + + @staticmethod + def from_rom(cry, address): + """ + Loads a map from bytes in ROM at the given address. + + :param cry: pokemon crystal wrapper + """ + raise NotImplementedError + + @staticmethod + def from_wram(cry): + """ + Loads a map from bytes in WRAM. + + :param cry: pokemon crystal wrapper + """ + raise NotImplementedError + + def draw_path(self, path): + """ + Draws a path on an image of the current map. The path must be an + iterable of nodes to visit in (y, x) format. + """ + palettes = pokemontools.map_gfx.read_palettes(self.config) + map_image = pokemontools.map_gfx.draw_map(self.map_group_id, self.map_id, palettes, show_sprites=True, config=self.config) + + for coordinates in path: + y = coordinates[0] + x = coordinates[1] + + some_image = Image.new("RGBA", (32, 32)) + draw = ImageDraw.Draw(some_image, "RGBA") + draw.rectangle([(0, 0), (32, 32)], fill=(0, 0, 0, 127)) + + target = [(x * 4, y * 4), ((x + 32) * 4, (y + 32) * 4)] + + map_image.paste(some_image, target, mask=some_image) + + return map_image + +class PathPlanner(object): + """ + Generic path finding implementation. + """ + + def __init__(self, some_map, initial_location, target_location): + self.some_map = some_map + self.initial_location = initial_location + self.target_location = target_location + + def plan(self): + """ + Runs the path planner and returns a list of positions making up the + path. + """ + return [(0, 0), (1, 0), (1, 1), (1, 2), (1, 3)] + +def plan_and_draw_path_on(map_group_id=1, map_id=1, initial_location=(0, 0), final_location=(2, 2), config=config): + """ + An attempt at an entry point. This hasn't been sufficiently considered yet. + """ + initial_location = (0, 0) + final_location = (2, 2) + map_group_id = 1 + map_id = 1 + + pokemontools.crystal.cachably_parse_rom() + pokemontools.map_gfx.add_pokecrystal_paths_to_configuration(config) + + # get the map based on data from the rom + parsed_map = pokemontools.crystal.map_names[map_group_id][map_id]["header_new"] + + # convert this map into a different structure + current_map = Map(cry=None, parsed_map=parsed_map, height=parsed_map.height.byte, width=parsed_map.width.byte, map_group_id=map_group_id, map_id=map_id, config=config) + + # make a graph based on the map data + nodes = create_graph(current_map) + + # make an instance of the planner implementation + planner = PathPlanner(current_map, initial_location, final_location) + + # Make that planner do its planning based on the current configuration. The + # planner should be callable in the future and still have + # previously-calculated state, like cached pre-computed routes or + # something. + path = planner.plan() + + # show the path on the map + drawn = current_map.draw_path(path) + + return drawn diff --git a/pokemontools/wram.py b/pokemontools/wram.py index dbff309..a132289 100644 --- a/pokemontools/wram.py +++ b/pokemontools/wram.py @@ -133,9 +133,21 @@ class WRAMProcessor(object): self.config = config self.paths = {} - self.paths["wram"] = os.path.join(self.config.path, "wram.asm") - self.paths["hram"] = os.path.join(self.config.path, "hram.asm") - self.paths["gbhw"] = os.path.join(self.config.path, "gbhw.asm") + + if hasattr(self.config, "wram"): + self.paths["wram"] = self.config.wram + else: + self.paths["wram"] = os.path.join(self.config.path, "wram.asm") + + if hasattr(self.config, "hram"): + self.paths["hram"] = self.config.hram + else: + self.paths["hram"] = os.path.join(self.config.path, "hram.asm") + + if hasattr(self.config, "gbhw"): + self.paths["gbhw"] = self.config.gbhw + else: + self.paths["gbhw"] = os.path.join(self.config.path, "gbhw.asm") def initialize(self): """ |