From b7da8dbb0e2236f37e4b4c99b88598369da3a008 Mon Sep 17 00:00:00 2001 From: Rangi Date: Sun, 9 Aug 2020 13:49:34 -0400 Subject: tools/ contains what's needed to build the ROMs; utils/ contains disassembly utilites --- Makefile | 77 +- compare.sh | 11 - gfx.py | 2 +- layout.link | 981 ++++++++++++ pokegold-spaceworld.link | 981 ------------ tools/__init__.py | 5 - tools/disasm_coverage.py | 4 +- tools/dump_names.py | 68 - tools/dump_text.py | 257 --- tools/fix_sections.py | 137 -- tools/fix_sections_directory.py | 1 - tools/gfx.py | 950 ----------- tools/lz.py | 580 ------- tools/make_shim.py | 3 +- tools/map2link.py | 187 --- tools/mapreader.py | 6 +- tools/png.py | 2650 ------------------------------- tools/read_charmap.py | 42 - tools/scan_includes.py | 2 +- tools/sort_sym.py | 68 - tools/sort_symfile.sh | 12 - tools/tests/README.txt | 1 - tools/tests/charmap.asm | 293 ---- tools/tests/dump_test.cc.txt | 26 - tools/tests/dump_test.cc_endless.txt | 33 - tools/tests/dump_test.cc_tc.txt | 27 - tools/tests/dump_test.cc_tc_endless.txt | 35 - tools/tests/dump_test.endless.txt | 9 - tools/tests/dump_test.tc.txt | 4 - tools/tests/dump_test.tc_endless.txt | 11 - tools/tests/dump_test.txt | 3 - tools/tests/dump_text_test.bin | Bin 69 -> 0 bytes utils/__init__.py | 5 + utils/compare.sh | 11 + utils/dump_names.py | 69 + utils/dump_text.py | 258 +++ utils/gfx.py | 951 +++++++++++ utils/lz.py | 580 +++++++ utils/map2link.py | 188 +++ utils/png.py | 2650 +++++++++++++++++++++++++++++++ utils/read_charmap.py | 45 + utils/tests/README.txt | 1 + utils/tests/charmap.asm | 293 ++++ utils/tests/dump_test.cc.txt | 26 + utils/tests/dump_test.cc_endless.txt | 33 + utils/tests/dump_test.cc_tc.txt | 27 + utils/tests/dump_test.cc_tc_endless.txt | 35 + utils/tests/dump_test.endless.txt | 9 + utils/tests/dump_test.tc.txt | 4 + utils/tests/dump_test.tc_endless.txt | 11 + utils/tests/dump_test.txt | 3 + utils/tests/dump_text_test.bin | Bin 0 -> 69 bytes 52 files changed, 6234 insertions(+), 6431 deletions(-) delete mode 100644 compare.sh create mode 100644 layout.link delete mode 100644 pokegold-spaceworld.link delete mode 100644 tools/__init__.py delete mode 100644 tools/dump_names.py delete mode 100644 tools/dump_text.py delete mode 100644 tools/fix_sections.py delete mode 100644 tools/fix_sections_directory.py delete mode 100644 tools/gfx.py delete mode 100644 tools/lz.py delete mode 100644 tools/map2link.py delete mode 100644 tools/png.py delete mode 100644 tools/read_charmap.py delete mode 100644 tools/sort_sym.py delete mode 100755 tools/sort_symfile.sh delete mode 100644 tools/tests/README.txt delete mode 100644 tools/tests/charmap.asm delete mode 100644 tools/tests/dump_test.cc.txt delete mode 100644 tools/tests/dump_test.cc_endless.txt delete mode 100644 tools/tests/dump_test.cc_tc.txt delete mode 100644 tools/tests/dump_test.cc_tc_endless.txt delete mode 100644 tools/tests/dump_test.endless.txt delete mode 100644 tools/tests/dump_test.tc.txt delete mode 100644 tools/tests/dump_test.tc_endless.txt delete mode 100644 tools/tests/dump_test.txt delete mode 100644 tools/tests/dump_text_test.bin create mode 100644 utils/__init__.py create mode 100644 utils/compare.sh create mode 100644 utils/dump_names.py create mode 100644 utils/dump_text.py create mode 100644 utils/gfx.py create mode 100644 utils/lz.py create mode 100644 utils/map2link.py create mode 100644 utils/png.py create mode 100644 utils/read_charmap.py create mode 100644 utils/tests/README.txt create mode 100644 utils/tests/charmap.asm create mode 100644 utils/tests/dump_test.cc.txt create mode 100644 utils/tests/dump_test.cc_endless.txt create mode 100644 utils/tests/dump_test.cc_tc.txt create mode 100644 utils/tests/dump_test.cc_tc_endless.txt create mode 100644 utils/tests/dump_test.endless.txt create mode 100644 utils/tests/dump_test.tc.txt create mode 100644 utils/tests/dump_test.tc_endless.txt create mode 100644 utils/tests/dump_test.txt create mode 100644 utils/tests/dump_text_test.bin diff --git a/Makefile b/Makefile index d45f7f8..af781be 100644 --- a/Makefile +++ b/Makefile @@ -1,30 +1,35 @@ +ROM := pokegold-spaceworld.gb +CORRECTEDROM := $(ROM:%.gb=%-correctheader.gb) +BASEROM := baserom.gb + +DIRS := home engine data audio maps +FILES := bin.asm gfx.asm vram.asm sram.asm wram.asm hram.asm + BUILD := build +rwildcard = $(foreach d, $(wildcard $1*), $(filter $(subst *, %, $2), $d) $(call rwildcard, $d/, $2)) +ASMFILES := $(call rwildcard, $(DIRS), *.asm) $(FILES) +OBJS := $(patsubst %.asm, $(BUILD)/%.o, $(ASMFILES)) +OBJS += $(BUILD)/shim.o + + +### Build tools + MD5 := md5sum -c -PYTHON := python -PYTHON3 := python3 +PYTHON := python3 RGBDS ?= -RGBASM := $(RGBDS)rgbasm -RGBGFX := $(RGBDS)rgbgfx -RGBLINK := $(RGBDS)rgblink -RGBFIX := $(RGBDS)rgbfix -sort_sym := tools/sort_symfile.sh +RGBASM ?= $(RGBDS)rgbasm +RGBFIX ?= $(RGBDS)rgbfix +RGBGFX ?= $(RGBDS)rgbgfx +RGBLINK ?= $(RGBDS)rgblink RGBASMFLAGS := -h -E -i $(BUILD)/ -DGOLD -DDEBUG=1 + tools/gfx := -ROM := pokegold-spaceworld.gb -LINKERSCRIPT := pokegold-spaceworld-gen.link -BASEROM := baserom.gb -SHIM := shim.sym -CORRECTEDROM := $(ROM:%.gb=%-correctheader.gb) -rwildcard = $(foreach d, $(wildcard $1*), $(filter $(subst *, %, $2), $d) $(call rwildcard, $d/, $2)) -DIRS := home engine data audio maps -ASMFILES := $(call rwildcard, $(DIRS), *.asm) bin.asm gfx.asm vram.asm sram.asm wram.asm hram.asm -OBJS := $(patsubst %.asm, $(BUILD)/%.o, $(ASMFILES)) -OBJS += $(BUILD)/shim.o +### Build targets .SECONDEXPANSION: @@ -45,22 +50,16 @@ clean: rm -rf $(ROM) $(CORRECTEDROM) $(BUILD) $(ROMS:.gb=.sym) $(ROMS:.gb=.map) *.d "$(MAKE)" -C tools clean -# Remove files except for graphics. -.PHONY: mostlyclean -mostlyclean: +# Remove generated files except for graphics. +.PHONY: tidy +tidy: rm -rf $(ROM) $(CORRECTEDROM) $(OBJS) $(ROMS:.gb=.sym) $(ROMS:.gb=.map) *.d -# Utilities +# Visualize disassembly progress. .PHONY: coverage coverage: $(ROM:.gb=.map) tools/disasm_coverage.py $(PYTHON) tools/disasm_coverage.py -m $< -b 0x40 -.PHONY: linkerscript -linkerscript: $(ROM:.gb=-gen.link) - -# TODO FIX HARDCODE -%.link: pokegold-spaceworld.map tools/map2link.py - $(PYTHON3) tools/map2link.py $< $@ %.map: %.gb @@ -70,22 +69,25 @@ $(CORRECTEDROM): %-correctheader.gb: %.gb $(RGBFIX) -f hg -m 0x10 $@ $(ROM): poke%-spaceworld.gb: $(OBJS) | $(BASEROM) - $(RGBLINK) -d -n $(@:.gb=.sym) -m $(@:.gb=.map) -l $(@:.gb=.link) -O $(BASEROM) -o $@ $^ + $(RGBLINK) -d -n $(@:.gb=.sym) -m $(@:.gb=.map) -l layout.link -O $(BASEROM) -o $@ $^ $(RGBFIX) -f lh -k 01 -l 0x33 -m 0x03 -p 0 -r 3 -t "POKEMON2$(shell echo $* | cut -d _ -f 1 | tr '[:lower:]' '[:upper:]')" $@ - $(sort_sym) $(@:.gb=.sym) $(BASEROM): @echo "Please obtain a copy of Gold_debug.sgb and put it in this directory as $@" @exit 1 -$(BUILD)/shim.asm: tools/make_shim.py $(SHIM) | $$(dir $$@) - $(PYTHON3) tools/make_shim.py -w $(filter-out $<, $^) > $@ +$(BUILD)/shim.asm: tools/make_shim.py shim.sym | $$(dir $$@) + $(PYTHON) tools/make_shim.py -w $(filter-out $<, $^) > $@ $(BUILD)/%.o: $(BUILD)/%.asm | $$(dir $$@) $(RGBASM) $(RGBASMFLAGS) $(OUTPUT_OPTION) $< + $(BUILD)/%.o: %.asm | $$(dir $$@) $(RGBASM) $(RGBASMFLAGS) $(OUTPUT_OPTION) $< + +### Misc file-specific graphics rules + $(BUILD)/gfx/sgb/sgb_border_alt.2bpp: tools/gfx += --trim-whitespace $(BUILD)/gfx/sgb/sgb_border_gold.2bpp: tools/gfx += --trim-whitespace $(BUILD)/gfx/sgb/sgb_border_gold_corrupted.2bpp: tools/gfx += --trim-whitespace @@ -94,14 +96,22 @@ $(BUILD)/gfx/sgb/corrupted_9e1c.2bpp: tools/gfx += --trim-whitespace $(BUILD)/gfx/sgb/corrupted_a66c.2bpp: tools/gfx += --trim-whitespace $(BUILD)/gfx/sgb/corrupted_b1e3.2bpp: tools/gfx += --trim-whitespace $(BUILD)/gfx/sgb/sgb_border_silver.2bpp: tools/gfx += --trim-whitespace + $(BUILD)/gfx/trainer_card/leaders.2bpp: tools/gfx += --trim-whitespace + $(BUILD)/gfx/pokegear/town_map.2bpp: tools/gfx += --trim-trailing + $(BUILD)/gfx/minigames/slots.2bpp: tools/gfx += --trim-whitespace $(BUILD)/gfx/minigames/poker.2bpp: tools/gfx += --trim-whitespace + $(BUILD)/gfx/intro/purin_pikachu.2bpp: tools/gfx += --trim-whitespace + $(BUILD)/gfx/battle_anims/attack_animations_1.2bpp: tools/gfx += --trim-whitespace $(BUILD)/gfx/battle_anims/attack_animations_2.2bpp: tools/gfx += --trim-whitespace + +### Catch-all graphics rules + .PRECIOUS: $(BUILD)/%.pic $(BUILD)/%.pic: $(BUILD)/%.2bpp tools/pkmncompress | $$(dir $$@) tools/pkmncompress $< $@ @@ -124,7 +134,10 @@ $(BUILD)/%.tilemap: %.png | $$(dir $$@) %/: mkdir -p $@ -DEPENDENCY_SCAN_EXIT_STATUS := $(shell $(PYTHON3) tools/scan_includes.py $(BUILD:%=-b %) $(ASMFILES) > dependencies.d; echo $$?) + +### Scan .asm files for INCLUDE/INCBIN dependencies + +DEPENDENCY_SCAN_EXIT_STATUS := $(shell $(PYTHON) tools/scan_includes.py $(BUILD:%=-b %) $(ASMFILES) > dependencies.d; echo $$?) ifneq ($(DEPENDENCY_SCAN_EXIT_STATUS), 0) $(error Dependency scan failed) endif diff --git a/compare.sh b/compare.sh deleted file mode 100644 index b52696d..0000000 --- a/compare.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -# Compares baserom.gb and pokegold-spaceworld.gb - -# create baserom.txt if necessary -if [ ! -f baserom.txt ]; then - hexdump -C baserom.gb > baserom.txt -fi - -hexdump -C pokegold-spaceworld.gb > pokegold-spaceworld.txt - -diff -u baserom.txt pokegold-spaceworld.txt | less \ No newline at end of file diff --git a/gfx.py b/gfx.py index cda03b0..04e9f7c 100644 --- a/gfx.py +++ b/gfx.py @@ -5,7 +5,7 @@ import os import argparse -from tools import gfx, lz +from utils import gfx, lz # Graphics with inverted tilemaps that aren't covered by filepath_rules. diff --git a/layout.link b/layout.link new file mode 100644 index 0000000..c3795e0 --- /dev/null +++ b/layout.link @@ -0,0 +1,981 @@ +; Automatically generated by map2link.py +ROM0 + "home/rst.asm@rst00" + ; $0001 + org $0008 + "home/rst.asm@rst08" + ; $0009 + org $0010 + "home/rst.asm@rst10" + ; $0011 + org $0018 + "home/rst.asm@rst18" + ; $0019 + org $0020 + "home/rst.asm@rst20" + ; $0021 + org $0028 + "home/rst.asm@rst28" + ; $0029 + org $0030 + "home/rst.asm@rst30" + ; $0031 + org $0038 + "home/rst.asm@rst38" + ; $003b + org $0040 + "home/interrupts.asm@VBlank interrupt vector" + ; $0043 + org $0048 + "home/interrupts.asm@LCD interrupt vector" + ; $004b + org $0050 + "home/interrupts.asm@Timer interrupt vector" + ; $0053 + org $0058 + "home/interrupts.asm@Serial interrupt vector" + ; $005b + org $0060 + "home/interrupts.asm@Joypad interrupt vector" + ; $0063 + org $0100 + "home/init.asm@Entry point" + ; $0104 + org $014e + "home/init.asm@Global check value" + "home/vblank.asm" + "home/delay.asm" + "home/rtc.asm" + "home/fade.asm" + "home/lcd.asm" + "home/interrupts.asm@Timer dummy interrupt" + "home/time.asm" + "home/init.asm@Init" + "home/serial.asm" + "home/interrupts.asm@Joypad dummy interrupt" + "home/joypad.asm" + "home/unknown.asm@Unknown_094c" + "home/pic.asm" + "home/copy2.asm" + "home/text.asm" + "home/vcopy.asm" + "home/map_objects.asm" + "home/movement.asm" + "home/menu_window.asm" + "home/menu.asm" + "home/window.asm" + "home/unknown.asm@Unknown functions" + "home/toolgear.asm" + "home/unknown.asm@Unknown_20f8" + "home/map.asm" + "home/overworld.asm@Startmenu and Select Button Check" + "home/tileset.asm" + "home/unknown.asm@Empty function" + "home/farcall.asm" + "home/predef.asm" + "home/talk_to_npc.asm" + "home/items.asm@TossItem" + "home/random.asm" + "home/sram.asm" + "home/bankswitch.asm" + "home/misc_32c8.asm@Unknown 32c8" + "home/clear_sprites.asm" + "home/copy.asm" + "home/math.asm" + "home/print_text.asm" + "home/util.asm" + "home/print_num.asm" + "home/print_hex.asm" + "home/jumptable.asm" + "home/overworld.asm@QueueScript" + "home/tables.asm" + "home/tilemap.asm" + "home/items.asm@GiveItem" + "home/misc_32c8.asm@Unknown 3686" + "home/names.asm@Names" + "home/scrolling_menu.asm" + "home/unknown_388f.asm" + "home/mon_stats.asm" + "home/cry.asm" + "home/pokemon.asm@3A4B" + "home/names.asm@GetNick" + "home/print_bcd.asm" + "home/pokemon.asm@3AED" + "home/misc_3c43.asm" + "home/audio.asm" +ROMX $01 + org $4000 + "engine/link/place_waiting_text.asm" + "engine/menu/debug_menu.asm" + "engine/dumps/bank01.asm@Function40fd" + "home/oam_dma.asm" + "engine/dumps/bank01.asm@Table416b" + "engine/menu/main_menu.asm@Initialize new game WRAM" + "engine/dumps/bank01.asm@Function5388" + "engine/menu/main_menu.asm@MainMenu" + "engine/intro.asm" + "engine/title.asm@Title screen" + "engine/title.asm@Title screen TEMPORARY" + "engine/predef.asm" + "engine/dumps/bank01.asm@ReanchorBGMap_NoOAMUpdate" + "engine/math/math.asm" + "data/items/attributes.asm" + "data/items/names.asm" + "engine/dumps/bank01.asm@Function771e" + "engine/overworld/object_collision.asm@GetSpritesNextTile" + "engine/dumps/bank01.asm@Function776e" + "engine/overworld/object_collision.asm@_CheckObjectCollision" + "engine/dumps/bank01.asm@Function782c" + "engine/overworld/object_collision.asm@_CheckPlayerObjectCollision" + "engine/dumps/bank01.asm@SettingsScreen" +ROMX $02 + "engine/dumps/bank02.asm@Function8000" + "gfx.asm@Bank 2 Misc GFX" + "engine/dumps/bank02.asm@QueueFollowerFirstStep" + "gfx.asm@Mon Nest Icon" + "engine/dumps/bank02.asm@Data8a17" + "gfx.asm@Pokegear GFX" + "engine/dumps/bank02.asm@Text91c2" + "gfx.asm@Title Screen BG Decoration Border" + "engine/dumps/bank02.asm@Function928b" + "gfx.asm@SGB GFX" + "bin.asm@Unknownaebc" + "gfx.asm@Corrupted SGB GFX" + "bin.asm@Unknownbb43" +ROMX $03 + org $4000 + "engine/overworld/player_movement.asm@Player Movement" + "data/collision/collision_type_table.asm" + "engine/overworld/player_movement.asm@Rest of Player Movement" + "engine/dumps/bank03.asm@Functionc77d" + "engine/overworld/spawn_points.asm" + "engine/dumps/bank03.asm@SpawnPoints" + "data/tileset_headers.asm" + "engine/dumps/bank03.asm@Functionc9c1" + "engine/items/inventory.asm@Inventory" + "engine/smallflag.asm" + "engine/pokemon/health.asm@HealParty" + "engine/bcd.asm" + "engine/items/inventory.asm@GetItemAmount" + "engine/pokemon/health.asm@HP Bar" + "engine/events/field_moves.asm" + "engine/items/inventory.asm@_CheckTossableItem" + "engine/dumps/bank03.asm@Functiond41d" + "engine/items/item_effects.asm" + "engine/dumps/bank03.asm@Functionf960" +ROMX $04 + org $4000 + "data/maps/maps.asm" + ; $4743 + org $47cf + "gfx.asm@Title Screen GFX" + "engine/items/tmhm.asm" + "data/moves/tmhm_moves.asm" + "engine/unknown_boxes.asm" + "engine/menu/text_entry.asm@naming" + "data/text/text_input_chars.asm" + "gfx.asm@Name Entry Extra Tiles" + "engine/menu/text_entry.asm@mail" + "gfx.asm@Mail Icon GFX" + "engine/menu/text_entry.asm@mail2" + "engine/unknown11d32.asm" + "engine/menu/start_menu.asm" + "gfx.asm@Trainer Card GFX" + ; $7be3 +ROMX $05 + org $4000 + "engine/overworld/player_movement.asm@_RedrawPlayerSprite" + org $4150 + "engine/sprites/sprites.asm@LoadOverworldSprite" + ; $4193 + org $423b + "engine/sprites/sprites.asm@OverworldSprites" + ; $43a7 +ROMX $06 + org $4000 + "gfx.asm@Bank 6 Tilesets 00" + ; $4400 + org $5800 + "gfx.asm@Bank 6 Tilesets 01" + ; $5c00 + org $6600 + "gfx.asm@Bank 6 Tilesets 02" + ; $6a00 + org $7400 + "gfx.asm@Bank 6 Tilesets 09" +ROMX $07 + org $4000 + "gfx.asm@Bank 7 Tilesets 13" + ; $4600 + org $4b00 + "gfx.asm@Bank 7 Tilesets 0e" + ; $5100 + org $5600 + "gfx.asm@Bank 7 Tilesets 06" + ; $5a00 + org $6400 + "gfx.asm@Bank 7 Tilesets 05" + ; $6800 + org $7200 + "gfx.asm@Bank 7 Tilesets 03" +ROMX $08 + org $4000 + "gfx.asm@Bank 8 Tilesets 04" + ; $4400 + org $4e00 + "gfx.asm@Bank 8 Tilesets 07" + ; $5200 + org $5c00 + "gfx.asm@Bank 8 Tilesets 08" + ; $6000 + org $6a00 + "gfx.asm@Bank 8 Tilesets 0f" + ; $7000 + org $7500 + "gfx.asm@Bank 8 Tilesets 11" +ROMX $09 + org $4000 + ; $4000 +ROMX $0a + org $4000 + ; $4000 + org $5641 + "gfx.asm@Gameboy GFX" +ROMX $0b + org $4000 + ; $4000 +ROMX $0c + org $4000 + "gfx.asm@Bank C Tilesets 12" + ; $4600 + org $4b00 + "gfx.asm@Bank C Tilesets 0b" + ; $5100 + org $5600 + "gfx.asm@Bank C Tilesets 0d" + ; $5c00 + org $6100 + "gfx.asm@Bank C Tilesets 14" + ; $6700 + org $7100 + "gfx.asm@Bank C Tilesets 0c" + ; $7700 + org $7c00 + "gfx.asm@Bank C Tilesets Common" + ; $7e00 +ROMX $0d + org $4000 + ; $4000 + org $506d + "data/types/type_matchups.asm" +ROMX $0e + org $4000 + ; $4000 + org $4494 + "gfx.asm@PokeBalls GFX" + ; $44d4 + org $4d90 + "data/trainers/class_names.asm" + ; $4f45 + org $5110 + "data/trainers/parties.asm@Trainer Parties" +ROMX $0f + org $4000 + ; $4000 + org $63da + "engine/battle/core.asm" + ; $6411 + org $6a3c + "data/wild.asm" +ROMX $10 + org $4000 + org $4943 + "data/pokemon/dex_order_alpha.asm" + ; $4a3e + org $4ecc + "data/types/search_strings.asm" + ; $4f17 + org $5068 + "data/types/search_types.asm" + ; $5077 + org $52a1 + "data/moves/names.asm" + ; $58b6 + org $6493 + "data/pokemon/evos_attacks.asm" +ROMX $11 + org $4000 + ; $4000 + org $40d5 + "gfx.asm@Pokedex GFX" +ROMX $12 + org $4000 + "gfx.asm@Trainer Battle Sprites" +ROMX $13 + org $4000 + "gfx.asm@Bank 13 Tilesets 0a" + ; $4600 + org $4b00 + "gfx.asm@Bank 13 Tilesets 16" + ; $5100 + org $5b00 + "gfx.asm@Bank 13 Tilesets 19" + ; $5f00 + org $6900 + "gfx.asm@Bank 13 Tilesets 1a" +ROMX $14 + org $4000 + org $40dd + "data/types/names.asm" + ; $4162 + org $4f10 + "data/pokemon/base_stats.asm" + "data/pokemon/names.asm" + "gfx.asm@PKMN Sprite Bank List" +ROMX $15 + org $4000 + "gfx/pokemon/pkmn_pics.asm@PKMN Pics 1" +ROMX $16 + org $4000 + "gfx/pokemon/pkmn_pics.asm@PKMN Pics 2" +ROMX $17 + org $4000 + "gfx/pokemon/pkmn_pics.asm@PKMN Pics 3" +ROMX $18 + org $4000 + "gfx/pokemon/pkmn_pics.asm@PKMN Pics 4" +ROMX $19 + org $4000 + "gfx/pokemon/pkmn_pics.asm@PKMN Pics 5" +ROMX $1a + org $4000 + "gfx/pokemon/pkmn_pics.asm@PKMN Pics 6" +ROMX $1b + org $4000 + "gfx/pokemon/pkmn_pics.asm@PKMN Pics 7" +ROMX $1c + org $4000 + "gfx/pokemon/pkmn_pics.asm@PKMN Pics 8" +ROMX $1d + org $4000 + "gfx/pokemon/pkmn_pics.asm@PKMN Pics 9" +ROMX $1e + org $4000 + "gfx/pokemon/pkmn_pics.asm@PKMN Pics 10" +ROMX $1f + org $4000 + "gfx/pokemon/pkmn_pics.asm@PKMN Pics 11" + "gfx.asm@Annon Pic Ptrs and Pics" + "gfx/pokemon/egg.asm" +ROMX $21 + org $4000 + "gfx.asm@Attack Animation GFX" +ROMX $23 + org $4000 + org $433e + "engine/palettes.asm@Overworld fade" + org $43d1 + "engine/palettes.asm@Palette fading, part 2?" + ; $446d + org $60cc + "gfx.asm@Pokemon Party Sprites" + org $722a + "data/pokemon/menu_icons.asm" +ROMX $24 + org $4000 + org $4fdb + "gfx.asm@Slot Machine GFX" +ROMX $25 + org $4000 + "data/maps/attributes/placeholder/Route2Gate1F.asm" + "maps/placeholder/Route2Gate1F.asm" + "data/maps/attributes/placeholder/Route2Gate2F.asm" + "maps/placeholder/Route2Gate2F.asm" + "data/maps/attributes/placeholder/Route2House.asm" + "maps/placeholder/Route2House.asm" + "data/maps/attributes/placeholder/OldCityMuseum.asm" + "maps/placeholder/OldCityMuseum.asm" + "data/maps/attributes/placeholder/OldCityGym.asm" + "maps/placeholder/OldCityGym.asm" + "data/maps/attributes/placeholder/OldCityTower1F.asm" + "maps/placeholder/OldCityTower1F.asm" + "data/maps/attributes/placeholder/OldCityTower2F.asm" + "maps/placeholder/OldCityTower2F.asm" + "data/maps/attributes/placeholder/OldCityTower3F.asm" + "maps/placeholder/OldCityTower3F.asm" + "data/maps/attributes/placeholder/OldCityTower4F.asm" + "maps/placeholder/OldCityTower4F.asm" + "data/maps/attributes/placeholder/OldCityTower5F.asm" + "maps/placeholder/OldCityTower5F.asm" + "data/maps/attributes/placeholder/OldCityBillsHouse.asm" + "maps/placeholder/OldCityBillsHouse.asm" + "data/maps/attributes/placeholder/OldCityMart.asm" + "maps/placeholder/OldCityMart.asm" + "data/maps/attributes/placeholder/OldCityHouse.asm" + "maps/placeholder/OldCityHouse.asm" + "data/maps/attributes/placeholder/OldCityPokecenter1F.asm" + "maps/placeholder/OldCityPokecenter1F.asm" + "data/maps/attributes/OldCityPokecenter2F.asm" + "maps/OldCityPokecenter2F.asm" + "data/maps/attributes/OldCityPokecenterTrade.asm" + "maps/OldCityPokecenterTrade.asm" + "data/maps/attributes/OldCityPokecenterBattle.asm" + "maps/OldCityPokecenterBattle.asm" + "data/maps/attributes/OldCityPokecenterTimeMachine.asm" + "maps/OldCityPokecenterTimeMachine.asm" + "data/maps/attributes/placeholder/OldCityKurtsHouse.asm" + "maps/placeholder/OldCityKurtsHouse.asm" + "data/maps/attributes/placeholder/OldCitySchool.asm" + "maps/placeholder/OldCitySchool.asm" +ROMX $26 + org $4000 + "data/maps/attributes/Route1Gate1F.asm" + "maps/Route1Gate1F.asm" + "data/maps/attributes/Route1Gate2F.asm" + "maps/Route1Gate2F.asm" + "data/maps/attributes/placeholder/WestMart1F.asm" + "maps/placeholder/WestMart1F.asm" + "data/maps/attributes/placeholder/WestMart2F.asm" + "maps/placeholder/WestMart2F.asm" + "data/maps/attributes/placeholder/WestMart3F.asm" + "maps/placeholder/WestMart3F.asm" + "data/maps/attributes/placeholder/WestMart4F.asm" + "maps/placeholder/WestMart4F.asm" + "data/maps/attributes/placeholder/WestMart5F.asm" + "maps/placeholder/WestMart5F.asm" + "data/maps/attributes/placeholder/WestMart6F.asm" + "maps/placeholder/WestMart6F.asm" + "data/maps/attributes/placeholder/WestMartElevator.asm" + "maps/placeholder/WestMartElevator.asm" + "data/maps/attributes/placeholder/WestRadioTower1F.asm" + "maps/placeholder/WestRadioTower1F.asm" + "data/maps/attributes/placeholder/WestRadioTower2F.asm" + "maps/placeholder/WestRadioTower2F.asm" + "data/maps/attributes/placeholder/WestRadioTower3F.asm" + "maps/placeholder/WestRadioTower3F.asm" + "data/maps/attributes/placeholder/WestRadioTower4F.asm" + "maps/placeholder/WestRadioTower4F.asm" + "data/maps/attributes/placeholder/WestRadioTower5F.asm" + "maps/placeholder/WestRadioTower5F.asm" + "data/maps/attributes/placeholder/WestRocketRaidedHouse.asm" + "maps/placeholder/WestRocketRaidedHouse.asm" + "data/maps/attributes/placeholder/WestPokecenter1F.asm" + "maps/placeholder/WestPokecenter1F.asm" + "data/maps/attributes/placeholder/WestPokecenter2F.asm" + "maps/placeholder/WestPokecenter2F.asm" + "data/maps/attributes/placeholder/WestGym.asm" + "maps/placeholder/WestGym.asm" + "data/maps/attributes/placeholder/WestHouse1.asm" + "maps/placeholder/WestHouse1.asm" + "data/maps/attributes/placeholder/WestHouse2.asm" + "maps/placeholder/WestHouse2.asm" +ROMX $27 + org $4000 + "data/maps/attributes/placeholder/HaitekuWestRouteGate.asm" + "maps/placeholder/HaitekuWestRouteGate.asm" + "data/maps/attributes/placeholder/HaitekuPokecenter1F.asm" + "maps/placeholder/HaitekuPokecenter1F.asm" + "data/maps/attributes/placeholder/HaitekuPokecenter2F.asm" + "maps/placeholder/HaitekuPokecenter2F.asm" + "data/maps/attributes/placeholder/HaitekuLeague1F.asm" + "maps/placeholder/HaitekuLeague1F.asm" + "data/maps/attributes/placeholder/HaitekuLeague2F.asm" + "maps/placeholder/HaitekuLeague2F.asm" + "data/maps/attributes/placeholder/HaitekuMart.asm" + "maps/placeholder/HaitekuMart.asm" + "data/maps/attributes/placeholder/HaitekuHouse1.asm" + "maps/placeholder/HaitekuHouse1.asm" + "data/maps/attributes/placeholder/HaitekuHouse2.asm" + "maps/placeholder/HaitekuHouse2.asm" + "data/maps/attributes/placeholder/HaitekuImposterOakHouse.asm" + "maps/placeholder/HaitekuImposterOakHouse.asm" + "data/maps/attributes/placeholder/HaitekuAquarium1F.asm" + "maps/placeholder/HaitekuAquarium1F.asm" + "data/maps/attributes/placeholder/HaitekuAquarium2F.asm" + "maps/placeholder/HaitekuAquarium2F.asm" + "data/maps/attributes/placeholder/FontoRouteGate1.asm" + "maps/placeholder/FontoRouteGate1.asm" + "data/maps/attributes/placeholder/FontoRouteGate2.asm" + "maps/placeholder/FontoRouteGate2.asm" + "data/maps/attributes/placeholder/FontoRouteGate3.asm" + "maps/placeholder/FontoRouteGate3.asm" + "data/maps/attributes/placeholder/FontoRocketHouse.asm" + "maps/placeholder/FontoRocketHouse.asm" + "data/maps/attributes/placeholder/FontoMart.asm" + "maps/placeholder/FontoMart.asm" + "data/maps/attributes/placeholder/FontoHouse.asm" + "maps/placeholder/FontoHouse.asm" + "data/maps/attributes/placeholder/FontoPokecenter1F.asm" + "maps/placeholder/FontoPokecenter1F.asm" + "data/maps/attributes/placeholder/FontoPokecenter2F.asm" + "maps/placeholder/FontoPokecenter2F.asm" + "data/maps/attributes/placeholder/FontoLab.asm" + "maps/placeholder/FontoLab.asm" + "data/maps/attributes/placeholder/BaadonMart.asm" + "maps/placeholder/BaadonMart.asm" + "data/maps/attributes/placeholder/BaadonPokecenter1F.asm" + "maps/placeholder/BaadonPokecenter1F.asm" + "data/maps/attributes/placeholder/BaadonPokecenter2F.asm" + "maps/placeholder/BaadonPokecenter2F.asm" + "data/maps/attributes/placeholder/BaadonHouse1.asm" + "maps/placeholder/BaadonHouse1.asm" + "data/maps/attributes/placeholder/BaadonWallpaperHouse.asm" + "maps/placeholder/BaadonWallpaperHouse.asm" + "data/maps/attributes/placeholder/BaadonHouse2.asm" + "maps/placeholder/BaadonHouse2.asm" + "data/maps/attributes/placeholder/BaadonLeague1F.asm" + "maps/placeholder/BaadonLeague1F.asm" + "data/maps/attributes/placeholder/BaadonLeague2F.asm" + "maps/placeholder/BaadonLeague2F.asm" + "data/maps/attributes/placeholder/BaadonRouteGateWest.asm" + "maps/placeholder/BaadonRouteGateWest.asm" + "data/maps/attributes/placeholder/BaadonRouteGateNewtype.asm" + "maps/placeholder/BaadonRouteGateNewtype.asm" + "data/maps/attributes/placeholder/NewtypePokecenter1F.asm" + "maps/placeholder/NewtypePokecenter1F.asm" + "data/maps/attributes/placeholder/NewtypePokecenter2F.asm" + "maps/placeholder/NewtypePokecenter2F.asm" + "data/maps/attributes/placeholder/NewtypeLeague1F.asm" + "maps/placeholder/NewtypeLeague1F.asm" + "data/maps/attributes/placeholder/NewtypeLeague2F.asm" + "maps/placeholder/NewtypeLeague2F.asm" + "data/maps/attributes/placeholder/NewtypeSailorHouse.asm" + "maps/placeholder/NewtypeSailorHouse.asm" + "data/maps/attributes/placeholder/NewtypeMart.asm" + "maps/placeholder/NewtypeMart.asm" + "data/maps/attributes/placeholder/NewtypeDojo.asm" + "maps/placeholder/NewtypeDojo.asm" + "data/maps/attributes/placeholder/NewtypeHouse1.asm" + "maps/placeholder/NewtypeHouse1.asm" + "data/maps/attributes/placeholder/NewtypeDiner.asm" + "maps/placeholder/NewtypeDiner.asm" + "data/maps/attributes/placeholder/NewtypeHouse2.asm" + "maps/placeholder/NewtypeHouse2.asm" + "data/maps/attributes/placeholder/NewtypeHouse3.asm" + "maps/placeholder/NewtypeHouse3.asm" + "data/maps/attributes/placeholder/Route15Pokecenter1F.asm" + "maps/placeholder/Route15Pokecenter1F.asm" + "data/maps/attributes/placeholder/Route15Pokecenter2F.asm" + "maps/placeholder/Route15Pokecenter2F.asm" + "data/maps/attributes/placeholder/NewtypeRouteGate.asm" + "maps/placeholder/NewtypeRouteGate.asm" + "data/maps/attributes/placeholder/Route18Pokecenter1F.asm" + "maps/placeholder/Route18Pokecenter1F.asm" + "data/maps/attributes/placeholder/Route18Pokecenter2F.asm" + "maps/placeholder/Route18Pokecenter2F.asm" + "data/maps/attributes/placeholder/SugarRouteGate.asm" + "maps/placeholder/SugarRouteGate.asm" + "data/maps/attributes/placeholder/SugarHouse.asm" + "maps/placeholder/SugarHouse.asm" + "data/maps/attributes/placeholder/SugarHouse2.asm" + "maps/placeholder/SugarHouse2.asm" + "data/maps/attributes/placeholder/SugarMart.asm" + "maps/placeholder/SugarMart.asm" + "data/maps/attributes/placeholder/SugarPokecenter1F.asm" + "maps/placeholder/SugarPokecenter1F.asm" + "data/maps/attributes/placeholder/SugarPokecenter2F.asm" + "maps/placeholder/SugarPokecenter2F.asm" + "data/maps/attributes/placeholder/BullForestRoute1House.asm" + "maps/placeholder/BullForestRoute1House.asm" + "data/maps/attributes/placeholder/BullForestRouteGateStand.asm" + "maps/placeholder/BullForestRouteGateStand.asm" + "data/maps/attributes/placeholder/BullMart.asm" + "maps/placeholder/BullMart.asm" + "data/maps/attributes/placeholder/BullHouse1.asm" + "maps/placeholder/BullHouse1.asm" + "data/maps/attributes/placeholder/BullHouse2.asm" + "maps/placeholder/BullHouse2.asm" + "data/maps/attributes/placeholder/BullHouse3.asm" + "maps/placeholder/BullHouse3.asm" + "data/maps/attributes/placeholder/BullPokecenter1F.asm" + "maps/placeholder/BullPokecenter1F.asm" + "data/maps/attributes/placeholder/BullPokecenter2F.asm" + "maps/placeholder/BullPokecenter2F.asm" + "data/maps/attributes/placeholder/BullLeague1F.asm" + "maps/placeholder/BullLeague1F.asm" + "data/maps/attributes/placeholder/BullLeague2F.asm" + "maps/placeholder/BullLeague2F.asm" + "data/maps/attributes/placeholder/BullHouse4.asm" + "maps/placeholder/BullHouse4.asm" + "data/maps/attributes/placeholder/StandRouteGateKanto.asm" + "maps/placeholder/StandRouteGateKanto.asm" + "data/maps/attributes/placeholder/StandLab.asm" + "maps/placeholder/StandLab.asm" + "data/maps/attributes/placeholder/StandPokecenter1F.asm" + "maps/placeholder/StandPokecenter1F.asm" + "data/maps/attributes/placeholder/StandPokecenter2F.asm" + "maps/placeholder/StandPokecenter2F.asm" + "data/maps/attributes/placeholder/StandOffice.asm" + "maps/placeholder/StandOffice.asm" + "data/maps/attributes/placeholder/StandMart.asm" + "maps/placeholder/StandMart.asm" + "data/maps/attributes/placeholder/StandHouse.asm" + "maps/placeholder/StandHouse.asm" + "data/maps/attributes/placeholder/StandRocketHouse1F.asm" + "maps/placeholder/StandRocketHouse1F.asm" + "data/maps/attributes/placeholder/StandRocketHouse2F.asm" + "maps/placeholder/StandRocketHouse2F.asm" + "data/maps/attributes/placeholder/StandLeague1F.asm" + "maps/placeholder/StandLeague1F.asm" + "data/maps/attributes/placeholder/StandLeague2F.asm" + "maps/placeholder/StandLeague2F.asm" + "data/maps/attributes/placeholder/KantoCeruleanHouse.asm" + "maps/placeholder/KantoCeruleanHouse.asm" + "data/maps/attributes/placeholder/KantoPokecenter1F.asm" + "maps/placeholder/KantoPokecenter1F.asm" + "data/maps/attributes/placeholder/KantoPokecenter2F.asm" + "maps/placeholder/KantoPokecenter2F.asm" + "data/maps/attributes/placeholder/KantoLeague1F.asm" + "maps/placeholder/KantoLeague1F.asm" + "data/maps/attributes/placeholder/KantoLeague2F.asm" + "maps/placeholder/KantoLeague2F.asm" + "data/maps/attributes/placeholder/KantoLavenderHouse.asm" + "maps/placeholder/KantoLavenderHouse.asm" + "data/maps/attributes/placeholder/KantoCeladonMart1F.asm" + "maps/placeholder/KantoCeladonMart1F.asm" + "data/maps/attributes/placeholder/KantoCeladonMart2F.asm" + "maps/placeholder/KantoCeladonMart2F.asm" + "data/maps/attributes/placeholder/KantoCeladonMart3F.asm" + "maps/placeholder/KantoCeladonMart3F.asm" + "data/maps/attributes/placeholder/KantoCeladonMart4F.asm" + "maps/placeholder/KantoCeladonMart4F.asm" + "data/maps/attributes/placeholder/KantoCeladonMart5F.asm" + "maps/placeholder/KantoCeladonMart5F.asm" + "data/maps/attributes/placeholder/KantoCeladonElevator.asm" + "maps/placeholder/KantoCeladonElevator.asm" + "data/maps/attributes/placeholder/KantoMart.asm" + "maps/placeholder/KantoMart.asm" + "data/maps/attributes/placeholder/KantoGamefreakHQ1.asm" + "maps/placeholder/KantoGamefreakHQ1.asm" + "data/maps/attributes/placeholder/KantoGamefreakHQ2.asm" + "maps/placeholder/KantoGamefreakHQ2.asm" + "data/maps/attributes/placeholder/KantoGamefreakHQ3.asm" + "maps/placeholder/KantoGamefreakHQ3.asm" + "data/maps/attributes/placeholder/KantoGamefreakHQ4.asm" + "maps/placeholder/KantoGamefreakHQ4.asm" + "data/maps/attributes/placeholder/KantoGamefreakHQ5.asm" + "maps/placeholder/KantoGamefreakHQ5.asm" + "data/maps/attributes/placeholder/KantoSilphCo.asm" + "maps/placeholder/KantoSilphCo.asm" + "data/maps/attributes/placeholder/KantoViridianHouse.asm" + "maps/placeholder/KantoViridianHouse.asm" + "data/maps/attributes/placeholder/KantoGameCorner.asm" + "maps/placeholder/KantoGameCorner.asm" + "data/maps/attributes/placeholder/KantoUnusedArea.asm" + "maps/placeholder/KantoUnusedArea.asm" + "data/maps/attributes/placeholder/KantoGameCornerPrizes.asm" + "maps/placeholder/KantoGameCornerPrizes.asm" + "data/maps/attributes/placeholder/KantoDiner.asm" + "maps/placeholder/KantoDiner.asm" + "data/maps/attributes/placeholder/KantoSchool.asm" + "maps/placeholder/KantoSchool.asm" + "data/maps/attributes/placeholder/KantoHospital.asm" + "maps/placeholder/KantoHospital.asm" + "data/maps/attributes/placeholder/KantoPokecenter21F.asm" + "maps/placeholder/KantoPokecenter21F.asm" + "data/maps/attributes/placeholder/KantoPokecenter22F.asm" + "maps/placeholder/KantoPokecenter22F.asm" + "data/maps/attributes/placeholder/KantoRedsHouse.asm" + "maps/placeholder/KantoRedsHouse.asm" + "data/maps/attributes/placeholder/KantoGreensHouse1F.asm" + "maps/placeholder/KantoGreensHouse1F.asm" + "data/maps/attributes/placeholder/KantoGreensHouse2F.asm" + "maps/placeholder/KantoGreensHouse2F.asm" + "data/maps/attributes/placeholder/KantoEldersHouse.asm" + "maps/placeholder/KantoEldersHouse.asm" + "data/maps/attributes/placeholder/KantoOaksLab.asm" + "maps/placeholder/KantoOaksLab.asm" + "data/maps/attributes/placeholder/KantoLeague21F.asm" + "maps/placeholder/KantoLeague21F.asm" + "data/maps/attributes/placeholder/KantoLeague22F.asm" + "maps/placeholder/KantoLeague22F.asm" + "data/maps/attributes/placeholder/KantoFishingGuru.asm" + "maps/placeholder/KantoFishingGuru.asm" + "data/maps/attributes/placeholder/SouthHouse1.asm" + "maps/placeholder/SouthHouse1.asm" + "data/maps/attributes/placeholder/SouthPokecenter1F.asm" + "maps/placeholder/SouthPokecenter1F.asm" + "data/maps/attributes/placeholder/SouthPokecenter2F.asm" + "maps/placeholder/SouthPokecenter2F.asm" + "data/maps/attributes/placeholder/SouthMart.asm" + "maps/placeholder/SouthMart.asm" + "data/maps/attributes/placeholder/SouthHouse2.asm" + "maps/placeholder/SouthHouse2.asm" + "data/maps/attributes/placeholder/NorthHouse1.asm" + "maps/placeholder/NorthHouse1.asm" + "data/maps/attributes/placeholder/NorthMart.asm" + "maps/placeholder/NorthMart.asm" + "data/maps/attributes/placeholder/NorthHouse2.asm" + "maps/placeholder/NorthHouse2.asm" + "data/maps/attributes/placeholder/NorthPokecenter1F.asm" + "maps/placeholder/NorthPokecenter1F.asm" + "data/maps/attributes/placeholder/NorthPokecenter2F.asm" + "maps/placeholder/NorthPokecenter2F.asm" +ROMX $2f + org $4000 + "data/maps/attributes/placeholder/PowerPlant1.asm" + "maps/placeholder/PowerPlant1.asm" + "data/maps/attributes/placeholder/PowerPlant2.asm" + "maps/placeholder/PowerPlant2.asm" + "data/maps/attributes/placeholder/PowerPlant3.asm" + "maps/placeholder/PowerPlant3.asm" + "data/maps/attributes/placeholder/PowerPlant4.asm" + "maps/placeholder/PowerPlant4.asm" + "data/maps/attributes/placeholder/RuinsOfAlphEntrance.asm" + "maps/placeholder/RuinsOfAlphEntrance.asm" + "data/maps/attributes/placeholder/RuinsOfAlphMain.asm" + "maps/placeholder/RuinsOfAlphMain.asm" + "data/maps/attributes/placeholder/CaveMinecarts1.asm" + "maps/placeholder/CaveMinecarts1.asm" + "data/maps/attributes/placeholder/CaveMinecarts2.asm" + "maps/placeholder/CaveMinecarts2.asm" + "data/maps/attributes/placeholder/CaveMinecarts3.asm" + "maps/placeholder/CaveMinecarts3.asm" + "data/maps/attributes/placeholder/CaveMinecarts4.asm" + "maps/placeholder/CaveMinecarts4.asm" + "data/maps/attributes/placeholder/CaveMinecarts5.asm" + "maps/placeholder/CaveMinecarts5.asm" + "data/maps/attributes/placeholder/CaveMinecarts6.asm" + "maps/placeholder/CaveMinecarts6.asm" + "data/maps/attributes/placeholder/CaveMinecarts7.asm" + "maps/placeholder/CaveMinecarts7.asm" + "data/maps/attributes/placeholder/Office1.asm" + "maps/placeholder/Office1.asm" + "data/maps/attributes/placeholder/Office2.asm" + "maps/placeholder/Office2.asm" + "data/maps/attributes/placeholder/Office3.asm" + "maps/placeholder/Office3.asm" + "data/maps/attributes/placeholder/SlowpokeWellEntrance.asm" + "maps/placeholder/SlowpokeWellEntrance.asm" + "data/maps/attributes/placeholder/SlowpokeWellMain.asm" + "maps/placeholder/SlowpokeWellMain.asm" + "data/maps/attributes/ShizukanaOka.asm" + "maps/ShizukanaOka.asm" +ROMX $30 + org $4000 + "gfx.asm@Bank 30 Sprites 1" +ROMX $31 + org $4000 + "gfx.asm@Bank 31 Sprites 2" +ROMX $32 + org $4000 + "engine/battle_anims/bg_effects.asm" + ; $4cfe +ROMX $33 + org $4000 +ROMX $34 + org $4000 + "data/maps/attributes/placeholder/RouteSilentEastGate.asm" + "maps/placeholder/RouteSilentEastGate.asm" + "data/maps/attributes/PlayerHouse1F.asm" + "maps/PlayerHouse1F.asm" + "data/maps/attributes/PlayerHouse2F.asm" + "maps/PlayerHouse2F.asm" + "data/maps/attributes/SilentHillPokecenter.asm" + "maps/SilentHillPokecenter.asm" + "data/maps/attributes/SilentHillHouse.asm" + "maps/SilentHillHouse.asm" + "data/maps/attributes/SilentHillLabFront.asm" + "maps/SilentHillLabFront.asm" + "data/maps/attributes/SilentHillLabBack.asm" + "maps/SilentHillLabBack.asm" + "data/maps/attributes/UnusedMap13.asm" + "maps/UnusedMap13.asm" +ROMX $36 + org $4000 + "maps/placeholder/dummy_text_pointers.asm" + "data/maps/attributes/SilentHill.asm" + "data/maps/attributes/placeholder/OldCity.asm" + "maps/placeholder/OldCity.asm" + "data/maps/attributes/placeholder/West.asm" + "maps/placeholder/West.asm" + "data/maps/attributes/placeholder/Haiteku.asm" + "maps/placeholder/Haiteku.asm" + "data/maps/attributes/placeholder/Fonto.asm" + "maps/placeholder/Fonto.asm" + "data/maps/attributes/placeholder/Baadon.asm" + "maps/placeholder/Baadon.asm" + "data/maps/attributes/placeholder/Newtype.asm" + "maps/placeholder/Newtype.asm" + "data/maps/attributes/placeholder/Sugar.asm" + "maps/placeholder/Sugar.asm" + "data/maps/attributes/placeholder/BullForest.asm" + "maps/placeholder/BullForest.asm" + "data/maps/attributes/placeholder/Stand.asm" + "maps/placeholder/Stand.asm" + "data/maps/attributes/placeholder/Kanto.asm" + "maps/placeholder/Kanto.asm" + "data/maps/attributes/placeholder/Prince.asm" + "maps/placeholder/Prince.asm" + "data/maps/attributes/placeholder/MtFuji.asm" + "maps/placeholder/MtFuji.asm" + "data/maps/attributes/placeholder/South.asm" + "maps/placeholder/South.asm" + "data/maps/attributes/placeholder/North.asm" + "maps/placeholder/North.asm" + "data/maps/attributes/Route1P1.asm" + "data/maps/attributes/Route1P2.asm" + "data/maps/attributes/placeholder/Route2.asm" + "maps/placeholder/Route2.asm" + "data/maps/attributes/placeholder/HaitekuWestRoute.asm" + "maps/placeholder/HaitekuWestRoute.asm" + "data/maps/attributes/placeholder/HaitekuWestRouteOcean.asm" + "maps/placeholder/HaitekuWestRouteOcean.asm" + "data/maps/attributes/placeholder/FontoRoute1.asm" + "maps/placeholder/FontoRoute1.asm" + "data/maps/attributes/placeholder/FontoRoute6.asm" + "maps/placeholder/FontoRoute6.asm" + "data/maps/attributes/placeholder/FontoRoute2.asm" + "maps/placeholder/FontoRoute2.asm" + "data/maps/attributes/placeholder/FontoRoute4.asm" + "maps/placeholder/FontoRoute4.asm" + "data/maps/attributes/placeholder/FontoRoute3.asm" + "maps/placeholder/FontoRoute3.asm" + "data/maps/attributes/placeholder/BaadonRoute1.asm" + "maps/placeholder/BaadonRoute1.asm" + "data/maps/attributes/placeholder/BaadonRoute2.asm" + "maps/placeholder/BaadonRoute2.asm" + "data/maps/attributes/placeholder/BaadonRoute3.asm" + "maps/placeholder/BaadonRoute3.asm" + "data/maps/attributes/placeholder/Route15.asm" + "maps/placeholder/Route15.asm" + "data/maps/attributes/placeholder/NewtypeRoute.asm" + "maps/placeholder/NewtypeRoute.asm" + "data/maps/attributes/placeholder/Route18.asm" + "maps/placeholder/Route18.asm" + "data/maps/attributes/placeholder/BullForestRoute1.asm" + "maps/placeholder/BullForestRoute1.asm" + "data/maps/attributes/placeholder/SugarRoute.asm" + "maps/placeholder/SugarRoute.asm" + "data/maps/attributes/placeholder/BullForestRoute2.asm" + "maps/placeholder/BullForestRoute2.asm" + "data/maps/attributes/placeholder/StandRoute.asm" + "maps/placeholder/StandRoute.asm" + "data/maps/attributes/placeholder/KantoEastRoute.asm" + "maps/placeholder/KantoEastRoute.asm" + "data/maps/attributes/placeholder/RouteSilentEast.asm" + "maps/placeholder/RouteSilentEast.asm" + "data/maps/attributes/placeholder/PrinceRoute.asm" + "maps/placeholder/PrinceRoute.asm" + "data/maps/attributes/placeholder/MtFujiRoute.asm" + "maps/placeholder/MtFujiRoute.asm" + "data/maps/attributes/placeholder/FontoRoute5.asm" + "maps/placeholder/FontoRoute5.asm" + "data/maps/attributes/placeholder/BullForestRoute3.asm" + "maps/placeholder/BullForestRoute3.asm" + "maps/SilentHill.asm" + "engine/menu/set_time.asm" + "maps/Route1P1.asm" + "maps/Route1P2.asm" +ROMX $37 + org $4000 + "gfx.asm@Bank 37 Tilesets 10" + ; $4600 + org $4b00 + "gfx.asm@Bank 37 Tilesets 15" + ; $5100 + org $5b00 + "gfx.asm@Bank 37 Tilesets 17" + ; $6100 + org $6b00 + "gfx.asm@Bank 37 Tilesets 18" +ROMX $38 + org $4000 + org $5403 + "gfx.asm@Poker GFX" + ; $5ae3 + org $5f93 + "gfx.asm@15 Puzzle GFX" + ; $6213 + org $6606 + "gfx.asm@Matches GFX" + ; $6886 + org $75b7 + "gfx.asm@Picross GFX" +ROMX $39 + org $4000 + org $41ff + "gfx.asm@Gamefreak Logo GFX" + org $4adf + "gfx.asm@Intro Underwater GFX" + ; $52df + org $55ef + "gfx.asm@Intro Water Mon and Forest GFX" + ; $60ef + org $626f + "gfx.asm@Intro Mon" +ROMX $3a + org $4000 + "audio/engine.asm@Audio" + ; $410b + org $42bd + "audio/engine.asm@IsChannelSFXOn" + "audio/engine.asm@Functione82f0" + org $4cee + "audio/engine.asm@Audio engine, part 2" + ; $4d8d + org $52c7 + "audio/engine.asm@Song Header Pointers" + org $52ee + "audio/songs/none.asm" +ROMX $3b + org $4000 + "audio/songs/nidorinointro.asm" + "audio/songs/viridiancity.asm" + "audio/songs/route1.asm" + "audio/songs/oakintro.asm" + "audio/songs/leaderbattle.asm" + "audio/songs/trainerbattle.asm" + "audio/songs/heal.asm" + "audio/songs/bicycle.asm" + "audio/songs/spottedrocket.asm" + "audio/songs/victorytrainer.asm" + "audio/songs/evolution.asm" +ROMX $3c + org $4000 + ; $4000 + org $51cb + "audio/cries.asm" + ; $51cb + org $62fe + "audio/songs/title.asm" +ROMX $3e + org $4000 + "engine/gfx.asm" + "gfx.asm@Misc GFX" +ROMX $3f + org $4000 + ; $4000 + org $4362 + "engine/menu/reset_dialog.asm" + ; $4381 + org $4aa5 + "engine/landmarks.asm" + "data/maps/landmarks.asm" + ; $4bc6 + org $506f + "gfx.asm@Town Map Cursor" + ; $50af +WRAM0 + org $c000 + "Music engine RAM" + ; $c1bc + org $c200 + "OAM Buffer" + ; $c570 + org $c5e8 + "Map Buffer" + org $dfff + "Stack Bottom" +VRAM 0 + "VRAM" +HRAM + "HRAM" +SRAM 0 + "Sprite Buffers" + ; $a498 + org $a600 + "Unknown, bank 0" + ; $a607 + org $bffe + "Window Stack Top" diff --git a/pokegold-spaceworld.link b/pokegold-spaceworld.link deleted file mode 100644 index c3795e0..0000000 --- a/pokegold-spaceworld.link +++ /dev/null @@ -1,981 +0,0 @@ -; Automatically generated by map2link.py -ROM0 - "home/rst.asm@rst00" - ; $0001 - org $0008 - "home/rst.asm@rst08" - ; $0009 - org $0010 - "home/rst.asm@rst10" - ; $0011 - org $0018 - "home/rst.asm@rst18" - ; $0019 - org $0020 - "home/rst.asm@rst20" - ; $0021 - org $0028 - "home/rst.asm@rst28" - ; $0029 - org $0030 - "home/rst.asm@rst30" - ; $0031 - org $0038 - "home/rst.asm@rst38" - ; $003b - org $0040 - "home/interrupts.asm@VBlank interrupt vector" - ; $0043 - org $0048 - "home/interrupts.asm@LCD interrupt vector" - ; $004b - org $0050 - "home/interrupts.asm@Timer interrupt vector" - ; $0053 - org $0058 - "home/interrupts.asm@Serial interrupt vector" - ; $005b - org $0060 - "home/interrupts.asm@Joypad interrupt vector" - ; $0063 - org $0100 - "home/init.asm@Entry point" - ; $0104 - org $014e - "home/init.asm@Global check value" - "home/vblank.asm" - "home/delay.asm" - "home/rtc.asm" - "home/fade.asm" - "home/lcd.asm" - "home/interrupts.asm@Timer dummy interrupt" - "home/time.asm" - "home/init.asm@Init" - "home/serial.asm" - "home/interrupts.asm@Joypad dummy interrupt" - "home/joypad.asm" - "home/unknown.asm@Unknown_094c" - "home/pic.asm" - "home/copy2.asm" - "home/text.asm" - "home/vcopy.asm" - "home/map_objects.asm" - "home/movement.asm" - "home/menu_window.asm" - "home/menu.asm" - "home/window.asm" - "home/unknown.asm@Unknown functions" - "home/toolgear.asm" - "home/unknown.asm@Unknown_20f8" - "home/map.asm" - "home/overworld.asm@Startmenu and Select Button Check" - "home/tileset.asm" - "home/unknown.asm@Empty function" - "home/farcall.asm" - "home/predef.asm" - "home/talk_to_npc.asm" - "home/items.asm@TossItem" - "home/random.asm" - "home/sram.asm" - "home/bankswitch.asm" - "home/misc_32c8.asm@Unknown 32c8" - "home/clear_sprites.asm" - "home/copy.asm" - "home/math.asm" - "home/print_text.asm" - "home/util.asm" - "home/print_num.asm" - "home/print_hex.asm" - "home/jumptable.asm" - "home/overworld.asm@QueueScript" - "home/tables.asm" - "home/tilemap.asm" - "home/items.asm@GiveItem" - "home/misc_32c8.asm@Unknown 3686" - "home/names.asm@Names" - "home/scrolling_menu.asm" - "home/unknown_388f.asm" - "home/mon_stats.asm" - "home/cry.asm" - "home/pokemon.asm@3A4B" - "home/names.asm@GetNick" - "home/print_bcd.asm" - "home/pokemon.asm@3AED" - "home/misc_3c43.asm" - "home/audio.asm" -ROMX $01 - org $4000 - "engine/link/place_waiting_text.asm" - "engine/menu/debug_menu.asm" - "engine/dumps/bank01.asm@Function40fd" - "home/oam_dma.asm" - "engine/dumps/bank01.asm@Table416b" - "engine/menu/main_menu.asm@Initialize new game WRAM" - "engine/dumps/bank01.asm@Function5388" - "engine/menu/main_menu.asm@MainMenu" - "engine/intro.asm" - "engine/title.asm@Title screen" - "engine/title.asm@Title screen TEMPORARY" - "engine/predef.asm" - "engine/dumps/bank01.asm@ReanchorBGMap_NoOAMUpdate" - "engine/math/math.asm" - "data/items/attributes.asm" - "data/items/names.asm" - "engine/dumps/bank01.asm@Function771e" - "engine/overworld/object_collision.asm@GetSpritesNextTile" - "engine/dumps/bank01.asm@Function776e" - "engine/overworld/object_collision.asm@_CheckObjectCollision" - "engine/dumps/bank01.asm@Function782c" - "engine/overworld/object_collision.asm@_CheckPlayerObjectCollision" - "engine/dumps/bank01.asm@SettingsScreen" -ROMX $02 - "engine/dumps/bank02.asm@Function8000" - "gfx.asm@Bank 2 Misc GFX" - "engine/dumps/bank02.asm@QueueFollowerFirstStep" - "gfx.asm@Mon Nest Icon" - "engine/dumps/bank02.asm@Data8a17" - "gfx.asm@Pokegear GFX" - "engine/dumps/bank02.asm@Text91c2" - "gfx.asm@Title Screen BG Decoration Border" - "engine/dumps/bank02.asm@Function928b" - "gfx.asm@SGB GFX" - "bin.asm@Unknownaebc" - "gfx.asm@Corrupted SGB GFX" - "bin.asm@Unknownbb43" -ROMX $03 - org $4000 - "engine/overworld/player_movement.asm@Player Movement" - "data/collision/collision_type_table.asm" - "engine/overworld/player_movement.asm@Rest of Player Movement" - "engine/dumps/bank03.asm@Functionc77d" - "engine/overworld/spawn_points.asm" - "engine/dumps/bank03.asm@SpawnPoints" - "data/tileset_headers.asm" - "engine/dumps/bank03.asm@Functionc9c1" - "engine/items/inventory.asm@Inventory" - "engine/smallflag.asm" - "engine/pokemon/health.asm@HealParty" - "engine/bcd.asm" - "engine/items/inventory.asm@GetItemAmount" - "engine/pokemon/health.asm@HP Bar" - "engine/events/field_moves.asm" - "engine/items/inventory.asm@_CheckTossableItem" - "engine/dumps/bank03.asm@Functiond41d" - "engine/items/item_effects.asm" - "engine/dumps/bank03.asm@Functionf960" -ROMX $04 - org $4000 - "data/maps/maps.asm" - ; $4743 - org $47cf - "gfx.asm@Title Screen GFX" - "engine/items/tmhm.asm" - "data/moves/tmhm_moves.asm" - "engine/unknown_boxes.asm" - "engine/menu/text_entry.asm@naming" - "data/text/text_input_chars.asm" - "gfx.asm@Name Entry Extra Tiles" - "engine/menu/text_entry.asm@mail" - "gfx.asm@Mail Icon GFX" - "engine/menu/text_entry.asm@mail2" - "engine/unknown11d32.asm" - "engine/menu/start_menu.asm" - "gfx.asm@Trainer Card GFX" - ; $7be3 -ROMX $05 - org $4000 - "engine/overworld/player_movement.asm@_RedrawPlayerSprite" - org $4150 - "engine/sprites/sprites.asm@LoadOverworldSprite" - ; $4193 - org $423b - "engine/sprites/sprites.asm@OverworldSprites" - ; $43a7 -ROMX $06 - org $4000 - "gfx.asm@Bank 6 Tilesets 00" - ; $4400 - org $5800 - "gfx.asm@Bank 6 Tilesets 01" - ; $5c00 - org $6600 - "gfx.asm@Bank 6 Tilesets 02" - ; $6a00 - org $7400 - "gfx.asm@Bank 6 Tilesets 09" -ROMX $07 - org $4000 - "gfx.asm@Bank 7 Tilesets 13" - ; $4600 - org $4b00 - "gfx.asm@Bank 7 Tilesets 0e" - ; $5100 - org $5600 - "gfx.asm@Bank 7 Tilesets 06" - ; $5a00 - org $6400 - "gfx.asm@Bank 7 Tilesets 05" - ; $6800 - org $7200 - "gfx.asm@Bank 7 Tilesets 03" -ROMX $08 - org $4000 - "gfx.asm@Bank 8 Tilesets 04" - ; $4400 - org $4e00 - "gfx.asm@Bank 8 Tilesets 07" - ; $5200 - org $5c00 - "gfx.asm@Bank 8 Tilesets 08" - ; $6000 - org $6a00 - "gfx.asm@Bank 8 Tilesets 0f" - ; $7000 - org $7500 - "gfx.asm@Bank 8 Tilesets 11" -ROMX $09 - org $4000 - ; $4000 -ROMX $0a - org $4000 - ; $4000 - org $5641 - "gfx.asm@Gameboy GFX" -ROMX $0b - org $4000 - ; $4000 -ROMX $0c - org $4000 - "gfx.asm@Bank C Tilesets 12" - ; $4600 - org $4b00 - "gfx.asm@Bank C Tilesets 0b" - ; $5100 - org $5600 - "gfx.asm@Bank C Tilesets 0d" - ; $5c00 - org $6100 - "gfx.asm@Bank C Tilesets 14" - ; $6700 - org $7100 - "gfx.asm@Bank C Tilesets 0c" - ; $7700 - org $7c00 - "gfx.asm@Bank C Tilesets Common" - ; $7e00 -ROMX $0d - org $4000 - ; $4000 - org $506d - "data/types/type_matchups.asm" -ROMX $0e - org $4000 - ; $4000 - org $4494 - "gfx.asm@PokeBalls GFX" - ; $44d4 - org $4d90 - "data/trainers/class_names.asm" - ; $4f45 - org $5110 - "data/trainers/parties.asm@Trainer Parties" -ROMX $0f - org $4000 - ; $4000 - org $63da - "engine/battle/core.asm" - ; $6411 - org $6a3c - "data/wild.asm" -ROMX $10 - org $4000 - org $4943 - "data/pokemon/dex_order_alpha.asm" - ; $4a3e - org $4ecc - "data/types/search_strings.asm" - ; $4f17 - org $5068 - "data/types/search_types.asm" - ; $5077 - org $52a1 - "data/moves/names.asm" - ; $58b6 - org $6493 - "data/pokemon/evos_attacks.asm" -ROMX $11 - org $4000 - ; $4000 - org $40d5 - "gfx.asm@Pokedex GFX" -ROMX $12 - org $4000 - "gfx.asm@Trainer Battle Sprites" -ROMX $13 - org $4000 - "gfx.asm@Bank 13 Tilesets 0a" - ; $4600 - org $4b00 - "gfx.asm@Bank 13 Tilesets 16" - ; $5100 - org $5b00 - "gfx.asm@Bank 13 Tilesets 19" - ; $5f00 - org $6900 - "gfx.asm@Bank 13 Tilesets 1a" -ROMX $14 - org $4000 - org $40dd - "data/types/names.asm" - ; $4162 - org $4f10 - "data/pokemon/base_stats.asm" - "data/pokemon/names.asm" - "gfx.asm@PKMN Sprite Bank List" -ROMX $15 - org $4000 - "gfx/pokemon/pkmn_pics.asm@PKMN Pics 1" -ROMX $16 - org $4000 - "gfx/pokemon/pkmn_pics.asm@PKMN Pics 2" -ROMX $17 - org $4000 - "gfx/pokemon/pkmn_pics.asm@PKMN Pics 3" -ROMX $18 - org $4000 - "gfx/pokemon/pkmn_pics.asm@PKMN Pics 4" -ROMX $19 - org $4000 - "gfx/pokemon/pkmn_pics.asm@PKMN Pics 5" -ROMX $1a - org $4000 - "gfx/pokemon/pkmn_pics.asm@PKMN Pics 6" -ROMX $1b - org $4000 - "gfx/pokemon/pkmn_pics.asm@PKMN Pics 7" -ROMX $1c - org $4000 - "gfx/pokemon/pkmn_pics.asm@PKMN Pics 8" -ROMX $1d - org $4000 - "gfx/pokemon/pkmn_pics.asm@PKMN Pics 9" -ROMX $1e - org $4000 - "gfx/pokemon/pkmn_pics.asm@PKMN Pics 10" -ROMX $1f - org $4000 - "gfx/pokemon/pkmn_pics.asm@PKMN Pics 11" - "gfx.asm@Annon Pic Ptrs and Pics" - "gfx/pokemon/egg.asm" -ROMX $21 - org $4000 - "gfx.asm@Attack Animation GFX" -ROMX $23 - org $4000 - org $433e - "engine/palettes.asm@Overworld fade" - org $43d1 - "engine/palettes.asm@Palette fading, part 2?" - ; $446d - org $60cc - "gfx.asm@Pokemon Party Sprites" - org $722a - "data/pokemon/menu_icons.asm" -ROMX $24 - org $4000 - org $4fdb - "gfx.asm@Slot Machine GFX" -ROMX $25 - org $4000 - "data/maps/attributes/placeholder/Route2Gate1F.asm" - "maps/placeholder/Route2Gate1F.asm" - "data/maps/attributes/placeholder/Route2Gate2F.asm" - "maps/placeholder/Route2Gate2F.asm" - "data/maps/attributes/placeholder/Route2House.asm" - "maps/placeholder/Route2House.asm" - "data/maps/attributes/placeholder/OldCityMuseum.asm" - "maps/placeholder/OldCityMuseum.asm" - "data/maps/attributes/placeholder/OldCityGym.asm" - "maps/placeholder/OldCityGym.asm" - "data/maps/attributes/placeholder/OldCityTower1F.asm" - "maps/placeholder/OldCityTower1F.asm" - "data/maps/attributes/placeholder/OldCityTower2F.asm" - "maps/placeholder/OldCityTower2F.asm" - "data/maps/attributes/placeholder/OldCityTower3F.asm" - "maps/placeholder/OldCityTower3F.asm" - "data/maps/attributes/placeholder/OldCityTower4F.asm" - "maps/placeholder/OldCityTower4F.asm" - "data/maps/attributes/placeholder/OldCityTower5F.asm" - "maps/placeholder/OldCityTower5F.asm" - "data/maps/attributes/placeholder/OldCityBillsHouse.asm" - "maps/placeholder/OldCityBillsHouse.asm" - "data/maps/attributes/placeholder/OldCityMart.asm" - "maps/placeholder/OldCityMart.asm" - "data/maps/attributes/placeholder/OldCityHouse.asm" - "maps/placeholder/OldCityHouse.asm" - "data/maps/attributes/placeholder/OldCityPokecenter1F.asm" - "maps/placeholder/OldCityPokecenter1F.asm" - "data/maps/attributes/OldCityPokecenter2F.asm" - "maps/OldCityPokecenter2F.asm" - "data/maps/attributes/OldCityPokecenterTrade.asm" - "maps/OldCityPokecenterTrade.asm" - "data/maps/attributes/OldCityPokecenterBattle.asm" - "maps/OldCityPokecenterBattle.asm" - "data/maps/attributes/OldCityPokecenterTimeMachine.asm" - "maps/OldCityPokecenterTimeMachine.asm" - "data/maps/attributes/placeholder/OldCityKurtsHouse.asm" - "maps/placeholder/OldCityKurtsHouse.asm" - "data/maps/attributes/placeholder/OldCitySchool.asm" - "maps/placeholder/OldCitySchool.asm" -ROMX $26 - org $4000 - "data/maps/attributes/Route1Gate1F.asm" - "maps/Route1Gate1F.asm" - "data/maps/attributes/Route1Gate2F.asm" - "maps/Route1Gate2F.asm" - "data/maps/attributes/placeholder/WestMart1F.asm" - "maps/placeholder/WestMart1F.asm" - "data/maps/attributes/placeholder/WestMart2F.asm" - "maps/placeholder/WestMart2F.asm" - "data/maps/attributes/placeholder/WestMart3F.asm" - "maps/placeholder/WestMart3F.asm" - "data/maps/attributes/placeholder/WestMart4F.asm" - "maps/placeholder/WestMart4F.asm" - "data/maps/attributes/placeholder/WestMart5F.asm" - "maps/placeholder/WestMart5F.asm" - "data/maps/attributes/placeholder/WestMart6F.asm" - "maps/placeholder/WestMart6F.asm" - "data/maps/attributes/placeholder/WestMartElevator.asm" - "maps/placeholder/WestMartElevator.asm" - "data/maps/attributes/placeholder/WestRadioTower1F.asm" - "maps/placeholder/WestRadioTower1F.asm" - "data/maps/attributes/placeholder/WestRadioTower2F.asm" - "maps/placeholder/WestRadioTower2F.asm" - "data/maps/attributes/placeholder/WestRadioTower3F.asm" - "maps/placeholder/WestRadioTower3F.asm" - "data/maps/attributes/placeholder/WestRadioTower4F.asm" - "maps/placeholder/WestRadioTower4F.asm" - "data/maps/attributes/placeholder/WestRadioTower5F.asm" - "maps/placeholder/WestRadioTower5F.asm" - "data/maps/attributes/placeholder/WestRocketRaidedHouse.asm" - "maps/placeholder/WestRocketRaidedHouse.asm" - "data/maps/attributes/placeholder/WestPokecenter1F.asm" - "maps/placeholder/WestPokecenter1F.asm" - "data/maps/attributes/placeholder/WestPokecenter2F.asm" - "maps/placeholder/WestPokecenter2F.asm" - "data/maps/attributes/placeholder/WestGym.asm" - "maps/placeholder/WestGym.asm" - "data/maps/attributes/placeholder/WestHouse1.asm" - "maps/placeholder/WestHouse1.asm" - "data/maps/attributes/placeholder/WestHouse2.asm" - "maps/placeholder/WestHouse2.asm" -ROMX $27 - org $4000 - "data/maps/attributes/placeholder/HaitekuWestRouteGate.asm" - "maps/placeholder/HaitekuWestRouteGate.asm" - "data/maps/attributes/placeholder/HaitekuPokecenter1F.asm" - "maps/placeholder/HaitekuPokecenter1F.asm" - "data/maps/attributes/placeholder/HaitekuPokecenter2F.asm" - "maps/placeholder/HaitekuPokecenter2F.asm" - "data/maps/attributes/placeholder/HaitekuLeague1F.asm" - "maps/placeholder/HaitekuLeague1F.asm" - "data/maps/attributes/placeholder/HaitekuLeague2F.asm" - "maps/placeholder/HaitekuLeague2F.asm" - "data/maps/attributes/placeholder/HaitekuMart.asm" - "maps/placeholder/HaitekuMart.asm" - "data/maps/attributes/placeholder/HaitekuHouse1.asm" - "maps/placeholder/HaitekuHouse1.asm" - "data/maps/attributes/placeholder/HaitekuHouse2.asm" - "maps/placeholder/HaitekuHouse2.asm" - "data/maps/attributes/placeholder/HaitekuImposterOakHouse.asm" - "maps/placeholder/HaitekuImposterOakHouse.asm" - "data/maps/attributes/placeholder/HaitekuAquarium1F.asm" - "maps/placeholder/HaitekuAquarium1F.asm" - "data/maps/attributes/placeholder/HaitekuAquarium2F.asm" - "maps/placeholder/HaitekuAquarium2F.asm" - "data/maps/attributes/placeholder/FontoRouteGate1.asm" - "maps/placeholder/FontoRouteGate1.asm" - "data/maps/attributes/placeholder/FontoRouteGate2.asm" - "maps/placeholder/FontoRouteGate2.asm" - "data/maps/attributes/placeholder/FontoRouteGate3.asm" - "maps/placeholder/FontoRouteGate3.asm" - "data/maps/attributes/placeholder/FontoRocketHouse.asm" - "maps/placeholder/FontoRocketHouse.asm" - "data/maps/attributes/placeholder/FontoMart.asm" - "maps/placeholder/FontoMart.asm" - "data/maps/attributes/placeholder/FontoHouse.asm" - "maps/placeholder/FontoHouse.asm" - "data/maps/attributes/placeholder/FontoPokecenter1F.asm" - "maps/placeholder/FontoPokecenter1F.asm" - "data/maps/attributes/placeholder/FontoPokecenter2F.asm" - "maps/placeholder/FontoPokecenter2F.asm" - "data/maps/attributes/placeholder/FontoLab.asm" - "maps/placeholder/FontoLab.asm" - "data/maps/attributes/placeholder/BaadonMart.asm" - "maps/placeholder/BaadonMart.asm" - "data/maps/attributes/placeholder/BaadonPokecenter1F.asm" - "maps/placeholder/BaadonPokecenter1F.asm" - "data/maps/attributes/placeholder/BaadonPokecenter2F.asm" - "maps/placeholder/BaadonPokecenter2F.asm" - "data/maps/attributes/placeholder/BaadonHouse1.asm" - "maps/placeholder/BaadonHouse1.asm" - "data/maps/attributes/placeholder/BaadonWallpaperHouse.asm" - "maps/placeholder/BaadonWallpaperHouse.asm" - "data/maps/attributes/placeholder/BaadonHouse2.asm" - "maps/placeholder/BaadonHouse2.asm" - "data/maps/attributes/placeholder/BaadonLeague1F.asm" - "maps/placeholder/BaadonLeague1F.asm" - "data/maps/attributes/placeholder/BaadonLeague2F.asm" - "maps/placeholder/BaadonLeague2F.asm" - "data/maps/attributes/placeholder/BaadonRouteGateWest.asm" - "maps/placeholder/BaadonRouteGateWest.asm" - "data/maps/attributes/placeholder/BaadonRouteGateNewtype.asm" - "maps/placeholder/BaadonRouteGateNewtype.asm" - "data/maps/attributes/placeholder/NewtypePokecenter1F.asm" - "maps/placeholder/NewtypePokecenter1F.asm" - "data/maps/attributes/placeholder/NewtypePokecenter2F.asm" - "maps/placeholder/NewtypePokecenter2F.asm" - "data/maps/attributes/placeholder/NewtypeLeague1F.asm" - "maps/placeholder/NewtypeLeague1F.asm" - "data/maps/attributes/placeholder/NewtypeLeague2F.asm" - "maps/placeholder/NewtypeLeague2F.asm" - "data/maps/attributes/placeholder/NewtypeSailorHouse.asm" - "maps/placeholder/NewtypeSailorHouse.asm" - "data/maps/attributes/placeholder/NewtypeMart.asm" - "maps/placeholder/NewtypeMart.asm" - "data/maps/attributes/placeholder/NewtypeDojo.asm" - "maps/placeholder/NewtypeDojo.asm" - "data/maps/attributes/placeholder/NewtypeHouse1.asm" - "maps/placeholder/NewtypeHouse1.asm" - "data/maps/attributes/placeholder/NewtypeDiner.asm" - "maps/placeholder/NewtypeDiner.asm" - "data/maps/attributes/placeholder/NewtypeHouse2.asm" - "maps/placeholder/NewtypeHouse2.asm" - "data/maps/attributes/placeholder/NewtypeHouse3.asm" - "maps/placeholder/NewtypeHouse3.asm" - "data/maps/attributes/placeholder/Route15Pokecenter1F.asm" - "maps/placeholder/Route15Pokecenter1F.asm" - "data/maps/attributes/placeholder/Route15Pokecenter2F.asm" - "maps/placeholder/Route15Pokecenter2F.asm" - "data/maps/attributes/placeholder/NewtypeRouteGate.asm" - "maps/placeholder/NewtypeRouteGate.asm" - "data/maps/attributes/placeholder/Route18Pokecenter1F.asm" - "maps/placeholder/Route18Pokecenter1F.asm" - "data/maps/attributes/placeholder/Route18Pokecenter2F.asm" - "maps/placeholder/Route18Pokecenter2F.asm" - "data/maps/attributes/placeholder/SugarRouteGate.asm" - "maps/placeholder/SugarRouteGate.asm" - "data/maps/attributes/placeholder/SugarHouse.asm" - "maps/placeholder/SugarHouse.asm" - "data/maps/attributes/placeholder/SugarHouse2.asm" - "maps/placeholder/SugarHouse2.asm" - "data/maps/attributes/placeholder/SugarMart.asm" - "maps/placeholder/SugarMart.asm" - "data/maps/attributes/placeholder/SugarPokecenter1F.asm" - "maps/placeholder/SugarPokecenter1F.asm" - "data/maps/attributes/placeholder/SugarPokecenter2F.asm" - "maps/placeholder/SugarPokecenter2F.asm" - "data/maps/attributes/placeholder/BullForestRoute1House.asm" - "maps/placeholder/BullForestRoute1House.asm" - "data/maps/attributes/placeholder/BullForestRouteGateStand.asm" - "maps/placeholder/BullForestRouteGateStand.asm" - "data/maps/attributes/placeholder/BullMart.asm" - "maps/placeholder/BullMart.asm" - "data/maps/attributes/placeholder/BullHouse1.asm" - "maps/placeholder/BullHouse1.asm" - "data/maps/attributes/placeholder/BullHouse2.asm" - "maps/placeholder/BullHouse2.asm" - "data/maps/attributes/placeholder/BullHouse3.asm" - "maps/placeholder/BullHouse3.asm" - "data/maps/attributes/placeholder/BullPokecenter1F.asm" - "maps/placeholder/BullPokecenter1F.asm" - "data/maps/attributes/placeholder/BullPokecenter2F.asm" - "maps/placeholder/BullPokecenter2F.asm" - "data/maps/attributes/placeholder/BullLeague1F.asm" - "maps/placeholder/BullLeague1F.asm" - "data/maps/attributes/placeholder/BullLeague2F.asm" - "maps/placeholder/BullLeague2F.asm" - "data/maps/attributes/placeholder/BullHouse4.asm" - "maps/placeholder/BullHouse4.asm" - "data/maps/attributes/placeholder/StandRouteGateKanto.asm" - "maps/placeholder/StandRouteGateKanto.asm" - "data/maps/attributes/placeholder/StandLab.asm" - "maps/placeholder/StandLab.asm" - "data/maps/attributes/placeholder/StandPokecenter1F.asm" - "maps/placeholder/StandPokecenter1F.asm" - "data/maps/attributes/placeholder/StandPokecenter2F.asm" - "maps/placeholder/StandPokecenter2F.asm" - "data/maps/attributes/placeholder/StandOffice.asm" - "maps/placeholder/StandOffice.asm" - "data/maps/attributes/placeholder/StandMart.asm" - "maps/placeholder/StandMart.asm" - "data/maps/attributes/placeholder/StandHouse.asm" - "maps/placeholder/StandHouse.asm" - "data/maps/attributes/placeholder/StandRocketHouse1F.asm" - "maps/placeholder/StandRocketHouse1F.asm" - "data/maps/attributes/placeholder/StandRocketHouse2F.asm" - "maps/placeholder/StandRocketHouse2F.asm" - "data/maps/attributes/placeholder/StandLeague1F.asm" - "maps/placeholder/StandLeague1F.asm" - "data/maps/attributes/placeholder/StandLeague2F.asm" - "maps/placeholder/StandLeague2F.asm" - "data/maps/attributes/placeholder/KantoCeruleanHouse.asm" - "maps/placeholder/KantoCeruleanHouse.asm" - "data/maps/attributes/placeholder/KantoPokecenter1F.asm" - "maps/placeholder/KantoPokecenter1F.asm" - "data/maps/attributes/placeholder/KantoPokecenter2F.asm" - "maps/placeholder/KantoPokecenter2F.asm" - "data/maps/attributes/placeholder/KantoLeague1F.asm" - "maps/placeholder/KantoLeague1F.asm" - "data/maps/attributes/placeholder/KantoLeague2F.asm" - "maps/placeholder/KantoLeague2F.asm" - "data/maps/attributes/placeholder/KantoLavenderHouse.asm" - "maps/placeholder/KantoLavenderHouse.asm" - "data/maps/attributes/placeholder/KantoCeladonMart1F.asm" - "maps/placeholder/KantoCeladonMart1F.asm" - "data/maps/attributes/placeholder/KantoCeladonMart2F.asm" - "maps/placeholder/KantoCeladonMart2F.asm" - "data/maps/attributes/placeholder/KantoCeladonMart3F.asm" - "maps/placeholder/KantoCeladonMart3F.asm" - "data/maps/attributes/placeholder/KantoCeladonMart4F.asm" - "maps/placeholder/KantoCeladonMart4F.asm" - "data/maps/attributes/placeholder/KantoCeladonMart5F.asm" - "maps/placeholder/KantoCeladonMart5F.asm" - "data/maps/attributes/placeholder/KantoCeladonElevator.asm" - "maps/placeholder/KantoCeladonElevator.asm" - "data/maps/attributes/placeholder/KantoMart.asm" - "maps/placeholder/KantoMart.asm" - "data/maps/attributes/placeholder/KantoGamefreakHQ1.asm" - "maps/placeholder/KantoGamefreakHQ1.asm" - "data/maps/attributes/placeholder/KantoGamefreakHQ2.asm" - "maps/placeholder/KantoGamefreakHQ2.asm" - "data/maps/attributes/placeholder/KantoGamefreakHQ3.asm" - "maps/placeholder/KantoGamefreakHQ3.asm" - "data/maps/attributes/placeholder/KantoGamefreakHQ4.asm" - "maps/placeholder/KantoGamefreakHQ4.asm" - "data/maps/attributes/placeholder/KantoGamefreakHQ5.asm" - "maps/placeholder/KantoGamefreakHQ5.asm" - "data/maps/attributes/placeholder/KantoSilphCo.asm" - "maps/placeholder/KantoSilphCo.asm" - "data/maps/attributes/placeholder/KantoViridianHouse.asm" - "maps/placeholder/KantoViridianHouse.asm" - "data/maps/attributes/placeholder/KantoGameCorner.asm" - "maps/placeholder/KantoGameCorner.asm" - "data/maps/attributes/placeholder/KantoUnusedArea.asm" - "maps/placeholder/KantoUnusedArea.asm" - "data/maps/attributes/placeholder/KantoGameCornerPrizes.asm" - "maps/placeholder/KantoGameCornerPrizes.asm" - "data/maps/attributes/placeholder/KantoDiner.asm" - "maps/placeholder/KantoDiner.asm" - "data/maps/attributes/placeholder/KantoSchool.asm" - "maps/placeholder/KantoSchool.asm" - "data/maps/attributes/placeholder/KantoHospital.asm" - "maps/placeholder/KantoHospital.asm" - "data/maps/attributes/placeholder/KantoPokecenter21F.asm" - "maps/placeholder/KantoPokecenter21F.asm" - "data/maps/attributes/placeholder/KantoPokecenter22F.asm" - "maps/placeholder/KantoPokecenter22F.asm" - "data/maps/attributes/placeholder/KantoRedsHouse.asm" - "maps/placeholder/KantoRedsHouse.asm" - "data/maps/attributes/placeholder/KantoGreensHouse1F.asm" - "maps/placeholder/KantoGreensHouse1F.asm" - "data/maps/attributes/placeholder/KantoGreensHouse2F.asm" - "maps/placeholder/KantoGreensHouse2F.asm" - "data/maps/attributes/placeholder/KantoEldersHouse.asm" - "maps/placeholder/KantoEldersHouse.asm" - "data/maps/attributes/placeholder/KantoOaksLab.asm" - "maps/placeholder/KantoOaksLab.asm" - "data/maps/attributes/placeholder/KantoLeague21F.asm" - "maps/placeholder/KantoLeague21F.asm" - "data/maps/attributes/placeholder/KantoLeague22F.asm" - "maps/placeholder/KantoLeague22F.asm" - "data/maps/attributes/placeholder/KantoFishingGuru.asm" - "maps/placeholder/KantoFishingGuru.asm" - "data/maps/attributes/placeholder/SouthHouse1.asm" - "maps/placeholder/SouthHouse1.asm" - "data/maps/attributes/placeholder/SouthPokecenter1F.asm" - "maps/placeholder/SouthPokecenter1F.asm" - "data/maps/attributes/placeholder/SouthPokecenter2F.asm" - "maps/placeholder/SouthPokecenter2F.asm" - "data/maps/attributes/placeholder/SouthMart.asm" - "maps/placeholder/SouthMart.asm" - "data/maps/attributes/placeholder/SouthHouse2.asm" - "maps/placeholder/SouthHouse2.asm" - "data/maps/attributes/placeholder/NorthHouse1.asm" - "maps/placeholder/NorthHouse1.asm" - "data/maps/attributes/placeholder/NorthMart.asm" - "maps/placeholder/NorthMart.asm" - "data/maps/attributes/placeholder/NorthHouse2.asm" - "maps/placeholder/NorthHouse2.asm" - "data/maps/attributes/placeholder/NorthPokecenter1F.asm" - "maps/placeholder/NorthPokecenter1F.asm" - "data/maps/attributes/placeholder/NorthPokecenter2F.asm" - "maps/placeholder/NorthPokecenter2F.asm" -ROMX $2f - org $4000 - "data/maps/attributes/placeholder/PowerPlant1.asm" - "maps/placeholder/PowerPlant1.asm" - "data/maps/attributes/placeholder/PowerPlant2.asm" - "maps/placeholder/PowerPlant2.asm" - "data/maps/attributes/placeholder/PowerPlant3.asm" - "maps/placeholder/PowerPlant3.asm" - "data/maps/attributes/placeholder/PowerPlant4.asm" - "maps/placeholder/PowerPlant4.asm" - "data/maps/attributes/placeholder/RuinsOfAlphEntrance.asm" - "maps/placeholder/RuinsOfAlphEntrance.asm" - "data/maps/attributes/placeholder/RuinsOfAlphMain.asm" - "maps/placeholder/RuinsOfAlphMain.asm" - "data/maps/attributes/placeholder/CaveMinecarts1.asm" - "maps/placeholder/CaveMinecarts1.asm" - "data/maps/attributes/placeholder/CaveMinecarts2.asm" - "maps/placeholder/CaveMinecarts2.asm" - "data/maps/attributes/placeholder/CaveMinecarts3.asm" - "maps/placeholder/CaveMinecarts3.asm" - "data/maps/attributes/placeholder/CaveMinecarts4.asm" - "maps/placeholder/CaveMinecarts4.asm" - "data/maps/attributes/placeholder/CaveMinecarts5.asm" - "maps/placeholder/CaveMinecarts5.asm" - "data/maps/attributes/placeholder/CaveMinecarts6.asm" - "maps/placeholder/CaveMinecarts6.asm" - "data/maps/attributes/placeholder/CaveMinecarts7.asm" - "maps/placeholder/CaveMinecarts7.asm" - "data/maps/attributes/placeholder/Office1.asm" - "maps/placeholder/Office1.asm" - "data/maps/attributes/placeholder/Office2.asm" - "maps/placeholder/Office2.asm" - "data/maps/attributes/placeholder/Office3.asm" - "maps/placeholder/Office3.asm" - "data/maps/attributes/placeholder/SlowpokeWellEntrance.asm" - "maps/placeholder/SlowpokeWellEntrance.asm" - "data/maps/attributes/placeholder/SlowpokeWellMain.asm" - "maps/placeholder/SlowpokeWellMain.asm" - "data/maps/attributes/ShizukanaOka.asm" - "maps/ShizukanaOka.asm" -ROMX $30 - org $4000 - "gfx.asm@Bank 30 Sprites 1" -ROMX $31 - org $4000 - "gfx.asm@Bank 31 Sprites 2" -ROMX $32 - org $4000 - "engine/battle_anims/bg_effects.asm" - ; $4cfe -ROMX $33 - org $4000 -ROMX $34 - org $4000 - "data/maps/attributes/placeholder/RouteSilentEastGate.asm" - "maps/placeholder/RouteSilentEastGate.asm" - "data/maps/attributes/PlayerHouse1F.asm" - "maps/PlayerHouse1F.asm" - "data/maps/attributes/PlayerHouse2F.asm" - "maps/PlayerHouse2F.asm" - "data/maps/attributes/SilentHillPokecenter.asm" - "maps/SilentHillPokecenter.asm" - "data/maps/attributes/SilentHillHouse.asm" - "maps/SilentHillHouse.asm" - "data/maps/attributes/SilentHillLabFront.asm" - "maps/SilentHillLabFront.asm" - "data/maps/attributes/SilentHillLabBack.asm" - "maps/SilentHillLabBack.asm" - "data/maps/attributes/UnusedMap13.asm" - "maps/UnusedMap13.asm" -ROMX $36 - org $4000 - "maps/placeholder/dummy_text_pointers.asm" - "data/maps/attributes/SilentHill.asm" - "data/maps/attributes/placeholder/OldCity.asm" - "maps/placeholder/OldCity.asm" - "data/maps/attributes/placeholder/West.asm" - "maps/placeholder/West.asm" - "data/maps/attributes/placeholder/Haiteku.asm" - "maps/placeholder/Haiteku.asm" - "data/maps/attributes/placeholder/Fonto.asm" - "maps/placeholder/Fonto.asm" - "data/maps/attributes/placeholder/Baadon.asm" - "maps/placeholder/Baadon.asm" - "data/maps/attributes/placeholder/Newtype.asm" - "maps/placeholder/Newtype.asm" - "data/maps/attributes/placeholder/Sugar.asm" - "maps/placeholder/Sugar.asm" - "data/maps/attributes/placeholder/BullForest.asm" - "maps/placeholder/BullForest.asm" - "data/maps/attributes/placeholder/Stand.asm" - "maps/placeholder/Stand.asm" - "data/maps/attributes/placeholder/Kanto.asm" - "maps/placeholder/Kanto.asm" - "data/maps/attributes/placeholder/Prince.asm" - "maps/placeholder/Prince.asm" - "data/maps/attributes/placeholder/MtFuji.asm" - "maps/placeholder/MtFuji.asm" - "data/maps/attributes/placeholder/South.asm" - "maps/placeholder/South.asm" - "data/maps/attributes/placeholder/North.asm" - "maps/placeholder/North.asm" - "data/maps/attributes/Route1P1.asm" - "data/maps/attributes/Route1P2.asm" - "data/maps/attributes/placeholder/Route2.asm" - "maps/placeholder/Route2.asm" - "data/maps/attributes/placeholder/HaitekuWestRoute.asm" - "maps/placeholder/HaitekuWestRoute.asm" - "data/maps/attributes/placeholder/HaitekuWestRouteOcean.asm" - "maps/placeholder/HaitekuWestRouteOcean.asm" - "data/maps/attributes/placeholder/FontoRoute1.asm" - "maps/placeholder/FontoRoute1.asm" - "data/maps/attributes/placeholder/FontoRoute6.asm" - "maps/placeholder/FontoRoute6.asm" - "data/maps/attributes/placeholder/FontoRoute2.asm" - "maps/placeholder/FontoRoute2.asm" - "data/maps/attributes/placeholder/FontoRoute4.asm" - "maps/placeholder/FontoRoute4.asm" - "data/maps/attributes/placeholder/FontoRoute3.asm" - "maps/placeholder/FontoRoute3.asm" - "data/maps/attributes/placeholder/BaadonRoute1.asm" - "maps/placeholder/BaadonRoute1.asm" - "data/maps/attributes/placeholder/BaadonRoute2.asm" - "maps/placeholder/BaadonRoute2.asm" - "data/maps/attributes/placeholder/BaadonRoute3.asm" - "maps/placeholder/BaadonRoute3.asm" - "data/maps/attributes/placeholder/Route15.asm" - "maps/placeholder/Route15.asm" - "data/maps/attributes/placeholder/NewtypeRoute.asm" - "maps/placeholder/NewtypeRoute.asm" - "data/maps/attributes/placeholder/Route18.asm" - "maps/placeholder/Route18.asm" - "data/maps/attributes/placeholder/BullForestRoute1.asm" - "maps/placeholder/BullForestRoute1.asm" - "data/maps/attributes/placeholder/SugarRoute.asm" - "maps/placeholder/SugarRoute.asm" - "data/maps/attributes/placeholder/BullForestRoute2.asm" - "maps/placeholder/BullForestRoute2.asm" - "data/maps/attributes/placeholder/StandRoute.asm" - "maps/placeholder/StandRoute.asm" - "data/maps/attributes/placeholder/KantoEastRoute.asm" - "maps/placeholder/KantoEastRoute.asm" - "data/maps/attributes/placeholder/RouteSilentEast.asm" - "maps/placeholder/RouteSilentEast.asm" - "data/maps/attributes/placeholder/PrinceRoute.asm" - "maps/placeholder/PrinceRoute.asm" - "data/maps/attributes/placeholder/MtFujiRoute.asm" - "maps/placeholder/MtFujiRoute.asm" - "data/maps/attributes/placeholder/FontoRoute5.asm" - "maps/placeholder/FontoRoute5.asm" - "data/maps/attributes/placeholder/BullForestRoute3.asm" - "maps/placeholder/BullForestRoute3.asm" - "maps/SilentHill.asm" - "engine/menu/set_time.asm" - "maps/Route1P1.asm" - "maps/Route1P2.asm" -ROMX $37 - org $4000 - "gfx.asm@Bank 37 Tilesets 10" - ; $4600 - org $4b00 - "gfx.asm@Bank 37 Tilesets 15" - ; $5100 - org $5b00 - "gfx.asm@Bank 37 Tilesets 17" - ; $6100 - org $6b00 - "gfx.asm@Bank 37 Tilesets 18" -ROMX $38 - org $4000 - org $5403 - "gfx.asm@Poker GFX" - ; $5ae3 - org $5f93 - "gfx.asm@15 Puzzle GFX" - ; $6213 - org $6606 - "gfx.asm@Matches GFX" - ; $6886 - org $75b7 - "gfx.asm@Picross GFX" -ROMX $39 - org $4000 - org $41ff - "gfx.asm@Gamefreak Logo GFX" - org $4adf - "gfx.asm@Intro Underwater GFX" - ; $52df - org $55ef - "gfx.asm@Intro Water Mon and Forest GFX" - ; $60ef - org $626f - "gfx.asm@Intro Mon" -ROMX $3a - org $4000 - "audio/engine.asm@Audio" - ; $410b - org $42bd - "audio/engine.asm@IsChannelSFXOn" - "audio/engine.asm@Functione82f0" - org $4cee - "audio/engine.asm@Audio engine, part 2" - ; $4d8d - org $52c7 - "audio/engine.asm@Song Header Pointers" - org $52ee - "audio/songs/none.asm" -ROMX $3b - org $4000 - "audio/songs/nidorinointro.asm" - "audio/songs/viridiancity.asm" - "audio/songs/route1.asm" - "audio/songs/oakintro.asm" - "audio/songs/leaderbattle.asm" - "audio/songs/trainerbattle.asm" - "audio/songs/heal.asm" - "audio/songs/bicycle.asm" - "audio/songs/spottedrocket.asm" - "audio/songs/victorytrainer.asm" - "audio/songs/evolution.asm" -ROMX $3c - org $4000 - ; $4000 - org $51cb - "audio/cries.asm" - ; $51cb - org $62fe - "audio/songs/title.asm" -ROMX $3e - org $4000 - "engine/gfx.asm" - "gfx.asm@Misc GFX" -ROMX $3f - org $4000 - ; $4000 - org $4362 - "engine/menu/reset_dialog.asm" - ; $4381 - org $4aa5 - "engine/landmarks.asm" - "data/maps/landmarks.asm" - ; $4bc6 - org $506f - "gfx.asm@Town Map Cursor" - ; $50af -WRAM0 - org $c000 - "Music engine RAM" - ; $c1bc - org $c200 - "OAM Buffer" - ; $c570 - org $c5e8 - "Map Buffer" - org $dfff - "Stack Bottom" -VRAM 0 - "VRAM" -HRAM - "HRAM" -SRAM 0 - "Sprite Buffers" - ; $a498 - org $a600 - "Unknown, bank 0" - ; $a607 - org $bffe - "Window Stack Top" diff --git a/tools/__init__.py b/tools/__init__.py deleted file mode 100644 index b64ec3b..0000000 --- a/tools/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# A subset of pret/pokemon-reverse-engineering-tools -# Only needed for decompressing 1bpp and 2bpp graphics -# https://github.com/pret/pokemon-reverse-engineering-tools - -__version__ = '1.6.0' diff --git a/tools/disasm_coverage.py b/tools/disasm_coverage.py index 9fcd61e..fcb9e86 100644 --- a/tools/disasm_coverage.py +++ b/tools/disasm_coverage.py @@ -1,5 +1,5 @@ -# #!/usr/bin/env python3 -# coding: utf-8 +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- from __future__ import division diff --git a/tools/dump_names.py b/tools/dump_names.py deleted file mode 100644 index 81f357a..0000000 --- a/tools/dump_names.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python - -import sys, os, io -from read_charmap import read_charmap - -def calc_bank(p): - return p // 0x4000 - -def calc_address(p): - b = calc_bank(p) - o = b * 0x4000 - return 0x4000 + p - o - -def get_sym_loc(p): - b, a = calc_bank(p), calc_address(p) - return '%02x:%04x' % (b, a) - -def get_project_dir(): - script_path = os.path.realpath(__file__) - script_dir = os.path.dirname(script_path) - project_dir = os.path.join(script_dir, '..') - return os.path.normpath(project_dir) - -def get_baserom_path(): - project_dir = get_project_dir() - return os.path.join(project_dir, 'baserom.gb') - -def dump_strings(data): - charmap = read_charmap() - ss = [] - chars = [] - for v in data: - if v in charmap: - c = charmap[v] - chars.append(c) - else: - if chars: - ss.append('"%s"' % ''.join(chars)) - chars = [] - ss.append('$%02x' % v) - if v == 0x50: - if chars: - ss.append('"%s"' % ''.join(chars)) - chars = [] - print '\tdb %s' % ', '.join(ss) - ss = [] - if ss: - print '\tdb %s' % ', '.join(ss) - -def read_data(bank, address, n): - offset = bank * 0x4000 + address - 0x4000 - baserom_path = get_baserom_path() - with open(baserom_path, 'rb') as f: - f.seek(offset) - data = [] - i = 0 - while i < n: - c = f.read(1) - v = ord(c) - if v == 0x50: - i += 1 - data.append(v) - return data - -p = 0xfcaaf # Landmarks -print get_sym_loc(p) -data = read_data(calc_bank(p), calc_address(p), 45) -dump_strings(data) diff --git a/tools/dump_text.py b/tools/dump_text.py deleted file mode 100644 index 808f674..0000000 --- a/tools/dump_text.py +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/python3 - -import argparse -import sys -from read_charmap import read_charmap - -charmap = {} - -bank_size = 0x4000 - -textcodes = { - '' : {'label': 'next', 'fin': False}, - '' : {'label': 'line', 'fin': False}, - '' : {'label': 'para', 'fin': False}, - '' : {'label': 'cont', 'fin': False}, - '' : {'label': 'done', 'fin': True}, - '': {'label': 'prompt', 'fin': True}, -} - -# codes that end text control code 0x00 -# True if this code exits control code parsing as well -end_codes = { - '@' : False, - '' : True, - '': True, -} - -def conv_address(x): - if ':' in x: - bank, addr = [ int(s, 16) for s in x.split(':') ] - if (addr < bank_size and bank != 0): - raise argparse.ArgumentTypeError('Illegal ROM bank 0x00 address {0:02X}:{1:04X}. ' - 'Bank 0x{0:02X} must be 0x00.'.format(bank, addr)) - elif (addr >= bank_size and bank == 0): - raise argparse.ArgumentTypeError('Illegal ROM bank 0x00 address {0:02X}:{1:04X}. ' - 'Address 0x{1:04X} > 0x{2:04X}.'.format(bank, addr, bank_size - 1)) - elif (addr >= 2*bank_size): - raise argparse.ArgumentTypeError('Illegal ROM bank address {0:02X}:{1:04X}. ' - 'Address 0x{1:04X} > 0x{2:04X}.'.format(bank, addr, 2*bank_size - 1)) - return bank * 0x4000 + (addr & 0x3fff) - return int(x, 0) - -def addr2gb(addr): - bank = addr // bank_size - offset = addr % bank_size - if (bank > 0): - offset += bank_size - return bank, offset - -def transform_char(char, do_transform, is_begin): - result = '' - if (do_transform and char in textcodes): - replace = textcodes[char] - if (not is_begin): - result += '"\n' - result += replace['label'] - if (not replace['fin']): - result += ' "' - return replace['fin'], result - else: - if (is_begin): - result += '"' - return False, (result + char) - -def dump_asm(data): - - result = 'start_asm ; text dumper cannot dump asm\n' - result += ' ; Try dumping asm from the following offset and\n' - result += ' ; then continue dumping text in control mode.\n' - return True, result - -def dump_control(data, label, signature): - - lengths = {'b': 1, 'n': 1, 'w': 2} - - required_bytes = sum([lengths.get(c, 0) for c in signature]) - - if (data['len'] - data['offset'] < required_bytes): - #silently drop split control code - return True, '' - - result = '' - - for c in signature: - if result != '': - result += ', ' - if c == 'b': - byte = data['bytes'][data['offset']] - data['offset'] += 1 - result += '${0:02x}'.format(byte) - elif c == 'n': - byte = data['bytes'][data['offset']] - data['offset'] += 1 - result += '${0:01x}, ${1:01x}'.format(byte >> 4, byte & 0x0f) - elif c == 'w': - word = data['bytes'][data['offset']] - data['offset'] += 1 - word |= data['bytes'][data['offset']] << 8 - data['offset'] += 1 - result += '${0:02x}'.format(word) - else: - raise ValueError('Unknown signature char in {0:s}\'s signature "{1:s}".'.format(label, signature)) - - if (result != ''): - result = ' ' + result - return False, label + result - -def dump_text(data): - - string = '' - exit_control = False - done = False - while (not done): - - byte = data['bytes'][data['offset']] - data['offset'] += 1 - - char = charmap[byte] - fin, tchar = transform_char(char, data['textmode'], string == '') - string += tchar - if (char in end_codes): - done = True - exit_control = end_codes[char] - # end string if textmode didn't do it - if not data['textmode'] or not fin: - string += '"' - if (data['offset'] >= data['len']): - done = True - string += '"' - - return exit_control, string - -def dump_text_control(data): - - res, text = dump_text(data) - return res, 'text ' + text - -control_codes = { - 0x00: dump_text_control, - 0x01: lambda data: dump_control(data, 'text_from_ram' , 'w' ), - 0x02: lambda data: dump_control(data, 'text_bcd' , 'wb' ), - 0x03: lambda data: dump_control(data, 'text_move' , 'w' ), - 0x04: lambda data: dump_control(data, 'text_box' , 'wbb' ), - 0x05: lambda data: dump_control(data, 'text_low' , '' ), - 0x06: lambda data: dump_control(data, 'text_waitbutton' , '' ), - 0x07: lambda data: dump_control(data, 'text_scroll' , '' ), - 0x08: dump_asm, - 0x09: lambda data: dump_control(data, 'deciram' , 'wn' ), - 0x0A: lambda data: dump_control(data, 'text_exit' , '' ), - 0x0B: lambda data: dump_control(data, 'sound_dex_fanfare_50_79' , '' ), - 0x0C: lambda data: dump_control(data, 'text_dots' , 'b' ), - 0x0D: lambda data: dump_control(data, 'link_wait_button' , '' ), - 0x0E: lambda data: dump_control(data, 'sound_dex_fanfare_20_49' , '' ), - 0x0F: lambda data: dump_control(data, 'sound_item' , '' ), - 0x10: lambda data: dump_control(data, 'sound_caught_mon' , '' ), - 0x11: lambda data: dump_control(data, 'sound_dex_fanfare_80_109', '' ), - 0x12: lambda data: dump_control(data, 'sound_fanfare' , '' ), - 0x13: lambda data: dump_control(data, 'sound_slot_machine_start', '' ), - 0x14: lambda data: dump_control(data, 'cry_nidorina' , '' ), - 0x15: lambda data: dump_control(data, 'cry_pigeot' , '' ), - 0x16: lambda data: dump_control(data, 'cry_jugon' , '' ), - 0x50: lambda data: (True, 'text_end\n'), -} - -def print_location(data): - return '.loc_{0:04X}:\n'.format(data['offset'] + (bank_size if data['source'] > bank_size else 0)) - -if __name__ == '__main__': - # argument parser - ap = argparse.ArgumentParser() - ap.add_argument('--cc', dest='ccmode', action='store_true', - help='dump in control code mode, implies text code macro mode' - ) - ap.add_argument('--endless', dest='endless', action='store_true', - help='continue dumping even if string end text code was reached' - ) - ap.add_argument('--tc', dest='textmode', action='store_true', - help='dump text codes (line breaks, prompt etc) as macros instead of inline text' - ) - ap.add_argument('-o', dest='outfile', default=sys.stdout, help='output file name') - ap.add_argument('-m', dest='charmap', default='../charmap.asm', help='charmap file name') - ap.add_argument('rom', help='path to ROM') - ap.add_argument('start', help='start offset', type=conv_address) - ap.add_argument('end', help='end offset', type=conv_address, nargs='?') - - args = ap.parse_args() - romname = args.rom - start_addr = args.start - end_addr = start_addr + bank_size if args.end is None else args.end - ccmode = args.ccmode - endless = args.endless - outfile = args.outfile - charmap = read_charmap(args.charmap) - - if (end_addr < start_addr): - print('End address 0x{0:06X} ({1:02X}:{2:04X}) is before ' - 'start address 0x{3:06X} ({4:02X}:{5:04X}).'.format( - end_addr, - *addr2gb(end_addr), - start_addr, - *addr2gb(start_addr) - ), - file=sys.stderr - ) - sys.exit(-1) - - bank_addr = start_addr & (~(bank_size - 1)) - offset = start_addr - bank_addr - end_offset = end_addr - bank_addr - - with open(romname, 'rb') as f: - f.seek(bank_addr) - bank_data = f.read(bank_size) - - data = {'offset': offset, - 'bytes': bank_data, - 'len': min(end_offset, len(bank_data)), - 'textmode': args.textmode, - 'source': bank_addr, - } - - with open(outfile, 'wb') if outfile != sys.stdout else outfile.buffer as f: - string = print_location(data) - while(data['offset'] < data['len']): - if (not ccmode): - # dumb mode - _, text = dump_text(data) - # start with db unless starting with a control code - # in textmode - if text[0] == '"' and data['textmode']: - string += '\tdb ' - elif text[0] == '"': - string += '\tdb ' - string += text.replace('\n', '\n\t') - string += '\n{0:s}'.format(print_location(data)) - if (not endless): - break - else: - # control code mode - control_byte = data['bytes'][data['offset']] - data['offset'] += 1 - - if (control_byte in control_codes): - res, text = control_codes[control_byte](data) - string += '\t' + text.replace('\n', '\n\t') - string += '\n' - if (res): - string += print_location(data) - # exit out of control code parsing - if (res and not endless): - break - else: - print('Encountered unknown control code 0x{0:02X}. Abort...'.format(control_byte), file=sys.stderr) - break - - f.write(string.encode('utf-8')) - f.close() diff --git a/tools/fix_sections.py b/tools/fix_sections.py deleted file mode 100644 index dc23f52..0000000 --- a/tools/fix_sections.py +++ /dev/null @@ -1,137 +0,0 @@ -import os, errno -import re -import fix_sections_directory - -os.chdir("..") -cwd = os.getcwd() - -debug_lines_startswith = [ - "SECTION ", - "else", - "SECTION ", - "endc" -] - -with open("pokegold-spaceworld-gen.link", "r") as f: - linkerscript = f.read() - -def clean_section(line, file, multiple): - global linkerscript - lines = line.lstrip().split("\"") - if "@" in lines[1] or file == lines[1] or file == "hram.asm" or file == "vram.asm" or file == "sram.asm": - file = lines[1] - else: - file += ("@" + lines[1]) if multiple else "" - - linkerscript = linkerscript.replace("\"" + lines[1] + "\"", "\"" + file + "\"") - if "ROMX" in lines[2]: - return "SECTION \"%s\", ROMX" % file - elif "HRAM" in lines[2]: - return "SECTION \"%s\", HRAM" % file - elif "VRAM" in lines[2]: - return "SECTION \"%s\", VRAM" % file - elif "ROM0" in lines[2]: - return "SECTION \"%s\", ROM0" % file - elif "SRAM" in lines[2]: - return "SECTION \"%s\", SRAM" % file - else: - raise - -TEMP_PATH = fix_sections_directory.TEMP_DIRECTORY - -for root, dirs, files in os.walk(cwd): - for file in files: - rel_root = os.path.relpath(root, cwd) - if not rel_root.startswith("build") and not rel_root.startswith("temp") and file.endswith(".asm") and file != "rst.asm" and file != "wram.asm" and file != "zero_checksum.asm": - canonical_path = os.path.join(root, file) - rel_path = os.path.relpath(canonical_path, cwd) - with open(canonical_path, "r") as f: - contents = f.read() - content_lines = contents.splitlines() - - if "SECTION" in contents: - print(canonical_path) - modify_flag = False - skip_next_line = False - for i, line in enumerate(content_lines): - if not skip_next_line: - if line.lstrip().startswith("SECTION"): - modify_flag = True - content_lines[i] = clean_section(content_lines[i], rel_path, contents.count("SECTION") > 1) - elif "if DEBUG" in line: - debug_content_lines = content_lines[i+1:i+5] - debug_code = False - for debug_content_line, debug_line_startswith in zip(debug_content_lines, debug_lines_startswith): - if not debug_content_line.lstrip().startswith(debug_line_startswith): - break - else: - modify_flag = True - content_lines[i] = "; " + content_lines[i] - content_lines[i+1] = clean_section(content_lines[i+1], rel_path, contents.count("SECTION") > 2) - content_lines[i+2] = "; " + content_lines[i+2] - content_lines[i+3] = "; " + content_lines[i+3] - content_lines[i+4] = "; " + content_lines[i+4] - skip_next_line = True - else: - skip_next_line = False - - if modify_flag: - output = "\n".join(content_lines) - print("rel root: " + rel_root) - try: - os.makedirs(TEMP_PATH + rel_root) - except OSError as e: - if e.errno != errno.EEXIST: - raise - - with open(TEMP_PATH + rel_path, "w+") as f: - f.write(output) - -linkerscript_lines = linkerscript.splitlines() - -i = 0 -clean_wram = False - -while i < len(linkerscript_lines): - line = linkerscript_lines[i] - if clean_wram: - if "org $dfff" not in line: - print(linkerscript_lines.pop(i)) - else: - clean_wram = False - i += 1 - elif "\"Shim for " in line: - no_pop_count = 0 - shim_addr = line.replace(", ", " ; ").split(" ; ")[1] - if linkerscript_lines[i-1] == "\torg " + shim_addr and linkerscript_lines[i-1] != "\torg $4000": - print(linkerscript_lines.pop(i-1)) - else: - no_pop_count += 1 - print(linkerscript_lines.pop(i-1 + no_pop_count)) - - if linkerscript_lines[i-1 + no_pop_count] == "\t; " + shim_addr: - print(linkerscript_lines.pop(i-1 + no_pop_count)) - else: - no_pop_count += 1 - - i -= 3 - no_pop_count - print("") - elif "ROMX" in line and "org $4000" not in linkerscript_lines[i+1]: - linkerscript_lines.insert(i+1, "\torg $4000") - i += 1 - elif line.startswith("WRAM0"): - linkerscript_lines.insert(i+1, "\torg $c000") - i += 1 - elif "\"Map Buffer\"" in line: - clean_wram = True - i += 1 - else: - i += 1 - -for i in range(len(linkerscript_lines)): - linkerscript_lines[i] = linkerscript_lines[i].split(" ; ")[0] - -linkerscript = "\n".join(linkerscript_lines) + "\n" - -with open(TEMP_PATH + "pokegold-spaceworld.link", "w+") as f: - f.write(linkerscript) \ No newline at end of file diff --git a/tools/fix_sections_directory.py b/tools/fix_sections_directory.py deleted file mode 100644 index d359c8c..0000000 --- a/tools/fix_sections_directory.py +++ /dev/null @@ -1 +0,0 @@ -TEMP_DIRECTORY = "" \ No newline at end of file diff --git a/tools/gfx.py b/tools/gfx.py deleted file mode 100644 index 3ccafe0..0000000 --- a/tools/gfx.py +++ /dev/null @@ -1,950 +0,0 @@ -# -*- coding: utf-8 -*- - -import os -import sys -import png -from math import sqrt, floor, ceil -import argparse -import operator - -from lz import Compressed, Decompressed - - -def split(list_, interval): - """ - Split a list by length. - """ - for i in xrange(0, len(list_), interval): - j = min(i + interval, len(list_)) - yield list_[i:j] - - -def hex_dump(data, length=0x10): - """ - just use hexdump -C - """ - margin = len('%x' % len(data)) - output = [] - address = 0 - for line in split(data, length): - output += [ - hex(address)[2:].zfill(margin) + - ' | ' + - ' '.join('%.2x' % byte for byte in line) - ] - address += length - return '\n'.join(output) - - -def get_tiles(image): - """ - Split a 2bpp image into 8x8 tiles. - """ - return list(split(image, 0x10)) - -def connect(tiles): - """ - Combine 8x8 tiles into a 2bpp image. - """ - return [byte for tile in tiles for byte in tile] - -def transpose(tiles, width=None): - """ - Transpose a tile arrangement along line y=-x. - - 00 01 02 03 04 05 00 06 0c 12 18 1e - 06 07 08 09 0a 0b 01 07 0d 13 19 1f - 0c 0d 0e 0f 10 11 <-> 02 08 0e 14 1a 20 - 12 13 14 15 16 17 03 09 0f 15 1b 21 - 18 19 1a 1b 1c 1d 04 0a 10 16 1c 22 - 1e 1f 20 21 22 23 05 0b 11 17 1d 23 - - 00 01 02 03 00 04 08 - 04 05 06 07 <-> 01 05 09 - 08 09 0a 0b 02 06 0a - 03 07 0b - """ - if width == None: - width = int(sqrt(len(tiles))) # assume square image - tiles = sorted(enumerate(tiles), key= lambda (i, tile): i % width) - return [tile for i, tile in tiles] - -def transpose_tiles(image, width=None): - return connect(transpose(get_tiles(image), width)) - -def interleave(tiles, width): - """ - 00 01 02 03 04 05 00 02 04 06 08 0a - 06 07 08 09 0a 0b 01 03 05 07 09 0b - 0c 0d 0e 0f 10 11 --> 0c 0e 10 12 14 16 - 12 13 14 15 16 17 0d 0f 11 13 15 17 - 18 19 1a 1b 1c 1d 18 1a 1c 1e 20 22 - 1e 1f 20 21 22 23 19 1b 1d 1f 21 23 - """ - interleaved = [] - left, right = split(tiles[::2], width), split(tiles[1::2], width) - for l, r in zip(left, right): - interleaved += l + r - return interleaved - -def deinterleave(tiles, width): - """ - 00 02 04 06 08 0a 00 01 02 03 04 05 - 01 03 05 07 09 0b 06 07 08 09 0a 0b - 0c 0e 10 12 14 16 --> 0c 0d 0e 0f 10 11 - 0d 0f 11 13 15 17 12 13 14 15 16 17 - 18 1a 1c 1e 20 22 18 19 1a 1b 1c 1d - 19 1b 1d 1f 21 23 1e 1f 20 21 22 23 - """ - deinterleaved = [] - rows = list(split(tiles, width)) - for left, right in zip(rows[::2], rows[1::2]): - for l, r in zip(left, right): - deinterleaved += [l, r] - return deinterleaved - -def interleave_tiles(image, width): - return connect(interleave(get_tiles(image), width)) - -def deinterleave_tiles(image, width): - return connect(deinterleave(get_tiles(image), width)) - - -def condense_image_to_map(image, pic=0): - """ - Reduce an image of adjacent frames to an image containing a base frame and any unrepeated tiles. - Returns the new image and the corresponding tilemap used to reconstruct the input image. - - If is 0, ignore the concept of frames. This behavior might be better off as another function. - """ - tiles = get_tiles(image) - new_tiles, tilemap = condense_tiles_to_map(tiles, pic) - new_image = connect(new_tiles) - return new_image, tilemap - -def condense_tiles_to_map(tiles, pic=0): - """ - Reduce a sequence of tiles representing adjacent frames to a base frame and any unrepeated tiles. - Returns the new tiles and the corresponding tilemap used to reconstruct the input tile sequence. - - If is 0, ignore the concept of frames. This behavior might be better off as another function. - """ - - # Leave the first frame intact for pics. - new_tiles = tiles[:pic] - tilemap = range(pic) - - for i, tile in enumerate(tiles[pic:]): - if tile not in new_tiles: - new_tiles.append(tile) - - if pic: - # Match the first frame exactly where possible. - # This reduces the space needed to replace tiles in pic animations. - # For example, if a tile is repeated twice in the first frame, - # but at the same relative index as the second tile, use the second index. - # When creating a bitmask later, the second index would not require a replacement, but the first index would have. - pic_i = i % pic - if tile == new_tiles[pic_i]: - tilemap.append(pic_i) - else: - tilemap.append(new_tiles.index(tile)) - else: - tilemap.append(new_tiles.index(tile)) - return new_tiles, tilemap - -def test_condense_tiles_to_map(): - test = condense_tiles_to_map(list('abcadbae')) - if test != (list('abcde'), [0, 1, 2, 0, 3, 1, 0, 4]): - raise Exception(test) - test = condense_tiles_to_map(list('abcadbae'), 2) - if test != (list('abcde'), [0, 1, 2, 0, 3, 1, 0, 4]): - raise Exception(test) - test = condense_tiles_to_map(list('abcadbae'), 4) - if test != (list('abcade'), [0, 1, 2, 3, 4, 1, 0, 5]): - raise Exception(test) - test = condense_tiles_to_map(list('abcadbea'), 4) - if test != (list('abcade'), [0, 1, 2, 3, 4, 1, 5, 3]): - raise Exception(test) - - -def to_file(filename, data): - """ - Apparently open(filename, 'wb').write(bytearray(data)) won't work. - """ - file = open(filename, 'wb') - for byte in data: - file.write('%c' % byte) - file.close() - - -def decompress_file(filein, fileout=None): - image = bytearray(open(filein).read()) - de = Decompressed(image) - - if fileout == None: - fileout = os.path.splitext(filein)[0] - to_file(fileout, de.output) - - -def compress_file(filein, fileout=None): - image = bytearray(open(filein).read()) - lz = Compressed(image) - - if fileout == None: - fileout = filein + '.lz' - to_file(fileout, lz.output) - - -def bin_to_rgb(word): - red = word & 0b11111 - word >>= 5 - green = word & 0b11111 - word >>= 5 - blue = word & 0b11111 - return (red, green, blue) - -def convert_binary_pal_to_text_by_filename(filename): - pal = bytearray(open(filename).read()) - return convert_binary_pal_to_text(pal) - -def convert_binary_pal_to_text(pal): - output = '' - words = [hi * 0x100 + lo for lo, hi in zip(pal[::2], pal[1::2])] - for word in words: - red, green, blue = ['%.2d' % c for c in bin_to_rgb(word)] - output += '\tRGB ' + ', '.join((red, green, blue)) - output += '\n' - return output - -def read_rgb_macros(lines): - colors = [] - for line in lines: - macro = line.split(" ")[0].strip() - if macro == 'RGB': - params = ' '.join(line.split(" ")[1:]).split(',') - red, green, blue = [int(v) for v in params] - colors += [[red, green, blue]] - return colors - - -def rewrite_binary_pals_to_text(filenames): - for filename in filenames: - pal_text = convert_binary_pal_to_text_by_filename(filename) - with open(filename, 'w') as out: - out.write(pal_text) - - -def flatten(planar): - """ - Flatten planar 2bpp image data into a quaternary pixel map. - """ - strips = [] - for bottom, top in split(planar, 2): - bottom = bottom - top = top - strip = [] - for i in xrange(7,-1,-1): - color = ( - (bottom >> i & 1) + - (top *2 >> i & 2) - ) - strip += [color] - strips += strip - return strips - -def to_lines(image, width): - """ - Convert a tiled quaternary pixel map to lines of quaternary pixels. - """ - tile_width = 8 - tile_height = 8 - num_columns = width / tile_width - height = len(image) / width - - lines = [] - for cur_line in xrange(height): - tile_row = cur_line / tile_height - line = [] - for column in xrange(num_columns): - anchor = ( - num_columns * tile_row * tile_width * tile_height + - column * tile_width * tile_height + - cur_line % tile_height * tile_width - ) - line += image[anchor : anchor + tile_width] - lines += [line] - return lines - - -def dmg2rgb(word): - """ - For PNGs. - """ - def shift(value): - while True: - yield value & (2**5 - 1) - value >>= 5 - word = shift(word) - # distribution is less even w/ << 3 - red, green, blue = [int(color * 8.25) for color in [word.next() for _ in xrange(3)]] - alpha = 255 - return (red, green, blue, alpha) - - -def rgb_to_dmg(color): - """ - For PNGs. - """ - word = (color['r'] / 8) - word += (color['g'] / 8) << 5 - word += (color['b'] / 8) << 10 - return word - - -def pal_to_png(filename): - """ - Interpret a .pal file as a png palette. - """ - with open(filename) as rgbs: - colors = read_rgb_macros(rgbs.readlines()) - a = 255 - palette = [] - for color in colors: - # even distribution over 000-255 - r, g, b = [int(hue * 8.25) for hue in color] - palette += [(r, g, b, a)] - white = (255,255,255,255) - black = (000,000,000,255) - if white not in palette and len(palette) < 4: - palette = [white] + palette - if black not in palette and len(palette) < 4: - palette = palette + [black] - return palette - - -def png_to_rgb(palette): - """ - Convert a png palette to rgb macros. - """ - output = '' - for color in palette: - r, g, b = [color[c] / 8 for c in 'rgb'] - output += '\tRGB ' + ', '.join(['%.2d' % hue for hue in (r, g, b)]) - output += '\n' - return output - - -def read_filename_arguments(filename): - """ - Infer graphics conversion arguments given a filename. - - Arguments are separated with '.'. - """ - parsed_arguments = {} - - int_arguments = { - 'w': 'width', - 'h': 'height', - 't': 'tile_padding', - } - arguments = os.path.splitext(filename)[0].lstrip('.').split('.')[1:] - for argument in arguments: - - # Check for integer arguments first (i.e. "w128"). - arg = argument[0] - param = argument[1:] - if param.isdigit(): - arg = int_arguments.get(arg, False) - if arg: - parsed_arguments[arg] = int(param) - - elif argument == 'arrange': - parsed_arguments['norepeat'] = True - parsed_arguments['tilemap'] = True - - # Pic dimensions (i.e. "6x6"). - elif 'x' in argument and any(map(str.isdigit, argument)): - w, h = argument.split('x') - if w.isdigit() and h.isdigit(): - parsed_arguments['pic_dimensions'] = (int(w), int(h)) - - else: - parsed_arguments[argument] = True - - return parsed_arguments - - -def export_2bpp_to_png(filein, fileout=None, pal_file=None, height=0, width=0, tile_padding=0, pic_dimensions=None, **kwargs): - - if fileout == None: - fileout = os.path.splitext(filein)[0] + '.png' - - image = open(filein, 'rb').read() - - arguments = { - 'width': width, - 'height': height, - 'pal_file': pal_file, - 'tile_padding': tile_padding, - 'pic_dimensions': pic_dimensions, - } - arguments.update(read_filename_arguments(filein)) - - if pal_file == None: - if os.path.exists(os.path.splitext(fileout)[0]+'.pal'): - arguments['pal_file'] = os.path.splitext(fileout)[0]+'.pal' - - arguments['is_tileset'] = 'tilesets' in filein - arguments['is_overworld'] = 'sprites' in filein - result = convert_2bpp_to_png(image, **arguments) - width, height, palette, greyscale, bitdepth, px_map = result - - w = png.Writer( - width, - height, - palette=palette, - compression=9, - greyscale=greyscale, - bitdepth=bitdepth - ) - with open(fileout, 'wb') as f: - w.write(f, px_map) - - -def convert_2bpp_to_png(image, **kwargs): - """ - Convert a planar 2bpp graphic to png. - """ - - image = bytearray(image) - - pad_color = bytearray([0]) - - width = kwargs.get('width', 0) - height = kwargs.get('height', 0) - tile_padding = kwargs.get('tile_padding', 0) - pic_dimensions = kwargs.get('pic_dimensions', None) - pal_file = kwargs.get('pal_file', None) - interleave = kwargs.get('interleave', False) - - # Width must be specified to interleave. - if interleave and width: - image = interleave_tiles(image, width / 8) - - # Pad the image by a given number of tiles if asked. - image += pad_color * 0x10 * tile_padding - - # Some images are transposed in blocks. - if pic_dimensions: - w, h = pic_dimensions - if not width: width = w * 8 - - pic_length = w * h * 0x10 - - trailing = len(image) % pic_length - - pic = [] - for i in xrange(0, len(image) - trailing, pic_length): - pic += transpose_tiles(image[i:i+pic_length], h) - image = bytearray(pic) + image[len(image) - trailing:] - - # Pad out trailing lines. - image += pad_color * 0x10 * ((w - (len(image) / 0x10) % h) % w) - - def px_length(img): - return len(img) * 4 - def tile_length(img): - return len(img) * 4 / (8*8) - - if width and height: - tile_width = width / 8 - more_tile_padding = (tile_width - (tile_length(image) % tile_width or tile_width)) - image += pad_color * 0x10 * more_tile_padding - - elif width and not height: - tile_width = width / 8 - more_tile_padding = (tile_width - (tile_length(image) % tile_width or tile_width)) - image += pad_color * 0x10 * more_tile_padding - height = px_length(image) / width - - elif height and not width: - tile_height = height / 8 - more_tile_padding = (tile_height - (tile_length(image) % tile_height or tile_height)) - image += pad_color * 0x10 * more_tile_padding - width = px_length(image) / height - - # at least one dimension should be given - if width * height != px_length(image): - # look for possible combos of width/height that would form a rectangle - matches = [] - # Height need not be divisible by 8, but width must. - # See pokered gfx/minimize_pic.1bpp. - for w in range(8, px_length(image) / 2 + 1, 8): - h = px_length(image) / w - if w * h == px_length(image): - matches += [(w, h)] - # go for the most square image - if len(matches): - width, height = sorted(matches, key= lambda (w, h): (h % 8 != 0, w + h))[0] # favor height - else: - raise Exception, 'Image can\'t be divided into tiles (%d px)!' % (px_length(image)) - # correct tileset dimensions - if kwargs.get('is_tileset', False) and not (width * height // 8) % 128: - area = width * height - width = 128 - height = area // width - # correct overworld dimensions - elif kwargs.get('is_overworld', False) and not (width * height // 8) % 16: - area = width * height - width = 16 - height = area // width - - # convert tiles to lines - lines = to_lines(flatten(image), width) - - if pal_file == None: - palette = None - greyscale = True - bitdepth = 2 - px_map = [[3 - pixel for pixel in line] for line in lines] - - else: # gbc color - palette = pal_to_png(pal_file) - greyscale = False - bitdepth = 8 - px_map = [[pixel for pixel in line] for line in lines] - - return width, height, palette, greyscale, bitdepth, px_map - - -def get_pic_animation(tmap, w, h): - """ - Generate pic animation data from a combined tilemap of each frame. - """ - frame_text = '' - bitmask_text = '' - - frames = list(split(tmap, w * h)) - base = frames.pop(0) - bitmasks = [] - - for i in xrange(len(frames)): - frame_text += '\tdw .frame{}\n'.format(i + 1) - - for i, frame in enumerate(frames): - bitmask = map(operator.ne, frame, base) - if bitmask not in bitmasks: - bitmasks.append(bitmask) - which_bitmask = bitmasks.index(bitmask) - - mask = iter(bitmask) - masked_frame = filter(lambda _: mask.next(), frame) - - frame_text += '.frame{}\n'.format(i + 1) - frame_text += '\tdb ${:02x} ; bitmask\n'.format(which_bitmask) - if masked_frame: - frame_text += '\tdb {}\n'.format(', '.join( - map('${:02x}'.format, masked_frame) - )) - - for i, bitmask in enumerate(bitmasks): - bitmask_text += '; {}\n'.format(i) - for byte in split(bitmask, 8): - byte = int(''.join(map(int.__repr__, reversed(byte))), 2) - bitmask_text += '\tdb %{:08b}\n'.format(byte) - - return frame_text, bitmask_text - - -def export_png_to_2bpp(filein, fileout=None, palout=None, **kwargs): - - arguments = { - 'tile_padding': 0, - 'pic_dimensions': None, - 'animate': False, - 'stupid_bitmask_hack': [], - } - arguments.update(kwargs) - arguments.update(read_filename_arguments(filein)) - - image, arguments = png_to_2bpp(filein, **arguments) - - if fileout == None: - fileout = os.path.splitext(filein)[0] + '.2bpp' - to_file(fileout, image) - - tmap = arguments.get('tmap') - - if tmap != None and arguments['animate'] and arguments['pic_dimensions']: - # Generate pic animation data. - frame_text, bitmask_text = get_pic_animation(tmap, *arguments['pic_dimensions']) - - frames_path = os.path.join(os.path.split(fileout)[0], 'frames.asm') - with open(frames_path, 'w') as out: - out.write(frame_text) - - bitmask_path = os.path.join(os.path.split(fileout)[0], 'bitmask.asm') - - # The following Pokemon have a bitmask dummied out. - for exception in arguments['stupid_bitmask_hack']: - if exception in bitmask_path: - bitmasks = bitmask_text.split(';') - bitmasks[-1] = bitmasks[-1].replace('1', '0') - bitmask_text = ';'.join(bitmasks) - - with open(bitmask_path, 'w') as out: - out.write(bitmask_text) - - elif tmap != None and arguments.get('tilemap', False): - tilemap_path = os.path.splitext(fileout)[0] + '.tilemap' - to_file(tilemap_path, tmap) - - palette = arguments.get('palette') - if palout == None: - palout = os.path.splitext(fileout)[0] + '.pal' - export_palette(palette, palout) - - -def get_image_padding(width, height, wstep=8, hstep=8): - - padding = { - 'left': 0, - 'right': 0, - 'top': 0, - 'bottom': 0, - } - - if width % wstep and width >= wstep: - pad = float(width % wstep) / 2 - padding['left'] = int(ceil(pad)) - padding['right'] = int(floor(pad)) - - if height % hstep and height >= hstep: - pad = float(height % hstep) / 2 - padding['top'] = int(ceil(pad)) - padding['bottom'] = int(floor(pad)) - - return padding - - -def png_to_2bpp(filein, **kwargs): - """ - Convert a png image to planar 2bpp. - """ - - arguments = { - 'tile_padding': 0, - 'pic_dimensions': False, - 'interleave': False, - 'norepeat': False, - 'tilemap': False, - } - arguments.update(kwargs) - - if type(filein) is str: - filein = open(filein, 'rb') - - assert type(filein) is file - - width, height, rgba, info = png.Reader(filein).asRGBA8() - - # png.Reader returns flat pixel data. Nested is easier to work with - len_px = len('rgba') - image = [] - palette = [] - for line in rgba: - newline = [] - for px in xrange(0, len(line), len_px): - color = dict(zip('rgba', line[px:px+len_px])) - if color not in palette: - if len(palette) < 4: - palette += [color] - else: - # TODO Find the nearest match - print 'WARNING: %s: Color %s truncated to' % (filein, color), - color = sorted(palette, key=lambda x: sum(x.values()))[0] - print color - newline += [color] - image += [newline] - - assert len(palette) <= 4, '%s: palette should be 4 colors, is really %d (%s)' % (filein, len(palette), palette) - - # Pad out smaller palettes with greyscale colors - greyscale = { - 'black': { 'r': 0x00, 'g': 0x00, 'b': 0x00, 'a': 0xff }, - 'grey': { 'r': 0x55, 'g': 0x55, 'b': 0x55, 'a': 0xff }, - 'gray': { 'r': 0xaa, 'g': 0xaa, 'b': 0xaa, 'a': 0xff }, - 'white': { 'r': 0xff, 'g': 0xff, 'b': 0xff, 'a': 0xff }, - } - preference = 'white', 'black', 'grey', 'gray' - for hue in map(greyscale.get, preference): - if len(palette) >= 4: - break - if hue not in palette: - palette += [hue] - - palette.sort(key=lambda x: sum(x.values())) - - # Game Boy palette order - palette.reverse() - - # Map pixels to quaternary color ids - padding = get_image_padding(width, height) - width += padding['left'] + padding['right'] - height += padding['top'] + padding['bottom'] - pad = bytearray([0]) - - qmap = [] - qmap += pad * width * padding['top'] - for line in image: - qmap += pad * padding['left'] - for color in line: - qmap += [palette.index(color)] - qmap += pad * padding['right'] - qmap += pad * width * padding['bottom'] - - # Graphics are stored in tiles instead of lines - tile_width = 8 - tile_height = 8 - num_columns = max(width, tile_width) / tile_width - num_rows = max(height, tile_height) / tile_height - image = [] - - for row in xrange(num_rows): - for column in xrange(num_columns): - - # Split it up into strips to convert to planar data - for strip in xrange(min(tile_height, height)): - anchor = ( - row * num_columns * tile_width * tile_height + - column * tile_width + - strip * width - ) - line = qmap[anchor : anchor + tile_width] - bottom, top = 0, 0 - for bit, quad in enumerate(line): - bottom += (quad & 1) << (7 - bit) - top += (quad /2 & 1) << (7 - bit) - image += [bottom, top] - - dim = arguments['pic_dimensions'] - if dim: - if type(dim) in (tuple, list): - w, h = dim - else: - # infer dimensions based on width. - w = width / tile_width - h = height / tile_height - if h % w == 0: - h = w - - tiles = get_tiles(image) - pic_length = w * h - tile_width = width / 8 - trailing = len(tiles) % pic_length - new_image = [] - for block in xrange(len(tiles) / pic_length): - offset = (h * tile_width) * ((block * w) / tile_width) + ((block * w) % tile_width) - pic = [] - for row in xrange(h): - index = offset + (row * tile_width) - pic += tiles[index:index + w] - new_image += transpose(pic, w) - new_image += tiles[len(tiles) - trailing:] - image = connect(new_image) - - # Remove any tile padding used to make the png rectangular. - image = image[:len(image) - arguments['tile_padding'] * 0x10] - - tmap = None - - if arguments['interleave']: - image = deinterleave_tiles(image, num_columns) - - if arguments['pic_dimensions']: - image, tmap = condense_image_to_map(image, w * h) - elif arguments['norepeat']: - image, tmap = condense_image_to_map(image) - if not arguments['tilemap']: - tmap = None - - arguments.update({ 'palette': palette, 'tmap': tmap, }) - - return image, arguments - - -def export_palette(palette, filename): - """ - Export a palette from png to rgb macros in a .pal file. - """ - - if os.path.exists(filename): - - # Pic palettes are 2 colors (black/white are added later). - with open(filename) as rgbs: - colors = read_rgb_macros(rgbs.readlines()) - - if len(colors) == 2: - palette = palette[1:3] - - text = png_to_rgb(palette) - with open(filename, 'w') as out: - out.write(text) - - -def png_to_lz(filein): - - name = os.path.splitext(filein)[0] - - export_png_to_2bpp(filein) - image = open(name+'.2bpp', 'rb').read() - to_file(name+'.2bpp'+'.lz', Compressed(image).output) - - -def convert_2bpp_to_1bpp(data): - """ - Convert planar 2bpp image data to 1bpp. Assume images are two colors. - """ - return data[::2] - -def convert_1bpp_to_2bpp(data): - """ - Convert 1bpp image data to planar 2bpp (black/white). - """ - output = [] - for i in data: - output += [i, i] - return output - - -def export_2bpp_to_1bpp(filename): - name, extension = os.path.splitext(filename) - image = open(filename, 'rb').read() - image = convert_2bpp_to_1bpp(image) - to_file(name + '.1bpp', image) - -def export_1bpp_to_2bpp(filename): - name, extension = os.path.splitext(filename) - image = open(filename, 'rb').read() - image = convert_1bpp_to_2bpp(image) - to_file(name + '.2bpp', image) - - -def export_1bpp_to_png(filename, fileout=None): - - if fileout == None: - fileout = os.path.splitext(filename)[0] + '.png' - - arguments = read_filename_arguments(filename) - - image = open(filename, 'rb').read() - image = convert_1bpp_to_2bpp(image) - - result = convert_2bpp_to_png(image, **arguments) - width, height, palette, greyscale, bitdepth, px_map = result - - w = png.Writer(width, height, palette=palette, compression=9, greyscale=greyscale, bitdepth=bitdepth) - with open(fileout, 'wb') as f: - w.write(f, px_map) - - -def export_png_to_1bpp(filename, fileout=None): - - if fileout == None: - fileout = os.path.splitext(filename)[0] + '.1bpp' - - arguments = read_filename_arguments(filename) - image = png_to_1bpp(filename, **arguments) - - to_file(fileout, image) - -def png_to_1bpp(filename, **kwargs): - image, kwargs = png_to_2bpp(filename, **kwargs) - return convert_2bpp_to_1bpp(image) - - -def convert_to_2bpp(filenames=[]): - for filename in filenames: - filename, name, extension = try_decompress(filename) - if extension == '.1bpp': - export_1bpp_to_2bpp(filename) - elif extension == '.2bpp': - pass - elif extension == '.png': - export_png_to_2bpp(filename) - else: - raise Exception, "Don't know how to convert {} to 2bpp!".format(filename) - -def convert_to_1bpp(filenames=[]): - for filename in filenames: - filename, name, extension = try_decompress(filename) - if extension == '.1bpp': - pass - elif extension == '.2bpp': - export_2bpp_to_1bpp(filename) - elif extension == '.png': - export_png_to_1bpp(filename) - else: - raise Exception, "Don't know how to convert {} to 1bpp!".format(filename) - -def convert_to_png(filenames=[]): - for filename in filenames: - filename, name, extension = try_decompress(filename) - if extension == '.1bpp': - export_1bpp_to_png(filename) - elif extension == '.2bpp': - export_2bpp_to_png(filename) - elif extension == '.png': - pass - else: - raise Exception, "Don't know how to convert {} to png!".format(filename) - -def compress(filenames=[]): - for filename in filenames: - data = open(filename, 'rb').read() - lz_data = Compressed(data).output - to_file(filename + '.lz', lz_data) - -def decompress(filenames=[]): - for filename in filenames: - name, extension = os.path.splitext(filename) - lz_data = open(filename, 'rb').read() - data = Decompressed(lz_data).output - to_file(name, data) - -def try_decompress(filename): - """ - Try to decompress a graphic when determining the filetype. - This skips the manual unlz step when attempting - to convert lz-compressed graphics to png. - """ - name, extension = os.path.splitext(filename) - if extension == '.lz': - decompress([filename]) - filename = name - name, extension = os.path.splitext(filename) - return filename, name, extension - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument('mode') - ap.add_argument('filenames', nargs='*') - args = ap.parse_args() - - method = { - '2bpp': convert_to_2bpp, - '1bpp': convert_to_1bpp, - 'png': convert_to_png, - 'lz': compress, - 'unlz': decompress, - }.get(args.mode, None) - - if method == None: - raise Exception, "Unknown conversion method!" - - method(args.filenames) - -if __name__ == "__main__": - main() diff --git a/tools/lz.py b/tools/lz.py deleted file mode 100644 index aef5c64..0000000 --- a/tools/lz.py +++ /dev/null @@ -1,580 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Pokemon Crystal data de/compression. -""" - -""" -A rundown of Pokemon Crystal's compression scheme: - -Control commands occupy bits 5-7. -Bits 0-4 serve as the first parameter for each command. -""" -lz_commands = { - 'literal': 0, # n values for n bytes - 'iterate': 1, # one value for n bytes - 'alternate': 2, # alternate two values for n bytes - 'blank': 3, # zero for n bytes -} - -""" -Repeater commands repeat any data that was just decompressed. -They take an additional signed parameter to mark a relative starting point. -These wrap around (positive from the start, negative from the current position). -""" -lz_commands.update({ - 'repeat': 4, # n bytes starting from s - 'flip': 5, # n bytes in reverse bit order starting from s - 'reverse': 6, # n bytes backwards starting from s -}) - -""" -The long command is used when 5 bits aren't enough. Bits 2-4 contain a new control code. -Bits 0-1 are appended to a new byte as 8-9, allowing a 10-bit parameter. -""" -lz_commands.update({ - 'long': 7, # n is now 10 bits for a new control code -}) -max_length = 1 << 10 # can't go higher than 10 bits -lowmax = 1 << 5 # standard 5-bit param - -""" -If 0xff is encountered instead of a command, decompression ends. -""" -lz_end = 0xff - - -bit_flipped = [ - sum(((byte >> i) & 1) << (7 - i) for i in xrange(8)) - for byte in xrange(0x100) -] - - -class Compressed: - - """ - Usage: - lz = Compressed(data).output - or - lz = Compressed().compress(data) - or - c = Compressed() - c.data = data - lz = c.compress() - - There are some issues with reproducing the target compressor. - Some notes are listed here: - - the criteria for detecting a lookback is inconsistent - - sometimes lookbacks that are mostly 0s are pruned, sometimes not - - target appears to skip ahead if it can use a lookback soon, stopping the current command short or in some cases truncating it with literals. - - this has been implemented, but the specifics are unknown - - self.min_scores: It's unknown if blank's minimum score should be 1 or 2. Most likely it's 1, with some other hack to account for edge cases. - - may be related to the above - - target does not appear to compress backwards - """ - - def __init__(self, *args, **kwargs): - - self.min_scores = { - 'blank': 1, - 'iterate': 2, - 'alternate': 3, - 'repeat': 3, - 'reverse': 3, - 'flip': 3, - } - - self.preference = [ - 'repeat', - 'blank', - 'flip', - 'reverse', - 'iterate', - 'alternate', - #'literal', - ] - - self.lookback_methods = 'repeat', 'reverse', 'flip' - - self.__dict__.update({ - 'data': None, - 'commands': lz_commands, - 'debug': False, - 'literal_only': False, - }) - - self.arg_names = 'data', 'commands', 'debug', 'literal_only' - - self.__dict__.update(kwargs) - self.__dict__.update(dict(zip(self.arg_names, args))) - - if self.data is not None: - self.compress() - - def compress(self, data=None): - if data is not None: - self.data = data - - self.data = list(bytearray(self.data)) - - self.indexes = {} - self.lookbacks = {} - for method in self.lookback_methods: - self.lookbacks[method] = {} - - self.address = 0 - self.end = len(self.data) - self.output = [] - self.literal = None - - while self.address < self.end: - - if self.score(): - self.do_literal() - self.do_winner() - - else: - if self.literal == None: - self.literal = self.address - self.address += 1 - - self.do_literal() - - self.output += [lz_end] - return self.output - - def reset_scores(self): - self.scores = {} - self.offsets = {} - self.helpers = {} - for method in self.min_scores.iterkeys(): - self.scores[method] = 0 - - def bit_flip(self, byte): - return bit_flipped[byte] - - def do_literal(self): - if self.literal != None: - length = abs(self.address - self.literal) - start = min(self.literal, self.address + 1) - self.helpers['literal'] = self.data[start:start+length] - self.do_cmd('literal', length) - self.literal = None - - def score(self): - self.reset_scores() - - map(self.score_literal, ['iterate', 'alternate', 'blank']) - - for method in self.lookback_methods: - self.scores[method], self.offsets[method] = self.find_lookback(method, self.address) - - self.stop_short() - - return any( - score - > self.min_scores[method] + int(score > lowmax) - for method, score in self.scores.iteritems() - ) - - def stop_short(self): - """ - If a lookback is close, reduce the scores of other commands. - """ - best_method, best_score = max( - self.scores.items(), - key = lambda x: ( - x[1], - -self.preference.index(x[0]) - ) - ) - for method in self.lookback_methods: - min_score = self.min_scores[method] - for address in xrange(self.address+1, self.address+best_score): - length, index = self.find_lookback(method, address) - if length > max(min_score, best_score): - # BUG: lookbacks can reduce themselves. This appears to be a bug in the target also. - for m, score in self.scores.items(): - self.scores[m] = min(score, address - self.address) - - - def read(self, address=None): - if address is None: - address = self.address - if 0 <= address < len(self.data): - return self.data[address] - return None - - def find_all_lookbacks(self): - for method in self.lookback_methods: - for address, byte in enumerate(self.data): - self.find_lookback(method, address) - - def find_lookback(self, method, address=None): - """Temporarily stubbed, because the real function doesn't run in polynomial time.""" - return 0, None - - def broken_find_lookback(self, method, address=None): - if address is None: - address = self.address - - existing = self.lookbacks.get(method, {}).get(address) - if existing != None: - return existing - - lookback = 0, None - - # Better to not carelessly optimize at the moment. - """ - if address < 2: - return lookback - """ - - byte = self.read(address) - if byte is None: - return lookback - - direction, mutate = { - 'repeat': ( 1, int), - 'reverse': (-1, int), - 'flip': ( 1, self.bit_flip), - }[method] - - # Doesn't seem to help - """ - if mutate == self.bit_flip: - if byte == 0: - self.lookbacks[method][address] = lookback - return lookback - """ - - data_len = len(self.data) - is_two_byte_index = lambda index: int(index < address - 0x7f) - - for index in self.get_indexes(mutate(byte)): - - if index >= address: - break - - old_length, old_index = lookback - if direction == 1: - if old_length > data_len - index: break - else: - if old_length > index: continue - - if self.read(index) in [None]: continue - - length = 1 # we know there's at least one match, or we wouldn't be checking this index - while 1: - this_byte = self.read(address + length) - that_byte = self.read(index + length * direction) - if that_byte == None or this_byte != mutate(that_byte): - break - length += 1 - - score = length - is_two_byte_index(index) - old_score = old_length - is_two_byte_index(old_index) - if score >= old_score or (score == old_score and length > old_length): - # XXX maybe avoid two-byte indexes when possible - if score >= lookback[0] - is_two_byte_index(lookback[1]): - lookback = length, index - - self.lookbacks[method][address] = lookback - return lookback - - def get_indexes(self, byte): - if not self.indexes.has_key(byte): - self.indexes[byte] = [] - index = -1 - while 1: - try: - index = self.data.index(byte, index + 1) - except ValueError: - break - self.indexes[byte].append(index) - return self.indexes[byte] - - def score_literal(self, method): - address = self.address - - compare = { - 'blank': [0], - 'iterate': [self.read(address)], - 'alternate': [self.read(address), self.read(address + 1)], - }[method] - - # XXX may or may not be correct - if method == 'alternate' and compare[0] == 0: - return - - length = 0 - while self.read(address + length) == compare[length % len(compare)]: - length += 1 - - self.scores[method] = length - self.helpers[method] = compare - - def do_winner(self): - winners = filter( - lambda (method, score): - score - > self.min_scores[method] + int(score > lowmax), - self.scores.iteritems() - ) - winners.sort( - key = lambda (method, score): ( - -(score - self.min_scores[method] - int(score > lowmax)), - self.preference.index(method) - ) - ) - winner, score = winners[0] - - length = min(score, max_length) - self.do_cmd(winner, length) - self.address += length - - def do_cmd(self, cmd, length): - start_address = self.address - - cmd_length = length - 1 - - output = [] - - if length > lowmax: - output.append( - (self.commands['long'] << 5) - + (self.commands[cmd] << 2) - + (cmd_length >> 8) - ) - output.append( - cmd_length & 0xff - ) - else: - output.append( - (self.commands[cmd] << 5) - + cmd_length - ) - - self.helpers['blank'] = [] # quick hack - output += self.helpers.get(cmd, []) - - if cmd in self.lookback_methods: - offset = self.offsets[cmd] - # Negative offsets are one byte. - # Positive offsets are two. - if 0 < start_address - offset - 1 <= 0x7f: - offset = (start_address - offset - 1) | 0x80 - output += [offset] - else: - output += [offset / 0x100, offset % 0x100] # big endian - - if self.debug: - print ' '.join(map(str, [ - cmd, length, '\t', - ' '.join(map('{:02x}'.format, output)), - self.data[start_address:start_address+length] if cmd in self.lookback_methods else '', - ])) - - self.output += output - - - -class Decompressed: - """ - Interpret and decompress lz-compressed data, usually 2bpp. - """ - - """ - Usage: - data = Decompressed(lz).output - or - data = Decompressed().decompress(lz) - or - d = Decompressed() - d.lz = lz - data = d.decompress() - - To decompress from offset 0x80000 in a rom: - data = Decompressed(rom, start=0x80000).output - """ - - lz = None - start = 0 - commands = lz_commands - debug = False - - arg_names = 'lz', 'start', 'commands', 'debug' - - def __init__(self, *args, **kwargs): - self.__dict__.update(dict(zip(self.arg_names, args))) - self.__dict__.update(kwargs) - - self.command_names = dict(map(reversed, self.commands.items())) - self.address = self.start - - if self.lz is not None: - self.decompress() - - if self.debug: print self.command_list() - - - def command_list(self): - """ - Print a list of commands that were used. Useful for debugging. - """ - - text = '' - - output_address = 0 - for name, attrs in self.used_commands: - length = attrs['length'] - address = attrs['address'] - offset = attrs['offset'] - direction = attrs['direction'] - - text += '{2:03x} {0}: {1}'.format(name, length, output_address) - text += '\t' + ' '.join( - '{:02x}'.format(int(byte)) - for byte in self.lz[ address : address + attrs['cmd_length'] ] - ) - - if offset is not None: - repeated_data = self.output[ offset : offset + length * direction : direction ] - if name == 'flip': - repeated_data = map(bit_flipped.__getitem__, repeated_data) - text += ' [' + ' '.join(map('{:02x}'.format, repeated_data)) + ']' - - text += '\n' - output_address += length - - return text - - - def decompress(self, lz=None): - - if lz is not None: - self.lz = lz - - self.lz = bytearray(self.lz) - - self.used_commands = [] - self.output = [] - - while 1: - - cmd_address = self.address - self.offset = None - self.direction = None - - if (self.byte == lz_end): - self.next() - break - - self.cmd = (self.byte & 0b11100000) >> 5 - - if self.cmd_name == 'long': - # 10-bit length - self.cmd = (self.byte & 0b00011100) >> 2 - self.length = (self.next() & 0b00000011) * 0x100 - self.length += self.next() + 1 - else: - # 5-bit length - self.length = (self.next() & 0b00011111) + 1 - - self.__class__.__dict__[self.cmd_name](self) - - self.used_commands += [( - self.cmd_name, - { - 'length': self.length, - 'address': cmd_address, - 'offset': self.offset, - 'cmd_length': self.address - cmd_address, - 'direction': self.direction, - } - )] - - # Keep track of the data we just decompressed. - self.compressed_data = self.lz[self.start : self.address] - - - @property - def byte(self): - return self.lz[ self.address ] - - def next(self): - byte = self.byte - self.address += 1 - return byte - - @property - def cmd_name(self): - return self.command_names.get(self.cmd) - - - def get_offset(self): - - if self.byte >= 0x80: # negative - # negative - offset = self.next() & 0x7f - offset = len(self.output) - offset - 1 - else: - # positive - offset = self.next() * 0x100 - offset += self.next() - - self.offset = offset - - - def literal(self): - """ - Copy data directly. - """ - self.output += self.lz[ self.address : self.address + self.length ] - self.address += self.length - - def iterate(self): - """ - Write one byte repeatedly. - """ - self.output += [self.next()] * self.length - - def alternate(self): - """ - Write alternating bytes. - """ - alts = [self.next(), self.next()] - self.output += [ alts[x & 1] for x in xrange(self.length) ] - - def blank(self): - """ - Write zeros. - """ - self.output += [0] * self.length - - def flip(self): - """ - Repeat flipped bytes from output. - - Example: 11100100 -> 00100111 - """ - self._repeat(table=bit_flipped) - - def reverse(self): - """ - Repeat reversed bytes from output. - """ - self._repeat(direction=-1) - - def repeat(self): - """ - Repeat bytes from output. - """ - self._repeat() - - def _repeat(self, direction=1, table=None): - self.get_offset() - self.direction = direction - # Note: appends must be one at a time (this way, repeats can draw from themselves if required) - for i in xrange(self.length): - byte = self.output[ self.offset + i * direction ] - self.output.append( table[byte] if table else byte ) diff --git a/tools/make_shim.py b/tools/make_shim.py index e2e4bf2..e88e3fc 100644 --- a/tools/make_shim.py +++ b/tools/make_shim.py @@ -1,4 +1,5 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- from __future__ import print_function import argparse diff --git a/tools/map2link.py b/tools/map2link.py deleted file mode 100644 index f1cd3a3..0000000 --- a/tools/map2link.py +++ /dev/null @@ -1,187 +0,0 @@ -#!/bin/python3.6 - -import argparse -import re -import sys -from collections import OrderedDict - - -class MapFile: - LINETYPE_BLANK = -1 - LINETYPE_BANK = 0 - LINETYPE_SECTION = 1 - LINETYPE_SYMBOL = 2 - LINETYPE_SLACK = 3 - LINETYPE_EMPTY = 4 - - bank_types = ('ROM', 'VRAM', 'SRAM', 'WRAM', 'OAM', 'HRAM') - bank_starts = (0x4000, 0x8000, 0xa000, 0xd000, 0xfe00, 0xff80) - - class MapFileLine: - def __init__(self, linestr): - self.raw = linestr - - def __str__(self): - return self.raw - - class BlankLine(MapFileLine): - def __init__(self, linestr): - super().__init__(linestr) - - class BankLine(MapFileLine): - def __init__(self, linestr): - super().__init__(linestr) - match = re.search('#(\d+)', linestr, re.I) - if match is None: - self.bankno = 0 - else: - self.bankno = int(match.group(1)) - bankname = linestr.split()[0].rstrip(':') - try: - self.banktype = MapFile.bank_types.index(bankname) - except ValueError as e: - raise ValueError(f'Unrecognized bank type: {bankname}') from e - - @property - def name(self): - if self.banktype == 0: # ROM - if self.bankno == 0: - return 'ROM0' - else: - return f'ROMX ${self.bankno:02x}' - elif self.banktype == 3: # WRAM - if self.bankno == 0: - return 'WRAM0' - else: - return f'WRAMX ${self.bankno:02x}' - elif self.banktype < 3: - return f'{MapFile.bank_types[self.banktype]} {self.bankno:d}' - else: - return f'{MapFile.bank_types[self.banktype]}' - - @property - def start(self): - if self.bankno == 0: - if self.banktype == 0: - return 0x0000 - elif self.banktype == 3: - return 0xc000 - return MapFile.bank_starts[self.banktype] - - def __hash__(self): - return hash(self.name) - - class SectionLine(MapFileLine): - def __init__(self, linestr): - super().__init__(linestr) - match = re.search(r'\$([0-9A-F]{4}) \(\$([0-9A-F]+) bytes\) \["(.+)"\]', linestr, re.I) - end, size, self.name = match.groups() - self.end = int(end, 16) - self.size = int(size, 16) - if self.size > 0: - self.end += 1 - self.start = self.end - self.size - self.symbols = [] - - class SymbolLine(MapFileLine): - def __init__(self, linestr): - super().__init__(linestr) - - class SlackLine(MapFileLine): - def __init__(self, linestr): - super().__init__(linestr) - match = re.search(r'\$([0-9A-F]{4}) bytes', linestr, re.I) - self.slack = int(match.group(1), 16) - - class EmptyLine(MapFileLine): - def __init__(self, linestr): - super().__init__(linestr) - - line_classes = { - LINETYPE_BLANK: BlankLine, - LINETYPE_BANK: BankLine, - LINETYPE_SECTION: SectionLine, - LINETYPE_SYMBOL: SymbolLine, - LINETYPE_SLACK: SlackLine, - LINETYPE_EMPTY: EmptyLine - } - - def __init__(self, fname, *fpargs, **fpkwargs): - self.fname = fname - self.fp = None - self.fpargs = fpargs - self.fpkwargs = fpkwargs - - def open(self): - if self.fp is None or self.fp.closed: - self.fp = open(self.fname, *self.fpargs, **self.fpkwargs) - - def close(self): - if not self.fp.closed: - self.fp.close() - self.fp = None - - def __enter__(self): - self.open() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.fp.__exit__(exc_type, exc_val, exc_tb) - self.fp = None - - def __iter__(self): - if self.fp is None or self.fp.closed: - print('Warning: Cowardly refusing to read closed file', file=sys.stderr) - raise StopIteration - for line in self.fp: - linestr = line.strip('\n') - if linestr == '': - line_type = self.LINETYPE_BLANK - elif 'SECTION:' in linestr: - line_type = self.LINETYPE_SECTION - elif 'SLACK:' in linestr: - line_type = self.LINETYPE_SLACK - elif linestr == ' EMPTY': - line_type = self.LINETYPE_EMPTY - elif ' = ' in linestr: - line_type = self.LINETYPE_SYMBOL - else: - line_type = self.LINETYPE_BANK - yield self.line_classes[line_type](linestr) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument('mapfile', type=MapFile) - parser.add_argument('linkfile', type=argparse.FileType('w')) - args = parser.parse_args() - - rom_map = OrderedDict() - cur_bank = None - - print('; Automatically generated by map2link.py', file=args.linkfile) - - with args.mapfile: - for line in args.mapfile: - if isinstance(line, MapFile.BankLine): - cur_bank = line - rom_map[cur_bank] = [] - elif isinstance(line, MapFile.SectionLine): - rom_map[cur_bank].append(line) - - for bank, sections in rom_map.items(): - if len(sections) == 0: - continue - sections.sort(key=lambda s: s.start) - print(bank.name, file=args.linkfile) - start = bank.start - for section in sections: - if section.start > start: - print(f'\t; ${start:04x}', file=args.linkfile) - print(f'\torg ${section.start:04x}', file=args.linkfile) - print(f'\t"{section.name}" ; ${section.start:04x}, size: ${section.size:04x}', file=args.linkfile) - start = section.end - - -if __name__ == '__main__': - main() diff --git a/tools/mapreader.py b/tools/mapreader.py index 41ad935..95a4723 100644 --- a/tools/mapreader.py +++ b/tools/mapreader.py @@ -1,5 +1,5 @@ -# #!/usr/bin/env python3 -# coding: utf-8 +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- import re @@ -168,4 +168,4 @@ class MapReader: else: v['sections'].sort(key=lambda x: x['beg']) for vv in v['sections']: - vv['symbols'].sort(key=lambda x: x['address']) \ No newline at end of file + vv['symbols'].sort(key=lambda x: x['address']) diff --git a/tools/png.py b/tools/png.py deleted file mode 100644 index db6da12..0000000 --- a/tools/png.py +++ /dev/null @@ -1,2650 +0,0 @@ -#!/usr/bin/env python - -from __future__ import print_function - -# png.py - PNG encoder/decoder in pure Python -# -# Copyright (C) 2006 Johann C. Rocholl -# Portions Copyright (C) 2009 David Jones -# And probably portions Copyright (C) 2006 Nicko van Someren -# -# Original concept by Johann C. Rocholl. -# -# LICENCE (MIT) -# -# Permission is hereby granted, free of charge, to any person -# obtaining a copy of this software and associated documentation files -# (the "Software"), to deal in the Software without restriction, -# including without limitation the rights to use, copy, modify, merge, -# publish, distribute, sublicense, and/or sell copies of the Software, -# and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS -# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN -# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -""" -Pure Python PNG Reader/Writer - -This Python module implements support for PNG images (see PNG -specification at http://www.w3.org/TR/2003/REC-PNG-20031110/ ). It reads -and writes PNG files with all allowable bit depths -(1/2/4/8/16/24/32/48/64 bits per pixel) and colour combinations: -greyscale (1/2/4/8/16 bit); RGB, RGBA, LA (greyscale with alpha) with -8/16 bits per channel; colour mapped images (1/2/4/8 bit). -Adam7 interlacing is supported for reading and -writing. A number of optional chunks can be specified (when writing) -and understood (when reading): ``tRNS``, ``bKGD``, ``gAMA``. - -For help, type ``import png; help(png)`` in your python interpreter. - -A good place to start is the :class:`Reader` and :class:`Writer` -classes. - -Requires Python 2.3. Limited support is available for Python 2.2, but -not everything works. Best with Python 2.4 and higher. Installation is -trivial, but see the ``README.txt`` file (with the source distribution) -for details. - -This file can also be used as a command-line utility to convert -`Netpbm `_ PNM files to PNG, and the -reverse conversion from PNG to PNM. The interface is similar to that -of the ``pnmtopng`` program from Netpbm. Type ``python png.py --help`` -at the shell prompt for usage and a list of options. - -A note on spelling and terminology ----------------------------------- - -Generally British English spelling is used in the documentation. So -that's "greyscale" and "colour". This not only matches the author's -native language, it's also used by the PNG specification. - -The major colour models supported by PNG (and hence by PyPNG) are: -greyscale, RGB, greyscale--alpha, RGB--alpha. These are sometimes -referred to using the abbreviations: L, RGB, LA, RGBA. In this case -each letter abbreviates a single channel: *L* is for Luminance or Luma -or Lightness which is the channel used in greyscale images; *R*, *G*, -*B* stand for Red, Green, Blue, the components of a colour image; *A* -stands for Alpha, the opacity channel (used for transparency effects, -but higher values are more opaque, so it makes sense to call it -opacity). - -A note on formats ------------------ - -When getting pixel data out of this module (reading) and presenting -data to this module (writing) there are a number of ways the data could -be represented as a Python value. Generally this module uses one of -three formats called "flat row flat pixel", "boxed row flat pixel", and -"boxed row boxed pixel". Basically the concern is whether each pixel -and each row comes in its own little tuple (box), or not. - -Consider an image that is 3 pixels wide by 2 pixels high, and each pixel -has RGB components: - -Boxed row flat pixel:: - - list([R,G,B, R,G,B, R,G,B], - [R,G,B, R,G,B, R,G,B]) - -Each row appears as its own list, but the pixels are flattened so -that three values for one pixel simply follow the three values for -the previous pixel. This is the most common format used, because it -provides a good compromise between space and convenience. PyPNG regards -itself as at liberty to replace any sequence type with any sufficiently -compatible other sequence type; in practice each row is an array (from -the array module), and the outer list is sometimes an iterator rather -than an explicit list (so that streaming is possible). - -Flat row flat pixel:: - - [R,G,B, R,G,B, R,G,B, - R,G,B, R,G,B, R,G,B] - -The entire image is one single giant sequence of colour values. -Generally an array will be used (to save space), not a list. - -Boxed row boxed pixel:: - - list([ (R,G,B), (R,G,B), (R,G,B) ], - [ (R,G,B), (R,G,B), (R,G,B) ]) - -Each row appears in its own list, but each pixel also appears in its own -tuple. A serious memory burn in Python. - -In all cases the top row comes first, and for each row the pixels are -ordered from left-to-right. Within a pixel the values appear in the -order, R-G-B-A (or L-A for greyscale--alpha). - -There is a fourth format, mentioned because it is used internally, -is close to what lies inside a PNG file itself, and has some support -from the public API. This format is called packed. When packed, -each row is a sequence of bytes (integers from 0 to 255), just as -it is before PNG scanline filtering is applied. When the bit depth -is 8 this is essentially the same as boxed row flat pixel; when the -bit depth is less than 8, several pixels are packed into each byte; -when the bit depth is 16 (the only value more than 8 that is supported -by the PNG image format) each pixel value is decomposed into 2 bytes -(and `packed` is a misnomer). This format is used by the -:meth:`Writer.write_packed` method. It isn't usually a convenient -format, but may be just right if the source data for the PNG image -comes from something that uses a similar format (for example, 1-bit -BMPs, or another PNG file). - -And now, my famous members --------------------------- -""" - -__version__ = "0.0.18" - -import itertools -import math -# http://www.python.org/doc/2.4.4/lib/module-operator.html -import operator -import struct -import sys -# http://www.python.org/doc/2.4.4/lib/module-warnings.html -import warnings -import zlib - -from array import array -from functools import reduce - -try: - # `cpngfilters` is a Cython module: it must be compiled by - # Cython for this import to work. - # If this import does work, then it overrides pure-python - # filtering functions defined later in this file (see `class - # pngfilters`). - import cpngfilters as pngfilters -except ImportError: - pass - - -__all__ = ['Image', 'Reader', 'Writer', 'write_chunks', 'from_array'] - - -# The PNG signature. -# http://www.w3.org/TR/PNG/#5PNG-file-signature -_signature = struct.pack('8B', 137, 80, 78, 71, 13, 10, 26, 10) - -_adam7 = ((0, 0, 8, 8), - (4, 0, 8, 8), - (0, 4, 4, 8), - (2, 0, 4, 4), - (0, 2, 2, 4), - (1, 0, 2, 2), - (0, 1, 1, 2)) - -def group(s, n): - # See http://www.python.org/doc/2.6/library/functions.html#zip - return list(zip(*[iter(s)]*n)) - -def isarray(x): - return isinstance(x, array) - -def tostring(row): - return row.tostring() - -def interleave_planes(ipixels, apixels, ipsize, apsize): - """ - Interleave (colour) planes, e.g. RGB + A = RGBA. - - Return an array of pixels consisting of the `ipsize` elements of - data from each pixel in `ipixels` followed by the `apsize` elements - of data from each pixel in `apixels`. Conventionally `ipixels` - and `apixels` are byte arrays so the sizes are bytes, but it - actually works with any arrays of the same type. The returned - array is the same type as the input arrays which should be the - same type as each other. - """ - - itotal = len(ipixels) - atotal = len(apixels) - newtotal = itotal + atotal - newpsize = ipsize + apsize - # Set up the output buffer - # See http://www.python.org/doc/2.4.4/lib/module-array.html#l2h-1356 - out = array(ipixels.typecode) - # It's annoying that there is no cheap way to set the array size :-( - out.extend(ipixels) - out.extend(apixels) - # Interleave in the pixel data - for i in range(ipsize): - out[i:newtotal:newpsize] = ipixels[i:itotal:ipsize] - for i in range(apsize): - out[i+ipsize:newtotal:newpsize] = apixels[i:atotal:apsize] - return out - -def check_palette(palette): - """Check a palette argument (to the :class:`Writer` class) - for validity. Returns the palette as a list if okay; raises an - exception otherwise. - """ - - # None is the default and is allowed. - if palette is None: - return None - - p = list(palette) - if not (0 < len(p) <= 256): - raise ValueError("a palette must have between 1 and 256 entries") - seen_triple = False - for i,t in enumerate(p): - if len(t) not in (3,4): - raise ValueError( - "palette entry %d: entries must be 3- or 4-tuples." % i) - if len(t) == 3: - seen_triple = True - if seen_triple and len(t) == 4: - raise ValueError( - "palette entry %d: all 4-tuples must precede all 3-tuples" % i) - for x in t: - if int(x) != x or not(0 <= x <= 255): - raise ValueError( - "palette entry %d: values must be integer: 0 <= x <= 255" % i) - return p - -def check_sizes(size, width, height): - """Check that these arguments, in supplied, are consistent. - Return a (width, height) pair. - """ - - if not size: - return width, height - - if len(size) != 2: - raise ValueError( - "size argument should be a pair (width, height)") - if width is not None and width != size[0]: - raise ValueError( - "size[0] (%r) and width (%r) should match when both are used." - % (size[0], width)) - if height is not None and height != size[1]: - raise ValueError( - "size[1] (%r) and height (%r) should match when both are used." - % (size[1], height)) - return size - -def check_color(c, greyscale, which): - """Checks that a colour argument for transparent or - background options is the right form. Returns the colour - (which, if it's a bar integer, is "corrected" to a 1-tuple). - """ - - if c is None: - return c - if greyscale: - try: - len(c) - except TypeError: - c = (c,) - if len(c) != 1: - raise ValueError("%s for greyscale must be 1-tuple" % - which) - if not isinteger(c[0]): - raise ValueError( - "%s colour for greyscale must be integer" % which) - else: - if not (len(c) == 3 and - isinteger(c[0]) and - isinteger(c[1]) and - isinteger(c[2])): - raise ValueError( - "%s colour must be a triple of integers" % which) - return c - -class Error(Exception): - def __str__(self): - return self.__class__.__name__ + ': ' + ' '.join(self.args) - -class FormatError(Error): - """Problem with input file format. In other words, PNG file does - not conform to the specification in some way and is invalid. - """ - -class ChunkError(FormatError): - pass - - -class Writer: - """ - PNG encoder in pure Python. - """ - - def __init__(self, width=None, height=None, - size=None, - greyscale=False, - alpha=False, - bitdepth=8, - palette=None, - transparent=None, - background=None, - gamma=None, - compression=None, - interlace=False, - bytes_per_sample=None, # deprecated - planes=None, - colormap=None, - maxval=None, - chunk_limit=2**20, - x_pixels_per_unit = None, - y_pixels_per_unit = None, - unit_is_meter = False): - """ - Create a PNG encoder object. - - Arguments: - - width, height - Image size in pixels, as two separate arguments. - size - Image size (w,h) in pixels, as single argument. - greyscale - Input data is greyscale, not RGB. - alpha - Input data has alpha channel (RGBA or LA). - bitdepth - Bit depth: from 1 to 16. - palette - Create a palette for a colour mapped image (colour type 3). - transparent - Specify a transparent colour (create a ``tRNS`` chunk). - background - Specify a default background colour (create a ``bKGD`` chunk). - gamma - Specify a gamma value (create a ``gAMA`` chunk). - compression - zlib compression level: 0 (none) to 9 (more compressed); - default: -1 or None. - interlace - Create an interlaced image. - chunk_limit - Write multiple ``IDAT`` chunks to save memory. - x_pixels_per_unit - Number of pixels a unit along the x axis (write a - `pHYs` chunk). - y_pixels_per_unit - Number of pixels a unit along the y axis (write a - `pHYs` chunk). Along with `x_pixel_unit`, this gives - the pixel size ratio. - unit_is_meter - `True` to indicate that the unit (for the `pHYs` - chunk) is metre. - - The image size (in pixels) can be specified either by using the - `width` and `height` arguments, or with the single `size` - argument. If `size` is used it should be a pair (*width*, - *height*). - - `greyscale` and `alpha` are booleans that specify whether - an image is greyscale (or colour), and whether it has an - alpha channel (or not). - - `bitdepth` specifies the bit depth of the source pixel values. - Each source pixel value must be an integer between 0 and - ``2**bitdepth-1``. For example, 8-bit images have values - between 0 and 255. PNG only stores images with bit depths of - 1,2,4,8, or 16. When `bitdepth` is not one of these values, - the next highest valid bit depth is selected, and an ``sBIT`` - (significant bits) chunk is generated that specifies the - original precision of the source image. In this case the - supplied pixel values will be rescaled to fit the range of - the selected bit depth. - - The details of which bit depth / colour model combinations the - PNG file format supports directly, are somewhat arcane - (refer to the PNG specification for full details). Briefly: - "small" bit depths (1,2,4) are only allowed with greyscale and - colour mapped images; colour mapped images cannot have bit depth - 16. - - For colour mapped images (in other words, when the `palette` - argument is specified) the `bitdepth` argument must match one of - the valid PNG bit depths: 1, 2, 4, or 8. (It is valid to have a - PNG image with a palette and an ``sBIT`` chunk, but the meaning - is slightly different; it would be awkward to press the - `bitdepth` argument into service for this.) - - The `palette` option, when specified, causes a colour - mapped image to be created: the PNG colour type is set to 3; - `greyscale` must not be set; `alpha` must not be set; - `transparent` must not be set; the bit depth must be 1,2,4, - or 8. When a colour mapped image is created, the pixel values - are palette indexes and the `bitdepth` argument specifies the - size of these indexes (not the size of the colour values in - the palette). - - The palette argument value should be a sequence of 3- or - 4-tuples. 3-tuples specify RGB palette entries; 4-tuples - specify RGBA palette entries. If both 4-tuples and 3-tuples - appear in the sequence then all the 4-tuples must come - before all the 3-tuples. A ``PLTE`` chunk is created; if there - are 4-tuples then a ``tRNS`` chunk is created as well. The - ``PLTE`` chunk will contain all the RGB triples in the same - sequence; the ``tRNS`` chunk will contain the alpha channel for - all the 4-tuples, in the same sequence. Palette entries - are always 8-bit. - - If specified, the `transparent` and `background` parameters must - be a tuple with three integer values for red, green, blue, or - a simple integer (or singleton tuple) for a greyscale image. - - If specified, the `gamma` parameter must be a positive number - (generally, a `float`). A ``gAMA`` chunk will be created. - Note that this will not change the values of the pixels as - they appear in the PNG file, they are assumed to have already - been converted appropriately for the gamma specified. - - The `compression` argument specifies the compression level to - be used by the ``zlib`` module. Values from 1 to 9 specify - compression, with 9 being "more compressed" (usually smaller - and slower, but it doesn't always work out that way). 0 means - no compression. -1 and ``None`` both mean that the default - level of compession will be picked by the ``zlib`` module - (which is generally acceptable). - - If `interlace` is true then an interlaced image is created - (using PNG's so far only interace method, *Adam7*). This does - not affect how the pixels should be presented to the encoder, - rather it changes how they are arranged into the PNG file. - On slow connexions interlaced images can be partially decoded - by the browser to give a rough view of the image that is - successively refined as more image data appears. - - .. note :: - - Enabling the `interlace` option requires the entire image - to be processed in working memory. - - `chunk_limit` is used to limit the amount of memory used whilst - compressing the image. In order to avoid using large amounts of - memory, multiple ``IDAT`` chunks may be created. - """ - - # At the moment the `planes` argument is ignored; - # its purpose is to act as a dummy so that - # ``Writer(x, y, **info)`` works, where `info` is a dictionary - # returned by Reader.read and friends. - # Ditto for `colormap`. - - width, height = check_sizes(size, width, height) - del size - - if width <= 0 or height <= 0: - raise ValueError("width and height must be greater than zero") - if not isinteger(width) or not isinteger(height): - raise ValueError("width and height must be integers") - # http://www.w3.org/TR/PNG/#7Integers-and-byte-order - if width > 2**32-1 or height > 2**32-1: - raise ValueError("width and height cannot exceed 2**32-1") - - if alpha and transparent is not None: - raise ValueError( - "transparent colour not allowed with alpha channel") - - if bytes_per_sample is not None: - warnings.warn('please use bitdepth instead of bytes_per_sample', - DeprecationWarning) - if bytes_per_sample not in (0.125, 0.25, 0.5, 1, 2): - raise ValueError( - "bytes per sample must be .125, .25, .5, 1, or 2") - bitdepth = int(8*bytes_per_sample) - del bytes_per_sample - if not isinteger(bitdepth) or bitdepth < 1 or 16 < bitdepth: - raise ValueError("bitdepth (%r) must be a positive integer <= 16" % - bitdepth) - - self.rescale = None - palette = check_palette(palette) - if palette: - if bitdepth not in (1,2,4,8): - raise ValueError("with palette, bitdepth must be 1, 2, 4, or 8") - if transparent is not None: - raise ValueError("transparent and palette not compatible") - if alpha: - raise ValueError("alpha and palette not compatible") - if greyscale: - raise ValueError("greyscale and palette not compatible") - else: - # No palette, check for sBIT chunk generation. - if alpha or not greyscale: - if bitdepth not in (8,16): - targetbitdepth = (8,16)[bitdepth > 8] - self.rescale = (bitdepth, targetbitdepth) - bitdepth = targetbitdepth - del targetbitdepth - else: - assert greyscale - assert not alpha - if bitdepth not in (1,2,4,8,16): - if bitdepth > 8: - targetbitdepth = 16 - elif bitdepth == 3: - targetbitdepth = 4 - else: - assert bitdepth in (5,6,7) - targetbitdepth = 8 - self.rescale = (bitdepth, targetbitdepth) - bitdepth = targetbitdepth - del targetbitdepth - - if bitdepth < 8 and (alpha or not greyscale and not palette): - raise ValueError( - "bitdepth < 8 only permitted with greyscale or palette") - if bitdepth > 8 and palette: - raise ValueError( - "bit depth must be 8 or less for images with palette") - - transparent = check_color(transparent, greyscale, 'transparent') - background = check_color(background, greyscale, 'background') - - # It's important that the true boolean values (greyscale, alpha, - # colormap, interlace) are converted to bool because Iverson's - # convention is relied upon later on. - self.width = width - self.height = height - self.transparent = transparent - self.background = background - self.gamma = gamma - self.greyscale = bool(greyscale) - self.alpha = bool(alpha) - self.colormap = bool(palette) - self.bitdepth = int(bitdepth) - self.compression = compression - self.chunk_limit = chunk_limit - self.interlace = bool(interlace) - self.palette = palette - self.x_pixels_per_unit = x_pixels_per_unit - self.y_pixels_per_unit = y_pixels_per_unit - self.unit_is_meter = bool(unit_is_meter) - - self.color_type = 4*self.alpha + 2*(not greyscale) + 1*self.colormap - assert self.color_type in (0,2,3,4,6) - - self.color_planes = (3,1)[self.greyscale or self.colormap] - self.planes = self.color_planes + self.alpha - # :todo: fix for bitdepth < 8 - self.psize = (self.bitdepth/8) * self.planes - - def make_palette(self): - """Create the byte sequences for a ``PLTE`` and if necessary a - ``tRNS`` chunk. Returned as a pair (*p*, *t*). *t* will be - ``None`` if no ``tRNS`` chunk is necessary. - """ - - p = array('B') - t = array('B') - - for x in self.palette: - p.extend(x[0:3]) - if len(x) > 3: - t.append(x[3]) - p = tostring(p) - t = tostring(t) - if t: - return p,t - return p,None - - def write(self, outfile, rows): - """Write a PNG image to the output file. `rows` should be - an iterable that yields each row in boxed row flat pixel - format. The rows should be the rows of the original image, - so there should be ``self.height`` rows of ``self.width * - self.planes`` values. If `interlace` is specified (when - creating the instance), then an interlaced PNG file will - be written. Supply the rows in the normal image order; - the interlacing is carried out internally. - - .. note :: - - Interlacing will require the entire image to be in working - memory. - """ - - if self.interlace: - fmt = 'BH'[self.bitdepth > 8] - a = array(fmt, itertools.chain(*rows)) - return self.write_array(outfile, a) - - nrows = self.write_passes(outfile, rows) - if nrows != self.height: - raise ValueError( - "rows supplied (%d) does not match height (%d)" % - (nrows, self.height)) - - def write_passes(self, outfile, rows, packed=False): - """ - Write a PNG image to the output file. - - Most users are expected to find the :meth:`write` or - :meth:`write_array` method more convenient. - - The rows should be given to this method in the order that - they appear in the output file. For straightlaced images, - this is the usual top to bottom ordering, but for interlaced - images the rows should have already been interlaced before - passing them to this function. - - `rows` should be an iterable that yields each row. When - `packed` is ``False`` the rows should be in boxed row flat pixel - format; when `packed` is ``True`` each row should be a packed - sequence of bytes. - """ - - # http://www.w3.org/TR/PNG/#5PNG-file-signature - outfile.write(_signature) - - # http://www.w3.org/TR/PNG/#11IHDR - write_chunk(outfile, b'IHDR', - struct.pack("!2I5B", self.width, self.height, - self.bitdepth, self.color_type, - 0, 0, self.interlace)) - - # See :chunk:order - # http://www.w3.org/TR/PNG/#11gAMA - if self.gamma is not None: - write_chunk(outfile, b'gAMA', - struct.pack("!L", int(round(self.gamma*1e5)))) - - # See :chunk:order - # http://www.w3.org/TR/PNG/#11sBIT - if self.rescale: - write_chunk(outfile, b'sBIT', - struct.pack('%dB' % self.planes, - *[self.rescale[0]]*self.planes)) - - # :chunk:order: Without a palette (PLTE chunk), ordering is - # relatively relaxed. With one, gAMA chunk must precede PLTE - # chunk which must precede tRNS and bKGD. - # See http://www.w3.org/TR/PNG/#5ChunkOrdering - if self.palette: - p,t = self.make_palette() - write_chunk(outfile, b'PLTE', p) - if t: - # tRNS chunk is optional. Only needed if palette entries - # have alpha. - write_chunk(outfile, b'tRNS', t) - - # http://www.w3.org/TR/PNG/#11tRNS - if self.transparent is not None: - if self.greyscale: - write_chunk(outfile, b'tRNS', - struct.pack("!1H", *self.transparent)) - else: - write_chunk(outfile, b'tRNS', - struct.pack("!3H", *self.transparent)) - - # http://www.w3.org/TR/PNG/#11bKGD - if self.background is not None: - if self.greyscale: - write_chunk(outfile, b'bKGD', - struct.pack("!1H", *self.background)) - else: - write_chunk(outfile, b'bKGD', - struct.pack("!3H", *self.background)) - - # http://www.w3.org/TR/PNG/#11pHYs - if self.x_pixels_per_unit is not None and self.y_pixels_per_unit is not None: - tup = (self.x_pixels_per_unit, self.y_pixels_per_unit, int(self.unit_is_meter)) - write_chunk(outfile, b'pHYs', struct.pack("!LLB",*tup)) - - # http://www.w3.org/TR/PNG/#11IDAT - if self.compression is not None: - compressor = zlib.compressobj(self.compression) - else: - compressor = zlib.compressobj() - - # Choose an extend function based on the bitdepth. The extend - # function packs/decomposes the pixel values into bytes and - # stuffs them onto the data array. - data = array('B') - if self.bitdepth == 8 or packed: - extend = data.extend - elif self.bitdepth == 16: - # Decompose into bytes - def extend(sl): - fmt = '!%dH' % len(sl) - data.extend(array('B', struct.pack(fmt, *sl))) - else: - # Pack into bytes - assert self.bitdepth < 8 - # samples per byte - spb = int(8/self.bitdepth) - def extend(sl): - a = array('B', sl) - # Adding padding bytes so we can group into a whole - # number of spb-tuples. - l = float(len(a)) - extra = math.ceil(l / float(spb))*spb - l - a.extend([0]*int(extra)) - # Pack into bytes - l = group(a, spb) - l = [reduce(lambda x,y: - (x << self.bitdepth) + y, e) for e in l] - data.extend(l) - if self.rescale: - oldextend = extend - factor = \ - float(2**self.rescale[1]-1) / float(2**self.rescale[0]-1) - def extend(sl): - oldextend([int(round(factor*x)) for x in sl]) - - # Build the first row, testing mostly to see if we need to - # changed the extend function to cope with NumPy integer types - # (they cause our ordinary definition of extend to fail, so we - # wrap it). See - # http://code.google.com/p/pypng/issues/detail?id=44 - enumrows = enumerate(rows) - del rows - - # First row's filter type. - data.append(0) - # :todo: Certain exceptions in the call to ``.next()`` or the - # following try would indicate no row data supplied. - # Should catch. - i,row = next(enumrows) - try: - # If this fails... - extend(row) - except: - # ... try a version that converts the values to int first. - # Not only does this work for the (slightly broken) NumPy - # types, there are probably lots of other, unknown, "nearly" - # int types it works for. - def wrapmapint(f): - return lambda sl: f([int(x) for x in sl]) - extend = wrapmapint(extend) - del wrapmapint - extend(row) - - for i,row in enumrows: - # Add "None" filter type. Currently, it's essential that - # this filter type be used for every scanline as we do not - # mark the first row of a reduced pass image; that means we - # could accidentally compute the wrong filtered scanline if - # we used "up", "average", or "paeth" on such a line. - data.append(0) - extend(row) - if len(data) > self.chunk_limit: - compressed = compressor.compress(tostring(data)) - if len(compressed): - write_chunk(outfile, b'IDAT', compressed) - # Because of our very witty definition of ``extend``, - # above, we must re-use the same ``data`` object. Hence - # we use ``del`` to empty this one, rather than create a - # fresh one (which would be my natural FP instinct). - del data[:] - if len(data): - compressed = compressor.compress(tostring(data)) - else: - compressed = b'' - flushed = compressor.flush() - if len(compressed) or len(flushed): - write_chunk(outfile, b'IDAT', compressed + flushed) - # http://www.w3.org/TR/PNG/#11IEND - write_chunk(outfile, b'IEND') - return i+1 - - def write_array(self, outfile, pixels): - """ - Write an array in flat row flat pixel format as a PNG file on - the output file. See also :meth:`write` method. - """ - - if self.interlace: - self.write_passes(outfile, self.array_scanlines_interlace(pixels)) - else: - self.write_passes(outfile, self.array_scanlines(pixels)) - - def write_packed(self, outfile, rows): - """ - Write PNG file to `outfile`. The pixel data comes from `rows` - which should be in boxed row packed format. Each row should be - a sequence of packed bytes. - - Technically, this method does work for interlaced images but it - is best avoided. For interlaced images, the rows should be - presented in the order that they appear in the file. - - This method should not be used when the source image bit depth - is not one naturally supported by PNG; the bit depth should be - 1, 2, 4, 8, or 16. - """ - - if self.rescale: - raise Error("write_packed method not suitable for bit depth %d" % - self.rescale[0]) - return self.write_passes(outfile, rows, packed=True) - - def convert_pnm(self, infile, outfile): - """ - Convert a PNM file containing raw pixel data into a PNG file - with the parameters set in the writer object. Works for - (binary) PGM, PPM, and PAM formats. - """ - - if self.interlace: - pixels = array('B') - pixels.fromfile(infile, - (self.bitdepth/8) * self.color_planes * - self.width * self.height) - self.write_passes(outfile, self.array_scanlines_interlace(pixels)) - else: - self.write_passes(outfile, self.file_scanlines(infile)) - - def convert_ppm_and_pgm(self, ppmfile, pgmfile, outfile): - """ - Convert a PPM and PGM file containing raw pixel data into a - PNG outfile with the parameters set in the writer object. - """ - pixels = array('B') - pixels.fromfile(ppmfile, - (self.bitdepth/8) * self.color_planes * - self.width * self.height) - apixels = array('B') - apixels.fromfile(pgmfile, - (self.bitdepth/8) * - self.width * self.height) - pixels = interleave_planes(pixels, apixels, - (self.bitdepth/8) * self.color_planes, - (self.bitdepth/8)) - if self.interlace: - self.write_passes(outfile, self.array_scanlines_interlace(pixels)) - else: - self.write_passes(outfile, self.array_scanlines(pixels)) - - def file_scanlines(self, infile): - """ - Generates boxed rows in flat pixel format, from the input file - `infile`. It assumes that the input file is in a "Netpbm-like" - binary format, and is positioned at the beginning of the first - pixel. The number of pixels to read is taken from the image - dimensions (`width`, `height`, `planes`) and the number of bytes - per value is implied by the image `bitdepth`. - """ - - # Values per row - vpr = self.width * self.planes - row_bytes = vpr - if self.bitdepth > 8: - assert self.bitdepth == 16 - row_bytes *= 2 - fmt = '>%dH' % vpr - def line(): - return array('H', struct.unpack(fmt, infile.read(row_bytes))) - else: - def line(): - scanline = array('B', infile.read(row_bytes)) - return scanline - for y in range(self.height): - yield line() - - def array_scanlines(self, pixels): - """ - Generates boxed rows (flat pixels) from flat rows (flat pixels) - in an array. - """ - - # Values per row - vpr = self.width * self.planes - stop = 0 - for y in range(self.height): - start = stop - stop = start + vpr - yield pixels[start:stop] - - def array_scanlines_interlace(self, pixels): - """ - Generator for interlaced scanlines from an array. `pixels` is - the full source image in flat row flat pixel format. The - generator yields each scanline of the reduced passes in turn, in - boxed row flat pixel format. - """ - - # http://www.w3.org/TR/PNG/#8InterlaceMethods - # Array type. - fmt = 'BH'[self.bitdepth > 8] - # Value per row - vpr = self.width * self.planes - for xstart, ystart, xstep, ystep in _adam7: - if xstart >= self.width: - continue - # Pixels per row (of reduced image) - ppr = int(math.ceil((self.width-xstart)/float(xstep))) - # number of values in reduced image row. - row_len = ppr*self.planes - for y in range(ystart, self.height, ystep): - if xstep == 1: - offset = y * vpr - yield pixels[offset:offset+vpr] - else: - row = array(fmt) - # There's no easier way to set the length of an array - row.extend(pixels[0:row_len]) - offset = y * vpr + xstart * self.planes - end_offset = (y+1) * vpr - skip = self.planes * xstep - for i in range(self.planes): - row[i::self.planes] = \ - pixels[offset+i:end_offset:skip] - yield row - -def write_chunk(outfile, tag, data=b''): - """ - Write a PNG chunk to the output file, including length and - checksum. - """ - - # http://www.w3.org/TR/PNG/#5Chunk-layout - outfile.write(struct.pack("!I", len(data))) - outfile.write(tag) - outfile.write(data) - checksum = zlib.crc32(tag) - checksum = zlib.crc32(data, checksum) - checksum &= 2**32-1 - outfile.write(struct.pack("!I", checksum)) - -def write_chunks(out, chunks): - """Create a PNG file by writing out the chunks.""" - - out.write(_signature) - for chunk in chunks: - write_chunk(out, *chunk) - -def filter_scanline(type, line, fo, prev=None): - """Apply a scanline filter to a scanline. `type` specifies the - filter type (0 to 4); `line` specifies the current (unfiltered) - scanline as a sequence of bytes; `prev` specifies the previous - (unfiltered) scanline as a sequence of bytes. `fo` specifies the - filter offset; normally this is size of a pixel in bytes (the number - of bytes per sample times the number of channels), but when this is - < 1 (for bit depths < 8) then the filter offset is 1. - """ - - assert 0 <= type < 5 - - # The output array. Which, pathetically, we extend one-byte at a - # time (fortunately this is linear). - out = array('B', [type]) - - def sub(): - ai = -fo - for x in line: - if ai >= 0: - x = (x - line[ai]) & 0xff - out.append(x) - ai += 1 - def up(): - for i,x in enumerate(line): - x = (x - prev[i]) & 0xff - out.append(x) - def average(): - ai = -fo - for i,x in enumerate(line): - if ai >= 0: - x = (x - ((line[ai] + prev[i]) >> 1)) & 0xff - else: - x = (x - (prev[i] >> 1)) & 0xff - out.append(x) - ai += 1 - def paeth(): - # http://www.w3.org/TR/PNG/#9Filter-type-4-Paeth - ai = -fo # also used for ci - for i,x in enumerate(line): - a = 0 - b = prev[i] - c = 0 - - if ai >= 0: - a = line[ai] - c = prev[ai] - p = a + b - c - pa = abs(p - a) - pb = abs(p - b) - pc = abs(p - c) - if pa <= pb and pa <= pc: - Pr = a - elif pb <= pc: - Pr = b - else: - Pr = c - - x = (x - Pr) & 0xff - out.append(x) - ai += 1 - - if not prev: - # We're on the first line. Some of the filters can be reduced - # to simpler cases which makes handling the line "off the top" - # of the image simpler. "up" becomes "none"; "paeth" becomes - # "left" (non-trivial, but true). "average" needs to be handled - # specially. - if type == 2: # "up" - type = 0 - elif type == 3: - prev = [0]*len(line) - elif type == 4: # "paeth" - type = 1 - if type == 0: - out.extend(line) - elif type == 1: - sub() - elif type == 2: - up() - elif type == 3: - average() - else: # type == 4 - paeth() - return out - - -def from_array(a, mode=None, info={}): - """Create a PNG :class:`Image` object from a 2- or 3-dimensional - array. One application of this function is easy PIL-style saving: - ``png.from_array(pixels, 'L').save('foo.png')``. - - Unless they are specified using the *info* parameter, the PNG's - height and width are taken from the array size. For a 3 dimensional - array the first axis is the height; the second axis is the width; - and the third axis is the channel number. Thus an RGB image that is - 16 pixels high and 8 wide will use an array that is 16x8x3. For 2 - dimensional arrays the first axis is the height, but the second axis - is ``width*channels``, so an RGB image that is 16 pixels high and 8 - wide will use a 2-dimensional array that is 16x24 (each row will be - 8*3 = 24 sample values). - - *mode* is a string that specifies the image colour format in a - PIL-style mode. It can be: - - ``'L'`` - greyscale (1 channel) - ``'LA'`` - greyscale with alpha (2 channel) - ``'RGB'`` - colour image (3 channel) - ``'RGBA'`` - colour image with alpha (4 channel) - - The mode string can also specify the bit depth (overriding how this - function normally derives the bit depth, see below). Appending - ``';16'`` to the mode will cause the PNG to be 16 bits per channel; - any decimal from 1 to 16 can be used to specify the bit depth. - - When a 2-dimensional array is used *mode* determines how many - channels the image has, and so allows the width to be derived from - the second array dimension. - - The array is expected to be a ``numpy`` array, but it can be any - suitable Python sequence. For example, a list of lists can be used: - ``png.from_array([[0, 255, 0], [255, 0, 255]], 'L')``. The exact - rules are: ``len(a)`` gives the first dimension, height; - ``len(a[0])`` gives the second dimension; ``len(a[0][0])`` gives the - third dimension, unless an exception is raised in which case a - 2-dimensional array is assumed. It's slightly more complicated than - that because an iterator of rows can be used, and it all still - works. Using an iterator allows data to be streamed efficiently. - - The bit depth of the PNG is normally taken from the array element's - datatype (but if *mode* specifies a bitdepth then that is used - instead). The array element's datatype is determined in a way which - is supposed to work both for ``numpy`` arrays and for Python - ``array.array`` objects. A 1 byte datatype will give a bit depth of - 8, a 2 byte datatype will give a bit depth of 16. If the datatype - does not have an implicit size, for example it is a plain Python - list of lists, as above, then a default of 8 is used. - - The *info* parameter is a dictionary that can be used to specify - metadata (in the same style as the arguments to the - :class:`png.Writer` class). For this function the keys that are - useful are: - - height - overrides the height derived from the array dimensions and allows - *a* to be an iterable. - width - overrides the width derived from the array dimensions. - bitdepth - overrides the bit depth derived from the element datatype (but - must match *mode* if that also specifies a bit depth). - - Generally anything specified in the - *info* dictionary will override any implicit choices that this - function would otherwise make, but must match any explicit ones. - For example, if the *info* dictionary has a ``greyscale`` key then - this must be true when mode is ``'L'`` or ``'LA'`` and false when - mode is ``'RGB'`` or ``'RGBA'``. - """ - - # We abuse the *info* parameter by modifying it. Take a copy here. - # (Also typechecks *info* to some extent). - info = dict(info) - - # Syntax check mode string. - bitdepth = None - try: - # Assign the 'L' or 'RGBA' part to `gotmode`. - if mode.startswith('L'): - gotmode = 'L' - mode = mode[1:] - elif mode.startswith('RGB'): - gotmode = 'RGB' - mode = mode[3:] - else: - raise Error() - if mode.startswith('A'): - gotmode += 'A' - mode = mode[1:] - - # Skip any optional ';' - while mode.startswith(';'): - mode = mode[1:] - - # Parse optional bitdepth - if mode: - try: - bitdepth = int(mode) - except (TypeError, ValueError): - raise Error() - except Error: - raise Error("mode string should be 'RGB' or 'L;16' or similar.") - mode = gotmode - - # Get bitdepth from *mode* if possible. - if bitdepth: - if info.get('bitdepth') and bitdepth != info['bitdepth']: - raise Error("mode bitdepth (%d) should match info bitdepth (%d)." % - (bitdepth, info['bitdepth'])) - info['bitdepth'] = bitdepth - - # Fill in and/or check entries in *info*. - # Dimensions. - if 'size' in info: - # Check width, height, size all match where used. - for dimension,axis in [('width', 0), ('height', 1)]: - if dimension in info: - if info[dimension] != info['size'][axis]: - raise Error( - "info[%r] should match info['size'][%r]." % - (dimension, axis)) - info['width'],info['height'] = info['size'] - if 'height' not in info: - try: - l = len(a) - except TypeError: - raise Error( - "len(a) does not work, supply info['height'] instead.") - info['height'] = l - # Colour format. - if 'greyscale' in info: - if bool(info['greyscale']) != ('L' in mode): - raise Error("info['greyscale'] should match mode.") - info['greyscale'] = 'L' in mode - if 'alpha' in info: - if bool(info['alpha']) != ('A' in mode): - raise Error("info['alpha'] should match mode.") - info['alpha'] = 'A' in mode - - planes = len(mode) - if 'planes' in info: - if info['planes'] != planes: - raise Error("info['planes'] should match mode.") - - # In order to work out whether we the array is 2D or 3D we need its - # first row, which requires that we take a copy of its iterator. - # We may also need the first row to derive width and bitdepth. - a,t = itertools.tee(a) - row = next(t) - del t - try: - row[0][0] - threed = True - testelement = row[0] - except (IndexError, TypeError): - threed = False - testelement = row - if 'width' not in info: - if threed: - width = len(row) - else: - width = len(row) // planes - info['width'] = width - - if threed: - # Flatten the threed rows - a = (itertools.chain.from_iterable(x) for x in a) - - if 'bitdepth' not in info: - try: - dtype = testelement.dtype - # goto the "else:" clause. Sorry. - except AttributeError: - try: - # Try a Python array.array. - bitdepth = 8 * testelement.itemsize - except AttributeError: - # We can't determine it from the array element's - # datatype, use a default of 8. - bitdepth = 8 - else: - # If we got here without exception, we now assume that - # the array is a numpy array. - if dtype.kind == 'b': - bitdepth = 1 - else: - bitdepth = 8 * dtype.itemsize - info['bitdepth'] = bitdepth - - for thing in 'width height bitdepth greyscale alpha'.split(): - assert thing in info - return Image(a, info) - -# So that refugee's from PIL feel more at home. Not documented. -fromarray = from_array - -class Image: - """A PNG image. You can create an :class:`Image` object from - an array of pixels by calling :meth:`png.from_array`. It can be - saved to disk with the :meth:`save` method. - """ - - def __init__(self, rows, info): - """ - .. note :: - - The constructor is not public. Please do not call it. - """ - - self.rows = rows - self.info = info - - def save(self, file): - """Save the image to *file*. If *file* looks like an open file - descriptor then it is used, otherwise it is treated as a - filename and a fresh file is opened. - - In general, you can only call this method once; after it has - been called the first time and the PNG image has been saved, the - source data will have been streamed, and cannot be streamed - again. - """ - - w = Writer(**self.info) - - try: - file.write - def close(): pass - except AttributeError: - file = open(file, 'wb') - def close(): file.close() - - try: - w.write(file, self.rows) - finally: - close() - -class _readable: - """ - A simple file-like interface for strings and arrays. - """ - - def __init__(self, buf): - self.buf = buf - self.offset = 0 - - def read(self, n): - r = self.buf[self.offset:self.offset+n] - if isarray(r): - r = r.tostring() - self.offset += n - return r - -try: - str(b'dummy', 'ascii') -except TypeError: - as_str = str -else: - def as_str(x): - return str(x, 'ascii') - -class Reader: - """ - PNG decoder in pure Python. - """ - - def __init__(self, _guess=None, **kw): - """ - Create a PNG decoder object. - - The constructor expects exactly one keyword argument. If you - supply a positional argument instead, it will guess the input - type. You can choose among the following keyword arguments: - - filename - Name of input file (a PNG file). - file - A file-like object (object with a read() method). - bytes - ``array`` or ``string`` with PNG data. - - """ - if ((_guess is not None and len(kw) != 0) or - (_guess is None and len(kw) != 1)): - raise TypeError("Reader() takes exactly 1 argument") - - # Will be the first 8 bytes, later on. See validate_signature. - self.signature = None - self.transparent = None - # A pair of (len,type) if a chunk has been read but its data and - # checksum have not (in other words the file position is just - # past the 4 bytes that specify the chunk type). See preamble - # method for how this is used. - self.atchunk = None - - if _guess is not None: - if isarray(_guess): - kw["bytes"] = _guess - elif isinstance(_guess, str): - kw["filename"] = _guess - elif hasattr(_guess, 'read'): - kw["file"] = _guess - - if "filename" in kw: - self.file = open(kw["filename"], "rb") - elif "file" in kw: - self.file = kw["file"] - elif "bytes" in kw: - self.file = _readable(kw["bytes"]) - else: - raise TypeError("expecting filename, file or bytes array") - - - def chunk(self, seek=None, lenient=False): - """ - Read the next PNG chunk from the input file; returns a - (*type*, *data*) tuple. *type* is the chunk's type as a - byte string (all PNG chunk types are 4 bytes long). - *data* is the chunk's data content, as a byte string. - - If the optional `seek` argument is - specified then it will keep reading chunks until it either runs - out of file or finds the type specified by the argument. Note - that in general the order of chunks in PNGs is unspecified, so - using `seek` can cause you to miss chunks. - - If the optional `lenient` argument evaluates to `True`, - checksum failures will raise warnings rather than exceptions. - """ - - self.validate_signature() - - while True: - # http://www.w3.org/TR/PNG/#5Chunk-layout - if not self.atchunk: - self.atchunk = self.chunklentype() - length, type = self.atchunk - self.atchunk = None - data = self.file.read(length) - if len(data) != length: - raise ChunkError('Chunk %s too short for required %i octets.' - % (type, length)) - checksum = self.file.read(4) - if len(checksum) != 4: - raise ChunkError('Chunk %s too short for checksum.' % type) - if seek and type != seek: - continue - verify = zlib.crc32(type) - verify = zlib.crc32(data, verify) - # Whether the output from zlib.crc32 is signed or not varies - # according to hideous implementation details, see - # http://bugs.python.org/issue1202 . - # We coerce it to be positive here (in a way which works on - # Python 2.3 and older). - verify &= 2**32 - 1 - verify = struct.pack('!I', verify) - if checksum != verify: - (a, ) = struct.unpack('!I', checksum) - (b, ) = struct.unpack('!I', verify) - message = "Checksum error in %s chunk: 0x%08X != 0x%08X." % (type, a, b) - if lenient: - warnings.warn(message, RuntimeWarning) - else: - raise ChunkError(message) - return type, data - - def chunks(self): - """Return an iterator that will yield each chunk as a - (*chunktype*, *content*) pair. - """ - - while True: - t,v = self.chunk() - yield t,v - if t == b'IEND': - break - - def undo_filter(self, filter_type, scanline, previous): - """Undo the filter for a scanline. `scanline` is a sequence of - bytes that does not include the initial filter type byte. - `previous` is decoded previous scanline (for straightlaced - images this is the previous pixel row, but for interlaced - images, it is the previous scanline in the reduced image, which - in general is not the previous pixel row in the final image). - When there is no previous scanline (the first row of a - straightlaced image, or the first row in one of the passes in an - interlaced image), then this argument should be ``None``. - - The scanline will have the effects of filtering removed, and the - result will be returned as a fresh sequence of bytes. - """ - - # :todo: Would it be better to update scanline in place? - # Yes, with the Cython extension making the undo_filter fast, - # updating scanline inplace makes the code 3 times faster - # (reading 50 images of 800x800 went from 40s to 16s) - result = scanline - - if filter_type == 0: - return result - - if filter_type not in (1,2,3,4): - raise FormatError('Invalid PNG Filter Type.' - ' See http://www.w3.org/TR/2003/REC-PNG-20031110/#9Filters .') - - # Filter unit. The stride from one pixel to the corresponding - # byte from the previous pixel. Normally this is the pixel - # size in bytes, but when this is smaller than 1, the previous - # byte is used instead. - fu = max(1, self.psize) - - # For the first line of a pass, synthesize a dummy previous - # line. An alternative approach would be to observe that on the - # first line 'up' is the same as 'null', 'paeth' is the same - # as 'sub', with only 'average' requiring any special case. - if not previous: - previous = array('B', [0]*len(scanline)) - - def sub(): - """Undo sub filter.""" - - ai = 0 - # Loop starts at index fu. Observe that the initial part - # of the result is already filled in correctly with - # scanline. - for i in range(fu, len(result)): - x = scanline[i] - a = result[ai] - result[i] = (x + a) & 0xff - ai += 1 - - def up(): - """Undo up filter.""" - - for i in range(len(result)): - x = scanline[i] - b = previous[i] - result[i] = (x + b) & 0xff - - def average(): - """Undo average filter.""" - - ai = -fu - for i in range(len(result)): - x = scanline[i] - if ai < 0: - a = 0 - else: - a = result[ai] - b = previous[i] - result[i] = (x + ((a + b) >> 1)) & 0xff - ai += 1 - - def paeth(): - """Undo Paeth filter.""" - - # Also used for ci. - ai = -fu - for i in range(len(result)): - x = scanline[i] - if ai < 0: - a = c = 0 - else: - a = result[ai] - c = previous[ai] - b = previous[i] - p = a + b - c - pa = abs(p - a) - pb = abs(p - b) - pc = abs(p - c) - if pa <= pb and pa <= pc: - pr = a - elif pb <= pc: - pr = b - else: - pr = c - result[i] = (x + pr) & 0xff - ai += 1 - - # Call appropriate filter algorithm. Note that 0 has already - # been dealt with. - (None, - pngfilters.undo_filter_sub, - pngfilters.undo_filter_up, - pngfilters.undo_filter_average, - pngfilters.undo_filter_paeth)[filter_type](fu, scanline, previous, result) - return result - - def deinterlace(self, raw): - """ - Read raw pixel data, undo filters, deinterlace, and flatten. - Return in flat row flat pixel format. - """ - - # Values per row (of the target image) - vpr = self.width * self.planes - - # Make a result array, and make it big enough. Interleaving - # writes to the output array randomly (well, not quite), so the - # entire output array must be in memory. - fmt = 'BH'[self.bitdepth > 8] - a = array(fmt, [0]*vpr*self.height) - source_offset = 0 - - for xstart, ystart, xstep, ystep in _adam7: - if xstart >= self.width: - continue - # The previous (reconstructed) scanline. None at the - # beginning of a pass to indicate that there is no previous - # line. - recon = None - # Pixels per row (reduced pass image) - ppr = int(math.ceil((self.width-xstart)/float(xstep))) - # Row size in bytes for this pass. - row_size = int(math.ceil(self.psize * ppr)) - for y in range(ystart, self.height, ystep): - filter_type = raw[source_offset] - source_offset += 1 - scanline = raw[source_offset:source_offset+row_size] - source_offset += row_size - recon = self.undo_filter(filter_type, scanline, recon) - # Convert so that there is one element per pixel value - flat = self.serialtoflat(recon, ppr) - if xstep == 1: - assert xstart == 0 - offset = y * vpr - a[offset:offset+vpr] = flat - else: - offset = y * vpr + xstart * self.planes - end_offset = (y+1) * vpr - skip = self.planes * xstep - for i in range(self.planes): - a[offset+i:end_offset:skip] = \ - flat[i::self.planes] - return a - - def iterboxed(self, rows): - """Iterator that yields each scanline in boxed row flat pixel - format. `rows` should be an iterator that yields the bytes of - each row in turn. - """ - - def asvalues(raw): - """Convert a row of raw bytes into a flat row. Result will - be a freshly allocated object, not shared with - argument. - """ - - if self.bitdepth == 8: - return array('B', raw) - if self.bitdepth == 16: - raw = tostring(raw) - return array('H', struct.unpack('!%dH' % (len(raw)//2), raw)) - assert self.bitdepth < 8 - width = self.width - # Samples per byte - spb = 8//self.bitdepth - out = array('B') - mask = 2**self.bitdepth - 1 - shifts = [self.bitdepth * i - for i in reversed(list(range(spb)))] - for o in raw: - out.extend([mask&(o>>i) for i in shifts]) - return out[:width] - - return map(asvalues, rows) - - def serialtoflat(self, bytes, width=None): - """Convert serial format (byte stream) pixel data to flat row - flat pixel. - """ - - if self.bitdepth == 8: - return bytes - if self.bitdepth == 16: - bytes = tostring(bytes) - return array('H', - struct.unpack('!%dH' % (len(bytes)//2), bytes)) - assert self.bitdepth < 8 - if width is None: - width = self.width - # Samples per byte - spb = 8//self.bitdepth - out = array('B') - mask = 2**self.bitdepth - 1 - shifts = list(map(self.bitdepth.__mul__, reversed(list(range(spb))))) - l = width - for o in bytes: - out.extend([(mask&(o>>s)) for s in shifts][:l]) - l -= spb - if l <= 0: - l = width - return out - - def iterstraight(self, raw): - """Iterator that undoes the effect of filtering, and yields - each row in serialised format (as a sequence of bytes). - Assumes input is straightlaced. `raw` should be an iterable - that yields the raw bytes in chunks of arbitrary size. - """ - - # length of row, in bytes - rb = self.row_bytes - a = array('B') - # The previous (reconstructed) scanline. None indicates first - # line of image. - recon = None - for some in raw: - a.extend(some) - while len(a) >= rb + 1: - filter_type = a[0] - scanline = a[1:rb+1] - del a[:rb+1] - recon = self.undo_filter(filter_type, scanline, recon) - yield recon - if len(a) != 0: - # :file:format We get here with a file format error: - # when the available bytes (after decompressing) do not - # pack into exact rows. - raise FormatError( - 'Wrong size for decompressed IDAT chunk.') - assert len(a) == 0 - - def validate_signature(self): - """If signature (header) has not been read then read and - validate it; otherwise do nothing. - """ - - if self.signature: - return - self.signature = self.file.read(8) - if self.signature != _signature: - raise FormatError("PNG file has invalid signature.") - - def preamble(self, lenient=False): - """ - Extract the image metadata by reading the initial part of - the PNG file up to the start of the ``IDAT`` chunk. All the - chunks that precede the ``IDAT`` chunk are read and either - processed for metadata or discarded. - - If the optional `lenient` argument evaluates to `True`, checksum - failures will raise warnings rather than exceptions. - """ - - self.validate_signature() - - while True: - if not self.atchunk: - self.atchunk = self.chunklentype() - if self.atchunk is None: - raise FormatError( - 'This PNG file has no IDAT chunks.') - if self.atchunk[1] == b'IDAT': - return - self.process_chunk(lenient=lenient) - - def chunklentype(self): - """Reads just enough of the input to determine the next - chunk's length and type, returned as a (*length*, *type*) pair - where *type* is a string. If there are no more chunks, ``None`` - is returned. - """ - - x = self.file.read(8) - if not x: - return None - if len(x) != 8: - raise FormatError( - 'End of file whilst reading chunk length and type.') - length,type = struct.unpack('!I4s', x) - if length > 2**31-1: - raise FormatError('Chunk %s is too large: %d.' % (type,length)) - return length,type - - def process_chunk(self, lenient=False): - """Process the next chunk and its data. This only processes the - following chunk types, all others are ignored: ``IHDR``, - ``PLTE``, ``bKGD``, ``tRNS``, ``gAMA``, ``sBIT``, ``pHYs``. - - If the optional `lenient` argument evaluates to `True`, - checksum failures will raise warnings rather than exceptions. - """ - - type, data = self.chunk(lenient=lenient) - method = '_process_' + as_str(type) - m = getattr(self, method, None) - if m: - m(data) - - def _process_IHDR(self, data): - # http://www.w3.org/TR/PNG/#11IHDR - if len(data) != 13: - raise FormatError('IHDR chunk has incorrect length.') - (self.width, self.height, self.bitdepth, self.color_type, - self.compression, self.filter, - self.interlace) = struct.unpack("!2I5B", data) - - check_bitdepth_colortype(self.bitdepth, self.color_type) - - if self.compression != 0: - raise Error("unknown compression method %d" % self.compression) - if self.filter != 0: - raise FormatError("Unknown filter method %d," - " see http://www.w3.org/TR/2003/REC-PNG-20031110/#9Filters ." - % self.filter) - if self.interlace not in (0,1): - raise FormatError("Unknown interlace method %d," - " see http://www.w3.org/TR/2003/REC-PNG-20031110/#8InterlaceMethods ." - % self.interlace) - - # Derived values - # http://www.w3.org/TR/PNG/#6Colour-values - colormap = bool(self.color_type & 1) - greyscale = not (self.color_type & 2) - alpha = bool(self.color_type & 4) - color_planes = (3,1)[greyscale or colormap] - planes = color_planes + alpha - - self.colormap = colormap - self.greyscale = greyscale - self.alpha = alpha - self.color_planes = color_planes - self.planes = planes - self.psize = float(self.bitdepth)/float(8) * planes - if int(self.psize) == self.psize: - self.psize = int(self.psize) - self.row_bytes = int(math.ceil(self.width * self.psize)) - # Stores PLTE chunk if present, and is used to check - # chunk ordering constraints. - self.plte = None - # Stores tRNS chunk if present, and is used to check chunk - # ordering constraints. - self.trns = None - # Stores sbit chunk if present. - self.sbit = None - - def _process_PLTE(self, data): - # http://www.w3.org/TR/PNG/#11PLTE - if self.plte: - warnings.warn("Multiple PLTE chunks present.") - self.plte = data - if len(data) % 3 != 0: - raise FormatError( - "PLTE chunk's length should be a multiple of 3.") - if len(data) > (2**self.bitdepth)*3: - raise FormatError("PLTE chunk is too long.") - if len(data) == 0: - raise FormatError("Empty PLTE is not allowed.") - - def _process_bKGD(self, data): - try: - if self.colormap: - if not self.plte: - warnings.warn( - "PLTE chunk is required before bKGD chunk.") - self.background = struct.unpack('B', data) - else: - self.background = struct.unpack("!%dH" % self.color_planes, - data) - except struct.error: - raise FormatError("bKGD chunk has incorrect length.") - - def _process_tRNS(self, data): - # http://www.w3.org/TR/PNG/#11tRNS - self.trns = data - if self.colormap: - if not self.plte: - warnings.warn("PLTE chunk is required before tRNS chunk.") - else: - if len(data) > len(self.plte)/3: - # Was warning, but promoted to Error as it - # would otherwise cause pain later on. - raise FormatError("tRNS chunk is too long.") - else: - if self.alpha: - raise FormatError( - "tRNS chunk is not valid with colour type %d." % - self.color_type) - try: - self.transparent = \ - struct.unpack("!%dH" % self.color_planes, data) - except struct.error: - raise FormatError("tRNS chunk has incorrect length.") - - def _process_gAMA(self, data): - try: - self.gamma = struct.unpack("!L", data)[0] / 100000.0 - except struct.error: - raise FormatError("gAMA chunk has incorrect length.") - - def _process_sBIT(self, data): - self.sbit = data - if (self.colormap and len(data) != 3 or - not self.colormap and len(data) != self.planes): - raise FormatError("sBIT chunk has incorrect length.") - - def _process_pHYs(self, data): - # http://www.w3.org/TR/PNG/#11pHYs - self.phys = data - fmt = "!LLB" - if len(data) != struct.calcsize(fmt): - raise FormatError("pHYs chunk has incorrect length.") - self.x_pixels_per_unit, self.y_pixels_per_unit, unit = struct.unpack(fmt,data) - self.unit_is_meter = bool(unit) - - def read(self, lenient=False): - """ - Read the PNG file and decode it. Returns (`width`, `height`, - `pixels`, `metadata`). - - May use excessive memory. - - `pixels` are returned in boxed row flat pixel format. - - If the optional `lenient` argument evaluates to True, - checksum failures will raise warnings rather than exceptions. - """ - - def iteridat(): - """Iterator that yields all the ``IDAT`` chunks as strings.""" - while True: - try: - type, data = self.chunk(lenient=lenient) - except ValueError as e: - raise ChunkError(e.args[0]) - if type == b'IEND': - # http://www.w3.org/TR/PNG/#11IEND - break - if type != b'IDAT': - continue - # type == b'IDAT' - # http://www.w3.org/TR/PNG/#11IDAT - if self.colormap and not self.plte: - warnings.warn("PLTE chunk is required before IDAT chunk") - yield data - - def iterdecomp(idat): - """Iterator that yields decompressed strings. `idat` should - be an iterator that yields the ``IDAT`` chunk data. - """ - - # Currently, with no max_length parameter to decompress, - # this routine will do one yield per IDAT chunk: Not very - # incremental. - d = zlib.decompressobj() - # Each IDAT chunk is passed to the decompressor, then any - # remaining state is decompressed out. - for data in idat: - # :todo: add a max_length argument here to limit output - # size. - yield array('B', d.decompress(data)) - yield array('B', d.flush()) - - self.preamble(lenient=lenient) - raw = iterdecomp(iteridat()) - - if self.interlace: - raw = array('B', itertools.chain(*raw)) - arraycode = 'BH'[self.bitdepth>8] - # Like :meth:`group` but producing an array.array object for - # each row. - pixels = map(lambda *row: array(arraycode, row), - *[iter(self.deinterlace(raw))]*self.width*self.planes) - else: - pixels = self.iterboxed(self.iterstraight(raw)) - meta = dict() - for attr in 'greyscale alpha planes bitdepth interlace'.split(): - meta[attr] = getattr(self, attr) - meta['size'] = (self.width, self.height) - for attr in 'gamma transparent background'.split(): - a = getattr(self, attr, None) - if a is not None: - meta[attr] = a - if self.plte: - meta['palette'] = self.palette() - return self.width, self.height, pixels, meta - - - def read_flat(self): - """ - Read a PNG file and decode it into flat row flat pixel format. - Returns (*width*, *height*, *pixels*, *metadata*). - - May use excessive memory. - - `pixels` are returned in flat row flat pixel format. - - See also the :meth:`read` method which returns pixels in the - more stream-friendly boxed row flat pixel format. - """ - - x, y, pixel, meta = self.read() - arraycode = 'BH'[meta['bitdepth']>8] - pixel = array(arraycode, itertools.chain(*pixel)) - return x, y, pixel, meta - - def palette(self, alpha='natural'): - """Returns a palette that is a sequence of 3-tuples or 4-tuples, - synthesizing it from the ``PLTE`` and ``tRNS`` chunks. These - chunks should have already been processed (for example, by - calling the :meth:`preamble` method). All the tuples are the - same size: 3-tuples if there is no ``tRNS`` chunk, 4-tuples when - there is a ``tRNS`` chunk. Assumes that the image is colour type - 3 and therefore a ``PLTE`` chunk is required. - - If the `alpha` argument is ``'force'`` then an alpha channel is - always added, forcing the result to be a sequence of 4-tuples. - """ - - if not self.plte: - raise FormatError( - "Required PLTE chunk is missing in colour type 3 image.") - plte = group(array('B', self.plte), 3) - if self.trns or alpha == 'force': - trns = array('B', self.trns or '') - trns.extend([255]*(len(plte)-len(trns))) - plte = list(map(operator.add, plte, group(trns, 1))) - return plte - - def asDirect(self): - """Returns the image data as a direct representation of an - ``x * y * planes`` array. This method is intended to remove the - need for callers to deal with palettes and transparency - themselves. Images with a palette (colour type 3) - are converted to RGB or RGBA; images with transparency (a - ``tRNS`` chunk) are converted to LA or RGBA as appropriate. - When returned in this format the pixel values represent the - colour value directly without needing to refer to palettes or - transparency information. - - Like the :meth:`read` method this method returns a 4-tuple: - - (*width*, *height*, *pixels*, *meta*) - - This method normally returns pixel values with the bit depth - they have in the source image, but when the source PNG has an - ``sBIT`` chunk it is inspected and can reduce the bit depth of - the result pixels; pixel values will be reduced according to - the bit depth specified in the ``sBIT`` chunk (PNG nerds should - note a single result bit depth is used for all channels; the - maximum of the ones specified in the ``sBIT`` chunk. An RGB565 - image will be rescaled to 6-bit RGB666). - - The *meta* dictionary that is returned reflects the `direct` - format and not the original source image. For example, an RGB - source image with a ``tRNS`` chunk to represent a transparent - colour, will have ``planes=3`` and ``alpha=False`` for the - source image, but the *meta* dictionary returned by this method - will have ``planes=4`` and ``alpha=True`` because an alpha - channel is synthesized and added. - - *pixels* is the pixel data in boxed row flat pixel format (just - like the :meth:`read` method). - - All the other aspects of the image data are not changed. - """ - - self.preamble() - - # Simple case, no conversion necessary. - if not self.colormap and not self.trns and not self.sbit: - return self.read() - - x,y,pixels,meta = self.read() - - if self.colormap: - meta['colormap'] = False - meta['alpha'] = bool(self.trns) - meta['bitdepth'] = 8 - meta['planes'] = 3 + bool(self.trns) - plte = self.palette() - def iterpal(pixels): - for row in pixels: - row = [plte[x] for x in row] - yield array('B', itertools.chain(*row)) - pixels = iterpal(pixels) - elif self.trns: - # It would be nice if there was some reasonable way - # of doing this without generating a whole load of - # intermediate tuples. But tuples does seem like the - # easiest way, with no other way clearly much simpler or - # much faster. (Actually, the L to LA conversion could - # perhaps go faster (all those 1-tuples!), but I still - # wonder whether the code proliferation is worth it) - it = self.transparent - maxval = 2**meta['bitdepth']-1 - planes = meta['planes'] - meta['alpha'] = True - meta['planes'] += 1 - typecode = 'BH'[meta['bitdepth']>8] - def itertrns(pixels): - for row in pixels: - # For each row we group it into pixels, then form a - # characterisation vector that says whether each - # pixel is opaque or not. Then we convert - # True/False to 0/maxval (by multiplication), - # and add it as the extra channel. - row = group(row, planes) - opa = map(it.__ne__, row) - opa = map(maxval.__mul__, opa) - opa = list(zip(opa)) # convert to 1-tuples - yield array(typecode, - itertools.chain(*map(operator.add, row, opa))) - pixels = itertrns(pixels) - targetbitdepth = None - if self.sbit: - sbit = struct.unpack('%dB' % len(self.sbit), self.sbit) - targetbitdepth = max(sbit) - if targetbitdepth > meta['bitdepth']: - raise Error('sBIT chunk %r exceeds bitdepth %d' % - (sbit,self.bitdepth)) - if min(sbit) <= 0: - raise Error('sBIT chunk %r has a 0-entry' % sbit) - if targetbitdepth == meta['bitdepth']: - targetbitdepth = None - if targetbitdepth: - shift = meta['bitdepth'] - targetbitdepth - meta['bitdepth'] = targetbitdepth - def itershift(pixels): - for row in pixels: - yield [p >> shift for p in row] - pixels = itershift(pixels) - return x,y,pixels,meta - - def asFloat(self, maxval=1.0): - """Return image pixels as per :meth:`asDirect` method, but scale - all pixel values to be floating point values between 0.0 and - *maxval*. - """ - - x,y,pixels,info = self.asDirect() - sourcemaxval = 2**info['bitdepth']-1 - del info['bitdepth'] - info['maxval'] = float(maxval) - factor = float(maxval)/float(sourcemaxval) - def iterfloat(): - for row in pixels: - yield [factor * p for p in row] - return x,y,iterfloat(),info - - def _as_rescale(self, get, targetbitdepth): - """Helper used by :meth:`asRGB8` and :meth:`asRGBA8`.""" - - width,height,pixels,meta = get() - maxval = 2**meta['bitdepth'] - 1 - targetmaxval = 2**targetbitdepth - 1 - factor = float(targetmaxval) / float(maxval) - meta['bitdepth'] = targetbitdepth - def iterscale(): - for row in pixels: - yield [int(round(x*factor)) for x in row] - if maxval == targetmaxval: - return width, height, pixels, meta - else: - return width, height, iterscale(), meta - - def asRGB8(self): - """Return the image data as an RGB pixels with 8-bits per - sample. This is like the :meth:`asRGB` method except that - this method additionally rescales the values so that they - are all between 0 and 255 (8-bit). In the case where the - source image has a bit depth < 8 the transformation preserves - all the information; where the source image has bit depth - > 8, then rescaling to 8-bit values loses precision. No - dithering is performed. Like :meth:`asRGB`, an alpha channel - in the source image will raise an exception. - - This function returns a 4-tuple: - (*width*, *height*, *pixels*, *metadata*). - *width*, *height*, *metadata* are as per the - :meth:`read` method. - - *pixels* is the pixel data in boxed row flat pixel format. - """ - - return self._as_rescale(self.asRGB, 8) - - def asRGBA8(self): - """Return the image data as RGBA pixels with 8-bits per - sample. This method is similar to :meth:`asRGB8` and - :meth:`asRGBA`: The result pixels have an alpha channel, *and* - values are rescaled to the range 0 to 255. The alpha channel is - synthesized if necessary (with a small speed penalty). - """ - - return self._as_rescale(self.asRGBA, 8) - - def asRGB(self): - """Return image as RGB pixels. RGB colour images are passed - through unchanged; greyscales are expanded into RGB - triplets (there is a small speed overhead for doing this). - - An alpha channel in the source image will raise an - exception. - - The return values are as for the :meth:`read` method - except that the *metadata* reflect the returned pixels, not the - source image. In particular, for this method - ``metadata['greyscale']`` will be ``False``. - """ - - width,height,pixels,meta = self.asDirect() - if meta['alpha']: - raise Error("will not convert image with alpha channel to RGB") - if not meta['greyscale']: - return width,height,pixels,meta - meta['greyscale'] = False - typecode = 'BH'[meta['bitdepth'] > 8] - def iterrgb(): - for row in pixels: - a = array(typecode, [0]) * 3 * width - for i in range(3): - a[i::3] = row - yield a - return width,height,iterrgb(),meta - - def asRGBA(self): - """Return image as RGBA pixels. Greyscales are expanded into - RGB triplets; an alpha channel is synthesized if necessary. - The return values are as for the :meth:`read` method - except that the *metadata* reflect the returned pixels, not the - source image. In particular, for this method - ``metadata['greyscale']`` will be ``False``, and - ``metadata['alpha']`` will be ``True``. - """ - - width,height,pixels,meta = self.asDirect() - if meta['alpha'] and not meta['greyscale']: - return width,height,pixels,meta - typecode = 'BH'[meta['bitdepth'] > 8] - maxval = 2**meta['bitdepth'] - 1 - maxbuffer = struct.pack('=' + typecode, maxval) * 4 * width - def newarray(): - return array(typecode, maxbuffer) - - if meta['alpha'] and meta['greyscale']: - # LA to RGBA - def convert(): - for row in pixels: - # Create a fresh target row, then copy L channel - # into first three target channels, and A channel - # into fourth channel. - a = newarray() - pngfilters.convert_la_to_rgba(row, a) - yield a - elif meta['greyscale']: - # L to RGBA - def convert(): - for row in pixels: - a = newarray() - pngfilters.convert_l_to_rgba(row, a) - yield a - else: - assert not meta['alpha'] and not meta['greyscale'] - # RGB to RGBA - def convert(): - for row in pixels: - a = newarray() - pngfilters.convert_rgb_to_rgba(row, a) - yield a - meta['alpha'] = True - meta['greyscale'] = False - return width,height,convert(),meta - -def check_bitdepth_colortype(bitdepth, colortype): - """Check that `bitdepth` and `colortype` are both valid, - and specified in a valid combination. Returns if valid, - raise an Exception if not valid. - """ - - if bitdepth not in (1,2,4,8,16): - raise FormatError("invalid bit depth %d" % bitdepth) - if colortype not in (0,2,3,4,6): - raise FormatError("invalid colour type %d" % colortype) - # Check indexed (palettized) images have 8 or fewer bits - # per pixel; check only indexed or greyscale images have - # fewer than 8 bits per pixel. - if colortype & 1 and bitdepth > 8: - raise FormatError( - "Indexed images (colour type %d) cannot" - " have bitdepth > 8 (bit depth %d)." - " See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 ." - % (bitdepth, colortype)) - if bitdepth < 8 and colortype not in (0,3): - raise FormatError("Illegal combination of bit depth (%d)" - " and colour type (%d)." - " See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 ." - % (bitdepth, colortype)) - -def isinteger(x): - try: - return int(x) == x - except (TypeError, ValueError): - return False - - -# === Support for users without Cython === - -try: - pngfilters -except NameError: - class pngfilters(object): - def undo_filter_sub(filter_unit, scanline, previous, result): - """Undo sub filter.""" - - ai = 0 - # Loops starts at index fu. Observe that the initial part - # of the result is already filled in correctly with - # scanline. - for i in range(filter_unit, len(result)): - x = scanline[i] - a = result[ai] - result[i] = (x + a) & 0xff - ai += 1 - undo_filter_sub = staticmethod(undo_filter_sub) - - def undo_filter_up(filter_unit, scanline, previous, result): - """Undo up filter.""" - - for i in range(len(result)): - x = scanline[i] - b = previous[i] - result[i] = (x + b) & 0xff - undo_filter_up = staticmethod(undo_filter_up) - - def undo_filter_average(filter_unit, scanline, previous, result): - """Undo up filter.""" - - ai = -filter_unit - for i in range(len(result)): - x = scanline[i] - if ai < 0: - a = 0 - else: - a = result[ai] - b = previous[i] - result[i] = (x + ((a + b) >> 1)) & 0xff - ai += 1 - undo_filter_average = staticmethod(undo_filter_average) - - def undo_filter_paeth(filter_unit, scanline, previous, result): - """Undo Paeth filter.""" - - # Also used for ci. - ai = -filter_unit - for i in range(len(result)): - x = scanline[i] - if ai < 0: - a = c = 0 - else: - a = result[ai] - c = previous[ai] - b = previous[i] - p = a + b - c - pa = abs(p - a) - pb = abs(p - b) - pc = abs(p - c) - if pa <= pb and pa <= pc: - pr = a - elif pb <= pc: - pr = b - else: - pr = c - result[i] = (x + pr) & 0xff - ai += 1 - undo_filter_paeth = staticmethod(undo_filter_paeth) - - def convert_la_to_rgba(row, result): - for i in range(3): - result[i::4] = row[0::2] - result[3::4] = row[1::2] - convert_la_to_rgba = staticmethod(convert_la_to_rgba) - - def convert_l_to_rgba(row, result): - """Convert a grayscale image to RGBA. This method assumes - the alpha channel in result is already correctly - initialized. - """ - for i in range(3): - result[i::4] = row - convert_l_to_rgba = staticmethod(convert_l_to_rgba) - - def convert_rgb_to_rgba(row, result): - """Convert an RGB image to RGBA. This method assumes the - alpha channel in result is already correctly initialized. - """ - for i in range(3): - result[i::4] = row[i::3] - convert_rgb_to_rgba = staticmethod(convert_rgb_to_rgba) - - -# === Command Line Support === - -def read_pam_header(infile): - """ - Read (the rest of a) PAM header. `infile` should be positioned - immediately after the initial 'P7' line (at the beginning of the - second line). Returns are as for `read_pnm_header`. - """ - - # Unlike PBM, PGM, and PPM, we can read the header a line at a time. - header = dict() - while True: - l = infile.readline().strip() - if l == b'ENDHDR': - break - if not l: - raise EOFError('PAM ended prematurely') - if l[0] == b'#': - continue - l = l.split(None, 1) - if l[0] not in header: - header[l[0]] = l[1] - else: - header[l[0]] += b' ' + l[1] - - required = [b'WIDTH', b'HEIGHT', b'DEPTH', b'MAXVAL'] - WIDTH,HEIGHT,DEPTH,MAXVAL = required - present = [x for x in required if x in header] - if len(present) != len(required): - raise Error('PAM file must specify WIDTH, HEIGHT, DEPTH, and MAXVAL') - width = int(header[WIDTH]) - height = int(header[HEIGHT]) - depth = int(header[DEPTH]) - maxval = int(header[MAXVAL]) - if (width <= 0 or - height <= 0 or - depth <= 0 or - maxval <= 0): - raise Error( - 'WIDTH, HEIGHT, DEPTH, MAXVAL must all be positive integers') - return 'P7', width, height, depth, maxval - -def read_pnm_header(infile, supported=(b'P5', b'P6')): - """ - Read a PNM header, returning (format,width,height,depth,maxval). - `width` and `height` are in pixels. `depth` is the number of - channels in the image; for PBM and PGM it is synthesized as 1, for - PPM as 3; for PAM images it is read from the header. `maxval` is - synthesized (as 1) for PBM images. - """ - - # Generally, see http://netpbm.sourceforge.net/doc/ppm.html - # and http://netpbm.sourceforge.net/doc/pam.html - - # Technically 'P7' must be followed by a newline, so by using - # rstrip() we are being liberal in what we accept. I think this - # is acceptable. - type = infile.read(3).rstrip() - if type not in supported: - raise NotImplementedError('file format %s not supported' % type) - if type == b'P7': - # PAM header parsing is completely different. - return read_pam_header(infile) - # Expected number of tokens in header (3 for P4, 4 for P6) - expected = 4 - pbm = (b'P1', b'P4') - if type in pbm: - expected = 3 - header = [type] - - # We have to read the rest of the header byte by byte because the - # final whitespace character (immediately following the MAXVAL in - # the case of P6) may not be a newline. Of course all PNM files in - # the wild use a newline at this point, so it's tempting to use - # readline; but it would be wrong. - def getc(): - c = infile.read(1) - if not c: - raise Error('premature EOF reading PNM header') - return c - - c = getc() - while True: - # Skip whitespace that precedes a token. - while c.isspace(): - c = getc() - # Skip comments. - while c == '#': - while c not in b'\n\r': - c = getc() - if not c.isdigit(): - raise Error('unexpected character %s found in header' % c) - # According to the specification it is legal to have comments - # that appear in the middle of a token. - # This is bonkers; I've never seen it; and it's a bit awkward to - # code good lexers in Python (no goto). So we break on such - # cases. - token = b'' - while c.isdigit(): - token += c - c = getc() - # Slight hack. All "tokens" are decimal integers, so convert - # them here. - header.append(int(token)) - if len(header) == expected: - break - # Skip comments (again) - while c == '#': - while c not in '\n\r': - c = getc() - if not c.isspace(): - raise Error('expected header to end with whitespace, not %s' % c) - - if type in pbm: - # synthesize a MAXVAL - header.append(1) - depth = (1,3)[type == b'P6'] - return header[0], header[1], header[2], depth, header[3] - -def write_pnm(file, width, height, pixels, meta): - """Write a Netpbm PNM/PAM file. - """ - - bitdepth = meta['bitdepth'] - maxval = 2**bitdepth - 1 - # Rudely, the number of image planes can be used to determine - # whether we are L (PGM), LA (PAM), RGB (PPM), or RGBA (PAM). - planes = meta['planes'] - # Can be an assert as long as we assume that pixels and meta came - # from a PNG file. - assert planes in (1,2,3,4) - if planes in (1,3): - if 1 == planes: - # PGM - # Could generate PBM if maxval is 1, but we don't (for one - # thing, we'd have to convert the data, not just blat it - # out). - fmt = 'P5' - else: - # PPM - fmt = 'P6' - header = '%s %d %d %d\n' % (fmt, width, height, maxval) - if planes in (2,4): - # PAM - # See http://netpbm.sourceforge.net/doc/pam.html - if 2 == planes: - tupltype = 'GRAYSCALE_ALPHA' - else: - tupltype = 'RGB_ALPHA' - header = ('P7\nWIDTH %d\nHEIGHT %d\nDEPTH %d\nMAXVAL %d\n' - 'TUPLTYPE %s\nENDHDR\n' % - (width, height, planes, maxval, tupltype)) - file.write(header.encode('ascii')) - # Values per row - vpr = planes * width - # struct format - fmt = '>%d' % vpr - if maxval > 0xff: - fmt = fmt + 'H' - else: - fmt = fmt + 'B' - for row in pixels: - file.write(struct.pack(fmt, *row)) - file.flush() - -def color_triple(color): - """ - Convert a command line colour value to a RGB triple of integers. - FIXME: Somewhere we need support for greyscale backgrounds etc. - """ - if color.startswith('#') and len(color) == 4: - return (int(color[1], 16), - int(color[2], 16), - int(color[3], 16)) - if color.startswith('#') and len(color) == 7: - return (int(color[1:3], 16), - int(color[3:5], 16), - int(color[5:7], 16)) - elif color.startswith('#') and len(color) == 13: - return (int(color[1:5], 16), - int(color[5:9], 16), - int(color[9:13], 16)) - -def _add_common_options(parser): - """Call *parser.add_option* for each of the options that are - common between this PNG--PNM conversion tool and the gen - tool. - """ - parser.add_option("-i", "--interlace", - default=False, action="store_true", - help="create an interlaced PNG file (Adam7)") - parser.add_option("-t", "--transparent", - action="store", type="string", metavar="#RRGGBB", - help="mark the specified colour as transparent") - parser.add_option("-b", "--background", - action="store", type="string", metavar="#RRGGBB", - help="save the specified background colour") - parser.add_option("-g", "--gamma", - action="store", type="float", metavar="value", - help="save the specified gamma value") - parser.add_option("-c", "--compression", - action="store", type="int", metavar="level", - help="zlib compression level (0-9)") - return parser - -def _main(argv): - """ - Run the PNG encoder with options from the command line. - """ - - # Parse command line arguments - from optparse import OptionParser - version = '%prog ' + __version__ - parser = OptionParser(version=version) - parser.set_usage("%prog [options] [imagefile]") - parser.add_option('-r', '--read-png', default=False, - action='store_true', - help='Read PNG, write PNM') - parser.add_option("-a", "--alpha", - action="store", type="string", metavar="pgmfile", - help="alpha channel transparency (RGBA)") - _add_common_options(parser) - - (options, args) = parser.parse_args(args=argv[1:]) - - # Convert options - if options.transparent is not None: - options.transparent = color_triple(options.transparent) - if options.background is not None: - options.background = color_triple(options.background) - - # Prepare input and output files - if len(args) == 0: - infilename = '-' - infile = sys.stdin - elif len(args) == 1: - infilename = args[0] - infile = open(infilename, 'rb') - else: - parser.error("more than one input file") - outfile = sys.stdout - if sys.platform == "win32": - import msvcrt, os - msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) - - if options.read_png: - # Encode PNG to PPM - png = Reader(file=infile) - width,height,pixels,meta = png.asDirect() - write_pnm(outfile, width, height, pixels, meta) - else: - # Encode PNM to PNG - format, width, height, depth, maxval = \ - read_pnm_header(infile, (b'P5',b'P6',b'P7')) - # When it comes to the variety of input formats, we do something - # rather rude. Observe that L, LA, RGB, RGBA are the 4 colour - # types supported by PNG and that they correspond to 1, 2, 3, 4 - # channels respectively. So we use the number of channels in - # the source image to determine which one we have. We do not - # care about TUPLTYPE. - greyscale = depth <= 2 - pamalpha = depth in (2,4) - supported = [2**x-1 for x in range(1,17)] - try: - mi = supported.index(maxval) - except ValueError: - raise NotImplementedError( - 'your maxval (%s) not in supported list %s' % - (maxval, str(supported))) - bitdepth = mi+1 - writer = Writer(width, height, - greyscale=greyscale, - bitdepth=bitdepth, - interlace=options.interlace, - transparent=options.transparent, - background=options.background, - alpha=bool(pamalpha or options.alpha), - gamma=options.gamma, - compression=options.compression) - if options.alpha: - pgmfile = open(options.alpha, 'rb') - format, awidth, aheight, adepth, amaxval = \ - read_pnm_header(pgmfile, 'P5') - if amaxval != '255': - raise NotImplementedError( - 'maxval %s not supported for alpha channel' % amaxval) - if (awidth, aheight) != (width, height): - raise ValueError("alpha channel image size mismatch" - " (%s has %sx%s but %s has %sx%s)" - % (infilename, width, height, - options.alpha, awidth, aheight)) - writer.convert_ppm_and_pgm(infile, pgmfile, outfile) - else: - writer.convert_pnm(infile, outfile) - - -if __name__ == '__main__': - try: - _main(sys.argv) - except Error as e: - print(e, file=sys.stderr) diff --git a/tools/read_charmap.py b/tools/read_charmap.py deleted file mode 100644 index af290c3..0000000 --- a/tools/read_charmap.py +++ /dev/null @@ -1,42 +0,0 @@ -import os, io -from re import compile -from sys import stderr - -charmap_regex = compile('[ \t]*charmap[ \t]+"(.*?)",[ \t]*(\$[0-9A-Fa-f]{2}|%[01]{8}|[0-9]{3})') -# A charmap line is -# [ \t]* - zero or more space chars -# charmap - literal charmap -# [ \t]+ - one or more space chars -# "(.*?)" - a lazily-matched text identifier in quotes -# , - literal comma -# [ \t]* - zero or more space chars -# ( - either of -# \$[0-9A-Fa-f]{2} - two hexadecimal digits preceeded by literal $ -# %[01]{8} - eight dual digits preceeded by literal % -# [0-9]{3} - three decimal digits -# ) - -def parse_int(s): - # assumes integers are literal; no +-*/, etc - s = s.strip() - if s.startswith('$'): - return int(s[1:], 16) - if s.startswith('%'): - return int(s[1:], 2) - return int(s) - -def read_charmap(charmap_path): - charmap = {} - with io.open(charmap_path, 'r', encoding='utf-8') as f: - lines = f.readlines() - for line in lines: - m = charmap_regex.match(line) - if m is None: - continue - char = m.group(1) - value = parse_int(m.group(2)) - if value in charmap: - print('Value {0:s} already in charmap, dropping it in favor of first charmap entry'.format(m.group(2))) - continue - charmap[value] = char - return charmap diff --git a/tools/scan_includes.py b/tools/scan_includes.py index 34e1f09..c30adc0 100644 --- a/tools/scan_includes.py +++ b/tools/scan_includes.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Get all the dependencies of RGBDS assembly files recursively, diff --git a/tools/sort_sym.py b/tools/sort_sym.py deleted file mode 100644 index f4674e8..0000000 --- a/tools/sort_sym.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/python3.6 - -import argparse -from functools import total_ordering - - -@total_ordering -class GBSection: - ROM0 = 0 - ROMX = 1 - VRAM = 2 - SRAM = 3 - WRAM0 = 4 - WRAMX = 5 - - __thresholds__ = (0x4000, 0x8000, 0xa000, 0xc000, 0xd000, 0xe000) - - def __init__(self, addr): - self.ident = sum(x <= addr for x in self.__thresholds__) - - @property - def start(self): - if self.ident == self.ROM0: - return 0 - return self.__thresholds__[self.ident - 1] - - @property - def end(self): - return self.__thresholds__[self.ident] - - def __eq__(self, other): - return self.ident == other.ident - - def __lt__(self, other): - return self.ident < other.ident - - -def read_sym(filename): - with open(filename) as fp: - for line in fp: - try: - line, *rest = line.split(';') - pointer, name = line.split() - bank, addr = (int(part, 16) for part in pointer.split(':')) - yield bank, addr, name - except ValueError: - continue - - -def sort_key(args): - bank, addr, name = args - return GBSection(addr), bank, addr, name - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument('symfile') - args = parser.parse_args() - - symbols = list(read_sym(args.symfile)) - symbols.sort(key=sort_key) - with open(args.symfile, 'w') as fp: - for bank, addr, name in symbols: - print(f'{bank:02X}:{addr:04X}', name, file=fp) - - -if __name__ == '__main__': - main() diff --git a/tools/sort_symfile.sh b/tools/sort_symfile.sh deleted file mode 100755 index 82fced5..0000000 --- a/tools/sort_symfile.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -sed \ - -e "s/^..:[0-3]/0_ROM0@&/g" \ - -e "s/^..:[4-7]/1_ROMX@&/g" \ - -e "s/^..:[8-9]/2_VRAM@&/g" \ - -e "s/^..:[A-B]/3_SRAM@&/g" \ - -e "s/^00:[C-D]/4_WRAM0@&/g" \ - -e "s/^..:[D-D]/5_WRAMX@&/g" \ - $1 \ -| sort -o $1 -TEMP_FILE=$(mktemp) -sed -e "s/^.*@//g" $1 > $TEMP_FILE && mv $TEMP_FILE $1 diff --git a/tools/tests/README.txt b/tools/tests/README.txt deleted file mode 100644 index 750482e..0000000 --- a/tools/tests/README.txt +++ /dev/null @@ -1 +0,0 @@ -python dump_text.py -o dump_test.txt -m charmap.asm dump_text_test.bin 00:0000 diff --git a/tools/tests/charmap.asm b/tools/tests/charmap.asm deleted file mode 100644 index 70e228c..0000000 --- a/tools/tests/charmap.asm +++ /dev/null @@ -1,293 +0,0 @@ - charmap "", $00 - - charmap "イ゛", $01 - charmap "ヴ", $02 - charmap "エ゛", $03 - charmap "オ゛", $04 - - charmap "ガ", $05 - charmap "ギ", $06 - charmap "グ", $07 - charmap "ゲ", $08 - charmap "ゴ", $09 - charmap "ザ", $0a - charmap "ジ", $0b - charmap "ズ", $0c - charmap "ゼ", $0d - charmap "ゾ", $0e - charmap "ダ", $0f - charmap "ヂ", $10 - charmap "ヅ", $11 - charmap "デ", $12 - charmap "ド", $13 - - charmap "", $14 ; "くん" or "ちゃん" - - charmap "<15>", $15 ; nothing - charmap "<16>", $16 ; nothing - - charmap "ネ゛", $17 - charmap "ノ゛", $18 - - charmap "バ", $19 - charmap "ビ", $1a - charmap "ブ", $1b - charmap "ボ", $1c - - charmap "", $1d ; "に " - charmap "", $1e ; "って" - charmap "", $1f ; "を " - - charmap "ィ゛", $20 - charmap "あ゛", $21 - - charmap "", $22 ; "た!" - charmap "", $23 ; "こうげき" - charmap "", $24 ; "は " - charmap "", $25 ; "の " - - charmap "が", $26 - charmap "ぎ", $27 - charmap "ぐ", $28 - charmap "げ", $29 - charmap "ご", $2a - charmap "ざ", $2b - charmap "じ", $2c - charmap "ず", $2d - charmap "ぜ", $2e - charmap "ぞ", $2f - charmap "だ", $30 - charmap "ぢ", $31 - charmap "づ", $32 - charmap "で", $33 - charmap "ど", $34 - - charmap "", $35 ; "ばん どうろ" - charmap "", $36 ; "わたし" - charmap "", $37 ; "ここは" - charmap "", $38 ; wRedsName - charmap "", $39 ; wGreensName - - charmap "ば", $3a - charmap "び", $3b - charmap "ぶ", $3c - charmap "べ", $3d - charmap "ぼ", $3e - - charmap "", $3f - - charmap "パ", $40 - charmap "ピ", $41 - charmap "プ", $42 - charmap "ポ", $43 - charmap "ぱ", $44 - charmap "ぴ", $45 - charmap "ぷ", $46 - charmap "ぺ", $47 - charmap "ぽ", $48 - - charmap "", $49 ; wMomsName - charmap "", $4a ; "が " - charmap "<_CONT>", $4b ; implements "" - charmap "", $4c - - charmap "も゜", $4d - - charmap "", $4e - charmap "", $4f - charmap "@", $50 ; string terminator - charmap "", $51 - charmap "", $52 ; wPlayerName - charmap "", $53 ; wRivalName - charmap "#", $54 ; "POKé" - charmap "", $55 - charmap "<⋯⋯>", $56 ; "⋯⋯" - charmap "", $57 - charmap "", $58 - charmap "", $59 - charmap "", $5a - charmap "", $5b ; "PC" - charmap "", $5c ; "TM" - charmap "", $5d ; "TRAINER" - charmap "", $5e ; "ROCKET" - charmap "", $5f - - charmap "■", $60 - charmap "▲", $61 - charmap "☎", $62 - - charmap "D", $63 - charmap "E", $64 - charmap "F", $65 - charmap "G", $66 - charmap "H", $67 - charmap "I", $68 - charmap "V", $69 - charmap "S", $6a - charmap "L", $6b - charmap "M", $6c - - charmap ":", $6d - - charmap "ぃ", $6e - charmap "ぅ", $6f - - charmap "「", $70 - charmap "」", $71 - charmap "『", $72 - charmap "』", $73 - charmap "・", $74 - charmap "⋯", $75 - - charmap "ぁ", $76 - charmap "ぇ", $77 - charmap "ぉ", $78 - - charmap "┌", $79 - charmap "─", $7a - charmap "┐", $7b - charmap "│", $7c - charmap "└", $7d - charmap "┘", $7e - - charmap " ", $7f - - charmap "ア", $80 - charmap "イ", $81 - charmap "ウ", $82 - charmap "エ", $83 - charmap "オ", $84 - charmap "カ", $85 - charmap "キ", $86 - charmap "ク", $87 - charmap "ケ", $88 - charmap "コ", $89 - charmap "サ", $8a - charmap "シ", $8b - charmap "ス", $8c - charmap "セ", $8d - charmap "ソ", $8e - charmap "タ", $8f - charmap "チ", $90 - charmap "ツ", $91 - charmap "テ", $92 - charmap "ト", $93 - charmap "ナ", $94 - charmap "ニ", $95 - charmap "ヌ", $96 - charmap "ネ", $97 - charmap "ノ", $98 - charmap "ハ", $99 - charmap "ヒ", $9a - charmap "フ", $9b - charmap "ホ", $9c - charmap "マ", $9d - charmap "ミ", $9e - charmap "ム", $9f - charmap "メ", $a0 - charmap "モ", $a1 - charmap "ヤ", $a2 - charmap "ユ", $a3 - charmap "ヨ", $a4 - charmap "ラ", $a5 - charmap "ル", $a6 - charmap "レ", $a7 - charmap "ロ", $a8 - charmap "ワ", $a9 - charmap "ヲ", $aa - charmap "ン", $ab - - charmap "ッ", $ac - charmap "ャ", $ad - charmap "ュ", $ae - charmap "ョ", $af - charmap "ィ", $b0 - - charmap "あ", $b1 - charmap "い", $b2 - charmap "う", $b3 - charmap "え", $b4 - charmap "お", $b5 - charmap "か", $b6 - charmap "き", $b7 - charmap "く", $b8 - charmap "け", $b9 - charmap "こ", $ba - charmap "さ", $bb - charmap "し", $bc - charmap "す", $bd - charmap "せ", $be - charmap "そ", $bf - charmap "た", $c0 - charmap "ち", $c1 - charmap "つ", $c2 - charmap "て", $c3 - charmap "と", $c4 - charmap "な", $c5 - charmap "に", $c6 - charmap "ぬ", $c7 - charmap "ね", $c8 - charmap "の", $c9 - charmap "は", $ca - charmap "ひ", $cb - charmap "ふ", $cc - charmap "へ", $cd - charmap "ほ", $ce - charmap "ま", $cf - charmap "み", $d0 - charmap "む", $d1 - charmap "め", $d2 - charmap "も", $d3 - charmap "や", $d4 - charmap "ゆ", $d5 - charmap "よ", $d6 - charmap "ら", $d7 - charmap "り", $d8 - charmap "る", $d9 - charmap "れ", $da - charmap "ろ", $db - charmap "わ", $dc - charmap "を", $dd - charmap "ん", $de - - charmap "っ", $df - charmap "ゃ", $e0 - charmap "ゅ", $e1 - charmap "ょ", $e2 - - charmap "ー", $e3 - - charmap "゚", $e4 - charmap "゙", $e5 - - charmap "?", $e6 - charmap "!", $e7 - charmap "。", $e8 - - charmap "ァ", $e9 - charmap "ゥ", $ea - charmap "ェ", $eb - - charmap "▷", $ec - charmap "▶", $ed - charmap "▲", $ed - charmap "▼", $ee - charmap "♂", $ef - charmap "円", $f0 - charmap "×", $f1 - charmap ".", $f2 - charmap "/", $f3 - - charmap "ォ", $f4 - - charmap "♀", $f5 - charmap "0", $f6 - charmap "1", $f7 - charmap "2", $f8 - charmap "3", $f9 - charmap "4", $fa - charmap "5", $fb - charmap "6", $fc - charmap "7", $fd - charmap "8", $fe - charmap "9", $ff diff --git a/tools/tests/dump_test.cc.txt b/tools/tests/dump_test.cc.txt deleted file mode 100644 index 4dfd14a..0000000 --- a/tools/tests/dump_test.cc.txt +++ /dev/null @@ -1,26 +0,0 @@ -.loc_0000: - text "ほんとにりセットしますか?@" - text_from_ram $ce33 - text_bcd $ce34, $78 - text_move $ce35 - text_box $cc36, $08, $12 - text_low - text_waitbutton - text_scroll - deciram $cc37, $6, $5 - text_exit - sound_dex_fanfare_50_79 - text_dots $14 - link_wait_button - sound_dex_fanfare_20_49 - sound_item - sound_caught_mon - sound_dex_fanfare_80_109 - sound_fanfare - sound_slot_machine_start - cry_nidorina - cry_pigeot - cry_jugon - text_end - -.loc_0035: diff --git a/tools/tests/dump_test.cc_endless.txt b/tools/tests/dump_test.cc_endless.txt deleted file mode 100644 index 5c59830..0000000 --- a/tools/tests/dump_test.cc_endless.txt +++ /dev/null @@ -1,33 +0,0 @@ -.loc_0000: - text "ほんとにりセットしますか?@" - text_from_ram $ce33 - text_bcd $ce34, $78 - text_move $ce35 - text_box $cc36, $08, $12 - text_low - text_waitbutton - text_scroll - deciram $cc37, $6, $5 - text_exit - sound_dex_fanfare_50_79 - text_dots $14 - link_wait_button - sound_dex_fanfare_20_49 - sound_item - sound_caught_mon - sound_dex_fanfare_80_109 - sound_fanfare - sound_slot_machine_start - cry_nidorina - cry_pigeot - cry_jugon - text_end - -.loc_0035: - text "ほんとにりセットしますか?" -.loc_0044: - start_asm ; text dumper cannot dump asm - ; Try dumping asm from the following offset and - ; then continue dumping text in control mode. - -.loc_0045: diff --git a/tools/tests/dump_test.cc_tc.txt b/tools/tests/dump_test.cc_tc.txt deleted file mode 100644 index e49a010..0000000 --- a/tools/tests/dump_test.cc_tc.txt +++ /dev/null @@ -1,27 +0,0 @@ -.loc_0000: - text "ほんとにりセットしますか?" - line "@" - text_from_ram $ce33 - text_bcd $ce34, $78 - text_move $ce35 - text_box $cc36, $08, $12 - text_low - text_waitbutton - text_scroll - deciram $cc37, $6, $5 - text_exit - sound_dex_fanfare_50_79 - text_dots $14 - link_wait_button - sound_dex_fanfare_20_49 - sound_item - sound_caught_mon - sound_dex_fanfare_80_109 - sound_fanfare - sound_slot_machine_start - cry_nidorina - cry_pigeot - cry_jugon - text_end - -.loc_0035: diff --git a/tools/tests/dump_test.cc_tc_endless.txt b/tools/tests/dump_test.cc_tc_endless.txt deleted file mode 100644 index dbf8e8b..0000000 --- a/tools/tests/dump_test.cc_tc_endless.txt +++ /dev/null @@ -1,35 +0,0 @@ -.loc_0000: - text "ほんとにりセットしますか?" - line "@" - text_from_ram $ce33 - text_bcd $ce34, $78 - text_move $ce35 - text_box $cc36, $08, $12 - text_low - text_waitbutton - text_scroll - deciram $cc37, $6, $5 - text_exit - sound_dex_fanfare_50_79 - text_dots $14 - link_wait_button - sound_dex_fanfare_20_49 - sound_item - sound_caught_mon - sound_dex_fanfare_80_109 - sound_fanfare - sound_slot_machine_start - cry_nidorina - cry_pigeot - cry_jugon - text_end - -.loc_0035: - text "ほんとにりセットしますか?" - done -.loc_0044: - start_asm ; text dumper cannot dump asm - ; Try dumping asm from the following offset and - ; then continue dumping text in control mode. - -.loc_0045: diff --git a/tools/tests/dump_test.endless.txt b/tools/tests/dump_test.endless.txt deleted file mode 100644 index fbbdeef..0000000 --- a/tools/tests/dump_test.endless.txt +++ /dev/null @@ -1,9 +0,0 @@ -.loc_0000: - db "ほんとにりセットしますか?@" -.loc_0010: - db "イ゛でほヴどほぉエ゛ほオ゛ふゲデガギグゴふFザジズゼゾダヂヅデド<15><16>@" -.loc_0035: - db "ほんとにりセットしますか?" -.loc_0044: - db "ゲ" -.loc_0045: diff --git a/tools/tests/dump_test.tc.txt b/tools/tests/dump_test.tc.txt deleted file mode 100644 index 82c276d..0000000 --- a/tools/tests/dump_test.tc.txt +++ /dev/null @@ -1,4 +0,0 @@ -.loc_0000: - db "ほんとにりセットしますか?" - line "@" -.loc_0010: diff --git a/tools/tests/dump_test.tc_endless.txt b/tools/tests/dump_test.tc_endless.txt deleted file mode 100644 index 37bdda0..0000000 --- a/tools/tests/dump_test.tc_endless.txt +++ /dev/null @@ -1,11 +0,0 @@ -.loc_0000: - db "ほんとにりセットしますか?" - line "@" -.loc_0010: - db "イ゛でほヴどほぉエ゛ほオ゛ふゲデガギグゴふFザジズゼゾダヂヅデド<15><16>@" -.loc_0035: - db "ほんとにりセットしますか?" - done -.loc_0044: - db "ゲ" -.loc_0045: diff --git a/tools/tests/dump_test.txt b/tools/tests/dump_test.txt deleted file mode 100644 index a181744..0000000 --- a/tools/tests/dump_test.txt +++ /dev/null @@ -1,3 +0,0 @@ -.loc_0000: - db "ほんとにりセットしますか?@" -.loc_0010: diff --git a/tools/tests/dump_text_test.bin b/tools/tests/dump_text_test.bin deleted file mode 100644 index 778a446..0000000 Binary files a/tools/tests/dump_text_test.bin and /dev/null differ diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..b64ec3b --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,5 @@ +# A subset of pret/pokemon-reverse-engineering-tools +# Only needed for decompressing 1bpp and 2bpp graphics +# https://github.com/pret/pokemon-reverse-engineering-tools + +__version__ = '1.6.0' diff --git a/utils/compare.sh b/utils/compare.sh new file mode 100644 index 0000000..44e0771 --- /dev/null +++ b/utils/compare.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Compares baserom.gb and pokegold-spaceworld.gb + +# create baserom.txt if necessary +if [ ! -f baserom.txt ]; then + hexdump -C baserom.gb > baserom.txt +fi + +hexdump -C pokegold-spaceworld.gb > pokegold-spaceworld.txt + +diff -u baserom.txt pokegold-spaceworld.txt | less diff --git a/utils/dump_names.py b/utils/dump_names.py new file mode 100644 index 0000000..12eafb5 --- /dev/null +++ b/utils/dump_names.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +import sys, os, io +from read_charmap import read_charmap + +def calc_bank(p): + return p // 0x4000 + +def calc_address(p): + b = calc_bank(p) + o = b * 0x4000 + return 0x4000 + p - o + +def get_sym_loc(p): + b, a = calc_bank(p), calc_address(p) + return '%02x:%04x' % (b, a) + +def get_project_dir(): + script_path = os.path.realpath(__file__) + script_dir = os.path.dirname(script_path) + project_dir = os.path.join(script_dir, '..') + return os.path.normpath(project_dir) + +def get_baserom_path(): + project_dir = get_project_dir() + return os.path.join(project_dir, 'baserom.gb') + +def dump_strings(data): + charmap = read_charmap() + ss = [] + chars = [] + for v in data: + if v in charmap: + c = charmap[v] + chars.append(c) + else: + if chars: + ss.append('"%s"' % ''.join(chars)) + chars = [] + ss.append('$%02x' % v) + if v == 0x50: + if chars: + ss.append('"%s"' % ''.join(chars)) + chars = [] + print '\tdb %s' % ', '.join(ss) + ss = [] + if ss: + print '\tdb %s' % ', '.join(ss) + +def read_data(bank, address, n): + offset = bank * 0x4000 + address - 0x4000 + baserom_path = get_baserom_path() + with open(baserom_path, 'rb') as f: + f.seek(offset) + data = [] + i = 0 + while i < n: + c = f.read(1) + v = ord(c) + if v == 0x50: + i += 1 + data.append(v) + return data + +p = 0xfcaaf # Landmarks +print get_sym_loc(p) +data = read_data(calc_bank(p), calc_address(p), 45) +dump_strings(data) diff --git a/utils/dump_text.py b/utils/dump_text.py new file mode 100644 index 0000000..c36298e --- /dev/null +++ b/utils/dump_text.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import argparse +import sys +from read_charmap import read_charmap + +charmap = {} + +bank_size = 0x4000 + +textcodes = { + '' : {'label': 'next', 'fin': False}, + '' : {'label': 'line', 'fin': False}, + '' : {'label': 'para', 'fin': False}, + '' : {'label': 'cont', 'fin': False}, + '' : {'label': 'done', 'fin': True}, + '': {'label': 'prompt', 'fin': True}, +} + +# codes that end text control code 0x00 +# True if this code exits control code parsing as well +end_codes = { + '@' : False, + '' : True, + '': True, +} + +def conv_address(x): + if ':' in x: + bank, addr = [ int(s, 16) for s in x.split(':') ] + if (addr < bank_size and bank != 0): + raise argparse.ArgumentTypeError('Illegal ROM bank 0x00 address {0:02X}:{1:04X}. ' + 'Bank 0x{0:02X} must be 0x00.'.format(bank, addr)) + elif (addr >= bank_size and bank == 0): + raise argparse.ArgumentTypeError('Illegal ROM bank 0x00 address {0:02X}:{1:04X}. ' + 'Address 0x{1:04X} > 0x{2:04X}.'.format(bank, addr, bank_size - 1)) + elif (addr >= 2*bank_size): + raise argparse.ArgumentTypeError('Illegal ROM bank address {0:02X}:{1:04X}. ' + 'Address 0x{1:04X} > 0x{2:04X}.'.format(bank, addr, 2*bank_size - 1)) + return bank * 0x4000 + (addr & 0x3fff) + return int(x, 0) + +def addr2gb(addr): + bank = addr // bank_size + offset = addr % bank_size + if (bank > 0): + offset += bank_size + return bank, offset + +def transform_char(char, do_transform, is_begin): + result = '' + if (do_transform and char in textcodes): + replace = textcodes[char] + if (not is_begin): + result += '"\n' + result += replace['label'] + if (not replace['fin']): + result += ' "' + return replace['fin'], result + else: + if (is_begin): + result += '"' + return False, (result + char) + +def dump_asm(data): + + result = 'start_asm ; text dumper cannot dump asm\n' + result += ' ; Try dumping asm from the following offset and\n' + result += ' ; then continue dumping text in control mode.\n' + return True, result + +def dump_control(data, label, signature): + + lengths = {'b': 1, 'n': 1, 'w': 2} + + required_bytes = sum([lengths.get(c, 0) for c in signature]) + + if (data['len'] - data['offset'] < required_bytes): + #silently drop split control code + return True, '' + + result = '' + + for c in signature: + if result != '': + result += ', ' + if c == 'b': + byte = data['bytes'][data['offset']] + data['offset'] += 1 + result += '${0:02x}'.format(byte) + elif c == 'n': + byte = data['bytes'][data['offset']] + data['offset'] += 1 + result += '${0:01x}, ${1:01x}'.format(byte >> 4, byte & 0x0f) + elif c == 'w': + word = data['bytes'][data['offset']] + data['offset'] += 1 + word |= data['bytes'][data['offset']] << 8 + data['offset'] += 1 + result += '${0:02x}'.format(word) + else: + raise ValueError('Unknown signature char in {0:s}\'s signature "{1:s}".'.format(label, signature)) + + if (result != ''): + result = ' ' + result + return False, label + result + +def dump_text(data): + + string = '' + exit_control = False + done = False + while (not done): + + byte = data['bytes'][data['offset']] + data['offset'] += 1 + + char = charmap[byte] + fin, tchar = transform_char(char, data['textmode'], string == '') + string += tchar + if (char in end_codes): + done = True + exit_control = end_codes[char] + # end string if textmode didn't do it + if not data['textmode'] or not fin: + string += '"' + if (data['offset'] >= data['len']): + done = True + string += '"' + + return exit_control, string + +def dump_text_control(data): + + res, text = dump_text(data) + return res, 'text ' + text + +control_codes = { + 0x00: dump_text_control, + 0x01: lambda data: dump_control(data, 'text_from_ram' , 'w' ), + 0x02: lambda data: dump_control(data, 'text_bcd' , 'wb' ), + 0x03: lambda data: dump_control(data, 'text_move' , 'w' ), + 0x04: lambda data: dump_control(data, 'text_box' , 'wbb' ), + 0x05: lambda data: dump_control(data, 'text_low' , '' ), + 0x06: lambda data: dump_control(data, 'text_waitbutton' , '' ), + 0x07: lambda data: dump_control(data, 'text_scroll' , '' ), + 0x08: dump_asm, + 0x09: lambda data: dump_control(data, 'deciram' , 'wn' ), + 0x0A: lambda data: dump_control(data, 'text_exit' , '' ), + 0x0B: lambda data: dump_control(data, 'sound_dex_fanfare_50_79' , '' ), + 0x0C: lambda data: dump_control(data, 'text_dots' , 'b' ), + 0x0D: lambda data: dump_control(data, 'link_wait_button' , '' ), + 0x0E: lambda data: dump_control(data, 'sound_dex_fanfare_20_49' , '' ), + 0x0F: lambda data: dump_control(data, 'sound_item' , '' ), + 0x10: lambda data: dump_control(data, 'sound_caught_mon' , '' ), + 0x11: lambda data: dump_control(data, 'sound_dex_fanfare_80_109', '' ), + 0x12: lambda data: dump_control(data, 'sound_fanfare' , '' ), + 0x13: lambda data: dump_control(data, 'sound_slot_machine_start', '' ), + 0x14: lambda data: dump_control(data, 'cry_nidorina' , '' ), + 0x15: lambda data: dump_control(data, 'cry_pigeot' , '' ), + 0x16: lambda data: dump_control(data, 'cry_jugon' , '' ), + 0x50: lambda data: (True, 'text_end\n'), +} + +def print_location(data): + return '.loc_{0:04X}:\n'.format(data['offset'] + (bank_size if data['source'] > bank_size else 0)) + +if __name__ == '__main__': + # argument parser + ap = argparse.ArgumentParser() + ap.add_argument('--cc', dest='ccmode', action='store_true', + help='dump in control code mode, implies text code macro mode' + ) + ap.add_argument('--endless', dest='endless', action='store_true', + help='continue dumping even if string end text code was reached' + ) + ap.add_argument('--tc', dest='textmode', action='store_true', + help='dump text codes (line breaks, prompt etc) as macros instead of inline text' + ) + ap.add_argument('-o', dest='outfile', default=sys.stdout, help='output file name') + ap.add_argument('-m', dest='charmap', default='../charmap.asm', help='charmap file name') + ap.add_argument('rom', help='path to ROM') + ap.add_argument('start', help='start offset', type=conv_address) + ap.add_argument('end', help='end offset', type=conv_address, nargs='?') + + args = ap.parse_args() + romname = args.rom + start_addr = args.start + end_addr = start_addr + bank_size if args.end is None else args.end + ccmode = args.ccmode + endless = args.endless + outfile = args.outfile + charmap = read_charmap(args.charmap) + + if (end_addr < start_addr): + print('End address 0x{0:06X} ({1:02X}:{2:04X}) is before ' + 'start address 0x{3:06X} ({4:02X}:{5:04X}).'.format( + end_addr, + *addr2gb(end_addr), + start_addr, + *addr2gb(start_addr) + ), + file=sys.stderr + ) + sys.exit(-1) + + bank_addr = start_addr & (~(bank_size - 1)) + offset = start_addr - bank_addr + end_offset = end_addr - bank_addr + + with open(romname, 'rb') as f: + f.seek(bank_addr) + bank_data = f.read(bank_size) + + data = {'offset': offset, + 'bytes': bank_data, + 'len': min(end_offset, len(bank_data)), + 'textmode': args.textmode, + 'source': bank_addr, + } + + with open(outfile, 'wb') if outfile != sys.stdout else outfile.buffer as f: + string = print_location(data) + while(data['offset'] < data['len']): + if (not ccmode): + # dumb mode + _, text = dump_text(data) + # start with db unless starting with a control code + # in textmode + if text[0] == '"' and data['textmode']: + string += '\tdb ' + elif text[0] == '"': + string += '\tdb ' + string += text.replace('\n', '\n\t') + string += '\n{0:s}'.format(print_location(data)) + if (not endless): + break + else: + # control code mode + control_byte = data['bytes'][data['offset']] + data['offset'] += 1 + + if (control_byte in control_codes): + res, text = control_codes[control_byte](data) + string += '\t' + text.replace('\n', '\n\t') + string += '\n' + if (res): + string += print_location(data) + # exit out of control code parsing + if (res and not endless): + break + else: + print('Encountered unknown control code 0x{0:02X}. Abort...'.format(control_byte), file=sys.stderr) + break + + f.write(string.encode('utf-8')) + f.close() diff --git a/utils/gfx.py b/utils/gfx.py new file mode 100644 index 0000000..80c84d3 --- /dev/null +++ b/utils/gfx.py @@ -0,0 +1,951 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +import os +import sys +import png +from math import sqrt, floor, ceil +import argparse +import operator + +from lz import Compressed, Decompressed + + +def split(list_, interval): + """ + Split a list by length. + """ + for i in xrange(0, len(list_), interval): + j = min(i + interval, len(list_)) + yield list_[i:j] + + +def hex_dump(data, length=0x10): + """ + just use hexdump -C + """ + margin = len('%x' % len(data)) + output = [] + address = 0 + for line in split(data, length): + output += [ + hex(address)[2:].zfill(margin) + + ' | ' + + ' '.join('%.2x' % byte for byte in line) + ] + address += length + return '\n'.join(output) + + +def get_tiles(image): + """ + Split a 2bpp image into 8x8 tiles. + """ + return list(split(image, 0x10)) + +def connect(tiles): + """ + Combine 8x8 tiles into a 2bpp image. + """ + return [byte for tile in tiles for byte in tile] + +def transpose(tiles, width=None): + """ + Transpose a tile arrangement along line y=-x. + + 00 01 02 03 04 05 00 06 0c 12 18 1e + 06 07 08 09 0a 0b 01 07 0d 13 19 1f + 0c 0d 0e 0f 10 11 <-> 02 08 0e 14 1a 20 + 12 13 14 15 16 17 03 09 0f 15 1b 21 + 18 19 1a 1b 1c 1d 04 0a 10 16 1c 22 + 1e 1f 20 21 22 23 05 0b 11 17 1d 23 + + 00 01 02 03 00 04 08 + 04 05 06 07 <-> 01 05 09 + 08 09 0a 0b 02 06 0a + 03 07 0b + """ + if width == None: + width = int(sqrt(len(tiles))) # assume square image + tiles = sorted(enumerate(tiles), key= lambda (i, tile): i % width) + return [tile for i, tile in tiles] + +def transpose_tiles(image, width=None): + return connect(transpose(get_tiles(image), width)) + +def interleave(tiles, width): + """ + 00 01 02 03 04 05 00 02 04 06 08 0a + 06 07 08 09 0a 0b 01 03 05 07 09 0b + 0c 0d 0e 0f 10 11 --> 0c 0e 10 12 14 16 + 12 13 14 15 16 17 0d 0f 11 13 15 17 + 18 19 1a 1b 1c 1d 18 1a 1c 1e 20 22 + 1e 1f 20 21 22 23 19 1b 1d 1f 21 23 + """ + interleaved = [] + left, right = split(tiles[::2], width), split(tiles[1::2], width) + for l, r in zip(left, right): + interleaved += l + r + return interleaved + +def deinterleave(tiles, width): + """ + 00 02 04 06 08 0a 00 01 02 03 04 05 + 01 03 05 07 09 0b 06 07 08 09 0a 0b + 0c 0e 10 12 14 16 --> 0c 0d 0e 0f 10 11 + 0d 0f 11 13 15 17 12 13 14 15 16 17 + 18 1a 1c 1e 20 22 18 19 1a 1b 1c 1d + 19 1b 1d 1f 21 23 1e 1f 20 21 22 23 + """ + deinterleaved = [] + rows = list(split(tiles, width)) + for left, right in zip(rows[::2], rows[1::2]): + for l, r in zip(left, right): + deinterleaved += [l, r] + return deinterleaved + +def interleave_tiles(image, width): + return connect(interleave(get_tiles(image), width)) + +def deinterleave_tiles(image, width): + return connect(deinterleave(get_tiles(image), width)) + + +def condense_image_to_map(image, pic=0): + """ + Reduce an image of adjacent frames to an image containing a base frame and any unrepeated tiles. + Returns the new image and the corresponding tilemap used to reconstruct the input image. + + If is 0, ignore the concept of frames. This behavior might be better off as another function. + """ + tiles = get_tiles(image) + new_tiles, tilemap = condense_tiles_to_map(tiles, pic) + new_image = connect(new_tiles) + return new_image, tilemap + +def condense_tiles_to_map(tiles, pic=0): + """ + Reduce a sequence of tiles representing adjacent frames to a base frame and any unrepeated tiles. + Returns the new tiles and the corresponding tilemap used to reconstruct the input tile sequence. + + If is 0, ignore the concept of frames. This behavior might be better off as another function. + """ + + # Leave the first frame intact for pics. + new_tiles = tiles[:pic] + tilemap = range(pic) + + for i, tile in enumerate(tiles[pic:]): + if tile not in new_tiles: + new_tiles.append(tile) + + if pic: + # Match the first frame exactly where possible. + # This reduces the space needed to replace tiles in pic animations. + # For example, if a tile is repeated twice in the first frame, + # but at the same relative index as the second tile, use the second index. + # When creating a bitmask later, the second index would not require a replacement, but the first index would have. + pic_i = i % pic + if tile == new_tiles[pic_i]: + tilemap.append(pic_i) + else: + tilemap.append(new_tiles.index(tile)) + else: + tilemap.append(new_tiles.index(tile)) + return new_tiles, tilemap + +def test_condense_tiles_to_map(): + test = condense_tiles_to_map(list('abcadbae')) + if test != (list('abcde'), [0, 1, 2, 0, 3, 1, 0, 4]): + raise Exception(test) + test = condense_tiles_to_map(list('abcadbae'), 2) + if test != (list('abcde'), [0, 1, 2, 0, 3, 1, 0, 4]): + raise Exception(test) + test = condense_tiles_to_map(list('abcadbae'), 4) + if test != (list('abcade'), [0, 1, 2, 3, 4, 1, 0, 5]): + raise Exception(test) + test = condense_tiles_to_map(list('abcadbea'), 4) + if test != (list('abcade'), [0, 1, 2, 3, 4, 1, 5, 3]): + raise Exception(test) + + +def to_file(filename, data): + """ + Apparently open(filename, 'wb').write(bytearray(data)) won't work. + """ + file = open(filename, 'wb') + for byte in data: + file.write('%c' % byte) + file.close() + + +def decompress_file(filein, fileout=None): + image = bytearray(open(filein).read()) + de = Decompressed(image) + + if fileout == None: + fileout = os.path.splitext(filein)[0] + to_file(fileout, de.output) + + +def compress_file(filein, fileout=None): + image = bytearray(open(filein).read()) + lz = Compressed(image) + + if fileout == None: + fileout = filein + '.lz' + to_file(fileout, lz.output) + + +def bin_to_rgb(word): + red = word & 0b11111 + word >>= 5 + green = word & 0b11111 + word >>= 5 + blue = word & 0b11111 + return (red, green, blue) + +def convert_binary_pal_to_text_by_filename(filename): + pal = bytearray(open(filename).read()) + return convert_binary_pal_to_text(pal) + +def convert_binary_pal_to_text(pal): + output = '' + words = [hi * 0x100 + lo for lo, hi in zip(pal[::2], pal[1::2])] + for word in words: + red, green, blue = ['%.2d' % c for c in bin_to_rgb(word)] + output += '\tRGB ' + ', '.join((red, green, blue)) + output += '\n' + return output + +def read_rgb_macros(lines): + colors = [] + for line in lines: + macro = line.split(" ")[0].strip() + if macro == 'RGB': + params = ' '.join(line.split(" ")[1:]).split(',') + red, green, blue = [int(v) for v in params] + colors += [[red, green, blue]] + return colors + + +def rewrite_binary_pals_to_text(filenames): + for filename in filenames: + pal_text = convert_binary_pal_to_text_by_filename(filename) + with open(filename, 'w') as out: + out.write(pal_text) + + +def flatten(planar): + """ + Flatten planar 2bpp image data into a quaternary pixel map. + """ + strips = [] + for bottom, top in split(planar, 2): + bottom = bottom + top = top + strip = [] + for i in xrange(7,-1,-1): + color = ( + (bottom >> i & 1) + + (top *2 >> i & 2) + ) + strip += [color] + strips += strip + return strips + +def to_lines(image, width): + """ + Convert a tiled quaternary pixel map to lines of quaternary pixels. + """ + tile_width = 8 + tile_height = 8 + num_columns = width / tile_width + height = len(image) / width + + lines = [] + for cur_line in xrange(height): + tile_row = cur_line / tile_height + line = [] + for column in xrange(num_columns): + anchor = ( + num_columns * tile_row * tile_width * tile_height + + column * tile_width * tile_height + + cur_line % tile_height * tile_width + ) + line += image[anchor : anchor + tile_width] + lines += [line] + return lines + + +def dmg2rgb(word): + """ + For PNGs. + """ + def shift(value): + while True: + yield value & (2**5 - 1) + value >>= 5 + word = shift(word) + # distribution is less even w/ << 3 + red, green, blue = [int(color * 8.25) for color in [word.next() for _ in xrange(3)]] + alpha = 255 + return (red, green, blue, alpha) + + +def rgb_to_dmg(color): + """ + For PNGs. + """ + word = (color['r'] / 8) + word += (color['g'] / 8) << 5 + word += (color['b'] / 8) << 10 + return word + + +def pal_to_png(filename): + """ + Interpret a .pal file as a png palette. + """ + with open(filename) as rgbs: + colors = read_rgb_macros(rgbs.readlines()) + a = 255 + palette = [] + for color in colors: + # even distribution over 000-255 + r, g, b = [int(hue * 8.25) for hue in color] + palette += [(r, g, b, a)] + white = (255,255,255,255) + black = (000,000,000,255) + if white not in palette and len(palette) < 4: + palette = [white] + palette + if black not in palette and len(palette) < 4: + palette = palette + [black] + return palette + + +def png_to_rgb(palette): + """ + Convert a png palette to rgb macros. + """ + output = '' + for color in palette: + r, g, b = [color[c] / 8 for c in 'rgb'] + output += '\tRGB ' + ', '.join(['%.2d' % hue for hue in (r, g, b)]) + output += '\n' + return output + + +def read_filename_arguments(filename): + """ + Infer graphics conversion arguments given a filename. + + Arguments are separated with '.'. + """ + parsed_arguments = {} + + int_arguments = { + 'w': 'width', + 'h': 'height', + 't': 'tile_padding', + } + arguments = os.path.splitext(filename)[0].lstrip('.').split('.')[1:] + for argument in arguments: + + # Check for integer arguments first (i.e. "w128"). + arg = argument[0] + param = argument[1:] + if param.isdigit(): + arg = int_arguments.get(arg, False) + if arg: + parsed_arguments[arg] = int(param) + + elif argument == 'arrange': + parsed_arguments['norepeat'] = True + parsed_arguments['tilemap'] = True + + # Pic dimensions (i.e. "6x6"). + elif 'x' in argument and any(map(str.isdigit, argument)): + w, h = argument.split('x') + if w.isdigit() and h.isdigit(): + parsed_arguments['pic_dimensions'] = (int(w), int(h)) + + else: + parsed_arguments[argument] = True + + return parsed_arguments + + +def export_2bpp_to_png(filein, fileout=None, pal_file=None, height=0, width=0, tile_padding=0, pic_dimensions=None, **kwargs): + + if fileout == None: + fileout = os.path.splitext(filein)[0] + '.png' + + image = open(filein, 'rb').read() + + arguments = { + 'width': width, + 'height': height, + 'pal_file': pal_file, + 'tile_padding': tile_padding, + 'pic_dimensions': pic_dimensions, + } + arguments.update(read_filename_arguments(filein)) + + if pal_file == None: + if os.path.exists(os.path.splitext(fileout)[0]+'.pal'): + arguments['pal_file'] = os.path.splitext(fileout)[0]+'.pal' + + arguments['is_tileset'] = 'tilesets' in filein + arguments['is_overworld'] = 'sprites' in filein + result = convert_2bpp_to_png(image, **arguments) + width, height, palette, greyscale, bitdepth, px_map = result + + w = png.Writer( + width, + height, + palette=palette, + compression=9, + greyscale=greyscale, + bitdepth=bitdepth + ) + with open(fileout, 'wb') as f: + w.write(f, px_map) + + +def convert_2bpp_to_png(image, **kwargs): + """ + Convert a planar 2bpp graphic to png. + """ + + image = bytearray(image) + + pad_color = bytearray([0]) + + width = kwargs.get('width', 0) + height = kwargs.get('height', 0) + tile_padding = kwargs.get('tile_padding', 0) + pic_dimensions = kwargs.get('pic_dimensions', None) + pal_file = kwargs.get('pal_file', None) + interleave = kwargs.get('interleave', False) + + # Width must be specified to interleave. + if interleave and width: + image = interleave_tiles(image, width / 8) + + # Pad the image by a given number of tiles if asked. + image += pad_color * 0x10 * tile_padding + + # Some images are transposed in blocks. + if pic_dimensions: + w, h = pic_dimensions + if not width: width = w * 8 + + pic_length = w * h * 0x10 + + trailing = len(image) % pic_length + + pic = [] + for i in xrange(0, len(image) - trailing, pic_length): + pic += transpose_tiles(image[i:i+pic_length], h) + image = bytearray(pic) + image[len(image) - trailing:] + + # Pad out trailing lines. + image += pad_color * 0x10 * ((w - (len(image) / 0x10) % h) % w) + + def px_length(img): + return len(img) * 4 + def tile_length(img): + return len(img) * 4 / (8*8) + + if width and height: + tile_width = width / 8 + more_tile_padding = (tile_width - (tile_length(image) % tile_width or tile_width)) + image += pad_color * 0x10 * more_tile_padding + + elif width and not height: + tile_width = width / 8 + more_tile_padding = (tile_width - (tile_length(image) % tile_width or tile_width)) + image += pad_color * 0x10 * more_tile_padding + height = px_length(image) / width + + elif height and not width: + tile_height = height / 8 + more_tile_padding = (tile_height - (tile_length(image) % tile_height or tile_height)) + image += pad_color * 0x10 * more_tile_padding + width = px_length(image) / height + + # at least one dimension should be given + if width * height != px_length(image): + # look for possible combos of width/height that would form a rectangle + matches = [] + # Height need not be divisible by 8, but width must. + # See pokered gfx/minimize_pic.1bpp. + for w in range(8, px_length(image) / 2 + 1, 8): + h = px_length(image) / w + if w * h == px_length(image): + matches += [(w, h)] + # go for the most square image + if len(matches): + width, height = sorted(matches, key= lambda (w, h): (h % 8 != 0, w + h))[0] # favor height + else: + raise Exception, 'Image can\'t be divided into tiles (%d px)!' % (px_length(image)) + # correct tileset dimensions + if kwargs.get('is_tileset', False) and not (width * height // 8) % 128: + area = width * height + width = 128 + height = area // width + # correct overworld dimensions + elif kwargs.get('is_overworld', False) and not (width * height // 8) % 16: + area = width * height + width = 16 + height = area // width + + # convert tiles to lines + lines = to_lines(flatten(image), width) + + if pal_file == None: + palette = None + greyscale = True + bitdepth = 2 + px_map = [[3 - pixel for pixel in line] for line in lines] + + else: # gbc color + palette = pal_to_png(pal_file) + greyscale = False + bitdepth = 8 + px_map = [[pixel for pixel in line] for line in lines] + + return width, height, palette, greyscale, bitdepth, px_map + + +def get_pic_animation(tmap, w, h): + """ + Generate pic animation data from a combined tilemap of each frame. + """ + frame_text = '' + bitmask_text = '' + + frames = list(split(tmap, w * h)) + base = frames.pop(0) + bitmasks = [] + + for i in xrange(len(frames)): + frame_text += '\tdw .frame{}\n'.format(i + 1) + + for i, frame in enumerate(frames): + bitmask = map(operator.ne, frame, base) + if bitmask not in bitmasks: + bitmasks.append(bitmask) + which_bitmask = bitmasks.index(bitmask) + + mask = iter(bitmask) + masked_frame = filter(lambda _: mask.next(), frame) + + frame_text += '.frame{}\n'.format(i + 1) + frame_text += '\tdb ${:02x} ; bitmask\n'.format(which_bitmask) + if masked_frame: + frame_text += '\tdb {}\n'.format(', '.join( + map('${:02x}'.format, masked_frame) + )) + + for i, bitmask in enumerate(bitmasks): + bitmask_text += '; {}\n'.format(i) + for byte in split(bitmask, 8): + byte = int(''.join(map(int.__repr__, reversed(byte))), 2) + bitmask_text += '\tdb %{:08b}\n'.format(byte) + + return frame_text, bitmask_text + + +def export_png_to_2bpp(filein, fileout=None, palout=None, **kwargs): + + arguments = { + 'tile_padding': 0, + 'pic_dimensions': None, + 'animate': False, + 'stupid_bitmask_hack': [], + } + arguments.update(kwargs) + arguments.update(read_filename_arguments(filein)) + + image, arguments = png_to_2bpp(filein, **arguments) + + if fileout == None: + fileout = os.path.splitext(filein)[0] + '.2bpp' + to_file(fileout, image) + + tmap = arguments.get('tmap') + + if tmap != None and arguments['animate'] and arguments['pic_dimensions']: + # Generate pic animation data. + frame_text, bitmask_text = get_pic_animation(tmap, *arguments['pic_dimensions']) + + frames_path = os.path.join(os.path.split(fileout)[0], 'frames.asm') + with open(frames_path, 'w') as out: + out.write(frame_text) + + bitmask_path = os.path.join(os.path.split(fileout)[0], 'bitmask.asm') + + # The following Pokemon have a bitmask dummied out. + for exception in arguments['stupid_bitmask_hack']: + if exception in bitmask_path: + bitmasks = bitmask_text.split(';') + bitmasks[-1] = bitmasks[-1].replace('1', '0') + bitmask_text = ';'.join(bitmasks) + + with open(bitmask_path, 'w') as out: + out.write(bitmask_text) + + elif tmap != None and arguments.get('tilemap', False): + tilemap_path = os.path.splitext(fileout)[0] + '.tilemap' + to_file(tilemap_path, tmap) + + palette = arguments.get('palette') + if palout == None: + palout = os.path.splitext(fileout)[0] + '.pal' + export_palette(palette, palout) + + +def get_image_padding(width, height, wstep=8, hstep=8): + + padding = { + 'left': 0, + 'right': 0, + 'top': 0, + 'bottom': 0, + } + + if width % wstep and width >= wstep: + pad = float(width % wstep) / 2 + padding['left'] = int(ceil(pad)) + padding['right'] = int(floor(pad)) + + if height % hstep and height >= hstep: + pad = float(height % hstep) / 2 + padding['top'] = int(ceil(pad)) + padding['bottom'] = int(floor(pad)) + + return padding + + +def png_to_2bpp(filein, **kwargs): + """ + Convert a png image to planar 2bpp. + """ + + arguments = { + 'tile_padding': 0, + 'pic_dimensions': False, + 'interleave': False, + 'norepeat': False, + 'tilemap': False, + } + arguments.update(kwargs) + + if type(filein) is str: + filein = open(filein, 'rb') + + assert type(filein) is file + + width, height, rgba, info = png.Reader(filein).asRGBA8() + + # png.Reader returns flat pixel data. Nested is easier to work with + len_px = len('rgba') + image = [] + palette = [] + for line in rgba: + newline = [] + for px in xrange(0, len(line), len_px): + color = dict(zip('rgba', line[px:px+len_px])) + if color not in palette: + if len(palette) < 4: + palette += [color] + else: + # TODO Find the nearest match + print 'WARNING: %s: Color %s truncated to' % (filein, color), + color = sorted(palette, key=lambda x: sum(x.values()))[0] + print color + newline += [color] + image += [newline] + + assert len(palette) <= 4, '%s: palette should be 4 colors, is really %d (%s)' % (filein, len(palette), palette) + + # Pad out smaller palettes with greyscale colors + greyscale = { + 'black': { 'r': 0x00, 'g': 0x00, 'b': 0x00, 'a': 0xff }, + 'grey': { 'r': 0x55, 'g': 0x55, 'b': 0x55, 'a': 0xff }, + 'gray': { 'r': 0xaa, 'g': 0xaa, 'b': 0xaa, 'a': 0xff }, + 'white': { 'r': 0xff, 'g': 0xff, 'b': 0xff, 'a': 0xff }, + } + preference = 'white', 'black', 'grey', 'gray' + for hue in map(greyscale.get, preference): + if len(palette) >= 4: + break + if hue not in palette: + palette += [hue] + + palette.sort(key=lambda x: sum(x.values())) + + # Game Boy palette order + palette.reverse() + + # Map pixels to quaternary color ids + padding = get_image_padding(width, height) + width += padding['left'] + padding['right'] + height += padding['top'] + padding['bottom'] + pad = bytearray([0]) + + qmap = [] + qmap += pad * width * padding['top'] + for line in image: + qmap += pad * padding['left'] + for color in line: + qmap += [palette.index(color)] + qmap += pad * padding['right'] + qmap += pad * width * padding['bottom'] + + # Graphics are stored in tiles instead of lines + tile_width = 8 + tile_height = 8 + num_columns = max(width, tile_width) / tile_width + num_rows = max(height, tile_height) / tile_height + image = [] + + for row in xrange(num_rows): + for column in xrange(num_columns): + + # Split it up into strips to convert to planar data + for strip in xrange(min(tile_height, height)): + anchor = ( + row * num_columns * tile_width * tile_height + + column * tile_width + + strip * width + ) + line = qmap[anchor : anchor + tile_width] + bottom, top = 0, 0 + for bit, quad in enumerate(line): + bottom += (quad & 1) << (7 - bit) + top += (quad /2 & 1) << (7 - bit) + image += [bottom, top] + + dim = arguments['pic_dimensions'] + if dim: + if type(dim) in (tuple, list): + w, h = dim + else: + # infer dimensions based on width. + w = width / tile_width + h = height / tile_height + if h % w == 0: + h = w + + tiles = get_tiles(image) + pic_length = w * h + tile_width = width / 8 + trailing = len(tiles) % pic_length + new_image = [] + for block in xrange(len(tiles) / pic_length): + offset = (h * tile_width) * ((block * w) / tile_width) + ((block * w) % tile_width) + pic = [] + for row in xrange(h): + index = offset + (row * tile_width) + pic += tiles[index:index + w] + new_image += transpose(pic, w) + new_image += tiles[len(tiles) - trailing:] + image = connect(new_image) + + # Remove any tile padding used to make the png rectangular. + image = image[:len(image) - arguments['tile_padding'] * 0x10] + + tmap = None + + if arguments['interleave']: + image = deinterleave_tiles(image, num_columns) + + if arguments['pic_dimensions']: + image, tmap = condense_image_to_map(image, w * h) + elif arguments['norepeat']: + image, tmap = condense_image_to_map(image) + if not arguments['tilemap']: + tmap = None + + arguments.update({ 'palette': palette, 'tmap': tmap, }) + + return image, arguments + + +def export_palette(palette, filename): + """ + Export a palette from png to rgb macros in a .pal file. + """ + + if os.path.exists(filename): + + # Pic palettes are 2 colors (black/white are added later). + with open(filename) as rgbs: + colors = read_rgb_macros(rgbs.readlines()) + + if len(colors) == 2: + palette = palette[1:3] + + text = png_to_rgb(palette) + with open(filename, 'w') as out: + out.write(text) + + +def png_to_lz(filein): + + name = os.path.splitext(filein)[0] + + export_png_to_2bpp(filein) + image = open(name+'.2bpp', 'rb').read() + to_file(name+'.2bpp'+'.lz', Compressed(image).output) + + +def convert_2bpp_to_1bpp(data): + """ + Convert planar 2bpp image data to 1bpp. Assume images are two colors. + """ + return data[::2] + +def convert_1bpp_to_2bpp(data): + """ + Convert 1bpp image data to planar 2bpp (black/white). + """ + output = [] + for i in data: + output += [i, i] + return output + + +def export_2bpp_to_1bpp(filename): + name, extension = os.path.splitext(filename) + image = open(filename, 'rb').read() + image = convert_2bpp_to_1bpp(image) + to_file(name + '.1bpp', image) + +def export_1bpp_to_2bpp(filename): + name, extension = os.path.splitext(filename) + image = open(filename, 'rb').read() + image = convert_1bpp_to_2bpp(image) + to_file(name + '.2bpp', image) + + +def export_1bpp_to_png(filename, fileout=None): + + if fileout == None: + fileout = os.path.splitext(filename)[0] + '.png' + + arguments = read_filename_arguments(filename) + + image = open(filename, 'rb').read() + image = convert_1bpp_to_2bpp(image) + + result = convert_2bpp_to_png(image, **arguments) + width, height, palette, greyscale, bitdepth, px_map = result + + w = png.Writer(width, height, palette=palette, compression=9, greyscale=greyscale, bitdepth=bitdepth) + with open(fileout, 'wb') as f: + w.write(f, px_map) + + +def export_png_to_1bpp(filename, fileout=None): + + if fileout == None: + fileout = os.path.splitext(filename)[0] + '.1bpp' + + arguments = read_filename_arguments(filename) + image = png_to_1bpp(filename, **arguments) + + to_file(fileout, image) + +def png_to_1bpp(filename, **kwargs): + image, kwargs = png_to_2bpp(filename, **kwargs) + return convert_2bpp_to_1bpp(image) + + +def convert_to_2bpp(filenames=[]): + for filename in filenames: + filename, name, extension = try_decompress(filename) + if extension == '.1bpp': + export_1bpp_to_2bpp(filename) + elif extension == '.2bpp': + pass + elif extension == '.png': + export_png_to_2bpp(filename) + else: + raise Exception, "Don't know how to convert {} to 2bpp!".format(filename) + +def convert_to_1bpp(filenames=[]): + for filename in filenames: + filename, name, extension = try_decompress(filename) + if extension == '.1bpp': + pass + elif extension == '.2bpp': + export_2bpp_to_1bpp(filename) + elif extension == '.png': + export_png_to_1bpp(filename) + else: + raise Exception, "Don't know how to convert {} to 1bpp!".format(filename) + +def convert_to_png(filenames=[]): + for filename in filenames: + filename, name, extension = try_decompress(filename) + if extension == '.1bpp': + export_1bpp_to_png(filename) + elif extension == '.2bpp': + export_2bpp_to_png(filename) + elif extension == '.png': + pass + else: + raise Exception, "Don't know how to convert {} to png!".format(filename) + +def compress(filenames=[]): + for filename in filenames: + data = open(filename, 'rb').read() + lz_data = Compressed(data).output + to_file(filename + '.lz', lz_data) + +def decompress(filenames=[]): + for filename in filenames: + name, extension = os.path.splitext(filename) + lz_data = open(filename, 'rb').read() + data = Decompressed(lz_data).output + to_file(name, data) + +def try_decompress(filename): + """ + Try to decompress a graphic when determining the filetype. + This skips the manual unlz step when attempting + to convert lz-compressed graphics to png. + """ + name, extension = os.path.splitext(filename) + if extension == '.lz': + decompress([filename]) + filename = name + name, extension = os.path.splitext(filename) + return filename, name, extension + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('mode') + ap.add_argument('filenames', nargs='*') + args = ap.parse_args() + + method = { + '2bpp': convert_to_2bpp, + '1bpp': convert_to_1bpp, + 'png': convert_to_png, + 'lz': compress, + 'unlz': decompress, + }.get(args.mode, None) + + if method == None: + raise Exception, "Unknown conversion method!" + + method(args.filenames) + +if __name__ == "__main__": + main() diff --git a/utils/lz.py b/utils/lz.py new file mode 100644 index 0000000..aef5c64 --- /dev/null +++ b/utils/lz.py @@ -0,0 +1,580 @@ +# -*- coding: utf-8 -*- +""" +Pokemon Crystal data de/compression. +""" + +""" +A rundown of Pokemon Crystal's compression scheme: + +Control commands occupy bits 5-7. +Bits 0-4 serve as the first parameter for each command. +""" +lz_commands = { + 'literal': 0, # n values for n bytes + 'iterate': 1, # one value for n bytes + 'alternate': 2, # alternate two values for n bytes + 'blank': 3, # zero for n bytes +} + +""" +Repeater commands repeat any data that was just decompressed. +They take an additional signed parameter to mark a relative starting point. +These wrap around (positive from the start, negative from the current position). +""" +lz_commands.update({ + 'repeat': 4, # n bytes starting from s + 'flip': 5, # n bytes in reverse bit order starting from s + 'reverse': 6, # n bytes backwards starting from s +}) + +""" +The long command is used when 5 bits aren't enough. Bits 2-4 contain a new control code. +Bits 0-1 are appended to a new byte as 8-9, allowing a 10-bit parameter. +""" +lz_commands.update({ + 'long': 7, # n is now 10 bits for a new control code +}) +max_length = 1 << 10 # can't go higher than 10 bits +lowmax = 1 << 5 # standard 5-bit param + +""" +If 0xff is encountered instead of a command, decompression ends. +""" +lz_end = 0xff + + +bit_flipped = [ + sum(((byte >> i) & 1) << (7 - i) for i in xrange(8)) + for byte in xrange(0x100) +] + + +class Compressed: + + """ + Usage: + lz = Compressed(data).output + or + lz = Compressed().compress(data) + or + c = Compressed() + c.data = data + lz = c.compress() + + There are some issues with reproducing the target compressor. + Some notes are listed here: + - the criteria for detecting a lookback is inconsistent + - sometimes lookbacks that are mostly 0s are pruned, sometimes not + - target appears to skip ahead if it can use a lookback soon, stopping the current command short or in some cases truncating it with literals. + - this has been implemented, but the specifics are unknown + - self.min_scores: It's unknown if blank's minimum score should be 1 or 2. Most likely it's 1, with some other hack to account for edge cases. + - may be related to the above + - target does not appear to compress backwards + """ + + def __init__(self, *args, **kwargs): + + self.min_scores = { + 'blank': 1, + 'iterate': 2, + 'alternate': 3, + 'repeat': 3, + 'reverse': 3, + 'flip': 3, + } + + self.preference = [ + 'repeat', + 'blank', + 'flip', + 'reverse', + 'iterate', + 'alternate', + #'literal', + ] + + self.lookback_methods = 'repeat', 'reverse', 'flip' + + self.__dict__.update({ + 'data': None, + 'commands': lz_commands, + 'debug': False, + 'literal_only': False, + }) + + self.arg_names = 'data', 'commands', 'debug', 'literal_only' + + self.__dict__.update(kwargs) + self.__dict__.update(dict(zip(self.arg_names, args))) + + if self.data is not None: + self.compress() + + def compress(self, data=None): + if data is not None: + self.data = data + + self.data = list(bytearray(self.data)) + + self.indexes = {} + self.lookbacks = {} + for method in self.lookback_methods: + self.lookbacks[method] = {} + + self.address = 0 + self.end = len(self.data) + self.output = [] + self.literal = None + + while self.address < self.end: + + if self.score(): + self.do_literal() + self.do_winner() + + else: + if self.literal == None: + self.literal = self.address + self.address += 1 + + self.do_literal() + + self.output += [lz_end] + return self.output + + def reset_scores(self): + self.scores = {} + self.offsets = {} + self.helpers = {} + for method in self.min_scores.iterkeys(): + self.scores[method] = 0 + + def bit_flip(self, byte): + return bit_flipped[byte] + + def do_literal(self): + if self.literal != None: + length = abs(self.address - self.literal) + start = min(self.literal, self.address + 1) + self.helpers['literal'] = self.data[start:start+length] + self.do_cmd('literal', length) + self.literal = None + + def score(self): + self.reset_scores() + + map(self.score_literal, ['iterate', 'alternate', 'blank']) + + for method in self.lookback_methods: + self.scores[method], self.offsets[method] = self.find_lookback(method, self.address) + + self.stop_short() + + return any( + score + > self.min_scores[method] + int(score > lowmax) + for method, score in self.scores.iteritems() + ) + + def stop_short(self): + """ + If a lookback is close, reduce the scores of other commands. + """ + best_method, best_score = max( + self.scores.items(), + key = lambda x: ( + x[1], + -self.preference.index(x[0]) + ) + ) + for method in self.lookback_methods: + min_score = self.min_scores[method] + for address in xrange(self.address+1, self.address+best_score): + length, index = self.find_lookback(method, address) + if length > max(min_score, best_score): + # BUG: lookbacks can reduce themselves. This appears to be a bug in the target also. + for m, score in self.scores.items(): + self.scores[m] = min(score, address - self.address) + + + def read(self, address=None): + if address is None: + address = self.address + if 0 <= address < len(self.data): + return self.data[address] + return None + + def find_all_lookbacks(self): + for method in self.lookback_methods: + for address, byte in enumerate(self.data): + self.find_lookback(method, address) + + def find_lookback(self, method, address=None): + """Temporarily stubbed, because the real function doesn't run in polynomial time.""" + return 0, None + + def broken_find_lookback(self, method, address=None): + if address is None: + address = self.address + + existing = self.lookbacks.get(method, {}).get(address) + if existing != None: + return existing + + lookback = 0, None + + # Better to not carelessly optimize at the moment. + """ + if address < 2: + return lookback + """ + + byte = self.read(address) + if byte is None: + return lookback + + direction, mutate = { + 'repeat': ( 1, int), + 'reverse': (-1, int), + 'flip': ( 1, self.bit_flip), + }[method] + + # Doesn't seem to help + """ + if mutate == self.bit_flip: + if byte == 0: + self.lookbacks[method][address] = lookback + return lookback + """ + + data_len = len(self.data) + is_two_byte_index = lambda index: int(index < address - 0x7f) + + for index in self.get_indexes(mutate(byte)): + + if index >= address: + break + + old_length, old_index = lookback + if direction == 1: + if old_length > data_len - index: break + else: + if old_length > index: continue + + if self.read(index) in [None]: continue + + length = 1 # we know there's at least one match, or we wouldn't be checking this index + while 1: + this_byte = self.read(address + length) + that_byte = self.read(index + length * direction) + if that_byte == None or this_byte != mutate(that_byte): + break + length += 1 + + score = length - is_two_byte_index(index) + old_score = old_length - is_two_byte_index(old_index) + if score >= old_score or (score == old_score and length > old_length): + # XXX maybe avoid two-byte indexes when possible + if score >= lookback[0] - is_two_byte_index(lookback[1]): + lookback = length, index + + self.lookbacks[method][address] = lookback + return lookback + + def get_indexes(self, byte): + if not self.indexes.has_key(byte): + self.indexes[byte] = [] + index = -1 + while 1: + try: + index = self.data.index(byte, index + 1) + except ValueError: + break + self.indexes[byte].append(index) + return self.indexes[byte] + + def score_literal(self, method): + address = self.address + + compare = { + 'blank': [0], + 'iterate': [self.read(address)], + 'alternate': [self.read(address), self.read(address + 1)], + }[method] + + # XXX may or may not be correct + if method == 'alternate' and compare[0] == 0: + return + + length = 0 + while self.read(address + length) == compare[length % len(compare)]: + length += 1 + + self.scores[method] = length + self.helpers[method] = compare + + def do_winner(self): + winners = filter( + lambda (method, score): + score + > self.min_scores[method] + int(score > lowmax), + self.scores.iteritems() + ) + winners.sort( + key = lambda (method, score): ( + -(score - self.min_scores[method] - int(score > lowmax)), + self.preference.index(method) + ) + ) + winner, score = winners[0] + + length = min(score, max_length) + self.do_cmd(winner, length) + self.address += length + + def do_cmd(self, cmd, length): + start_address = self.address + + cmd_length = length - 1 + + output = [] + + if length > lowmax: + output.append( + (self.commands['long'] << 5) + + (self.commands[cmd] << 2) + + (cmd_length >> 8) + ) + output.append( + cmd_length & 0xff + ) + else: + output.append( + (self.commands[cmd] << 5) + + cmd_length + ) + + self.helpers['blank'] = [] # quick hack + output += self.helpers.get(cmd, []) + + if cmd in self.lookback_methods: + offset = self.offsets[cmd] + # Negative offsets are one byte. + # Positive offsets are two. + if 0 < start_address - offset - 1 <= 0x7f: + offset = (start_address - offset - 1) | 0x80 + output += [offset] + else: + output += [offset / 0x100, offset % 0x100] # big endian + + if self.debug: + print ' '.join(map(str, [ + cmd, length, '\t', + ' '.join(map('{:02x}'.format, output)), + self.data[start_address:start_address+length] if cmd in self.lookback_methods else '', + ])) + + self.output += output + + + +class Decompressed: + """ + Interpret and decompress lz-compressed data, usually 2bpp. + """ + + """ + Usage: + data = Decompressed(lz).output + or + data = Decompressed().decompress(lz) + or + d = Decompressed() + d.lz = lz + data = d.decompress() + + To decompress from offset 0x80000 in a rom: + data = Decompressed(rom, start=0x80000).output + """ + + lz = None + start = 0 + commands = lz_commands + debug = False + + arg_names = 'lz', 'start', 'commands', 'debug' + + def __init__(self, *args, **kwargs): + self.__dict__.update(dict(zip(self.arg_names, args))) + self.__dict__.update(kwargs) + + self.command_names = dict(map(reversed, self.commands.items())) + self.address = self.start + + if self.lz is not None: + self.decompress() + + if self.debug: print self.command_list() + + + def command_list(self): + """ + Print a list of commands that were used. Useful for debugging. + """ + + text = '' + + output_address = 0 + for name, attrs in self.used_commands: + length = attrs['length'] + address = attrs['address'] + offset = attrs['offset'] + direction = attrs['direction'] + + text += '{2:03x} {0}: {1}'.format(name, length, output_address) + text += '\t' + ' '.join( + '{:02x}'.format(int(byte)) + for byte in self.lz[ address : address + attrs['cmd_length'] ] + ) + + if offset is not None: + repeated_data = self.output[ offset : offset + length * direction : direction ] + if name == 'flip': + repeated_data = map(bit_flipped.__getitem__, repeated_data) + text += ' [' + ' '.join(map('{:02x}'.format, repeated_data)) + ']' + + text += '\n' + output_address += length + + return text + + + def decompress(self, lz=None): + + if lz is not None: + self.lz = lz + + self.lz = bytearray(self.lz) + + self.used_commands = [] + self.output = [] + + while 1: + + cmd_address = self.address + self.offset = None + self.direction = None + + if (self.byte == lz_end): + self.next() + break + + self.cmd = (self.byte & 0b11100000) >> 5 + + if self.cmd_name == 'long': + # 10-bit length + self.cmd = (self.byte & 0b00011100) >> 2 + self.length = (self.next() & 0b00000011) * 0x100 + self.length += self.next() + 1 + else: + # 5-bit length + self.length = (self.next() & 0b00011111) + 1 + + self.__class__.__dict__[self.cmd_name](self) + + self.used_commands += [( + self.cmd_name, + { + 'length': self.length, + 'address': cmd_address, + 'offset': self.offset, + 'cmd_length': self.address - cmd_address, + 'direction': self.direction, + } + )] + + # Keep track of the data we just decompressed. + self.compressed_data = self.lz[self.start : self.address] + + + @property + def byte(self): + return self.lz[ self.address ] + + def next(self): + byte = self.byte + self.address += 1 + return byte + + @property + def cmd_name(self): + return self.command_names.get(self.cmd) + + + def get_offset(self): + + if self.byte >= 0x80: # negative + # negative + offset = self.next() & 0x7f + offset = len(self.output) - offset - 1 + else: + # positive + offset = self.next() * 0x100 + offset += self.next() + + self.offset = offset + + + def literal(self): + """ + Copy data directly. + """ + self.output += self.lz[ self.address : self.address + self.length ] + self.address += self.length + + def iterate(self): + """ + Write one byte repeatedly. + """ + self.output += [self.next()] * self.length + + def alternate(self): + """ + Write alternating bytes. + """ + alts = [self.next(), self.next()] + self.output += [ alts[x & 1] for x in xrange(self.length) ] + + def blank(self): + """ + Write zeros. + """ + self.output += [0] * self.length + + def flip(self): + """ + Repeat flipped bytes from output. + + Example: 11100100 -> 00100111 + """ + self._repeat(table=bit_flipped) + + def reverse(self): + """ + Repeat reversed bytes from output. + """ + self._repeat(direction=-1) + + def repeat(self): + """ + Repeat bytes from output. + """ + self._repeat() + + def _repeat(self, direction=1, table=None): + self.get_offset() + self.direction = direction + # Note: appends must be one at a time (this way, repeats can draw from themselves if required) + for i in xrange(self.length): + byte = self.output[ self.offset + i * direction ] + self.output.append( table[byte] if table else byte ) diff --git a/utils/map2link.py b/utils/map2link.py new file mode 100644 index 0000000..2c7d40c --- /dev/null +++ b/utils/map2link.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import argparse +import re +import sys +from collections import OrderedDict + + +class MapFile: + LINETYPE_BLANK = -1 + LINETYPE_BANK = 0 + LINETYPE_SECTION = 1 + LINETYPE_SYMBOL = 2 + LINETYPE_SLACK = 3 + LINETYPE_EMPTY = 4 + + bank_types = ('ROM', 'VRAM', 'SRAM', 'WRAM', 'OAM', 'HRAM') + bank_starts = (0x4000, 0x8000, 0xa000, 0xd000, 0xfe00, 0xff80) + + class MapFileLine: + def __init__(self, linestr): + self.raw = linestr + + def __str__(self): + return self.raw + + class BlankLine(MapFileLine): + def __init__(self, linestr): + super().__init__(linestr) + + class BankLine(MapFileLine): + def __init__(self, linestr): + super().__init__(linestr) + match = re.search('#(\d+)', linestr, re.I) + if match is None: + self.bankno = 0 + else: + self.bankno = int(match.group(1)) + bankname = linestr.split()[0].rstrip(':') + try: + self.banktype = MapFile.bank_types.index(bankname) + except ValueError as e: + raise ValueError(f'Unrecognized bank type: {bankname}') from e + + @property + def name(self): + if self.banktype == 0: # ROM + if self.bankno == 0: + return 'ROM0' + else: + return f'ROMX ${self.bankno:02x}' + elif self.banktype == 3: # WRAM + if self.bankno == 0: + return 'WRAM0' + else: + return f'WRAMX ${self.bankno:02x}' + elif self.banktype < 3: + return f'{MapFile.bank_types[self.banktype]} {self.bankno:d}' + else: + return f'{MapFile.bank_types[self.banktype]}' + + @property + def start(self): + if self.bankno == 0: + if self.banktype == 0: + return 0x0000 + elif self.banktype == 3: + return 0xc000 + return MapFile.bank_starts[self.banktype] + + def __hash__(self): + return hash(self.name) + + class SectionLine(MapFileLine): + def __init__(self, linestr): + super().__init__(linestr) + match = re.search(r'\$([0-9A-F]{4}) \(\$([0-9A-F]+) bytes\) \["(.+)"\]', linestr, re.I) + end, size, self.name = match.groups() + self.end = int(end, 16) + self.size = int(size, 16) + if self.size > 0: + self.end += 1 + self.start = self.end - self.size + self.symbols = [] + + class SymbolLine(MapFileLine): + def __init__(self, linestr): + super().__init__(linestr) + + class SlackLine(MapFileLine): + def __init__(self, linestr): + super().__init__(linestr) + match = re.search(r'\$([0-9A-F]{4}) bytes', linestr, re.I) + self.slack = int(match.group(1), 16) + + class EmptyLine(MapFileLine): + def __init__(self, linestr): + super().__init__(linestr) + + line_classes = { + LINETYPE_BLANK: BlankLine, + LINETYPE_BANK: BankLine, + LINETYPE_SECTION: SectionLine, + LINETYPE_SYMBOL: SymbolLine, + LINETYPE_SLACK: SlackLine, + LINETYPE_EMPTY: EmptyLine + } + + def __init__(self, fname, *fpargs, **fpkwargs): + self.fname = fname + self.fp = None + self.fpargs = fpargs + self.fpkwargs = fpkwargs + + def open(self): + if self.fp is None or self.fp.closed: + self.fp = open(self.fname, *self.fpargs, **self.fpkwargs) + + def close(self): + if not self.fp.closed: + self.fp.close() + self.fp = None + + def __enter__(self): + self.open() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.fp.__exit__(exc_type, exc_val, exc_tb) + self.fp = None + + def __iter__(self): + if self.fp is None or self.fp.closed: + print('Warning: Cowardly refusing to read closed file', file=sys.stderr) + raise StopIteration + for line in self.fp: + linestr = line.strip('\n') + if linestr == '': + line_type = self.LINETYPE_BLANK + elif 'SECTION:' in linestr: + line_type = self.LINETYPE_SECTION + elif 'SLACK:' in linestr: + line_type = self.LINETYPE_SLACK + elif linestr == ' EMPTY': + line_type = self.LINETYPE_EMPTY + elif ' = ' in linestr: + line_type = self.LINETYPE_SYMBOL + else: + line_type = self.LINETYPE_BANK + yield self.line_classes[line_type](linestr) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('mapfile', type=MapFile) + parser.add_argument('linkfile', type=argparse.FileType('w')) + args = parser.parse_args() + + rom_map = OrderedDict() + cur_bank = None + + print('; Automatically generated by map2link.py', file=args.linkfile) + + with args.mapfile: + for line in args.mapfile: + if isinstance(line, MapFile.BankLine): + cur_bank = line + rom_map[cur_bank] = [] + elif isinstance(line, MapFile.SectionLine): + rom_map[cur_bank].append(line) + + for bank, sections in rom_map.items(): + if len(sections) == 0: + continue + sections.sort(key=lambda s: s.start) + print(bank.name, file=args.linkfile) + start = bank.start + for section in sections: + if section.start > start: + print(f'\t; ${start:04x}', file=args.linkfile) + print(f'\torg ${section.start:04x}', file=args.linkfile) + print(f'\t"{section.name}" ; ${section.start:04x}, size: ${section.size:04x}', file=args.linkfile) + start = section.end + + +if __name__ == '__main__': + main() diff --git a/utils/png.py b/utils/png.py new file mode 100644 index 0000000..db6da12 --- /dev/null +++ b/utils/png.py @@ -0,0 +1,2650 @@ +#!/usr/bin/env python + +from __future__ import print_function + +# png.py - PNG encoder/decoder in pure Python +# +# Copyright (C) 2006 Johann C. Rocholl +# Portions Copyright (C) 2009 David Jones +# And probably portions Copyright (C) 2006 Nicko van Someren +# +# Original concept by Johann C. Rocholl. +# +# LICENCE (MIT) +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Pure Python PNG Reader/Writer + +This Python module implements support for PNG images (see PNG +specification at http://www.w3.org/TR/2003/REC-PNG-20031110/ ). It reads +and writes PNG files with all allowable bit depths +(1/2/4/8/16/24/32/48/64 bits per pixel) and colour combinations: +greyscale (1/2/4/8/16 bit); RGB, RGBA, LA (greyscale with alpha) with +8/16 bits per channel; colour mapped images (1/2/4/8 bit). +Adam7 interlacing is supported for reading and +writing. A number of optional chunks can be specified (when writing) +and understood (when reading): ``tRNS``, ``bKGD``, ``gAMA``. + +For help, type ``import png; help(png)`` in your python interpreter. + +A good place to start is the :class:`Reader` and :class:`Writer` +classes. + +Requires Python 2.3. Limited support is available for Python 2.2, but +not everything works. Best with Python 2.4 and higher. Installation is +trivial, but see the ``README.txt`` file (with the source distribution) +for details. + +This file can also be used as a command-line utility to convert +`Netpbm `_ PNM files to PNG, and the +reverse conversion from PNG to PNM. The interface is similar to that +of the ``pnmtopng`` program from Netpbm. Type ``python png.py --help`` +at the shell prompt for usage and a list of options. + +A note on spelling and terminology +---------------------------------- + +Generally British English spelling is used in the documentation. So +that's "greyscale" and "colour". This not only matches the author's +native language, it's also used by the PNG specification. + +The major colour models supported by PNG (and hence by PyPNG) are: +greyscale, RGB, greyscale--alpha, RGB--alpha. These are sometimes +referred to using the abbreviations: L, RGB, LA, RGBA. In this case +each letter abbreviates a single channel: *L* is for Luminance or Luma +or Lightness which is the channel used in greyscale images; *R*, *G*, +*B* stand for Red, Green, Blue, the components of a colour image; *A* +stands for Alpha, the opacity channel (used for transparency effects, +but higher values are more opaque, so it makes sense to call it +opacity). + +A note on formats +----------------- + +When getting pixel data out of this module (reading) and presenting +data to this module (writing) there are a number of ways the data could +be represented as a Python value. Generally this module uses one of +three formats called "flat row flat pixel", "boxed row flat pixel", and +"boxed row boxed pixel". Basically the concern is whether each pixel +and each row comes in its own little tuple (box), or not. + +Consider an image that is 3 pixels wide by 2 pixels high, and each pixel +has RGB components: + +Boxed row flat pixel:: + + list([R,G,B, R,G,B, R,G,B], + [R,G,B, R,G,B, R,G,B]) + +Each row appears as its own list, but the pixels are flattened so +that three values for one pixel simply follow the three values for +the previous pixel. This is the most common format used, because it +provides a good compromise between space and convenience. PyPNG regards +itself as at liberty to replace any sequence type with any sufficiently +compatible other sequence type; in practice each row is an array (from +the array module), and the outer list is sometimes an iterator rather +than an explicit list (so that streaming is possible). + +Flat row flat pixel:: + + [R,G,B, R,G,B, R,G,B, + R,G,B, R,G,B, R,G,B] + +The entire image is one single giant sequence of colour values. +Generally an array will be used (to save space), not a list. + +Boxed row boxed pixel:: + + list([ (R,G,B), (R,G,B), (R,G,B) ], + [ (R,G,B), (R,G,B), (R,G,B) ]) + +Each row appears in its own list, but each pixel also appears in its own +tuple. A serious memory burn in Python. + +In all cases the top row comes first, and for each row the pixels are +ordered from left-to-right. Within a pixel the values appear in the +order, R-G-B-A (or L-A for greyscale--alpha). + +There is a fourth format, mentioned because it is used internally, +is close to what lies inside a PNG file itself, and has some support +from the public API. This format is called packed. When packed, +each row is a sequence of bytes (integers from 0 to 255), just as +it is before PNG scanline filtering is applied. When the bit depth +is 8 this is essentially the same as boxed row flat pixel; when the +bit depth is less than 8, several pixels are packed into each byte; +when the bit depth is 16 (the only value more than 8 that is supported +by the PNG image format) each pixel value is decomposed into 2 bytes +(and `packed` is a misnomer). This format is used by the +:meth:`Writer.write_packed` method. It isn't usually a convenient +format, but may be just right if the source data for the PNG image +comes from something that uses a similar format (for example, 1-bit +BMPs, or another PNG file). + +And now, my famous members +-------------------------- +""" + +__version__ = "0.0.18" + +import itertools +import math +# http://www.python.org/doc/2.4.4/lib/module-operator.html +import operator +import struct +import sys +# http://www.python.org/doc/2.4.4/lib/module-warnings.html +import warnings +import zlib + +from array import array +from functools import reduce + +try: + # `cpngfilters` is a Cython module: it must be compiled by + # Cython for this import to work. + # If this import does work, then it overrides pure-python + # filtering functions defined later in this file (see `class + # pngfilters`). + import cpngfilters as pngfilters +except ImportError: + pass + + +__all__ = ['Image', 'Reader', 'Writer', 'write_chunks', 'from_array'] + + +# The PNG signature. +# http://www.w3.org/TR/PNG/#5PNG-file-signature +_signature = struct.pack('8B', 137, 80, 78, 71, 13, 10, 26, 10) + +_adam7 = ((0, 0, 8, 8), + (4, 0, 8, 8), + (0, 4, 4, 8), + (2, 0, 4, 4), + (0, 2, 2, 4), + (1, 0, 2, 2), + (0, 1, 1, 2)) + +def group(s, n): + # See http://www.python.org/doc/2.6/library/functions.html#zip + return list(zip(*[iter(s)]*n)) + +def isarray(x): + return isinstance(x, array) + +def tostring(row): + return row.tostring() + +def interleave_planes(ipixels, apixels, ipsize, apsize): + """ + Interleave (colour) planes, e.g. RGB + A = RGBA. + + Return an array of pixels consisting of the `ipsize` elements of + data from each pixel in `ipixels` followed by the `apsize` elements + of data from each pixel in `apixels`. Conventionally `ipixels` + and `apixels` are byte arrays so the sizes are bytes, but it + actually works with any arrays of the same type. The returned + array is the same type as the input arrays which should be the + same type as each other. + """ + + itotal = len(ipixels) + atotal = len(apixels) + newtotal = itotal + atotal + newpsize = ipsize + apsize + # Set up the output buffer + # See http://www.python.org/doc/2.4.4/lib/module-array.html#l2h-1356 + out = array(ipixels.typecode) + # It's annoying that there is no cheap way to set the array size :-( + out.extend(ipixels) + out.extend(apixels) + # Interleave in the pixel data + for i in range(ipsize): + out[i:newtotal:newpsize] = ipixels[i:itotal:ipsize] + for i in range(apsize): + out[i+ipsize:newtotal:newpsize] = apixels[i:atotal:apsize] + return out + +def check_palette(palette): + """Check a palette argument (to the :class:`Writer` class) + for validity. Returns the palette as a list if okay; raises an + exception otherwise. + """ + + # None is the default and is allowed. + if palette is None: + return None + + p = list(palette) + if not (0 < len(p) <= 256): + raise ValueError("a palette must have between 1 and 256 entries") + seen_triple = False + for i,t in enumerate(p): + if len(t) not in (3,4): + raise ValueError( + "palette entry %d: entries must be 3- or 4-tuples." % i) + if len(t) == 3: + seen_triple = True + if seen_triple and len(t) == 4: + raise ValueError( + "palette entry %d: all 4-tuples must precede all 3-tuples" % i) + for x in t: + if int(x) != x or not(0 <= x <= 255): + raise ValueError( + "palette entry %d: values must be integer: 0 <= x <= 255" % i) + return p + +def check_sizes(size, width, height): + """Check that these arguments, in supplied, are consistent. + Return a (width, height) pair. + """ + + if not size: + return width, height + + if len(size) != 2: + raise ValueError( + "size argument should be a pair (width, height)") + if width is not None and width != size[0]: + raise ValueError( + "size[0] (%r) and width (%r) should match when both are used." + % (size[0], width)) + if height is not None and height != size[1]: + raise ValueError( + "size[1] (%r) and height (%r) should match when both are used." + % (size[1], height)) + return size + +def check_color(c, greyscale, which): + """Checks that a colour argument for transparent or + background options is the right form. Returns the colour + (which, if it's a bar integer, is "corrected" to a 1-tuple). + """ + + if c is None: + return c + if greyscale: + try: + len(c) + except TypeError: + c = (c,) + if len(c) != 1: + raise ValueError("%s for greyscale must be 1-tuple" % + which) + if not isinteger(c[0]): + raise ValueError( + "%s colour for greyscale must be integer" % which) + else: + if not (len(c) == 3 and + isinteger(c[0]) and + isinteger(c[1]) and + isinteger(c[2])): + raise ValueError( + "%s colour must be a triple of integers" % which) + return c + +class Error(Exception): + def __str__(self): + return self.__class__.__name__ + ': ' + ' '.join(self.args) + +class FormatError(Error): + """Problem with input file format. In other words, PNG file does + not conform to the specification in some way and is invalid. + """ + +class ChunkError(FormatError): + pass + + +class Writer: + """ + PNG encoder in pure Python. + """ + + def __init__(self, width=None, height=None, + size=None, + greyscale=False, + alpha=False, + bitdepth=8, + palette=None, + transparent=None, + background=None, + gamma=None, + compression=None, + interlace=False, + bytes_per_sample=None, # deprecated + planes=None, + colormap=None, + maxval=None, + chunk_limit=2**20, + x_pixels_per_unit = None, + y_pixels_per_unit = None, + unit_is_meter = False): + """ + Create a PNG encoder object. + + Arguments: + + width, height + Image size in pixels, as two separate arguments. + size + Image size (w,h) in pixels, as single argument. + greyscale + Input data is greyscale, not RGB. + alpha + Input data has alpha channel (RGBA or LA). + bitdepth + Bit depth: from 1 to 16. + palette + Create a palette for a colour mapped image (colour type 3). + transparent + Specify a transparent colour (create a ``tRNS`` chunk). + background + Specify a default background colour (create a ``bKGD`` chunk). + gamma + Specify a gamma value (create a ``gAMA`` chunk). + compression + zlib compression level: 0 (none) to 9 (more compressed); + default: -1 or None. + interlace + Create an interlaced image. + chunk_limit + Write multiple ``IDAT`` chunks to save memory. + x_pixels_per_unit + Number of pixels a unit along the x axis (write a + `pHYs` chunk). + y_pixels_per_unit + Number of pixels a unit along the y axis (write a + `pHYs` chunk). Along with `x_pixel_unit`, this gives + the pixel size ratio. + unit_is_meter + `True` to indicate that the unit (for the `pHYs` + chunk) is metre. + + The image size (in pixels) can be specified either by using the + `width` and `height` arguments, or with the single `size` + argument. If `size` is used it should be a pair (*width*, + *height*). + + `greyscale` and `alpha` are booleans that specify whether + an image is greyscale (or colour), and whether it has an + alpha channel (or not). + + `bitdepth` specifies the bit depth of the source pixel values. + Each source pixel value must be an integer between 0 and + ``2**bitdepth-1``. For example, 8-bit images have values + between 0 and 255. PNG only stores images with bit depths of + 1,2,4,8, or 16. When `bitdepth` is not one of these values, + the next highest valid bit depth is selected, and an ``sBIT`` + (significant bits) chunk is generated that specifies the + original precision of the source image. In this case the + supplied pixel values will be rescaled to fit the range of + the selected bit depth. + + The details of which bit depth / colour model combinations the + PNG file format supports directly, are somewhat arcane + (refer to the PNG specification for full details). Briefly: + "small" bit depths (1,2,4) are only allowed with greyscale and + colour mapped images; colour mapped images cannot have bit depth + 16. + + For colour mapped images (in other words, when the `palette` + argument is specified) the `bitdepth` argument must match one of + the valid PNG bit depths: 1, 2, 4, or 8. (It is valid to have a + PNG image with a palette and an ``sBIT`` chunk, but the meaning + is slightly different; it would be awkward to press the + `bitdepth` argument into service for this.) + + The `palette` option, when specified, causes a colour + mapped image to be created: the PNG colour type is set to 3; + `greyscale` must not be set; `alpha` must not be set; + `transparent` must not be set; the bit depth must be 1,2,4, + or 8. When a colour mapped image is created, the pixel values + are palette indexes and the `bitdepth` argument specifies the + size of these indexes (not the size of the colour values in + the palette). + + The palette argument value should be a sequence of 3- or + 4-tuples. 3-tuples specify RGB palette entries; 4-tuples + specify RGBA palette entries. If both 4-tuples and 3-tuples + appear in the sequence then all the 4-tuples must come + before all the 3-tuples. A ``PLTE`` chunk is created; if there + are 4-tuples then a ``tRNS`` chunk is created as well. The + ``PLTE`` chunk will contain all the RGB triples in the same + sequence; the ``tRNS`` chunk will contain the alpha channel for + all the 4-tuples, in the same sequence. Palette entries + are always 8-bit. + + If specified, the `transparent` and `background` parameters must + be a tuple with three integer values for red, green, blue, or + a simple integer (or singleton tuple) for a greyscale image. + + If specified, the `gamma` parameter must be a positive number + (generally, a `float`). A ``gAMA`` chunk will be created. + Note that this will not change the values of the pixels as + they appear in the PNG file, they are assumed to have already + been converted appropriately for the gamma specified. + + The `compression` argument specifies the compression level to + be used by the ``zlib`` module. Values from 1 to 9 specify + compression, with 9 being "more compressed" (usually smaller + and slower, but it doesn't always work out that way). 0 means + no compression. -1 and ``None`` both mean that the default + level of compession will be picked by the ``zlib`` module + (which is generally acceptable). + + If `interlace` is true then an interlaced image is created + (using PNG's so far only interace method, *Adam7*). This does + not affect how the pixels should be presented to the encoder, + rather it changes how they are arranged into the PNG file. + On slow connexions interlaced images can be partially decoded + by the browser to give a rough view of the image that is + successively refined as more image data appears. + + .. note :: + + Enabling the `interlace` option requires the entire image + to be processed in working memory. + + `chunk_limit` is used to limit the amount of memory used whilst + compressing the image. In order to avoid using large amounts of + memory, multiple ``IDAT`` chunks may be created. + """ + + # At the moment the `planes` argument is ignored; + # its purpose is to act as a dummy so that + # ``Writer(x, y, **info)`` works, where `info` is a dictionary + # returned by Reader.read and friends. + # Ditto for `colormap`. + + width, height = check_sizes(size, width, height) + del size + + if width <= 0 or height <= 0: + raise ValueError("width and height must be greater than zero") + if not isinteger(width) or not isinteger(height): + raise ValueError("width and height must be integers") + # http://www.w3.org/TR/PNG/#7Integers-and-byte-order + if width > 2**32-1 or height > 2**32-1: + raise ValueError("width and height cannot exceed 2**32-1") + + if alpha and transparent is not None: + raise ValueError( + "transparent colour not allowed with alpha channel") + + if bytes_per_sample is not None: + warnings.warn('please use bitdepth instead of bytes_per_sample', + DeprecationWarning) + if bytes_per_sample not in (0.125, 0.25, 0.5, 1, 2): + raise ValueError( + "bytes per sample must be .125, .25, .5, 1, or 2") + bitdepth = int(8*bytes_per_sample) + del bytes_per_sample + if not isinteger(bitdepth) or bitdepth < 1 or 16 < bitdepth: + raise ValueError("bitdepth (%r) must be a positive integer <= 16" % + bitdepth) + + self.rescale = None + palette = check_palette(palette) + if palette: + if bitdepth not in (1,2,4,8): + raise ValueError("with palette, bitdepth must be 1, 2, 4, or 8") + if transparent is not None: + raise ValueError("transparent and palette not compatible") + if alpha: + raise ValueError("alpha and palette not compatible") + if greyscale: + raise ValueError("greyscale and palette not compatible") + else: + # No palette, check for sBIT chunk generation. + if alpha or not greyscale: + if bitdepth not in (8,16): + targetbitdepth = (8,16)[bitdepth > 8] + self.rescale = (bitdepth, targetbitdepth) + bitdepth = targetbitdepth + del targetbitdepth + else: + assert greyscale + assert not alpha + if bitdepth not in (1,2,4,8,16): + if bitdepth > 8: + targetbitdepth = 16 + elif bitdepth == 3: + targetbitdepth = 4 + else: + assert bitdepth in (5,6,7) + targetbitdepth = 8 + self.rescale = (bitdepth, targetbitdepth) + bitdepth = targetbitdepth + del targetbitdepth + + if bitdepth < 8 and (alpha or not greyscale and not palette): + raise ValueError( + "bitdepth < 8 only permitted with greyscale or palette") + if bitdepth > 8 and palette: + raise ValueError( + "bit depth must be 8 or less for images with palette") + + transparent = check_color(transparent, greyscale, 'transparent') + background = check_color(background, greyscale, 'background') + + # It's important that the true boolean values (greyscale, alpha, + # colormap, interlace) are converted to bool because Iverson's + # convention is relied upon later on. + self.width = width + self.height = height + self.transparent = transparent + self.background = background + self.gamma = gamma + self.greyscale = bool(greyscale) + self.alpha = bool(alpha) + self.colormap = bool(palette) + self.bitdepth = int(bitdepth) + self.compression = compression + self.chunk_limit = chunk_limit + self.interlace = bool(interlace) + self.palette = palette + self.x_pixels_per_unit = x_pixels_per_unit + self.y_pixels_per_unit = y_pixels_per_unit + self.unit_is_meter = bool(unit_is_meter) + + self.color_type = 4*self.alpha + 2*(not greyscale) + 1*self.colormap + assert self.color_type in (0,2,3,4,6) + + self.color_planes = (3,1)[self.greyscale or self.colormap] + self.planes = self.color_planes + self.alpha + # :todo: fix for bitdepth < 8 + self.psize = (self.bitdepth/8) * self.planes + + def make_palette(self): + """Create the byte sequences for a ``PLTE`` and if necessary a + ``tRNS`` chunk. Returned as a pair (*p*, *t*). *t* will be + ``None`` if no ``tRNS`` chunk is necessary. + """ + + p = array('B') + t = array('B') + + for x in self.palette: + p.extend(x[0:3]) + if len(x) > 3: + t.append(x[3]) + p = tostring(p) + t = tostring(t) + if t: + return p,t + return p,None + + def write(self, outfile, rows): + """Write a PNG image to the output file. `rows` should be + an iterable that yields each row in boxed row flat pixel + format. The rows should be the rows of the original image, + so there should be ``self.height`` rows of ``self.width * + self.planes`` values. If `interlace` is specified (when + creating the instance), then an interlaced PNG file will + be written. Supply the rows in the normal image order; + the interlacing is carried out internally. + + .. note :: + + Interlacing will require the entire image to be in working + memory. + """ + + if self.interlace: + fmt = 'BH'[self.bitdepth > 8] + a = array(fmt, itertools.chain(*rows)) + return self.write_array(outfile, a) + + nrows = self.write_passes(outfile, rows) + if nrows != self.height: + raise ValueError( + "rows supplied (%d) does not match height (%d)" % + (nrows, self.height)) + + def write_passes(self, outfile, rows, packed=False): + """ + Write a PNG image to the output file. + + Most users are expected to find the :meth:`write` or + :meth:`write_array` method more convenient. + + The rows should be given to this method in the order that + they appear in the output file. For straightlaced images, + this is the usual top to bottom ordering, but for interlaced + images the rows should have already been interlaced before + passing them to this function. + + `rows` should be an iterable that yields each row. When + `packed` is ``False`` the rows should be in boxed row flat pixel + format; when `packed` is ``True`` each row should be a packed + sequence of bytes. + """ + + # http://www.w3.org/TR/PNG/#5PNG-file-signature + outfile.write(_signature) + + # http://www.w3.org/TR/PNG/#11IHDR + write_chunk(outfile, b'IHDR', + struct.pack("!2I5B", self.width, self.height, + self.bitdepth, self.color_type, + 0, 0, self.interlace)) + + # See :chunk:order + # http://www.w3.org/TR/PNG/#11gAMA + if self.gamma is not None: + write_chunk(outfile, b'gAMA', + struct.pack("!L", int(round(self.gamma*1e5)))) + + # See :chunk:order + # http://www.w3.org/TR/PNG/#11sBIT + if self.rescale: + write_chunk(outfile, b'sBIT', + struct.pack('%dB' % self.planes, + *[self.rescale[0]]*self.planes)) + + # :chunk:order: Without a palette (PLTE chunk), ordering is + # relatively relaxed. With one, gAMA chunk must precede PLTE + # chunk which must precede tRNS and bKGD. + # See http://www.w3.org/TR/PNG/#5ChunkOrdering + if self.palette: + p,t = self.make_palette() + write_chunk(outfile, b'PLTE', p) + if t: + # tRNS chunk is optional. Only needed if palette entries + # have alpha. + write_chunk(outfile, b'tRNS', t) + + # http://www.w3.org/TR/PNG/#11tRNS + if self.transparent is not None: + if self.greyscale: + write_chunk(outfile, b'tRNS', + struct.pack("!1H", *self.transparent)) + else: + write_chunk(outfile, b'tRNS', + struct.pack("!3H", *self.transparent)) + + # http://www.w3.org/TR/PNG/#11bKGD + if self.background is not None: + if self.greyscale: + write_chunk(outfile, b'bKGD', + struct.pack("!1H", *self.background)) + else: + write_chunk(outfile, b'bKGD', + struct.pack("!3H", *self.background)) + + # http://www.w3.org/TR/PNG/#11pHYs + if self.x_pixels_per_unit is not None and self.y_pixels_per_unit is not None: + tup = (self.x_pixels_per_unit, self.y_pixels_per_unit, int(self.unit_is_meter)) + write_chunk(outfile, b'pHYs', struct.pack("!LLB",*tup)) + + # http://www.w3.org/TR/PNG/#11IDAT + if self.compression is not None: + compressor = zlib.compressobj(self.compression) + else: + compressor = zlib.compressobj() + + # Choose an extend function based on the bitdepth. The extend + # function packs/decomposes the pixel values into bytes and + # stuffs them onto the data array. + data = array('B') + if self.bitdepth == 8 or packed: + extend = data.extend + elif self.bitdepth == 16: + # Decompose into bytes + def extend(sl): + fmt = '!%dH' % len(sl) + data.extend(array('B', struct.pack(fmt, *sl))) + else: + # Pack into bytes + assert self.bitdepth < 8 + # samples per byte + spb = int(8/self.bitdepth) + def extend(sl): + a = array('B', sl) + # Adding padding bytes so we can group into a whole + # number of spb-tuples. + l = float(len(a)) + extra = math.ceil(l / float(spb))*spb - l + a.extend([0]*int(extra)) + # Pack into bytes + l = group(a, spb) + l = [reduce(lambda x,y: + (x << self.bitdepth) + y, e) for e in l] + data.extend(l) + if self.rescale: + oldextend = extend + factor = \ + float(2**self.rescale[1]-1) / float(2**self.rescale[0]-1) + def extend(sl): + oldextend([int(round(factor*x)) for x in sl]) + + # Build the first row, testing mostly to see if we need to + # changed the extend function to cope with NumPy integer types + # (they cause our ordinary definition of extend to fail, so we + # wrap it). See + # http://code.google.com/p/pypng/issues/detail?id=44 + enumrows = enumerate(rows) + del rows + + # First row's filter type. + data.append(0) + # :todo: Certain exceptions in the call to ``.next()`` or the + # following try would indicate no row data supplied. + # Should catch. + i,row = next(enumrows) + try: + # If this fails... + extend(row) + except: + # ... try a version that converts the values to int first. + # Not only does this work for the (slightly broken) NumPy + # types, there are probably lots of other, unknown, "nearly" + # int types it works for. + def wrapmapint(f): + return lambda sl: f([int(x) for x in sl]) + extend = wrapmapint(extend) + del wrapmapint + extend(row) + + for i,row in enumrows: + # Add "None" filter type. Currently, it's essential that + # this filter type be used for every scanline as we do not + # mark the first row of a reduced pass image; that means we + # could accidentally compute the wrong filtered scanline if + # we used "up", "average", or "paeth" on such a line. + data.append(0) + extend(row) + if len(data) > self.chunk_limit: + compressed = compressor.compress(tostring(data)) + if len(compressed): + write_chunk(outfile, b'IDAT', compressed) + # Because of our very witty definition of ``extend``, + # above, we must re-use the same ``data`` object. Hence + # we use ``del`` to empty this one, rather than create a + # fresh one (which would be my natural FP instinct). + del data[:] + if len(data): + compressed = compressor.compress(tostring(data)) + else: + compressed = b'' + flushed = compressor.flush() + if len(compressed) or len(flushed): + write_chunk(outfile, b'IDAT', compressed + flushed) + # http://www.w3.org/TR/PNG/#11IEND + write_chunk(outfile, b'IEND') + return i+1 + + def write_array(self, outfile, pixels): + """ + Write an array in flat row flat pixel format as a PNG file on + the output file. See also :meth:`write` method. + """ + + if self.interlace: + self.write_passes(outfile, self.array_scanlines_interlace(pixels)) + else: + self.write_passes(outfile, self.array_scanlines(pixels)) + + def write_packed(self, outfile, rows): + """ + Write PNG file to `outfile`. The pixel data comes from `rows` + which should be in boxed row packed format. Each row should be + a sequence of packed bytes. + + Technically, this method does work for interlaced images but it + is best avoided. For interlaced images, the rows should be + presented in the order that they appear in the file. + + This method should not be used when the source image bit depth + is not one naturally supported by PNG; the bit depth should be + 1, 2, 4, 8, or 16. + """ + + if self.rescale: + raise Error("write_packed method not suitable for bit depth %d" % + self.rescale[0]) + return self.write_passes(outfile, rows, packed=True) + + def convert_pnm(self, infile, outfile): + """ + Convert a PNM file containing raw pixel data into a PNG file + with the parameters set in the writer object. Works for + (binary) PGM, PPM, and PAM formats. + """ + + if self.interlace: + pixels = array('B') + pixels.fromfile(infile, + (self.bitdepth/8) * self.color_planes * + self.width * self.height) + self.write_passes(outfile, self.array_scanlines_interlace(pixels)) + else: + self.write_passes(outfile, self.file_scanlines(infile)) + + def convert_ppm_and_pgm(self, ppmfile, pgmfile, outfile): + """ + Convert a PPM and PGM file containing raw pixel data into a + PNG outfile with the parameters set in the writer object. + """ + pixels = array('B') + pixels.fromfile(ppmfile, + (self.bitdepth/8) * self.color_planes * + self.width * self.height) + apixels = array('B') + apixels.fromfile(pgmfile, + (self.bitdepth/8) * + self.width * self.height) + pixels = interleave_planes(pixels, apixels, + (self.bitdepth/8) * self.color_planes, + (self.bitdepth/8)) + if self.interlace: + self.write_passes(outfile, self.array_scanlines_interlace(pixels)) + else: + self.write_passes(outfile, self.array_scanlines(pixels)) + + def file_scanlines(self, infile): + """ + Generates boxed rows in flat pixel format, from the input file + `infile`. It assumes that the input file is in a "Netpbm-like" + binary format, and is positioned at the beginning of the first + pixel. The number of pixels to read is taken from the image + dimensions (`width`, `height`, `planes`) and the number of bytes + per value is implied by the image `bitdepth`. + """ + + # Values per row + vpr = self.width * self.planes + row_bytes = vpr + if self.bitdepth > 8: + assert self.bitdepth == 16 + row_bytes *= 2 + fmt = '>%dH' % vpr + def line(): + return array('H', struct.unpack(fmt, infile.read(row_bytes))) + else: + def line(): + scanline = array('B', infile.read(row_bytes)) + return scanline + for y in range(self.height): + yield line() + + def array_scanlines(self, pixels): + """ + Generates boxed rows (flat pixels) from flat rows (flat pixels) + in an array. + """ + + # Values per row + vpr = self.width * self.planes + stop = 0 + for y in range(self.height): + start = stop + stop = start + vpr + yield pixels[start:stop] + + def array_scanlines_interlace(self, pixels): + """ + Generator for interlaced scanlines from an array. `pixels` is + the full source image in flat row flat pixel format. The + generator yields each scanline of the reduced passes in turn, in + boxed row flat pixel format. + """ + + # http://www.w3.org/TR/PNG/#8InterlaceMethods + # Array type. + fmt = 'BH'[self.bitdepth > 8] + # Value per row + vpr = self.width * self.planes + for xstart, ystart, xstep, ystep in _adam7: + if xstart >= self.width: + continue + # Pixels per row (of reduced image) + ppr = int(math.ceil((self.width-xstart)/float(xstep))) + # number of values in reduced image row. + row_len = ppr*self.planes + for y in range(ystart, self.height, ystep): + if xstep == 1: + offset = y * vpr + yield pixels[offset:offset+vpr] + else: + row = array(fmt) + # There's no easier way to set the length of an array + row.extend(pixels[0:row_len]) + offset = y * vpr + xstart * self.planes + end_offset = (y+1) * vpr + skip = self.planes * xstep + for i in range(self.planes): + row[i::self.planes] = \ + pixels[offset+i:end_offset:skip] + yield row + +def write_chunk(outfile, tag, data=b''): + """ + Write a PNG chunk to the output file, including length and + checksum. + """ + + # http://www.w3.org/TR/PNG/#5Chunk-layout + outfile.write(struct.pack("!I", len(data))) + outfile.write(tag) + outfile.write(data) + checksum = zlib.crc32(tag) + checksum = zlib.crc32(data, checksum) + checksum &= 2**32-1 + outfile.write(struct.pack("!I", checksum)) + +def write_chunks(out, chunks): + """Create a PNG file by writing out the chunks.""" + + out.write(_signature) + for chunk in chunks: + write_chunk(out, *chunk) + +def filter_scanline(type, line, fo, prev=None): + """Apply a scanline filter to a scanline. `type` specifies the + filter type (0 to 4); `line` specifies the current (unfiltered) + scanline as a sequence of bytes; `prev` specifies the previous + (unfiltered) scanline as a sequence of bytes. `fo` specifies the + filter offset; normally this is size of a pixel in bytes (the number + of bytes per sample times the number of channels), but when this is + < 1 (for bit depths < 8) then the filter offset is 1. + """ + + assert 0 <= type < 5 + + # The output array. Which, pathetically, we extend one-byte at a + # time (fortunately this is linear). + out = array('B', [type]) + + def sub(): + ai = -fo + for x in line: + if ai >= 0: + x = (x - line[ai]) & 0xff + out.append(x) + ai += 1 + def up(): + for i,x in enumerate(line): + x = (x - prev[i]) & 0xff + out.append(x) + def average(): + ai = -fo + for i,x in enumerate(line): + if ai >= 0: + x = (x - ((line[ai] + prev[i]) >> 1)) & 0xff + else: + x = (x - (prev[i] >> 1)) & 0xff + out.append(x) + ai += 1 + def paeth(): + # http://www.w3.org/TR/PNG/#9Filter-type-4-Paeth + ai = -fo # also used for ci + for i,x in enumerate(line): + a = 0 + b = prev[i] + c = 0 + + if ai >= 0: + a = line[ai] + c = prev[ai] + p = a + b - c + pa = abs(p - a) + pb = abs(p - b) + pc = abs(p - c) + if pa <= pb and pa <= pc: + Pr = a + elif pb <= pc: + Pr = b + else: + Pr = c + + x = (x - Pr) & 0xff + out.append(x) + ai += 1 + + if not prev: + # We're on the first line. Some of the filters can be reduced + # to simpler cases which makes handling the line "off the top" + # of the image simpler. "up" becomes "none"; "paeth" becomes + # "left" (non-trivial, but true). "average" needs to be handled + # specially. + if type == 2: # "up" + type = 0 + elif type == 3: + prev = [0]*len(line) + elif type == 4: # "paeth" + type = 1 + if type == 0: + out.extend(line) + elif type == 1: + sub() + elif type == 2: + up() + elif type == 3: + average() + else: # type == 4 + paeth() + return out + + +def from_array(a, mode=None, info={}): + """Create a PNG :class:`Image` object from a 2- or 3-dimensional + array. One application of this function is easy PIL-style saving: + ``png.from_array(pixels, 'L').save('foo.png')``. + + Unless they are specified using the *info* parameter, the PNG's + height and width are taken from the array size. For a 3 dimensional + array the first axis is the height; the second axis is the width; + and the third axis is the channel number. Thus an RGB image that is + 16 pixels high and 8 wide will use an array that is 16x8x3. For 2 + dimensional arrays the first axis is the height, but the second axis + is ``width*channels``, so an RGB image that is 16 pixels high and 8 + wide will use a 2-dimensional array that is 16x24 (each row will be + 8*3 = 24 sample values). + + *mode* is a string that specifies the image colour format in a + PIL-style mode. It can be: + + ``'L'`` + greyscale (1 channel) + ``'LA'`` + greyscale with alpha (2 channel) + ``'RGB'`` + colour image (3 channel) + ``'RGBA'`` + colour image with alpha (4 channel) + + The mode string can also specify the bit depth (overriding how this + function normally derives the bit depth, see below). Appending + ``';16'`` to the mode will cause the PNG to be 16 bits per channel; + any decimal from 1 to 16 can be used to specify the bit depth. + + When a 2-dimensional array is used *mode* determines how many + channels the image has, and so allows the width to be derived from + the second array dimension. + + The array is expected to be a ``numpy`` array, but it can be any + suitable Python sequence. For example, a list of lists can be used: + ``png.from_array([[0, 255, 0], [255, 0, 255]], 'L')``. The exact + rules are: ``len(a)`` gives the first dimension, height; + ``len(a[0])`` gives the second dimension; ``len(a[0][0])`` gives the + third dimension, unless an exception is raised in which case a + 2-dimensional array is assumed. It's slightly more complicated than + that because an iterator of rows can be used, and it all still + works. Using an iterator allows data to be streamed efficiently. + + The bit depth of the PNG is normally taken from the array element's + datatype (but if *mode* specifies a bitdepth then that is used + instead). The array element's datatype is determined in a way which + is supposed to work both for ``numpy`` arrays and for Python + ``array.array`` objects. A 1 byte datatype will give a bit depth of + 8, a 2 byte datatype will give a bit depth of 16. If the datatype + does not have an implicit size, for example it is a plain Python + list of lists, as above, then a default of 8 is used. + + The *info* parameter is a dictionary that can be used to specify + metadata (in the same style as the arguments to the + :class:`png.Writer` class). For this function the keys that are + useful are: + + height + overrides the height derived from the array dimensions and allows + *a* to be an iterable. + width + overrides the width derived from the array dimensions. + bitdepth + overrides the bit depth derived from the element datatype (but + must match *mode* if that also specifies a bit depth). + + Generally anything specified in the + *info* dictionary will override any implicit choices that this + function would otherwise make, but must match any explicit ones. + For example, if the *info* dictionary has a ``greyscale`` key then + this must be true when mode is ``'L'`` or ``'LA'`` and false when + mode is ``'RGB'`` or ``'RGBA'``. + """ + + # We abuse the *info* parameter by modifying it. Take a copy here. + # (Also typechecks *info* to some extent). + info = dict(info) + + # Syntax check mode string. + bitdepth = None + try: + # Assign the 'L' or 'RGBA' part to `gotmode`. + if mode.startswith('L'): + gotmode = 'L' + mode = mode[1:] + elif mode.startswith('RGB'): + gotmode = 'RGB' + mode = mode[3:] + else: + raise Error() + if mode.startswith('A'): + gotmode += 'A' + mode = mode[1:] + + # Skip any optional ';' + while mode.startswith(';'): + mode = mode[1:] + + # Parse optional bitdepth + if mode: + try: + bitdepth = int(mode) + except (TypeError, ValueError): + raise Error() + except Error: + raise Error("mode string should be 'RGB' or 'L;16' or similar.") + mode = gotmode + + # Get bitdepth from *mode* if possible. + if bitdepth: + if info.get('bitdepth') and bitdepth != info['bitdepth']: + raise Error("mode bitdepth (%d) should match info bitdepth (%d)." % + (bitdepth, info['bitdepth'])) + info['bitdepth'] = bitdepth + + # Fill in and/or check entries in *info*. + # Dimensions. + if 'size' in info: + # Check width, height, size all match where used. + for dimension,axis in [('width', 0), ('height', 1)]: + if dimension in info: + if info[dimension] != info['size'][axis]: + raise Error( + "info[%r] should match info['size'][%r]." % + (dimension, axis)) + info['width'],info['height'] = info['size'] + if 'height' not in info: + try: + l = len(a) + except TypeError: + raise Error( + "len(a) does not work, supply info['height'] instead.") + info['height'] = l + # Colour format. + if 'greyscale' in info: + if bool(info['greyscale']) != ('L' in mode): + raise Error("info['greyscale'] should match mode.") + info['greyscale'] = 'L' in mode + if 'alpha' in info: + if bool(info['alpha']) != ('A' in mode): + raise Error("info['alpha'] should match mode.") + info['alpha'] = 'A' in mode + + planes = len(mode) + if 'planes' in info: + if info['planes'] != planes: + raise Error("info['planes'] should match mode.") + + # In order to work out whether we the array is 2D or 3D we need its + # first row, which requires that we take a copy of its iterator. + # We may also need the first row to derive width and bitdepth. + a,t = itertools.tee(a) + row = next(t) + del t + try: + row[0][0] + threed = True + testelement = row[0] + except (IndexError, TypeError): + threed = False + testelement = row + if 'width' not in info: + if threed: + width = len(row) + else: + width = len(row) // planes + info['width'] = width + + if threed: + # Flatten the threed rows + a = (itertools.chain.from_iterable(x) for x in a) + + if 'bitdepth' not in info: + try: + dtype = testelement.dtype + # goto the "else:" clause. Sorry. + except AttributeError: + try: + # Try a Python array.array. + bitdepth = 8 * testelement.itemsize + except AttributeError: + # We can't determine it from the array element's + # datatype, use a default of 8. + bitdepth = 8 + else: + # If we got here without exception, we now assume that + # the array is a numpy array. + if dtype.kind == 'b': + bitdepth = 1 + else: + bitdepth = 8 * dtype.itemsize + info['bitdepth'] = bitdepth + + for thing in 'width height bitdepth greyscale alpha'.split(): + assert thing in info + return Image(a, info) + +# So that refugee's from PIL feel more at home. Not documented. +fromarray = from_array + +class Image: + """A PNG image. You can create an :class:`Image` object from + an array of pixels by calling :meth:`png.from_array`. It can be + saved to disk with the :meth:`save` method. + """ + + def __init__(self, rows, info): + """ + .. note :: + + The constructor is not public. Please do not call it. + """ + + self.rows = rows + self.info = info + + def save(self, file): + """Save the image to *file*. If *file* looks like an open file + descriptor then it is used, otherwise it is treated as a + filename and a fresh file is opened. + + In general, you can only call this method once; after it has + been called the first time and the PNG image has been saved, the + source data will have been streamed, and cannot be streamed + again. + """ + + w = Writer(**self.info) + + try: + file.write + def close(): pass + except AttributeError: + file = open(file, 'wb') + def close(): file.close() + + try: + w.write(file, self.rows) + finally: + close() + +class _readable: + """ + A simple file-like interface for strings and arrays. + """ + + def __init__(self, buf): + self.buf = buf + self.offset = 0 + + def read(self, n): + r = self.buf[self.offset:self.offset+n] + if isarray(r): + r = r.tostring() + self.offset += n + return r + +try: + str(b'dummy', 'ascii') +except TypeError: + as_str = str +else: + def as_str(x): + return str(x, 'ascii') + +class Reader: + """ + PNG decoder in pure Python. + """ + + def __init__(self, _guess=None, **kw): + """ + Create a PNG decoder object. + + The constructor expects exactly one keyword argument. If you + supply a positional argument instead, it will guess the input + type. You can choose among the following keyword arguments: + + filename + Name of input file (a PNG file). + file + A file-like object (object with a read() method). + bytes + ``array`` or ``string`` with PNG data. + + """ + if ((_guess is not None and len(kw) != 0) or + (_guess is None and len(kw) != 1)): + raise TypeError("Reader() takes exactly 1 argument") + + # Will be the first 8 bytes, later on. See validate_signature. + self.signature = None + self.transparent = None + # A pair of (len,type) if a chunk has been read but its data and + # checksum have not (in other words the file position is just + # past the 4 bytes that specify the chunk type). See preamble + # method for how this is used. + self.atchunk = None + + if _guess is not None: + if isarray(_guess): + kw["bytes"] = _guess + elif isinstance(_guess, str): + kw["filename"] = _guess + elif hasattr(_guess, 'read'): + kw["file"] = _guess + + if "filename" in kw: + self.file = open(kw["filename"], "rb") + elif "file" in kw: + self.file = kw["file"] + elif "bytes" in kw: + self.file = _readable(kw["bytes"]) + else: + raise TypeError("expecting filename, file or bytes array") + + + def chunk(self, seek=None, lenient=False): + """ + Read the next PNG chunk from the input file; returns a + (*type*, *data*) tuple. *type* is the chunk's type as a + byte string (all PNG chunk types are 4 bytes long). + *data* is the chunk's data content, as a byte string. + + If the optional `seek` argument is + specified then it will keep reading chunks until it either runs + out of file or finds the type specified by the argument. Note + that in general the order of chunks in PNGs is unspecified, so + using `seek` can cause you to miss chunks. + + If the optional `lenient` argument evaluates to `True`, + checksum failures will raise warnings rather than exceptions. + """ + + self.validate_signature() + + while True: + # http://www.w3.org/TR/PNG/#5Chunk-layout + if not self.atchunk: + self.atchunk = self.chunklentype() + length, type = self.atchunk + self.atchunk = None + data = self.file.read(length) + if len(data) != length: + raise ChunkError('Chunk %s too short for required %i octets.' + % (type, length)) + checksum = self.file.read(4) + if len(checksum) != 4: + raise ChunkError('Chunk %s too short for checksum.' % type) + if seek and type != seek: + continue + verify = zlib.crc32(type) + verify = zlib.crc32(data, verify) + # Whether the output from zlib.crc32 is signed or not varies + # according to hideous implementation details, see + # http://bugs.python.org/issue1202 . + # We coerce it to be positive here (in a way which works on + # Python 2.3 and older). + verify &= 2**32 - 1 + verify = struct.pack('!I', verify) + if checksum != verify: + (a, ) = struct.unpack('!I', checksum) + (b, ) = struct.unpack('!I', verify) + message = "Checksum error in %s chunk: 0x%08X != 0x%08X." % (type, a, b) + if lenient: + warnings.warn(message, RuntimeWarning) + else: + raise ChunkError(message) + return type, data + + def chunks(self): + """Return an iterator that will yield each chunk as a + (*chunktype*, *content*) pair. + """ + + while True: + t,v = self.chunk() + yield t,v + if t == b'IEND': + break + + def undo_filter(self, filter_type, scanline, previous): + """Undo the filter for a scanline. `scanline` is a sequence of + bytes that does not include the initial filter type byte. + `previous` is decoded previous scanline (for straightlaced + images this is the previous pixel row, but for interlaced + images, it is the previous scanline in the reduced image, which + in general is not the previous pixel row in the final image). + When there is no previous scanline (the first row of a + straightlaced image, or the first row in one of the passes in an + interlaced image), then this argument should be ``None``. + + The scanline will have the effects of filtering removed, and the + result will be returned as a fresh sequence of bytes. + """ + + # :todo: Would it be better to update scanline in place? + # Yes, with the Cython extension making the undo_filter fast, + # updating scanline inplace makes the code 3 times faster + # (reading 50 images of 800x800 went from 40s to 16s) + result = scanline + + if filter_type == 0: + return result + + if filter_type not in (1,2,3,4): + raise FormatError('Invalid PNG Filter Type.' + ' See http://www.w3.org/TR/2003/REC-PNG-20031110/#9Filters .') + + # Filter unit. The stride from one pixel to the corresponding + # byte from the previous pixel. Normally this is the pixel + # size in bytes, but when this is smaller than 1, the previous + # byte is used instead. + fu = max(1, self.psize) + + # For the first line of a pass, synthesize a dummy previous + # line. An alternative approach would be to observe that on the + # first line 'up' is the same as 'null', 'paeth' is the same + # as 'sub', with only 'average' requiring any special case. + if not previous: + previous = array('B', [0]*len(scanline)) + + def sub(): + """Undo sub filter.""" + + ai = 0 + # Loop starts at index fu. Observe that the initial part + # of the result is already filled in correctly with + # scanline. + for i in range(fu, len(result)): + x = scanline[i] + a = result[ai] + result[i] = (x + a) & 0xff + ai += 1 + + def up(): + """Undo up filter.""" + + for i in range(len(result)): + x = scanline[i] + b = previous[i] + result[i] = (x + b) & 0xff + + def average(): + """Undo average filter.""" + + ai = -fu + for i in range(len(result)): + x = scanline[i] + if ai < 0: + a = 0 + else: + a = result[ai] + b = previous[i] + result[i] = (x + ((a + b) >> 1)) & 0xff + ai += 1 + + def paeth(): + """Undo Paeth filter.""" + + # Also used for ci. + ai = -fu + for i in range(len(result)): + x = scanline[i] + if ai < 0: + a = c = 0 + else: + a = result[ai] + c = previous[ai] + b = previous[i] + p = a + b - c + pa = abs(p - a) + pb = abs(p - b) + pc = abs(p - c) + if pa <= pb and pa <= pc: + pr = a + elif pb <= pc: + pr = b + else: + pr = c + result[i] = (x + pr) & 0xff + ai += 1 + + # Call appropriate filter algorithm. Note that 0 has already + # been dealt with. + (None, + pngfilters.undo_filter_sub, + pngfilters.undo_filter_up, + pngfilters.undo_filter_average, + pngfilters.undo_filter_paeth)[filter_type](fu, scanline, previous, result) + return result + + def deinterlace(self, raw): + """ + Read raw pixel data, undo filters, deinterlace, and flatten. + Return in flat row flat pixel format. + """ + + # Values per row (of the target image) + vpr = self.width * self.planes + + # Make a result array, and make it big enough. Interleaving + # writes to the output array randomly (well, not quite), so the + # entire output array must be in memory. + fmt = 'BH'[self.bitdepth > 8] + a = array(fmt, [0]*vpr*self.height) + source_offset = 0 + + for xstart, ystart, xstep, ystep in _adam7: + if xstart >= self.width: + continue + # The previous (reconstructed) scanline. None at the + # beginning of a pass to indicate that there is no previous + # line. + recon = None + # Pixels per row (reduced pass image) + ppr = int(math.ceil((self.width-xstart)/float(xstep))) + # Row size in bytes for this pass. + row_size = int(math.ceil(self.psize * ppr)) + for y in range(ystart, self.height, ystep): + filter_type = raw[source_offset] + source_offset += 1 + scanline = raw[source_offset:source_offset+row_size] + source_offset += row_size + recon = self.undo_filter(filter_type, scanline, recon) + # Convert so that there is one element per pixel value + flat = self.serialtoflat(recon, ppr) + if xstep == 1: + assert xstart == 0 + offset = y * vpr + a[offset:offset+vpr] = flat + else: + offset = y * vpr + xstart * self.planes + end_offset = (y+1) * vpr + skip = self.planes * xstep + for i in range(self.planes): + a[offset+i:end_offset:skip] = \ + flat[i::self.planes] + return a + + def iterboxed(self, rows): + """Iterator that yields each scanline in boxed row flat pixel + format. `rows` should be an iterator that yields the bytes of + each row in turn. + """ + + def asvalues(raw): + """Convert a row of raw bytes into a flat row. Result will + be a freshly allocated object, not shared with + argument. + """ + + if self.bitdepth == 8: + return array('B', raw) + if self.bitdepth == 16: + raw = tostring(raw) + return array('H', struct.unpack('!%dH' % (len(raw)//2), raw)) + assert self.bitdepth < 8 + width = self.width + # Samples per byte + spb = 8//self.bitdepth + out = array('B') + mask = 2**self.bitdepth - 1 + shifts = [self.bitdepth * i + for i in reversed(list(range(spb)))] + for o in raw: + out.extend([mask&(o>>i) for i in shifts]) + return out[:width] + + return map(asvalues, rows) + + def serialtoflat(self, bytes, width=None): + """Convert serial format (byte stream) pixel data to flat row + flat pixel. + """ + + if self.bitdepth == 8: + return bytes + if self.bitdepth == 16: + bytes = tostring(bytes) + return array('H', + struct.unpack('!%dH' % (len(bytes)//2), bytes)) + assert self.bitdepth < 8 + if width is None: + width = self.width + # Samples per byte + spb = 8//self.bitdepth + out = array('B') + mask = 2**self.bitdepth - 1 + shifts = list(map(self.bitdepth.__mul__, reversed(list(range(spb))))) + l = width + for o in bytes: + out.extend([(mask&(o>>s)) for s in shifts][:l]) + l -= spb + if l <= 0: + l = width + return out + + def iterstraight(self, raw): + """Iterator that undoes the effect of filtering, and yields + each row in serialised format (as a sequence of bytes). + Assumes input is straightlaced. `raw` should be an iterable + that yields the raw bytes in chunks of arbitrary size. + """ + + # length of row, in bytes + rb = self.row_bytes + a = array('B') + # The previous (reconstructed) scanline. None indicates first + # line of image. + recon = None + for some in raw: + a.extend(some) + while len(a) >= rb + 1: + filter_type = a[0] + scanline = a[1:rb+1] + del a[:rb+1] + recon = self.undo_filter(filter_type, scanline, recon) + yield recon + if len(a) != 0: + # :file:format We get here with a file format error: + # when the available bytes (after decompressing) do not + # pack into exact rows. + raise FormatError( + 'Wrong size for decompressed IDAT chunk.') + assert len(a) == 0 + + def validate_signature(self): + """If signature (header) has not been read then read and + validate it; otherwise do nothing. + """ + + if self.signature: + return + self.signature = self.file.read(8) + if self.signature != _signature: + raise FormatError("PNG file has invalid signature.") + + def preamble(self, lenient=False): + """ + Extract the image metadata by reading the initial part of + the PNG file up to the start of the ``IDAT`` chunk. All the + chunks that precede the ``IDAT`` chunk are read and either + processed for metadata or discarded. + + If the optional `lenient` argument evaluates to `True`, checksum + failures will raise warnings rather than exceptions. + """ + + self.validate_signature() + + while True: + if not self.atchunk: + self.atchunk = self.chunklentype() + if self.atchunk is None: + raise FormatError( + 'This PNG file has no IDAT chunks.') + if self.atchunk[1] == b'IDAT': + return + self.process_chunk(lenient=lenient) + + def chunklentype(self): + """Reads just enough of the input to determine the next + chunk's length and type, returned as a (*length*, *type*) pair + where *type* is a string. If there are no more chunks, ``None`` + is returned. + """ + + x = self.file.read(8) + if not x: + return None + if len(x) != 8: + raise FormatError( + 'End of file whilst reading chunk length and type.') + length,type = struct.unpack('!I4s', x) + if length > 2**31-1: + raise FormatError('Chunk %s is too large: %d.' % (type,length)) + return length,type + + def process_chunk(self, lenient=False): + """Process the next chunk and its data. This only processes the + following chunk types, all others are ignored: ``IHDR``, + ``PLTE``, ``bKGD``, ``tRNS``, ``gAMA``, ``sBIT``, ``pHYs``. + + If the optional `lenient` argument evaluates to `True`, + checksum failures will raise warnings rather than exceptions. + """ + + type, data = self.chunk(lenient=lenient) + method = '_process_' + as_str(type) + m = getattr(self, method, None) + if m: + m(data) + + def _process_IHDR(self, data): + # http://www.w3.org/TR/PNG/#11IHDR + if len(data) != 13: + raise FormatError('IHDR chunk has incorrect length.') + (self.width, self.height, self.bitdepth, self.color_type, + self.compression, self.filter, + self.interlace) = struct.unpack("!2I5B", data) + + check_bitdepth_colortype(self.bitdepth, self.color_type) + + if self.compression != 0: + raise Error("unknown compression method %d" % self.compression) + if self.filter != 0: + raise FormatError("Unknown filter method %d," + " see http://www.w3.org/TR/2003/REC-PNG-20031110/#9Filters ." + % self.filter) + if self.interlace not in (0,1): + raise FormatError("Unknown interlace method %d," + " see http://www.w3.org/TR/2003/REC-PNG-20031110/#8InterlaceMethods ." + % self.interlace) + + # Derived values + # http://www.w3.org/TR/PNG/#6Colour-values + colormap = bool(self.color_type & 1) + greyscale = not (self.color_type & 2) + alpha = bool(self.color_type & 4) + color_planes = (3,1)[greyscale or colormap] + planes = color_planes + alpha + + self.colormap = colormap + self.greyscale = greyscale + self.alpha = alpha + self.color_planes = color_planes + self.planes = planes + self.psize = float(self.bitdepth)/float(8) * planes + if int(self.psize) == self.psize: + self.psize = int(self.psize) + self.row_bytes = int(math.ceil(self.width * self.psize)) + # Stores PLTE chunk if present, and is used to check + # chunk ordering constraints. + self.plte = None + # Stores tRNS chunk if present, and is used to check chunk + # ordering constraints. + self.trns = None + # Stores sbit chunk if present. + self.sbit = None + + def _process_PLTE(self, data): + # http://www.w3.org/TR/PNG/#11PLTE + if self.plte: + warnings.warn("Multiple PLTE chunks present.") + self.plte = data + if len(data) % 3 != 0: + raise FormatError( + "PLTE chunk's length should be a multiple of 3.") + if len(data) > (2**self.bitdepth)*3: + raise FormatError("PLTE chunk is too long.") + if len(data) == 0: + raise FormatError("Empty PLTE is not allowed.") + + def _process_bKGD(self, data): + try: + if self.colormap: + if not self.plte: + warnings.warn( + "PLTE chunk is required before bKGD chunk.") + self.background = struct.unpack('B', data) + else: + self.background = struct.unpack("!%dH" % self.color_planes, + data) + except struct.error: + raise FormatError("bKGD chunk has incorrect length.") + + def _process_tRNS(self, data): + # http://www.w3.org/TR/PNG/#11tRNS + self.trns = data + if self.colormap: + if not self.plte: + warnings.warn("PLTE chunk is required before tRNS chunk.") + else: + if len(data) > len(self.plte)/3: + # Was warning, but promoted to Error as it + # would otherwise cause pain later on. + raise FormatError("tRNS chunk is too long.") + else: + if self.alpha: + raise FormatError( + "tRNS chunk is not valid with colour type %d." % + self.color_type) + try: + self.transparent = \ + struct.unpack("!%dH" % self.color_planes, data) + except struct.error: + raise FormatError("tRNS chunk has incorrect length.") + + def _process_gAMA(self, data): + try: + self.gamma = struct.unpack("!L", data)[0] / 100000.0 + except struct.error: + raise FormatError("gAMA chunk has incorrect length.") + + def _process_sBIT(self, data): + self.sbit = data + if (self.colormap and len(data) != 3 or + not self.colormap and len(data) != self.planes): + raise FormatError("sBIT chunk has incorrect length.") + + def _process_pHYs(self, data): + # http://www.w3.org/TR/PNG/#11pHYs + self.phys = data + fmt = "!LLB" + if len(data) != struct.calcsize(fmt): + raise FormatError("pHYs chunk has incorrect length.") + self.x_pixels_per_unit, self.y_pixels_per_unit, unit = struct.unpack(fmt,data) + self.unit_is_meter = bool(unit) + + def read(self, lenient=False): + """ + Read the PNG file and decode it. Returns (`width`, `height`, + `pixels`, `metadata`). + + May use excessive memory. + + `pixels` are returned in boxed row flat pixel format. + + If the optional `lenient` argument evaluates to True, + checksum failures will raise warnings rather than exceptions. + """ + + def iteridat(): + """Iterator that yields all the ``IDAT`` chunks as strings.""" + while True: + try: + type, data = self.chunk(lenient=lenient) + except ValueError as e: + raise ChunkError(e.args[0]) + if type == b'IEND': + # http://www.w3.org/TR/PNG/#11IEND + break + if type != b'IDAT': + continue + # type == b'IDAT' + # http://www.w3.org/TR/PNG/#11IDAT + if self.colormap and not self.plte: + warnings.warn("PLTE chunk is required before IDAT chunk") + yield data + + def iterdecomp(idat): + """Iterator that yields decompressed strings. `idat` should + be an iterator that yields the ``IDAT`` chunk data. + """ + + # Currently, with no max_length parameter to decompress, + # this routine will do one yield per IDAT chunk: Not very + # incremental. + d = zlib.decompressobj() + # Each IDAT chunk is passed to the decompressor, then any + # remaining state is decompressed out. + for data in idat: + # :todo: add a max_length argument here to limit output + # size. + yield array('B', d.decompress(data)) + yield array('B', d.flush()) + + self.preamble(lenient=lenient) + raw = iterdecomp(iteridat()) + + if self.interlace: + raw = array('B', itertools.chain(*raw)) + arraycode = 'BH'[self.bitdepth>8] + # Like :meth:`group` but producing an array.array object for + # each row. + pixels = map(lambda *row: array(arraycode, row), + *[iter(self.deinterlace(raw))]*self.width*self.planes) + else: + pixels = self.iterboxed(self.iterstraight(raw)) + meta = dict() + for attr in 'greyscale alpha planes bitdepth interlace'.split(): + meta[attr] = getattr(self, attr) + meta['size'] = (self.width, self.height) + for attr in 'gamma transparent background'.split(): + a = getattr(self, attr, None) + if a is not None: + meta[attr] = a + if self.plte: + meta['palette'] = self.palette() + return self.width, self.height, pixels, meta + + + def read_flat(self): + """ + Read a PNG file and decode it into flat row flat pixel format. + Returns (*width*, *height*, *pixels*, *metadata*). + + May use excessive memory. + + `pixels` are returned in flat row flat pixel format. + + See also the :meth:`read` method which returns pixels in the + more stream-friendly boxed row flat pixel format. + """ + + x, y, pixel, meta = self.read() + arraycode = 'BH'[meta['bitdepth']>8] + pixel = array(arraycode, itertools.chain(*pixel)) + return x, y, pixel, meta + + def palette(self, alpha='natural'): + """Returns a palette that is a sequence of 3-tuples or 4-tuples, + synthesizing it from the ``PLTE`` and ``tRNS`` chunks. These + chunks should have already been processed (for example, by + calling the :meth:`preamble` method). All the tuples are the + same size: 3-tuples if there is no ``tRNS`` chunk, 4-tuples when + there is a ``tRNS`` chunk. Assumes that the image is colour type + 3 and therefore a ``PLTE`` chunk is required. + + If the `alpha` argument is ``'force'`` then an alpha channel is + always added, forcing the result to be a sequence of 4-tuples. + """ + + if not self.plte: + raise FormatError( + "Required PLTE chunk is missing in colour type 3 image.") + plte = group(array('B', self.plte), 3) + if self.trns or alpha == 'force': + trns = array('B', self.trns or '') + trns.extend([255]*(len(plte)-len(trns))) + plte = list(map(operator.add, plte, group(trns, 1))) + return plte + + def asDirect(self): + """Returns the image data as a direct representation of an + ``x * y * planes`` array. This method is intended to remove the + need for callers to deal with palettes and transparency + themselves. Images with a palette (colour type 3) + are converted to RGB or RGBA; images with transparency (a + ``tRNS`` chunk) are converted to LA or RGBA as appropriate. + When returned in this format the pixel values represent the + colour value directly without needing to refer to palettes or + transparency information. + + Like the :meth:`read` method this method returns a 4-tuple: + + (*width*, *height*, *pixels*, *meta*) + + This method normally returns pixel values with the bit depth + they have in the source image, but when the source PNG has an + ``sBIT`` chunk it is inspected and can reduce the bit depth of + the result pixels; pixel values will be reduced according to + the bit depth specified in the ``sBIT`` chunk (PNG nerds should + note a single result bit depth is used for all channels; the + maximum of the ones specified in the ``sBIT`` chunk. An RGB565 + image will be rescaled to 6-bit RGB666). + + The *meta* dictionary that is returned reflects the `direct` + format and not the original source image. For example, an RGB + source image with a ``tRNS`` chunk to represent a transparent + colour, will have ``planes=3`` and ``alpha=False`` for the + source image, but the *meta* dictionary returned by this method + will have ``planes=4`` and ``alpha=True`` because an alpha + channel is synthesized and added. + + *pixels* is the pixel data in boxed row flat pixel format (just + like the :meth:`read` method). + + All the other aspects of the image data are not changed. + """ + + self.preamble() + + # Simple case, no conversion necessary. + if not self.colormap and not self.trns and not self.sbit: + return self.read() + + x,y,pixels,meta = self.read() + + if self.colormap: + meta['colormap'] = False + meta['alpha'] = bool(self.trns) + meta['bitdepth'] = 8 + meta['planes'] = 3 + bool(self.trns) + plte = self.palette() + def iterpal(pixels): + for row in pixels: + row = [plte[x] for x in row] + yield array('B', itertools.chain(*row)) + pixels = iterpal(pixels) + elif self.trns: + # It would be nice if there was some reasonable way + # of doing this without generating a whole load of + # intermediate tuples. But tuples does seem like the + # easiest way, with no other way clearly much simpler or + # much faster. (Actually, the L to LA conversion could + # perhaps go faster (all those 1-tuples!), but I still + # wonder whether the code proliferation is worth it) + it = self.transparent + maxval = 2**meta['bitdepth']-1 + planes = meta['planes'] + meta['alpha'] = True + meta['planes'] += 1 + typecode = 'BH'[meta['bitdepth']>8] + def itertrns(pixels): + for row in pixels: + # For each row we group it into pixels, then form a + # characterisation vector that says whether each + # pixel is opaque or not. Then we convert + # True/False to 0/maxval (by multiplication), + # and add it as the extra channel. + row = group(row, planes) + opa = map(it.__ne__, row) + opa = map(maxval.__mul__, opa) + opa = list(zip(opa)) # convert to 1-tuples + yield array(typecode, + itertools.chain(*map(operator.add, row, opa))) + pixels = itertrns(pixels) + targetbitdepth = None + if self.sbit: + sbit = struct.unpack('%dB' % len(self.sbit), self.sbit) + targetbitdepth = max(sbit) + if targetbitdepth > meta['bitdepth']: + raise Error('sBIT chunk %r exceeds bitdepth %d' % + (sbit,self.bitdepth)) + if min(sbit) <= 0: + raise Error('sBIT chunk %r has a 0-entry' % sbit) + if targetbitdepth == meta['bitdepth']: + targetbitdepth = None + if targetbitdepth: + shift = meta['bitdepth'] - targetbitdepth + meta['bitdepth'] = targetbitdepth + def itershift(pixels): + for row in pixels: + yield [p >> shift for p in row] + pixels = itershift(pixels) + return x,y,pixels,meta + + def asFloat(self, maxval=1.0): + """Return image pixels as per :meth:`asDirect` method, but scale + all pixel values to be floating point values between 0.0 and + *maxval*. + """ + + x,y,pixels,info = self.asDirect() + sourcemaxval = 2**info['bitdepth']-1 + del info['bitdepth'] + info['maxval'] = float(maxval) + factor = float(maxval)/float(sourcemaxval) + def iterfloat(): + for row in pixels: + yield [factor * p for p in row] + return x,y,iterfloat(),info + + def _as_rescale(self, get, targetbitdepth): + """Helper used by :meth:`asRGB8` and :meth:`asRGBA8`.""" + + width,height,pixels,meta = get() + maxval = 2**meta['bitdepth'] - 1 + targetmaxval = 2**targetbitdepth - 1 + factor = float(targetmaxval) / float(maxval) + meta['bitdepth'] = targetbitdepth + def iterscale(): + for row in pixels: + yield [int(round(x*factor)) for x in row] + if maxval == targetmaxval: + return width, height, pixels, meta + else: + return width, height, iterscale(), meta + + def asRGB8(self): + """Return the image data as an RGB pixels with 8-bits per + sample. This is like the :meth:`asRGB` method except that + this method additionally rescales the values so that they + are all between 0 and 255 (8-bit). In the case where the + source image has a bit depth < 8 the transformation preserves + all the information; where the source image has bit depth + > 8, then rescaling to 8-bit values loses precision. No + dithering is performed. Like :meth:`asRGB`, an alpha channel + in the source image will raise an exception. + + This function returns a 4-tuple: + (*width*, *height*, *pixels*, *metadata*). + *width*, *height*, *metadata* are as per the + :meth:`read` method. + + *pixels* is the pixel data in boxed row flat pixel format. + """ + + return self._as_rescale(self.asRGB, 8) + + def asRGBA8(self): + """Return the image data as RGBA pixels with 8-bits per + sample. This method is similar to :meth:`asRGB8` and + :meth:`asRGBA`: The result pixels have an alpha channel, *and* + values are rescaled to the range 0 to 255. The alpha channel is + synthesized if necessary (with a small speed penalty). + """ + + return self._as_rescale(self.asRGBA, 8) + + def asRGB(self): + """Return image as RGB pixels. RGB colour images are passed + through unchanged; greyscales are expanded into RGB + triplets (there is a small speed overhead for doing this). + + An alpha channel in the source image will raise an + exception. + + The return values are as for the :meth:`read` method + except that the *metadata* reflect the returned pixels, not the + source image. In particular, for this method + ``metadata['greyscale']`` will be ``False``. + """ + + width,height,pixels,meta = self.asDirect() + if meta['alpha']: + raise Error("will not convert image with alpha channel to RGB") + if not meta['greyscale']: + return width,height,pixels,meta + meta['greyscale'] = False + typecode = 'BH'[meta['bitdepth'] > 8] + def iterrgb(): + for row in pixels: + a = array(typecode, [0]) * 3 * width + for i in range(3): + a[i::3] = row + yield a + return width,height,iterrgb(),meta + + def asRGBA(self): + """Return image as RGBA pixels. Greyscales are expanded into + RGB triplets; an alpha channel is synthesized if necessary. + The return values are as for the :meth:`read` method + except that the *metadata* reflect the returned pixels, not the + source image. In particular, for this method + ``metadata['greyscale']`` will be ``False``, and + ``metadata['alpha']`` will be ``True``. + """ + + width,height,pixels,meta = self.asDirect() + if meta['alpha'] and not meta['greyscale']: + return width,height,pixels,meta + typecode = 'BH'[meta['bitdepth'] > 8] + maxval = 2**meta['bitdepth'] - 1 + maxbuffer = struct.pack('=' + typecode, maxval) * 4 * width + def newarray(): + return array(typecode, maxbuffer) + + if meta['alpha'] and meta['greyscale']: + # LA to RGBA + def convert(): + for row in pixels: + # Create a fresh target row, then copy L channel + # into first three target channels, and A channel + # into fourth channel. + a = newarray() + pngfilters.convert_la_to_rgba(row, a) + yield a + elif meta['greyscale']: + # L to RGBA + def convert(): + for row in pixels: + a = newarray() + pngfilters.convert_l_to_rgba(row, a) + yield a + else: + assert not meta['alpha'] and not meta['greyscale'] + # RGB to RGBA + def convert(): + for row in pixels: + a = newarray() + pngfilters.convert_rgb_to_rgba(row, a) + yield a + meta['alpha'] = True + meta['greyscale'] = False + return width,height,convert(),meta + +def check_bitdepth_colortype(bitdepth, colortype): + """Check that `bitdepth` and `colortype` are both valid, + and specified in a valid combination. Returns if valid, + raise an Exception if not valid. + """ + + if bitdepth not in (1,2,4,8,16): + raise FormatError("invalid bit depth %d" % bitdepth) + if colortype not in (0,2,3,4,6): + raise FormatError("invalid colour type %d" % colortype) + # Check indexed (palettized) images have 8 or fewer bits + # per pixel; check only indexed or greyscale images have + # fewer than 8 bits per pixel. + if colortype & 1 and bitdepth > 8: + raise FormatError( + "Indexed images (colour type %d) cannot" + " have bitdepth > 8 (bit depth %d)." + " See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 ." + % (bitdepth, colortype)) + if bitdepth < 8 and colortype not in (0,3): + raise FormatError("Illegal combination of bit depth (%d)" + " and colour type (%d)." + " See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 ." + % (bitdepth, colortype)) + +def isinteger(x): + try: + return int(x) == x + except (TypeError, ValueError): + return False + + +# === Support for users without Cython === + +try: + pngfilters +except NameError: + class pngfilters(object): + def undo_filter_sub(filter_unit, scanline, previous, result): + """Undo sub filter.""" + + ai = 0 + # Loops starts at index fu. Observe that the initial part + # of the result is already filled in correctly with + # scanline. + for i in range(filter_unit, len(result)): + x = scanline[i] + a = result[ai] + result[i] = (x + a) & 0xff + ai += 1 + undo_filter_sub = staticmethod(undo_filter_sub) + + def undo_filter_up(filter_unit, scanline, previous, result): + """Undo up filter.""" + + for i in range(len(result)): + x = scanline[i] + b = previous[i] + result[i] = (x + b) & 0xff + undo_filter_up = staticmethod(undo_filter_up) + + def undo_filter_average(filter_unit, scanline, previous, result): + """Undo up filter.""" + + ai = -filter_unit + for i in range(len(result)): + x = scanline[i] + if ai < 0: + a = 0 + else: + a = result[ai] + b = previous[i] + result[i] = (x + ((a + b) >> 1)) & 0xff + ai += 1 + undo_filter_average = staticmethod(undo_filter_average) + + def undo_filter_paeth(filter_unit, scanline, previous, result): + """Undo Paeth filter.""" + + # Also used for ci. + ai = -filter_unit + for i in range(len(result)): + x = scanline[i] + if ai < 0: + a = c = 0 + else: + a = result[ai] + c = previous[ai] + b = previous[i] + p = a + b - c + pa = abs(p - a) + pb = abs(p - b) + pc = abs(p - c) + if pa <= pb and pa <= pc: + pr = a + elif pb <= pc: + pr = b + else: + pr = c + result[i] = (x + pr) & 0xff + ai += 1 + undo_filter_paeth = staticmethod(undo_filter_paeth) + + def convert_la_to_rgba(row, result): + for i in range(3): + result[i::4] = row[0::2] + result[3::4] = row[1::2] + convert_la_to_rgba = staticmethod(convert_la_to_rgba) + + def convert_l_to_rgba(row, result): + """Convert a grayscale image to RGBA. This method assumes + the alpha channel in result is already correctly + initialized. + """ + for i in range(3): + result[i::4] = row + convert_l_to_rgba = staticmethod(convert_l_to_rgba) + + def convert_rgb_to_rgba(row, result): + """Convert an RGB image to RGBA. This method assumes the + alpha channel in result is already correctly initialized. + """ + for i in range(3): + result[i::4] = row[i::3] + convert_rgb_to_rgba = staticmethod(convert_rgb_to_rgba) + + +# === Command Line Support === + +def read_pam_header(infile): + """ + Read (the rest of a) PAM header. `infile` should be positioned + immediately after the initial 'P7' line (at the beginning of the + second line). Returns are as for `read_pnm_header`. + """ + + # Unlike PBM, PGM, and PPM, we can read the header a line at a time. + header = dict() + while True: + l = infile.readline().strip() + if l == b'ENDHDR': + break + if not l: + raise EOFError('PAM ended prematurely') + if l[0] == b'#': + continue + l = l.split(None, 1) + if l[0] not in header: + header[l[0]] = l[1] + else: + header[l[0]] += b' ' + l[1] + + required = [b'WIDTH', b'HEIGHT', b'DEPTH', b'MAXVAL'] + WIDTH,HEIGHT,DEPTH,MAXVAL = required + present = [x for x in required if x in header] + if len(present) != len(required): + raise Error('PAM file must specify WIDTH, HEIGHT, DEPTH, and MAXVAL') + width = int(header[WIDTH]) + height = int(header[HEIGHT]) + depth = int(header[DEPTH]) + maxval = int(header[MAXVAL]) + if (width <= 0 or + height <= 0 or + depth <= 0 or + maxval <= 0): + raise Error( + 'WIDTH, HEIGHT, DEPTH, MAXVAL must all be positive integers') + return 'P7', width, height, depth, maxval + +def read_pnm_header(infile, supported=(b'P5', b'P6')): + """ + Read a PNM header, returning (format,width,height,depth,maxval). + `width` and `height` are in pixels. `depth` is the number of + channels in the image; for PBM and PGM it is synthesized as 1, for + PPM as 3; for PAM images it is read from the header. `maxval` is + synthesized (as 1) for PBM images. + """ + + # Generally, see http://netpbm.sourceforge.net/doc/ppm.html + # and http://netpbm.sourceforge.net/doc/pam.html + + # Technically 'P7' must be followed by a newline, so by using + # rstrip() we are being liberal in what we accept. I think this + # is acceptable. + type = infile.read(3).rstrip() + if type not in supported: + raise NotImplementedError('file format %s not supported' % type) + if type == b'P7': + # PAM header parsing is completely different. + return read_pam_header(infile) + # Expected number of tokens in header (3 for P4, 4 for P6) + expected = 4 + pbm = (b'P1', b'P4') + if type in pbm: + expected = 3 + header = [type] + + # We have to read the rest of the header byte by byte because the + # final whitespace character (immediately following the MAXVAL in + # the case of P6) may not be a newline. Of course all PNM files in + # the wild use a newline at this point, so it's tempting to use + # readline; but it would be wrong. + def getc(): + c = infile.read(1) + if not c: + raise Error('premature EOF reading PNM header') + return c + + c = getc() + while True: + # Skip whitespace that precedes a token. + while c.isspace(): + c = getc() + # Skip comments. + while c == '#': + while c not in b'\n\r': + c = getc() + if not c.isdigit(): + raise Error('unexpected character %s found in header' % c) + # According to the specification it is legal to have comments + # that appear in the middle of a token. + # This is bonkers; I've never seen it; and it's a bit awkward to + # code good lexers in Python (no goto). So we break on such + # cases. + token = b'' + while c.isdigit(): + token += c + c = getc() + # Slight hack. All "tokens" are decimal integers, so convert + # them here. + header.append(int(token)) + if len(header) == expected: + break + # Skip comments (again) + while c == '#': + while c not in '\n\r': + c = getc() + if not c.isspace(): + raise Error('expected header to end with whitespace, not %s' % c) + + if type in pbm: + # synthesize a MAXVAL + header.append(1) + depth = (1,3)[type == b'P6'] + return header[0], header[1], header[2], depth, header[3] + +def write_pnm(file, width, height, pixels, meta): + """Write a Netpbm PNM/PAM file. + """ + + bitdepth = meta['bitdepth'] + maxval = 2**bitdepth - 1 + # Rudely, the number of image planes can be used to determine + # whether we are L (PGM), LA (PAM), RGB (PPM), or RGBA (PAM). + planes = meta['planes'] + # Can be an assert as long as we assume that pixels and meta came + # from a PNG file. + assert planes in (1,2,3,4) + if planes in (1,3): + if 1 == planes: + # PGM + # Could generate PBM if maxval is 1, but we don't (for one + # thing, we'd have to convert the data, not just blat it + # out). + fmt = 'P5' + else: + # PPM + fmt = 'P6' + header = '%s %d %d %d\n' % (fmt, width, height, maxval) + if planes in (2,4): + # PAM + # See http://netpbm.sourceforge.net/doc/pam.html + if 2 == planes: + tupltype = 'GRAYSCALE_ALPHA' + else: + tupltype = 'RGB_ALPHA' + header = ('P7\nWIDTH %d\nHEIGHT %d\nDEPTH %d\nMAXVAL %d\n' + 'TUPLTYPE %s\nENDHDR\n' % + (width, height, planes, maxval, tupltype)) + file.write(header.encode('ascii')) + # Values per row + vpr = planes * width + # struct format + fmt = '>%d' % vpr + if maxval > 0xff: + fmt = fmt + 'H' + else: + fmt = fmt + 'B' + for row in pixels: + file.write(struct.pack(fmt, *row)) + file.flush() + +def color_triple(color): + """ + Convert a command line colour value to a RGB triple of integers. + FIXME: Somewhere we need support for greyscale backgrounds etc. + """ + if color.startswith('#') and len(color) == 4: + return (int(color[1], 16), + int(color[2], 16), + int(color[3], 16)) + if color.startswith('#') and len(color) == 7: + return (int(color[1:3], 16), + int(color[3:5], 16), + int(color[5:7], 16)) + elif color.startswith('#') and len(color) == 13: + return (int(color[1:5], 16), + int(color[5:9], 16), + int(color[9:13], 16)) + +def _add_common_options(parser): + """Call *parser.add_option* for each of the options that are + common between this PNG--PNM conversion tool and the gen + tool. + """ + parser.add_option("-i", "--interlace", + default=False, action="store_true", + help="create an interlaced PNG file (Adam7)") + parser.add_option("-t", "--transparent", + action="store", type="string", metavar="#RRGGBB", + help="mark the specified colour as transparent") + parser.add_option("-b", "--background", + action="store", type="string", metavar="#RRGGBB", + help="save the specified background colour") + parser.add_option("-g", "--gamma", + action="store", type="float", metavar="value", + help="save the specified gamma value") + parser.add_option("-c", "--compression", + action="store", type="int", metavar="level", + help="zlib compression level (0-9)") + return parser + +def _main(argv): + """ + Run the PNG encoder with options from the command line. + """ + + # Parse command line arguments + from optparse import OptionParser + version = '%prog ' + __version__ + parser = OptionParser(version=version) + parser.set_usage("%prog [options] [imagefile]") + parser.add_option('-r', '--read-png', default=False, + action='store_true', + help='Read PNG, write PNM') + parser.add_option("-a", "--alpha", + action="store", type="string", metavar="pgmfile", + help="alpha channel transparency (RGBA)") + _add_common_options(parser) + + (options, args) = parser.parse_args(args=argv[1:]) + + # Convert options + if options.transparent is not None: + options.transparent = color_triple(options.transparent) + if options.background is not None: + options.background = color_triple(options.background) + + # Prepare input and output files + if len(args) == 0: + infilename = '-' + infile = sys.stdin + elif len(args) == 1: + infilename = args[0] + infile = open(infilename, 'rb') + else: + parser.error("more than one input file") + outfile = sys.stdout + if sys.platform == "win32": + import msvcrt, os + msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) + + if options.read_png: + # Encode PNG to PPM + png = Reader(file=infile) + width,height,pixels,meta = png.asDirect() + write_pnm(outfile, width, height, pixels, meta) + else: + # Encode PNM to PNG + format, width, height, depth, maxval = \ + read_pnm_header(infile, (b'P5',b'P6',b'P7')) + # When it comes to the variety of input formats, we do something + # rather rude. Observe that L, LA, RGB, RGBA are the 4 colour + # types supported by PNG and that they correspond to 1, 2, 3, 4 + # channels respectively. So we use the number of channels in + # the source image to determine which one we have. We do not + # care about TUPLTYPE. + greyscale = depth <= 2 + pamalpha = depth in (2,4) + supported = [2**x-1 for x in range(1,17)] + try: + mi = supported.index(maxval) + except ValueError: + raise NotImplementedError( + 'your maxval (%s) not in supported list %s' % + (maxval, str(supported))) + bitdepth = mi+1 + writer = Writer(width, height, + greyscale=greyscale, + bitdepth=bitdepth, + interlace=options.interlace, + transparent=options.transparent, + background=options.background, + alpha=bool(pamalpha or options.alpha), + gamma=options.gamma, + compression=options.compression) + if options.alpha: + pgmfile = open(options.alpha, 'rb') + format, awidth, aheight, adepth, amaxval = \ + read_pnm_header(pgmfile, 'P5') + if amaxval != '255': + raise NotImplementedError( + 'maxval %s not supported for alpha channel' % amaxval) + if (awidth, aheight) != (width, height): + raise ValueError("alpha channel image size mismatch" + " (%s has %sx%s but %s has %sx%s)" + % (infilename, width, height, + options.alpha, awidth, aheight)) + writer.convert_ppm_and_pgm(infile, pgmfile, outfile) + else: + writer.convert_pnm(infile, outfile) + + +if __name__ == '__main__': + try: + _main(sys.argv) + except Error as e: + print(e, file=sys.stderr) diff --git a/utils/read_charmap.py b/utils/read_charmap.py new file mode 100644 index 0000000..77036f6 --- /dev/null +++ b/utils/read_charmap.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os, io +from re import compile +from sys import stderr + +charmap_regex = compile('[ \t]*charmap[ \t]+"(.*?)",[ \t]*(\$[0-9A-Fa-f]{2}|%[01]{8}|[0-9]{3})') +# A charmap line is +# [ \t]* - zero or more space chars +# charmap - literal charmap +# [ \t]+ - one or more space chars +# "(.*?)" - a lazily-matched text identifier in quotes +# , - literal comma +# [ \t]* - zero or more space chars +# ( - either of +# \$[0-9A-Fa-f]{2} - two hexadecimal digits preceeded by literal $ +# %[01]{8} - eight dual digits preceeded by literal % +# [0-9]{3} - three decimal digits +# ) + +def parse_int(s): + # assumes integers are literal; no +-*/, etc + s = s.strip() + if s.startswith('$'): + return int(s[1:], 16) + if s.startswith('%'): + return int(s[1:], 2) + return int(s) + +def read_charmap(charmap_path): + charmap = {} + with io.open(charmap_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + for line in lines: + m = charmap_regex.match(line) + if m is None: + continue + char = m.group(1) + value = parse_int(m.group(2)) + if value in charmap: + print('Value {0:s} already in charmap, dropping it in favor of first charmap entry'.format(m.group(2))) + continue + charmap[value] = char + return charmap diff --git a/utils/tests/README.txt b/utils/tests/README.txt new file mode 100644 index 0000000..750482e --- /dev/null +++ b/utils/tests/README.txt @@ -0,0 +1 @@ +python dump_text.py -o dump_test.txt -m charmap.asm dump_text_test.bin 00:0000 diff --git a/utils/tests/charmap.asm b/utils/tests/charmap.asm new file mode 100644 index 0000000..70e228c --- /dev/null +++ b/utils/tests/charmap.asm @@ -0,0 +1,293 @@ + charmap "", $00 + + charmap "イ゛", $01 + charmap "ヴ", $02 + charmap "エ゛", $03 + charmap "オ゛", $04 + + charmap "ガ", $05 + charmap "ギ", $06 + charmap "グ", $07 + charmap "ゲ", $08 + charmap "ゴ", $09 + charmap "ザ", $0a + charmap "ジ", $0b + charmap "ズ", $0c + charmap "ゼ", $0d + charmap "ゾ", $0e + charmap "ダ", $0f + charmap "ヂ", $10 + charmap "ヅ", $11 + charmap "デ", $12 + charmap "ド", $13 + + charmap "", $14 ; "くん" or "ちゃん" + + charmap "<15>", $15 ; nothing + charmap "<16>", $16 ; nothing + + charmap "ネ゛", $17 + charmap "ノ゛", $18 + + charmap "バ", $19 + charmap "ビ", $1a + charmap "ブ", $1b + charmap "ボ", $1c + + charmap "", $1d ; "に " + charmap "", $1e ; "って" + charmap "", $1f ; "を " + + charmap "ィ゛", $20 + charmap "あ゛", $21 + + charmap "", $22 ; "た!" + charmap "", $23 ; "こうげき" + charmap "", $24 ; "は " + charmap "", $25 ; "の " + + charmap "が", $26 + charmap "ぎ", $27 + charmap "ぐ", $28 + charmap "げ", $29 + charmap "ご", $2a + charmap "ざ", $2b + charmap "じ", $2c + charmap "ず", $2d + charmap "ぜ", $2e + charmap "ぞ", $2f + charmap "だ", $30 + charmap "ぢ", $31 + charmap "づ", $32 + charmap "で", $33 + charmap "ど", $34 + + charmap "", $35 ; "ばん どうろ" + charmap "", $36 ; "わたし" + charmap "", $37 ; "ここは" + charmap "", $38 ; wRedsName + charmap "", $39 ; wGreensName + + charmap "ば", $3a + charmap "び", $3b + charmap "ぶ", $3c + charmap "べ", $3d + charmap "ぼ", $3e + + charmap "", $3f + + charmap "パ", $40 + charmap "ピ", $41 + charmap "プ", $42 + charmap "ポ", $43 + charmap "ぱ", $44 + charmap "ぴ", $45 + charmap "ぷ", $46 + charmap "ぺ", $47 + charmap "ぽ", $48 + + charmap "", $49 ; wMomsName + charmap "", $4a ; "が " + charmap "<_CONT>", $4b ; implements "" + charmap "", $4c + + charmap "も゜", $4d + + charmap "", $4e + charmap "", $4f + charmap "@", $50 ; string terminator + charmap "", $51 + charmap "", $52 ; wPlayerName + charmap "", $53 ; wRivalName + charmap "#", $54 ; "POKé" + charmap "", $55 + charmap "<⋯⋯>", $56 ; "⋯⋯" + charmap "", $57 + charmap "", $58 + charmap "", $59 + charmap "", $5a + charmap "", $5b ; "PC" + charmap "", $5c ; "TM" + charmap "", $5d ; "TRAINER" + charmap "", $5e ; "ROCKET" + charmap "", $5f + + charmap "■", $60 + charmap "▲", $61 + charmap "☎", $62 + + charmap "D", $63 + charmap "E", $64 + charmap "F", $65 + charmap "G", $66 + charmap "H", $67 + charmap "I", $68 + charmap "V", $69 + charmap "S", $6a + charmap "L", $6b + charmap "M", $6c + + charmap ":", $6d + + charmap "ぃ", $6e + charmap "ぅ", $6f + + charmap "「", $70 + charmap "」", $71 + charmap "『", $72 + charmap "』", $73 + charmap "・", $74 + charmap "⋯", $75 + + charmap "ぁ", $76 + charmap "ぇ", $77 + charmap "ぉ", $78 + + charmap "┌", $79 + charmap "─", $7a + charmap "┐", $7b + charmap "│", $7c + charmap "└", $7d + charmap "┘", $7e + + charmap " ", $7f + + charmap "ア", $80 + charmap "イ", $81 + charmap "ウ", $82 + charmap "エ", $83 + charmap "オ", $84 + charmap "カ", $85 + charmap "キ", $86 + charmap "ク", $87 + charmap "ケ", $88 + charmap "コ", $89 + charmap "サ", $8a + charmap "シ", $8b + charmap "ス", $8c + charmap "セ", $8d + charmap "ソ", $8e + charmap "タ", $8f + charmap "チ", $90 + charmap "ツ", $91 + charmap "テ", $92 + charmap "ト", $93 + charmap "ナ", $94 + charmap "ニ", $95 + charmap "ヌ", $96 + charmap "ネ", $97 + charmap "ノ", $98 + charmap "ハ", $99 + charmap "ヒ", $9a + charmap "フ", $9b + charmap "ホ", $9c + charmap "マ", $9d + charmap "ミ", $9e + charmap "ム", $9f + charmap "メ", $a0 + charmap "モ", $a1 + charmap "ヤ", $a2 + charmap "ユ", $a3 + charmap "ヨ", $a4 + charmap "ラ", $a5 + charmap "ル", $a6 + charmap "レ", $a7 + charmap "ロ", $a8 + charmap "ワ", $a9 + charmap "ヲ", $aa + charmap "ン", $ab + + charmap "ッ", $ac + charmap "ャ", $ad + charmap "ュ", $ae + charmap "ョ", $af + charmap "ィ", $b0 + + charmap "あ", $b1 + charmap "い", $b2 + charmap "う", $b3 + charmap "え", $b4 + charmap "お", $b5 + charmap "か", $b6 + charmap "き", $b7 + charmap "く", $b8 + charmap "け", $b9 + charmap "こ", $ba + charmap "さ", $bb + charmap "し", $bc + charmap "す", $bd + charmap "せ", $be + charmap "そ", $bf + charmap "た", $c0 + charmap "ち", $c1 + charmap "つ", $c2 + charmap "て", $c3 + charmap "と", $c4 + charmap "な", $c5 + charmap "に", $c6 + charmap "ぬ", $c7 + charmap "ね", $c8 + charmap "の", $c9 + charmap "は", $ca + charmap "ひ", $cb + charmap "ふ", $cc + charmap "へ", $cd + charmap "ほ", $ce + charmap "ま", $cf + charmap "み", $d0 + charmap "む", $d1 + charmap "め", $d2 + charmap "も", $d3 + charmap "や", $d4 + charmap "ゆ", $d5 + charmap "よ", $d6 + charmap "ら", $d7 + charmap "り", $d8 + charmap "る", $d9 + charmap "れ", $da + charmap "ろ", $db + charmap "わ", $dc + charmap "を", $dd + charmap "ん", $de + + charmap "っ", $df + charmap "ゃ", $e0 + charmap "ゅ", $e1 + charmap "ょ", $e2 + + charmap "ー", $e3 + + charmap "゚", $e4 + charmap "゙", $e5 + + charmap "?", $e6 + charmap "!", $e7 + charmap "。", $e8 + + charmap "ァ", $e9 + charmap "ゥ", $ea + charmap "ェ", $eb + + charmap "▷", $ec + charmap "▶", $ed + charmap "▲", $ed + charmap "▼", $ee + charmap "♂", $ef + charmap "円", $f0 + charmap "×", $f1 + charmap ".", $f2 + charmap "/", $f3 + + charmap "ォ", $f4 + + charmap "♀", $f5 + charmap "0", $f6 + charmap "1", $f7 + charmap "2", $f8 + charmap "3", $f9 + charmap "4", $fa + charmap "5", $fb + charmap "6", $fc + charmap "7", $fd + charmap "8", $fe + charmap "9", $ff diff --git a/utils/tests/dump_test.cc.txt b/utils/tests/dump_test.cc.txt new file mode 100644 index 0000000..4dfd14a --- /dev/null +++ b/utils/tests/dump_test.cc.txt @@ -0,0 +1,26 @@ +.loc_0000: + text "ほんとにりセットしますか?@" + text_from_ram $ce33 + text_bcd $ce34, $78 + text_move $ce35 + text_box $cc36, $08, $12 + text_low + text_waitbutton + text_scroll + deciram $cc37, $6, $5 + text_exit + sound_dex_fanfare_50_79 + text_dots $14 + link_wait_button + sound_dex_fanfare_20_49 + sound_item + sound_caught_mon + sound_dex_fanfare_80_109 + sound_fanfare + sound_slot_machine_start + cry_nidorina + cry_pigeot + cry_jugon + text_end + +.loc_0035: diff --git a/utils/tests/dump_test.cc_endless.txt b/utils/tests/dump_test.cc_endless.txt new file mode 100644 index 0000000..5c59830 --- /dev/null +++ b/utils/tests/dump_test.cc_endless.txt @@ -0,0 +1,33 @@ +.loc_0000: + text "ほんとにりセットしますか?@" + text_from_ram $ce33 + text_bcd $ce34, $78 + text_move $ce35 + text_box $cc36, $08, $12 + text_low + text_waitbutton + text_scroll + deciram $cc37, $6, $5 + text_exit + sound_dex_fanfare_50_79 + text_dots $14 + link_wait_button + sound_dex_fanfare_20_49 + sound_item + sound_caught_mon + sound_dex_fanfare_80_109 + sound_fanfare + sound_slot_machine_start + cry_nidorina + cry_pigeot + cry_jugon + text_end + +.loc_0035: + text "ほんとにりセットしますか?" +.loc_0044: + start_asm ; text dumper cannot dump asm + ; Try dumping asm from the following offset and + ; then continue dumping text in control mode. + +.loc_0045: diff --git a/utils/tests/dump_test.cc_tc.txt b/utils/tests/dump_test.cc_tc.txt new file mode 100644 index 0000000..e49a010 --- /dev/null +++ b/utils/tests/dump_test.cc_tc.txt @@ -0,0 +1,27 @@ +.loc_0000: + text "ほんとにりセットしますか?" + line "@" + text_from_ram $ce33 + text_bcd $ce34, $78 + text_move $ce35 + text_box $cc36, $08, $12 + text_low + text_waitbutton + text_scroll + deciram $cc37, $6, $5 + text_exit + sound_dex_fanfare_50_79 + text_dots $14 + link_wait_button + sound_dex_fanfare_20_49 + sound_item + sound_caught_mon + sound_dex_fanfare_80_109 + sound_fanfare + sound_slot_machine_start + cry_nidorina + cry_pigeot + cry_jugon + text_end + +.loc_0035: diff --git a/utils/tests/dump_test.cc_tc_endless.txt b/utils/tests/dump_test.cc_tc_endless.txt new file mode 100644 index 0000000..dbf8e8b --- /dev/null +++ b/utils/tests/dump_test.cc_tc_endless.txt @@ -0,0 +1,35 @@ +.loc_0000: + text "ほんとにりセットしますか?" + line "@" + text_from_ram $ce33 + text_bcd $ce34, $78 + text_move $ce35 + text_box $cc36, $08, $12 + text_low + text_waitbutton + text_scroll + deciram $cc37, $6, $5 + text_exit + sound_dex_fanfare_50_79 + text_dots $14 + link_wait_button + sound_dex_fanfare_20_49 + sound_item + sound_caught_mon + sound_dex_fanfare_80_109 + sound_fanfare + sound_slot_machine_start + cry_nidorina + cry_pigeot + cry_jugon + text_end + +.loc_0035: + text "ほんとにりセットしますか?" + done +.loc_0044: + start_asm ; text dumper cannot dump asm + ; Try dumping asm from the following offset and + ; then continue dumping text in control mode. + +.loc_0045: diff --git a/utils/tests/dump_test.endless.txt b/utils/tests/dump_test.endless.txt new file mode 100644 index 0000000..fbbdeef --- /dev/null +++ b/utils/tests/dump_test.endless.txt @@ -0,0 +1,9 @@ +.loc_0000: + db "ほんとにりセットしますか?@" +.loc_0010: + db "イ゛でほヴどほぉエ゛ほオ゛ふゲデガギグゴふFザジズゼゾダヂヅデド<15><16>@" +.loc_0035: + db "ほんとにりセットしますか?" +.loc_0044: + db "ゲ" +.loc_0045: diff --git a/utils/tests/dump_test.tc.txt b/utils/tests/dump_test.tc.txt new file mode 100644 index 0000000..82c276d --- /dev/null +++ b/utils/tests/dump_test.tc.txt @@ -0,0 +1,4 @@ +.loc_0000: + db "ほんとにりセットしますか?" + line "@" +.loc_0010: diff --git a/utils/tests/dump_test.tc_endless.txt b/utils/tests/dump_test.tc_endless.txt new file mode 100644 index 0000000..37bdda0 --- /dev/null +++ b/utils/tests/dump_test.tc_endless.txt @@ -0,0 +1,11 @@ +.loc_0000: + db "ほんとにりセットしますか?" + line "@" +.loc_0010: + db "イ゛でほヴどほぉエ゛ほオ゛ふゲデガギグゴふFザジズゼゾダヂヅデド<15><16>@" +.loc_0035: + db "ほんとにりセットしますか?" + done +.loc_0044: + db "ゲ" +.loc_0045: diff --git a/utils/tests/dump_test.txt b/utils/tests/dump_test.txt new file mode 100644 index 0000000..a181744 --- /dev/null +++ b/utils/tests/dump_test.txt @@ -0,0 +1,3 @@ +.loc_0000: + db "ほんとにりセットしますか?@" +.loc_0010: diff --git a/utils/tests/dump_text_test.bin b/utils/tests/dump_text_test.bin new file mode 100644 index 0000000..778a446 Binary files /dev/null and b/utils/tests/dump_text_test.bin differ -- cgit v1.2.3