From c4672b4f441561d34c9a8cb7d7be8c1e9aaca3cc Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:48:56 +0930 Subject: [PATCH 01/15] YML data dumping --- coilsnake/assets/modulelist.txt | 1 + coilsnake/assets/structures/eb.yml | 15 +-- coilsnake/modules/eb/BattleAnimationModule.py | 105 ++++++++++++++++++ 3 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 coilsnake/modules/eb/BattleAnimationModule.py diff --git a/coilsnake/assets/modulelist.txt b/coilsnake/assets/modulelist.txt index b0487247..d5cdfae0 100644 --- a/coilsnake/assets/modulelist.txt +++ b/coilsnake/assets/modulelist.txt @@ -17,6 +17,7 @@ eb.DeathScreenModule eb.SoundStoneModule eb.CastModule eb.AnimationModule +eb.BattleAnimationModule eb.MapModule eb.MapEnemyModule eb.MapMusicModule diff --git a/coilsnake/assets/structures/eb.yml b/coilsnake/assets/structures/eb.yml index 28be8488..b37beee3 100644 --- a/coilsnake/assets/structures/eb.yml +++ b/coilsnake/assets/structures/eb.yml @@ -414,16 +414,17 @@ type: pointer - name: Frame duration (1/60th seconds) size: 1 - - name: Palette duration (1/60th seconds) + - name: Palette cycle duration (1/60th seconds) size: 1 - - name: unknown - size: 2 - type: bytearray + - name: Palette cycle lower index + size: 1 + - name: Palette cycle upper index - name: Frame count size: 1 - - name: unknown 2 - size: 2 - type: bytearray + - name: Targetting + size: 1 + - name: Delay before enemy colour change + size: 1 - name: Duration of enemy colour change size: 1 - name: Enemy colour diff --git a/coilsnake/modules/eb/BattleAnimationModule.py b/coilsnake/modules/eb/BattleAnimationModule.py new file mode 100644 index 00000000..89819051 --- /dev/null +++ b/coilsnake/modules/eb/BattleAnimationModule.py @@ -0,0 +1,105 @@ +from coilsnake.exceptions.common.exceptions import CoilSnakeTraceableError +from coilsnake.model.eb.blocks import EbCompressibleBlock +from coilsnake.model.eb.graphics import EbGraphicTileset, EbTileArrangement +from coilsnake.model.eb.palettes import EbPalette +from coilsnake.model.eb.table import eb_table_from_offset +from coilsnake.modules.eb.EbModule import EbModule +from coilsnake.util.eb.pointer import from_snes_address, to_snes_address +from coilsnake.util.common.image import open_indexed_image +from coilsnake.util.common.yml import yml_dump, yml_load + +import logging + +log = logging.getLogger(__name__) + +BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS = 0xCCF04D + +BATTLE_ANIMATIONS_BANK = 0x0C + +class BattleAnimation: + def __init__(self, + tileset_pointer_short, frame_duration, palette_cycle_duration, + palette_cycle_lower_index, palette_cycle_upper_index, + frame_count, targetting, enemy_colour_delay, + enemy_colour_duration, enemy_colour + ): + self.tileset_pointer_short = tileset_pointer_short + self.frame_duration = frame_duration + self.palette_cycle_duration = palette_cycle_duration + self.palette_cycle_lower_index = palette_cycle_lower_index + self.palette_cycle_upper_index = palette_cycle_upper_index + self.frame_count = frame_count + self.targetting = targetting + self.enemy_colour_delay = enemy_colour_delay + self.enemy_colour_duration = enemy_colour_duration + self.enemy_colour = enemy_colour + + self.palette = EbPalette(num_subpalettes=1, subpalette_length=4) + self.graphics = EbGraphicTileset(num_tiles=256) + self.arrangements = [EbTileArrangement(width=32, height=28) for _ in range(self.frame_count)] + + # def from_block(self, block, offset): + # with EbCompressibleBlock() as compressed_block: + + + +class BattleAnimationModule(EbModule): + """Extracts battle animations from EarthBound""" + NAME = "Battle Animations" + + # Animations config, arrangements, arrangement pointers, tilesets, and palettes + # FREE_RANGES = [(0x0C2E19, 0x0CF617)] + + def __init__(self): + super(BattleAnimationModule, self).__init__() + self.table = eb_table_from_offset(BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS) + self.battle_animations: list[BattleAnimation] = [] + + def read_from_rom(self, rom): + self.table.from_block( + rom, offset=from_snes_address(BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS) + ) + + for index in range(self.table.num_rows): + row = self.table[index] + tileset_pointer_short, frame_duration, palette_cycle_duration,\ + palette_cycle_lower_index, palette_cycle_upper_index,\ + frame_count, targetting, enemy_colour_delay,\ + enemy_colour_duration, enemy_colour = row + + battle_animation = BattleAnimation(row[0], row[1], row[2], row[3], + row[4], row[5], row[6], row[7], + row[8], row[9]) + + self.battle_animations.append(battle_animation) + + def write_to_rom(self, rom): + log.warning("Not implemented yet") + # TODO this + + def read_from_project(self, resource_open): + log.warning("Not implemented yet") + # TODO this + + def write_to_project(self, resource_open): + animation_data = {} + for i, animation in enumerate(self.battle_animations): + animation_data[i] = { + "Frame duration": animation.frame_duration, + "Palette cycle duration": animation.palette_cycle_duration, + "Palette cycle lower index": animation.palette_cycle_lower_index, + "Palette cycle upper index": animation.palette_cycle_upper_index, + # "Frame count": animation.frame_count, # Infer from data? + "Targetting": animation.targetting, + "Delay before enemy colour change": animation.enemy_colour_delay, + "Duration of enemy colour change": animation.enemy_colour_duration, + # "Enemy colour": animation.enemy_colour, # Need yaml rep + } + # TODO rest of data dump. Images, maps, etc + with resource_open("BattleAnimations/battle_animations", "yml", True) as f: + yml_dump(animation_data, f, default_flow_style=False) + + def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + if old_version < 14: + self.read_from_rom(rom) + self.write_to_project(resource_open_w) \ No newline at end of file From 7a8493e2f8846779579b918f5c16a68ba632f99d Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:48:55 +0930 Subject: [PATCH 02/15] Dumping the rest of the data --- coilsnake/assets/structures/eb.yml | 3 +- coilsnake/model/eb/graphics.py | 32 +++++ coilsnake/modules/eb/BattleAnimationModule.py | 118 +++++++++++++----- 3 files changed, 120 insertions(+), 33 deletions(-) diff --git a/coilsnake/assets/structures/eb.yml b/coilsnake/assets/structures/eb.yml index b37beee3..55fe9cfa 100644 --- a/coilsnake/assets/structures/eb.yml +++ b/coilsnake/assets/structures/eb.yml @@ -419,6 +419,7 @@ - name: Palette cycle lower index size: 1 - name: Palette cycle upper index + size: 1 - name: Frame count size: 1 - name: Targetting @@ -437,7 +438,7 @@ 13431935: &id091 entries: - name: Palette - size: 16 + size: 8 type: palette name: PSI_ANIM_PALETTES offset: 13431935 diff --git a/coilsnake/model/eb/graphics.py b/coilsnake/model/eb/graphics.py index 37820b6d..b709d0d8 100644 --- a/coilsnake/model/eb/graphics.py +++ b/coilsnake/model/eb/graphics.py @@ -244,6 +244,17 @@ def to_block(self, block, offset=0): | self.tile), 2) +class EbOneByteTileArrangementItem(EbTileArrangementItem): + def check_validity(self): + if self.tile < 0 or self.tile > 0xFF: + raise InvalidArgumentError("Invalid tile[{}]".format(self.tile)) + + def from_block(self, block, offset=0): + self.tile = block[offset] + + def to_block(self, block, offset=0): + self.check_validity() + block[offset] = self.tile class EbTileArrangement(EqualityMixin): """A class representing an image formed by an arrangement of tile-based graphics with a certain palette.""" @@ -387,7 +398,28 @@ def __getitem__(self, key): x, y, self.width, self.height)) return self.arrangement[y][x] +class EbOneByteTileArrangement(EbTileArrangement): + """A class representing a tilemap that consists only of a single byte with a 0-255 tile ID per tile. + It's expected that other data, such as palette, priority, etc, is appended later.""" + def __init__(self, width, height): + super().__init__(width, height) + self.arrangement = [[EbOneByteTileArrangementItem() for x in range(self.width)] for y in range(self.height)] + + def from_block(self, block, offset=0): + for row in self.arrangement: + for item in row: + item.from_block(block, offset) + offset += 1 + def to_block(self, block, offset=0): + for row in self.arrangement: + for item in row: + item.to_block(block, offset) + offset += 1 + + def block_size(self): + return sum([len(x) for x in self.arrangement]) + class EbCompressedGraphic(object): def __init__(self, num_tiles, tile_width, tile_height, bpp, arrangement_width, arrangement_height, num_palettes, num_subpalettes, subpalette_length, compressed_palettes=True): diff --git a/coilsnake/modules/eb/BattleAnimationModule.py b/coilsnake/modules/eb/BattleAnimationModule.py index 89819051..a8d42236 100644 --- a/coilsnake/modules/eb/BattleAnimationModule.py +++ b/coilsnake/modules/eb/BattleAnimationModule.py @@ -1,27 +1,34 @@ from coilsnake.exceptions.common.exceptions import CoilSnakeTraceableError from coilsnake.model.eb.blocks import EbCompressibleBlock -from coilsnake.model.eb.graphics import EbGraphicTileset, EbTileArrangement +from coilsnake.model.eb.graphics import EbGraphicTileset, EbOneByteTileArrangement, EbTileArrangement from coilsnake.model.eb.palettes import EbPalette from coilsnake.model.eb.table import eb_table_from_offset from coilsnake.modules.eb.EbModule import EbModule from coilsnake.util.eb.pointer import from_snes_address, to_snes_address from coilsnake.util.common.image import open_indexed_image from coilsnake.util.common.yml import yml_dump, yml_load +from coilsnake.util.common.type import enum_class_from_name_list import logging log = logging.getLogger(__name__) +BATTLE_ANIMATION_TARGET = ["one", "row", "all", "random"] + +BattleAnimationTargetEnum = enum_class_from_name_list(BATTLE_ANIMATION_TARGET) + BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS = 0xCCF04D +BATTLE_ANIMATION_PALETTES_DEFAULT_ADDRESS = 0xCCF47F +BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS = 0xCCF58F -BATTLE_ANIMATIONS_BANK = 0x0C +BATTLE_ANIMATIONS_BANK = 0xCC class BattleAnimation: def __init__(self, tileset_pointer_short, frame_duration, palette_cycle_duration, palette_cycle_lower_index, palette_cycle_upper_index, - frame_count, targetting, enemy_colour_delay, - enemy_colour_duration, enemy_colour + frame_count, targetting, enemy_color_delay, + enemy_color_duration, enemy_color ): self.tileset_pointer_short = tileset_pointer_short self.frame_duration = frame_duration @@ -30,17 +37,26 @@ def __init__(self, self.palette_cycle_upper_index = palette_cycle_upper_index self.frame_count = frame_count self.targetting = targetting - self.enemy_colour_delay = enemy_colour_delay - self.enemy_colour_duration = enemy_colour_duration - self.enemy_colour = enemy_colour + self.enemy_color_delay = enemy_color_delay + self.enemy_color_duration = enemy_color_duration + self.enemy_color = enemy_color self.palette = EbPalette(num_subpalettes=1, subpalette_length=4) - self.graphics = EbGraphicTileset(num_tiles=256) - self.arrangements = [EbTileArrangement(width=32, height=28) for _ in range(self.frame_count)] - - # def from_block(self, block, offset): - # with EbCompressibleBlock() as compressed_block: - + self.tileset = EbGraphicTileset(num_tiles=256) + self.arrangements = [EbOneByteTileArrangement(width=32, height=32) for _ in range(self.frame_count)] + + def arrangements_from_block(self, block, offset): + with EbCompressibleBlock() as compressed_block: + compressed_block.from_compressed_block(block, offset) + next_offset = 0 + for frame in self.arrangements: + frame.from_block(compressed_block, next_offset) + next_offset += frame.block_size() + + def tileset_from_block(self, block, offset): + with EbCompressibleBlock() as compressed_block: + compressed_block.from_compressed_block(block, offset) + self.tileset.from_block(compressed_block) class BattleAnimationModule(EbModule): @@ -52,24 +68,33 @@ class BattleAnimationModule(EbModule): def __init__(self): super(BattleAnimationModule, self).__init__() - self.table = eb_table_from_offset(BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS) + self.config_table = eb_table_from_offset(BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS) + self.palette_table = eb_table_from_offset(BATTLE_ANIMATION_PALETTES_DEFAULT_ADDRESS) + self.arrangement_ptr_table = eb_table_from_offset(BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS) self.battle_animations: list[BattleAnimation] = [] def read_from_rom(self, rom): - self.table.from_block( + self.config_table.from_block( rom, offset=from_snes_address(BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS) ) + self.palette_table.from_block( + rom, offset=from_snes_address(BATTLE_ANIMATION_PALETTES_DEFAULT_ADDRESS) + ) + self.arrangement_ptr_table.from_block( + rom, offset=from_snes_address(BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS) + ) - for index in range(self.table.num_rows): - row = self.table[index] - tileset_pointer_short, frame_duration, palette_cycle_duration,\ - palette_cycle_lower_index, palette_cycle_upper_index,\ - frame_count, targetting, enemy_colour_delay,\ - enemy_colour_duration, enemy_colour = row + for index in range(self.config_table.num_rows): + row = self.config_table[index] + battle_animation = BattleAnimation(*row) + + battle_animation.palette = self.palette_table[index][0] + + arrangement_ptr = from_snes_address(self.arrangement_ptr_table[index][0]) + battle_animation.arrangements_from_block(rom, arrangement_ptr) - battle_animation = BattleAnimation(row[0], row[1], row[2], row[3], - row[4], row[5], row[6], row[7], - row[8], row[9]) + tileset_ptr = from_snes_address(battle_animation.tileset_pointer_short + (BATTLE_ANIMATIONS_BANK << 16)) + battle_animation.tileset_from_block(rom, tileset_ptr) self.battle_animations.append(battle_animation) @@ -82,22 +107,51 @@ def read_from_project(self, resource_open): # TODO this def write_to_project(self, resource_open): + # Common EbTileArrangement used to render the tileset to an image + tileset_arrangement = EbTileArrangement(16, 16) + tile_id_to_write = 0 + for tile_row in tileset_arrangement.arrangement: + for tile in tile_row: + tile.tile = tile_id_to_write + tile_id_to_write += 1 + animation_data = {} for i, animation in enumerate(self.battle_animations): + # Organise yaml data animation_data[i] = { + "Delay before enemy color change": animation.enemy_color_delay, + "Duration of enemy color change": animation.enemy_color_duration, + "Enemy color": animation.enemy_color.yml_rep(), # why doesn't this happen automatically? + # "Frame count": animation.frame_count, # Infer from map file "Frame duration": animation.frame_duration, + # "Palette": animation.palette.yml_rep(), # Infer from tileset file "Palette cycle duration": animation.palette_cycle_duration, "Palette cycle lower index": animation.palette_cycle_lower_index, "Palette cycle upper index": animation.palette_cycle_upper_index, - # "Frame count": animation.frame_count, # Infer from data? - "Targetting": animation.targetting, - "Delay before enemy colour change": animation.enemy_colour_delay, - "Duration of enemy colour change": animation.enemy_colour_duration, - # "Enemy colour": animation.enemy_colour, # Need yaml rep + "Targetting": BattleAnimationTargetEnum.tostring(animation.targetting), } - # TODO rest of data dump. Images, maps, etc - with resource_open("BattleAnimations/battle_animations", "yml", True) as f: - yml_dump(animation_data, f, default_flow_style=False) + + # Write arrangements (tilemaps) + # This is called ".map" just like the overworld map but it is a little different. + # - Tile indexes are 2-digit instead of 3-digit (we can only have 256 tiles) + # - The data is arranged into a series of rectangles representing a frame each + # - Maybe we need a different file extension... + with resource_open("BattleAnimations/{:02d}/arrangement".format(i), "map", True) as f: + for frame in animation.arrangements: + for row in range(frame.height): + for col in range(frame.width): + f.write(hex(frame[col, row].tile)[2:].zfill(2)) + f.write(" ") + f.write("\n") + f.write("\n") + + # Write tileset image + with resource_open("BattleAnimations/{:02d}/tileset".format(i), "png") as f: + image = tileset_arrangement.image(animation.tileset, animation.palette, True) + image.save(f, "png") + + with resource_open("BattleAnimations/battle_animations", "yml", True) as f: + yml_dump(animation_data, f, default_flow_style=False) def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): if old_version < 14: From fed4a456e56169d1d59a07c1a11ba7ba4b5e6baa Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:14:25 +0930 Subject: [PATCH 03/15] Writing to ROM. Plus a bunch of other improvements --- coilsnake/assets/structures/eb.yml | 5 + coilsnake/modules/eb/BattleAnimationModule.py | 265 +++++++++++++++--- 2 files changed, 227 insertions(+), 43 deletions(-) diff --git a/coilsnake/assets/structures/eb.yml b/coilsnake/assets/structures/eb.yml index 55fe9cfa..98c6179c 100644 --- a/coilsnake/assets/structures/eb.yml +++ b/coilsnake/assets/structures/eb.yml @@ -423,6 +423,11 @@ - name: Frame count size: 1 - name: Targetting + values: + - single + - row + - all + - random size: 1 - name: Delay before enemy colour change size: 1 diff --git a/coilsnake/modules/eb/BattleAnimationModule.py b/coilsnake/modules/eb/BattleAnimationModule.py index a8d42236..a7631e91 100644 --- a/coilsnake/modules/eb/BattleAnimationModule.py +++ b/coilsnake/modules/eb/BattleAnimationModule.py @@ -1,10 +1,10 @@ from coilsnake.exceptions.common.exceptions import CoilSnakeTraceableError from coilsnake.model.eb.blocks import EbCompressibleBlock -from coilsnake.model.eb.graphics import EbGraphicTileset, EbOneByteTileArrangement, EbTileArrangement +from coilsnake.model.eb.graphics import EbGraphicTileset, EbOneByteTileArrangement, EbTileArrangement, EbOneByteTileArrangementItem from coilsnake.model.eb.palettes import EbPalette from coilsnake.model.eb.table import eb_table_from_offset from coilsnake.modules.eb.EbModule import EbModule -from coilsnake.util.eb.pointer import from_snes_address, to_snes_address +from coilsnake.util.eb.pointer import from_snes_address, to_snes_address, AsmPointerReference, XlPointerReference from coilsnake.util.common.image import open_indexed_image from coilsnake.util.common.yml import yml_dump, yml_load from coilsnake.util.common.type import enum_class_from_name_list @@ -21,8 +21,35 @@ BATTLE_ANIMATION_PALETTES_DEFAULT_ADDRESS = 0xCCF47F BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS = 0xCCF58F +BATTLE_ANIMATION_TABLE_REFERENCES = ( + AsmPointerReference(0x02E34f), + XlPointerReference(0x02E153), + XlPointerReference(0x02E1BA), + XlPointerReference(0x02E509), +) +BATTLE_ANIMATION_PALETTE_TABLE_REFERENCES = ( + AsmPointerReference(0x02E2F4), +) +BATTLE_ANIMATION_ARRANGEMENT_PTRS_REFERENCES = ( + AsmPointerReference(0x02E461), +) +BATTLE_ANIMATION_TILESETS_REFERENCES = ( + # Pointer to the bank which the compressed tilesets are in + AsmPointerReference(0x02E13F), + AsmPointerReference(0x02E1A6), +) + BATTLE_ANIMATIONS_BANK = 0xCC +# Common EbTileArrangement used to render the tileset to an image +TILESET_IMAGE_ARRANGEMENT = EbTileArrangement(16, 16) +tile_id_to_write = 0 +for tile_row in TILESET_IMAGE_ARRANGEMENT.arrangement: + for tile in tile_row: + tile.tile = tile_id_to_write + tile_id_to_write += 1 +del tile_id_to_write + class BattleAnimation: def __init__(self, tileset_pointer_short, frame_duration, palette_cycle_duration, @@ -43,7 +70,12 @@ def __init__(self, self.palette = EbPalette(num_subpalettes=1, subpalette_length=4) self.tileset = EbGraphicTileset(num_tiles=256) - self.arrangements = [EbOneByteTileArrangement(width=32, height=32) for _ in range(self.frame_count)] + + if self.frame_count is None: + # Happens when reading from project, because we don't know the frame count yet + self.arrangements = [] + else: + self.arrangements = [EbOneByteTileArrangement(width=32, height=32) for _ in range(self.frame_count)] def arrangements_from_block(self, block, offset): with EbCompressibleBlock() as compressed_block: @@ -53,10 +85,41 @@ def arrangements_from_block(self, block, offset): frame.from_block(compressed_block, next_offset) next_offset += frame.block_size() - def tileset_from_block(self, block, offset): - with EbCompressibleBlock() as compressed_block: - compressed_block.from_compressed_block(block, offset) - self.tileset.from_block(compressed_block) + def arrangements_to_block(self): + size = sum(frame.block_size() for frame in self.arrangements) + block = EbCompressibleBlock(size) + next_offset = 0 + for frame in self.arrangements: + frame.to_block(block, next_offset) + next_offset += frame.block_size() + block.compress() + return block + + def arrangements_from_map(self, map_file): + self.arrangements = [] + self.frame_count = 0 + raw: str = map_file.read() + text_frames = raw.split("\n\n") + + for text_frame in text_frames[:-1]: # Last split is the trailing gap + arrangement = EbOneByteTileArrangement(32, 32) + + rows = text_frame.split("\n") + if len(rows) != 32: + raise CoilSnakeTraceableError("Frame #{}: Incorrect number of rows. Expected 32, got {}".format( + self.frame_count, len(rows))) + + for y, row in enumerate(rows): + if len(row) != 96: # 32 tiles of 3 chars each, 2 letters and a space + raise CoilSnakeTraceableError("Frame #{}: Incorrect number of columns. Expected 32, got {}".format( + self.frame_count, len(row) + )) + for x, tile in enumerate(row.split()): + arrangement.arrangement[y][x] = EbOneByteTileArrangementItem(int(tile, 16)) + arrangement.arrangement[y][x].check_validity() + + self.arrangements.append(arrangement) + self.frame_count += 1 class BattleAnimationModule(EbModule): @@ -64,17 +127,20 @@ class BattleAnimationModule(EbModule): NAME = "Battle Animations" # Animations config, arrangements, arrangement pointers, tilesets, and palettes - # FREE_RANGES = [(0x0C2E19, 0x0CF617)] + FREE_RANGES = [(0x0C2E19, 0x0CF617)] def __init__(self): super(BattleAnimationModule, self).__init__() - self.config_table = eb_table_from_offset(BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS) + self.battle_animation_table = eb_table_from_offset(BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS, + hidden_columns=["Short pointer to tileset", "Frame count"]) self.palette_table = eb_table_from_offset(BATTLE_ANIMATION_PALETTES_DEFAULT_ADDRESS) self.arrangement_ptr_table = eb_table_from_offset(BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS) self.battle_animations: list[BattleAnimation] = [] + self.tilesets: list[EbGraphicTileset] = [] # List of tilesets, for deduplication purposes + def read_from_rom(self, rom): - self.config_table.from_block( + self.battle_animation_table.from_block( rom, offset=from_snes_address(BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS) ) self.palette_table.from_block( @@ -84,53 +150,166 @@ def read_from_rom(self, rom): rom, offset=from_snes_address(BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS) ) - for index in range(self.config_table.num_rows): - row = self.config_table[index] + known_tilesets = {} # Maps short addresses to indexes in self.tilesets + + for index in range(self.battle_animation_table.num_rows): + row = self.battle_animation_table[index] battle_animation = BattleAnimation(*row) battle_animation.palette = self.palette_table[index][0] arrangement_ptr = from_snes_address(self.arrangement_ptr_table[index][0]) battle_animation.arrangements_from_block(rom, arrangement_ptr) - + tileset_ptr = from_snes_address(battle_animation.tileset_pointer_short + (BATTLE_ANIMATIONS_BANK << 16)) - battle_animation.tileset_from_block(rom, tileset_ptr) + # Keep track of tilesets we've already seen and skip duplicating them + # Not as crucial here as it is when writing ... + if not tileset_ptr in known_tilesets: + with EbCompressibleBlock() as compressed_block: + tileset = EbGraphicTileset(256) + compressed_block.from_compressed_block(rom, tileset_ptr) + tileset.from_block(compressed_block) + known_tilesets[tileset_ptr] = len(self.tilesets) + self.tilesets.append(tileset) + + battle_animation.tileset = self.tilesets[known_tilesets[tileset_ptr]] self.battle_animations.append(battle_animation) def write_to_rom(self, rom): - log.warning("Not implemented yet") - # TODO this + # Write palette table + palette_table_offset = rom.allocate(size=self.palette_table.size) + self.palette_table.to_block(rom, palette_table_offset) + # Relocate references to palette table + for reference in BATTLE_ANIMATION_PALETTE_TABLE_REFERENCES: + if reference.validate_structure(rom): + reference.write(rom, to_snes_address(palette_table_offset)) + else: + log.warning("Palette table relocation at %#x failed structure check - skipping...", reference.offset) + + # Write compressed arrangements + # The arrangements for different animations can be in different banks. Lucky us + for id, animation in enumerate(self.battle_animations): + block = animation.arrangements_to_block() + arrangement_offset = rom.allocate(size=block.size) + # Write to ROM + rom.to_block(block, arrangement_offset) + # Reconstruct table of arrangement pointers + self.arrangement_ptr_table[id] = [to_snes_address(arrangement_offset)] + + # Write table of arrangement pointers + arrangement_ptr_table_offset = rom.allocate(size=self.arrangement_ptr_table.size) + self.arrangement_ptr_table.to_block(rom, arrangement_ptr_table_offset) + # Relocate references to table of arrangement pointers + for reference in BATTLE_ANIMATION_ARRANGEMENT_PTRS_REFERENCES: + if reference.validate_structure(rom): + reference.write(rom, to_snes_address(arrangement_ptr_table_offset)) + else: + log.warning("Arrangement pointer table relocation at %#x failed structure check - skipping...", reference.offset) + + tilesets_compressed: list[EbCompressibleBlock] = [] + for tileset in self.tilesets: + with EbCompressibleBlock(tileset.block_size()) as compressed_block: + tileset.to_block(compressed_block) + compressed_block.compress() + tilesets_compressed.append(compressed_block) + compressed_total_size = sum(i.size for i in tilesets_compressed) + + current_tileset_offset = rom.allocate(size=compressed_total_size) + tileset_short_ptrs = [] + tilesets_bank_only = current_tileset_offset & 0xFF0000 + + # Write the compressed tilesets + for compressed in tilesets_compressed: + # compressed.to_block(rom, current_tileset_offset) + rom.to_block(compressed, current_tileset_offset) + tileset_short_ptrs.append(current_tileset_offset & 0xFFFF) + current_tileset_offset += compressed.size + # Repoint them + for reference in BATTLE_ANIMATION_TILESETS_REFERENCES: + if reference.validate_structure(rom): + reference.write(rom, to_snes_address(tilesets_bank_only)) + else: + log.warning("Tileset bank relocation at %#x failed structure check - skipping...", reference.offset) + + + # Write the config table. Fill in the final field: the short pointer to the tileset + self.battle_animation_table.recreate(len(self.battle_animations)) + for id, animation in enumerate(self.battle_animations): + # This is slower, but better than keeping an index into the self.tilesets list in the + # BattleAnimation object, because that keeps it more separate. + animation.tileset_pointer_short = tileset_short_ptrs[self.tilesets.index(animation.tileset)] + # Now fill in the fields + self.battle_animation_table[id] = [ + animation.tileset_pointer_short, + animation.frame_duration, + animation.palette_cycle_duration, + animation.palette_cycle_lower_index, + animation.palette_cycle_upper_index, + animation.frame_count, + animation.targetting, + animation.enemy_color_delay, + animation.enemy_color_duration, + animation.enemy_color + ] + battle_animation_table_offset = rom.allocate(size=self.battle_animation_table.size) + self.battle_animation_table.to_block(rom, battle_animation_table_offset) + # And repoint + for reference in BATTLE_ANIMATION_TABLE_REFERENCES: + if reference.validate_structure(rom): + reference.write(rom, to_snes_address(battle_animation_table_offset)) + else: + log.warning("Battle animation table relocation at %#x failed structure check - skipping...", reference.offset) def read_from_project(self, resource_open): - log.warning("Not implemented yet") - # TODO this + with resource_open("BattleAnimations/battle_animations", "yml", True) as f: + yml_rep = yml_load(f) + num_rows = len(yml_rep) + self.battle_animation_table.recreate(num_rows=num_rows) + self.battle_animation_table.from_yml_rep(yml_rep) + + # Recreate the palette and arrangement tables too + self.palette_table.recreate(num_rows=self.battle_animation_table.num_rows) + self.arrangement_ptr_table.recreate(num_rows=self.battle_animation_table.num_rows) + + for animation_id in range(self.battle_animation_table.num_rows): + try: + data = self.battle_animation_table[animation_id] + animation = BattleAnimation(*data) + self.battle_animations.append(animation) + # Frame count and short pointer to tileset still need to be filled in + # We'll fill in frame count here after we read the map files, + # and tileset pointer after we figure out where it's going in the ROM + + with resource_open("BattleAnimations/{:02d}/tileset".format(animation_id), "png") as tileset_f: + tileset_image = open_indexed_image(tileset_f) + + palette = EbPalette(1, 4) + palette.from_image(tileset_image) + animation.palette = palette + self.palette_table[animation_id] = [palette] + + # Dedup identical tilesets + known_tilesets = {} + tileset = EbGraphicTileset(256) + tileset.from_image(tileset_image, TILESET_IMAGE_ARRANGEMENT, palette) + if str(tileset.tiles) not in known_tilesets: + # Converting to a string so it's hashable and can be put in a dict. + # Probably stupid. Let me know if you have a better idea + known_tilesets[str(tileset.tiles)] = len(self.tilesets) + self.tilesets.append(tileset) + animation.tileset = self.tilesets[known_tilesets[str(tileset.tiles)]] + + with resource_open("BattleAnimations/{:02d}/arrangement".format(animation_id), "map", True) as map_f: + animation.arrangements_from_map(map_f) # Frame count is filled in now + # Will fill in the arrangement pointers when we actually have the arrangements in the ROM + + except Exception as e: + message = "Encountered an error while reading battle animation #{}.".format(animation_id) + raise CoilSnakeTraceableError(message, e) def write_to_project(self, resource_open): - # Common EbTileArrangement used to render the tileset to an image - tileset_arrangement = EbTileArrangement(16, 16) - tile_id_to_write = 0 - for tile_row in tileset_arrangement.arrangement: - for tile in tile_row: - tile.tile = tile_id_to_write - tile_id_to_write += 1 - - animation_data = {} for i, animation in enumerate(self.battle_animations): - # Organise yaml data - animation_data[i] = { - "Delay before enemy color change": animation.enemy_color_delay, - "Duration of enemy color change": animation.enemy_color_duration, - "Enemy color": animation.enemy_color.yml_rep(), # why doesn't this happen automatically? - # "Frame count": animation.frame_count, # Infer from map file - "Frame duration": animation.frame_duration, - # "Palette": animation.palette.yml_rep(), # Infer from tileset file - "Palette cycle duration": animation.palette_cycle_duration, - "Palette cycle lower index": animation.palette_cycle_lower_index, - "Palette cycle upper index": animation.palette_cycle_upper_index, - "Targetting": BattleAnimationTargetEnum.tostring(animation.targetting), - } - # Write arrangements (tilemaps) # This is called ".map" just like the overworld map but it is a little different. # - Tile indexes are 2-digit instead of 3-digit (we can only have 256 tiles) @@ -147,11 +326,11 @@ def write_to_project(self, resource_open): # Write tileset image with resource_open("BattleAnimations/{:02d}/tileset".format(i), "png") as f: - image = tileset_arrangement.image(animation.tileset, animation.palette, True) + image = TILESET_IMAGE_ARRANGEMENT.image(animation.tileset, animation.palette, True) image.save(f, "png") with resource_open("BattleAnimations/battle_animations", "yml", True) as f: - yml_dump(animation_data, f, default_flow_style=False) + self.battle_animation_table.to_yml_file(f) def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): if old_version < 14: From 35b7903e67aa05a72d40d0e446566dd7433d6014 Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:31:18 +0930 Subject: [PATCH 04/15] Fix tileset deduplication not working. Find pointers from code. Add heuristics for finding animation count. Attempt to optimise --- coilsnake/model/eb/graphics.py | 9 ++ coilsnake/modules/eb/BattleAnimationModule.py | 129 +++++++++++++----- coilsnake/util/eb/pointer.py | 22 ++- 3 files changed, 120 insertions(+), 40 deletions(-) diff --git a/coilsnake/model/eb/graphics.py b/coilsnake/model/eb/graphics.py index b709d0d8..9f7e5245 100644 --- a/coilsnake/model/eb/graphics.py +++ b/coilsnake/model/eb/graphics.py @@ -3,6 +3,8 @@ from PIL import Image +from zlib import crc32 + from coilsnake.exceptions.common.exceptions import InvalidArgumentError, OutOfBoundsError, InvalidUserDataError from coilsnake.model.eb.blocks import EbCompressibleBlock from coilsnake.model.eb.palettes import EbPalette, EbColor @@ -209,6 +211,13 @@ def __eq__(self, other): def __getitem__(self, key): return self.tiles[key] + def hash(self): + csum = 0 + for tile in self.tiles: + for row in tile: + csum = crc32(bytes(row), csum) & 0xffffffff + return csum + class EbTileArrangementItem(EqualityMixin, StringRepresentationMixin): def __init__(self, tile=0, subpalette=0, is_vertically_flipped=False, is_horizontally_flipped=False, diff --git a/coilsnake/modules/eb/BattleAnimationModule.py b/coilsnake/modules/eb/BattleAnimationModule.py index a7631e91..47e3ed2b 100644 --- a/coilsnake/modules/eb/BattleAnimationModule.py +++ b/coilsnake/modules/eb/BattleAnimationModule.py @@ -13,16 +13,13 @@ log = logging.getLogger(__name__) -BATTLE_ANIMATION_TARGET = ["one", "row", "all", "random"] - -BattleAnimationTargetEnum = enum_class_from_name_list(BATTLE_ANIMATION_TARGET) - BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS = 0xCCF04D BATTLE_ANIMATION_PALETTES_DEFAULT_ADDRESS = 0xCCF47F BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS = 0xCCF58F +BATTLE_ANIMATIONS_TILESET_BANK_DEFAULT_ADDRESS = 0xCC0000 BATTLE_ANIMATION_TABLE_REFERENCES = ( - AsmPointerReference(0x02E34f), + AsmPointerReference(0x02E34F), XlPointerReference(0x02E153), XlPointerReference(0x02E1BA), XlPointerReference(0x02E509), @@ -39,7 +36,6 @@ AsmPointerReference(0x02E1A6), ) -BATTLE_ANIMATIONS_BANK = 0xCC # Common EbTileArrangement used to render the tileset to an image TILESET_IMAGE_ARRANGEMENT = EbTileArrangement(16, 16) @@ -86,12 +82,11 @@ def arrangements_from_block(self, block, offset): next_offset += frame.block_size() def arrangements_to_block(self): - size = sum(frame.block_size() for frame in self.arrangements) - block = EbCompressibleBlock(size) + block = EbCompressibleBlock(32*32*len(self.arrangements)) next_offset = 0 for frame in self.arrangements: frame.to_block(block, next_offset) - next_offset += frame.block_size() + next_offset += 32*32 block.compress() return block @@ -99,25 +94,11 @@ def arrangements_from_map(self, map_file): self.arrangements = [] self.frame_count = 0 raw: str = map_file.read() - text_frames = raw.split("\n\n") + text_frames = raw.split("\n\n")[:-1] # Last split is the trailing gap - for text_frame in text_frames[:-1]: # Last split is the trailing gap + for text_frame in text_frames: arrangement = EbOneByteTileArrangement(32, 32) - - rows = text_frame.split("\n") - if len(rows) != 32: - raise CoilSnakeTraceableError("Frame #{}: Incorrect number of rows. Expected 32, got {}".format( - self.frame_count, len(rows))) - - for y, row in enumerate(rows): - if len(row) != 96: # 32 tiles of 3 chars each, 2 letters and a space - raise CoilSnakeTraceableError("Frame #{}: Incorrect number of columns. Expected 32, got {}".format( - self.frame_count, len(row) - )) - for x, tile in enumerate(row.split()): - arrangement.arrangement[y][x] = EbOneByteTileArrangementItem(int(tile, 16)) - arrangement.arrangement[y][x].check_validity() - + arrangement.arrangement = [[EbOneByteTileArrangementItem(int(x, 16)) for x in y.split()] for y in text_frame.split("\n")] self.arrangements.append(arrangement) self.frame_count += 1 @@ -140,16 +121,90 @@ def __init__(self): self.tilesets: list[EbGraphicTileset] = [] # List of tilesets, for deduplication purposes def read_from_rom(self, rom): + # Before this module was added, PSI animations were expanded manually + # and applied to a base ROM for future compilations. + # That means we need to read the code to find pointers to potentially relocated + # tables, and do some guesswork (fancy programmers call it "heuristics") to find the length. + config_ptr = BATTLE_ANIMATION_TABLE_REFERENCES[0].read(rom) + if not config_ptr: + log.warning("Code-read battle animation table reference starting at ${:06X} was invalid, defaulting to vanilla address".format(BATTLE_ANIMATION_TABLE_REFERENCES[0].offset)) + config_ptr = BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS + + palettes_ptr = BATTLE_ANIMATION_PALETTE_TABLE_REFERENCES[0].read(rom) + if not palettes_ptr: + log.warning("Code-read battle animation palettes reference starting at ${:06X} was invalid, defaulting to vanilla address".format(BATTLE_ANIMATION_PALETTE_TABLE_REFERENCES[0].offset)) + palettes_ptr = BATTLE_ANIMATION_PALETTES_DEFAULT_ADDRESS + + arrangements_ptrs_ptr = BATTLE_ANIMATION_ARRANGEMENT_PTRS_REFERENCES[0].read(rom) + if not arrangements_ptrs_ptr: + log.warning("Code-read battle animation arrangement pointer table reference starting at ${:06X} was invalid, defaulting to vanilla address".format(BATTLE_ANIMATION_ARRANGEMENT_PTRS_REFERENCES[0].offset)) + arrangements_ptrs_ptr = BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS + + tilesets_bank_ptr = BATTLE_ANIMATION_TILESETS_REFERENCES[0].read(rom) + if not tilesets_bank_ptr or tilesets_bank_ptr & 0xFFFF != 0: # Pointer should only include the bank byte + log.warning("Code-read battle animation tileset bank reference starting at ${:06X} was invalid, defaulting to vanilla address".format(BATTLE_ANIMATION_TILESETS_REFERENCES[0].offset)) + tilesets_bank_ptr = BATTLE_ANIMATIONS_TILESET_BANK_DEFAULT_ADDRESS + + log.info("Found battle animation pointers:\n Config table: ${:06X}\n Palettes: ${:06X}\n Arrangement ptrs: ${:06X}\n Tileset bank: ${:06X}".format( + config_ptr, palettes_ptr, arrangements_ptrs_ptr, tilesets_bank_ptr + )) + + # Now we need to guess the length of things. + guessed_length = 0 + while True: + # Keep going until any of these happen: + # - Arrangement ptr is invalid, null, or goes over bank boundary + # - Config table goes over bank boundary + # - Decompressed arrangement data is not a multiple of the frame size + # - Decompressed tileset length is incorrect + # Other things which could be added: + # - Check for data overlap + + # Check arrangement pointer table + arrangement_ptr_ptr = arrangements_ptrs_ptr + guessed_length*self.arrangement_ptr_table.schema.size + arrangement_ptr = rom.read_multi(from_snes_address(arrangement_ptr_ptr), 4) + if arrangement_ptr_ptr & 0xFF0000 != arrangements_ptrs_ptr & 0xFF0000: + # Bank cross + log.info("Battle animation count stopping at {} due to arrangement pointer table bank cross".format(guessed_length)) + break + if arrangement_ptr == 0 or arrangement_ptr & 0xFF000000: + # Null or upper byte is set which is invalid + log.info("Battle animation count stopping at {} due to invalid arrangement pointer ${:06X}".format(guessed_length, arrangement_ptr)) + break + + # Check config table + cfg_table_entry = config_ptr + guessed_length*self.battle_animation_table.schema.size + if cfg_table_entry & 0xFF0000 != config_ptr & 0xFF0000: + # Bank cross + log.info("Battle animation count stopping at {} due to config table bank cross".format(guessed_length)) + break + # Check decomp'd arrangement data is a multiple of the frame size + # Can't do framecount because Rockin G has 10 unused frames and the Switch Online ROM trims off a couple frames on Counter-PSI Unit + with EbCompressibleBlock() as arrangement_block: + arrangement_block.from_compressed_block(rom, from_snes_address(arrangement_ptr)) + if arrangement_block.size % 1024: # Sizeof a frame of decomp'd data + log.info("Battle animation count stopping at {} due to arrangement data not being a multiple of frame size".format(guessed_length)) + break + + guessed_length += 1 + + # Recreate the tables at the correct size + self.battle_animation_table.recreate(num_rows=guessed_length) + self.palette_table.recreate(num_rows=guessed_length) + self.arrangement_ptr_table.recreate(num_rows=guessed_length) + + # Actually dump the data self.battle_animation_table.from_block( - rom, offset=from_snes_address(BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS) + rom, offset=from_snes_address(config_ptr) ) self.palette_table.from_block( - rom, offset=from_snes_address(BATTLE_ANIMATION_PALETTES_DEFAULT_ADDRESS) + rom, offset=from_snes_address(palettes_ptr) ) self.arrangement_ptr_table.from_block( - rom, offset=from_snes_address(BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS) + rom, offset=from_snes_address(arrangements_ptrs_ptr) ) + # Translate to internal format known_tilesets = {} # Maps short addresses to indexes in self.tilesets for index in range(self.battle_animation_table.num_rows): @@ -161,7 +216,7 @@ def read_from_rom(self, rom): arrangement_ptr = from_snes_address(self.arrangement_ptr_table[index][0]) battle_animation.arrangements_from_block(rom, arrangement_ptr) - tileset_ptr = from_snes_address(battle_animation.tileset_pointer_short + (BATTLE_ANIMATIONS_BANK << 16)) + tileset_ptr = from_snes_address(battle_animation.tileset_pointer_short | tilesets_bank_ptr) # Keep track of tilesets we've already seen and skip duplicating them # Not as crucial here as it is when writing ... if not tileset_ptr in known_tilesets: @@ -272,6 +327,9 @@ def read_from_project(self, resource_open): self.palette_table.recreate(num_rows=self.battle_animation_table.num_rows) self.arrangement_ptr_table.recreate(num_rows=self.battle_animation_table.num_rows) + # For deduplication + known_tilesets = {} + for animation_id in range(self.battle_animation_table.num_rows): try: data = self.battle_animation_table[animation_id] @@ -290,15 +348,14 @@ def read_from_project(self, resource_open): self.palette_table[animation_id] = [palette] # Dedup identical tilesets - known_tilesets = {} + # TODO -- trim end of tilesets past the max used tile ID tileset = EbGraphicTileset(256) tileset.from_image(tileset_image, TILESET_IMAGE_ARRANGEMENT, palette) - if str(tileset.tiles) not in known_tilesets: - # Converting to a string so it's hashable and can be put in a dict. - # Probably stupid. Let me know if you have a better idea - known_tilesets[str(tileset.tiles)] = len(self.tilesets) + tileset_hash = tileset.hash() + if tileset_hash not in known_tilesets.keys(): + known_tilesets[tileset_hash] = len(self.tilesets) self.tilesets.append(tileset) - animation.tileset = self.tilesets[known_tilesets[str(tileset.tiles)]] + animation.tileset = self.tilesets[known_tilesets[tileset_hash]] with resource_open("BattleAnimations/{:02d}/arrangement".format(animation_id), "map", True) as map_f: animation.arrangements_from_map(map_f) # Frame count is filled in now diff --git a/coilsnake/util/eb/pointer.py b/coilsnake/util/eb/pointer.py index c0d11e8a..ed217da5 100644 --- a/coilsnake/util/eb/pointer.py +++ b/coilsnake/util/eb/pointer.py @@ -40,10 +40,10 @@ def write_xl_pointer(block, offset, pointer): class AsmPointerReference(object): POINTER_FORMAT = re.compile( - rb'''[\xa9\xa2\xa0].. # Match LDA_i / LDX_i / LDY_i - [\x85\x86\x84](.) # Match STA_d / STX_d / STY_d - [\xa9\xa2\xa0].. # Match LDA_i / LDX_i / LDY_i - [\x85\x86\x84](.) # Match STA_d / STX_d / STY_d + rb'''[\xa9\xa2\xa0].. # Match LDA_i / LDX_i / LDY_i + [\x85\x86\x84](.) # Match STA_d / STX_d / STY_d + [\xa9\xa2\xa0].\x00 # Match LDA_i / LDX_i / LDY_i with upper byte zero + [\x85\x86\x84](.) # Match STA_d / STX_d / STY_d ''', re.VERBOSE | re.DOTALL) def __init__(self, offset): @@ -67,6 +67,13 @@ def validate_structure(self, rom): def write(self, rom, address): log.info("Writing pointer at " + hex(self.offset)) write_asm_pointer(rom, self.offset, address) + + def read(self, rom): + # Get the address from the code at this point + if self.validate_structure(rom): + return (rom.read_multi(self.offset+6, 2) << 16) | rom.read_multi(self.offset+1, 2) + else: + return False class XlPointerReference(object): def __init__(self, offset): @@ -80,3 +87,10 @@ def validate_structure(self, rom): def write(self, rom, address): log.info("Writing xl pointer at " + hex(self.offset)) write_xl_pointer(rom, self.offset, address) + + def read(self, rom): + # Get the address from the code at this point + if self.validate_structure(rom): + return rom.read_multi(self.offset+1, 3) + else: + return False From aa9e36cb85202f3d80febe6259aa079d5e2ecc66 Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:31:43 +0930 Subject: [PATCH 05/15] Require old compiled ROM for project upgrade because expanded battle animations may need it --- coilsnake/modules/common/GenericModule.py | 2 +- coilsnake/modules/common/PatchModule.py | 2 +- coilsnake/modules/common/UsedRangeModule.py | 3 ++- coilsnake/modules/eb/AnimationModule.py | 2 +- coilsnake/modules/eb/BattleAnimationModule.py | 6 ++++-- coilsnake/modules/eb/CastModule.py | 2 +- .../modules/eb/CharacterSubstitutionsModule.py | 2 +- .../modules/eb/CompressedGraphicsModule.py | 6 +++--- coilsnake/modules/eb/DeathScreenModule.py | 4 ++-- coilsnake/modules/eb/EnemyModule.py | 6 +++--- coilsnake/modules/eb/FontModule.py | 8 ++++---- coilsnake/modules/eb/MapEventModule.py | 6 +++--- coilsnake/modules/eb/MapModule.py | 6 +++--- coilsnake/modules/eb/MiscTablesModule.py | 7 +++++-- coilsnake/modules/eb/MiscTextModule.py | 8 ++++---- coilsnake/modules/eb/MusicModule.py | 3 ++- coilsnake/modules/eb/SoundStoneModule.py | 2 +- coilsnake/modules/eb/SpriteGroupModule.py | 8 ++++---- coilsnake/modules/eb/StaffModule.py | 2 +- coilsnake/modules/eb/SwirlModule.py | 4 +++- coilsnake/modules/eb/TilesetModule.py | 6 +++--- coilsnake/modules/eb/TitleScreenModule.py | 4 ++-- coilsnake/modules/eb/TownMapIconModule.py | 8 ++++---- coilsnake/ui/cli.py | 2 ++ coilsnake/ui/common.py | 10 ++++++++-- coilsnake/ui/gui.py | 17 ++++++++++++----- 26 files changed, 80 insertions(+), 56 deletions(-) diff --git a/coilsnake/modules/common/GenericModule.py b/coilsnake/modules/common/GenericModule.py index 01f9d0c0..c28d292a 100644 --- a/coilsnake/modules/common/GenericModule.py +++ b/coilsnake/modules/common/GenericModule.py @@ -27,5 +27,5 @@ def read_from_project(self, resource_open): def write_to_project(self, resource_open): pass - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): pass diff --git a/coilsnake/modules/common/PatchModule.py b/coilsnake/modules/common/PatchModule.py index 60c9890f..084b9c33 100644 --- a/coilsnake/modules/common/PatchModule.py +++ b/coilsnake/modules/common/PatchModule.py @@ -84,7 +84,7 @@ def read_from_project(self, resource_open): with resource_open("patches", "yml", True) as f: self.patches = yml_load(f) - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version == 1: self.read_from_rom(rom) self.write_to_project(resource_open_w) diff --git a/coilsnake/modules/common/UsedRangeModule.py b/coilsnake/modules/common/UsedRangeModule.py index ee9129fc..38881cdd 100644 --- a/coilsnake/modules/common/UsedRangeModule.py +++ b/coilsnake/modules/common/UsedRangeModule.py @@ -53,7 +53,7 @@ def __init__(self): super(UsedRangeModule, self).__init__() self.ranges = [] - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): """ Upgrades a project's used ranges module to the latest version. """ @@ -66,6 +66,7 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour old_version + 1, new_version, rom, + old_compiled_rom, resource_open_r, resource_open_w, resource_delete) diff --git a/coilsnake/modules/eb/AnimationModule.py b/coilsnake/modules/eb/AnimationModule.py index 50b975cb..e6f942ee 100644 --- a/coilsnake/modules/eb/AnimationModule.py +++ b/coilsnake/modules/eb/AnimationModule.py @@ -188,7 +188,7 @@ def write_to_project(self, resource_open): with resource_open('Animations/animations', 'yml', True) as f: yml_dump(animation_data, f, default_flow_style=False) - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version < 10: self.read_from_rom(rom) self.write_to_project(resource_open_w) diff --git a/coilsnake/modules/eb/BattleAnimationModule.py b/coilsnake/modules/eb/BattleAnimationModule.py index 47e3ed2b..fab09c8b 100644 --- a/coilsnake/modules/eb/BattleAnimationModule.py +++ b/coilsnake/modules/eb/BattleAnimationModule.py @@ -389,7 +389,9 @@ def write_to_project(self, resource_open): with resource_open("BattleAnimations/battle_animations", "yml", True) as f: self.battle_animation_table.to_yml_file(f) - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version < 14: - self.read_from_rom(rom) + self.read_from_rom(old_compiled_rom) + # We use the old compiled ROM because, in CoilSnake projects prior to the addition of this module, + # typically battle animation repointing happens via CCScript at compile-time. self.write_to_project(resource_open_w) \ No newline at end of file diff --git a/coilsnake/modules/eb/CastModule.py b/coilsnake/modules/eb/CastModule.py index bb6c1292..bbdcbfad 100644 --- a/coilsnake/modules/eb/CastModule.py +++ b/coilsnake/modules/eb/CastModule.py @@ -270,7 +270,7 @@ def write_gfx_to_project(self, obj, resource_open): with resource_open(obj.path(), 'png') as image_file: image.save(image_file, 'png') - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version < 10: self.read_from_rom(rom) self.write_to_project(resource_open_w) diff --git a/coilsnake/modules/eb/CharacterSubstitutionsModule.py b/coilsnake/modules/eb/CharacterSubstitutionsModule.py index 8219e9b4..217f3a34 100644 --- a/coilsnake/modules/eb/CharacterSubstitutionsModule.py +++ b/coilsnake/modules/eb/CharacterSubstitutionsModule.py @@ -35,6 +35,6 @@ def write_to_project(self, resource_open): with resource_open(self.FILE, 'yml', True) as f: f.write(MODULE_COMMENT) - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version <= 7: self.write_to_project(resource_open_w) \ No newline at end of file diff --git a/coilsnake/modules/eb/CompressedGraphicsModule.py b/coilsnake/modules/eb/CompressedGraphicsModule.py index 0f170b56..b4928505 100644 --- a/coilsnake/modules/eb/CompressedGraphicsModule.py +++ b/coilsnake/modules/eb/CompressedGraphicsModule.py @@ -287,7 +287,7 @@ def write_gas_station_to_project(self, resource_open): with resource_open(GAS_STATION_INFO.name + "3", "png") as image_file: images[2].save(image_file, "png") - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version == new_version: return elif old_version <= 2: @@ -300,6 +300,6 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour self.read_gas_station_from_rom(rom) self.write_gas_station_to_project(resource_open_w) - self.upgrade_project(3, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(3, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) else: - self.upgrade_project(old_version + 1, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(old_version + 1, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) diff --git a/coilsnake/modules/eb/DeathScreenModule.py b/coilsnake/modules/eb/DeathScreenModule.py index 75d3f45b..ca50be49 100644 --- a/coilsnake/modules/eb/DeathScreenModule.py +++ b/coilsnake/modules/eb/DeathScreenModule.py @@ -138,8 +138,8 @@ def write_to_project(self, resource_open): yml_dump(subpalettes, f, None) def upgrade_project( - self, old_version, new_version, rom, resource_open_r, - resource_open_w, resource_delete): + self, old_version, new_version, rom, old_compiled_rom, + resource_open_r, resource_open_w, resource_delete): # version 1-8: no death screen support # version 9: Only supported Ness' death screen (DeathScreen.png) # version 10+: Supports Ness and Jeff (DeathScreen_*.png) diff --git a/coilsnake/modules/eb/EnemyModule.py b/coilsnake/modules/eb/EnemyModule.py index 8489714e..d4db7807 100644 --- a/coilsnake/modules/eb/EnemyModule.py +++ b/coilsnake/modules/eb/EnemyModule.py @@ -238,7 +238,7 @@ def read_from_project(self, resource_open): group = [EnemyGroupTableEntry.from_yml_rep(x) for x in enemy_group] self.enemy_groups.append(group) - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version == new_version: return elif old_version == 3: @@ -274,7 +274,7 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour 6: "64/128", 7: "128/128"}) self.upgrade_project( - old_version + 1, new_version, rom, resource_open_r, resource_open_w, resource_delete) + old_version + 1, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) else: self.upgrade_project( - old_version + 1, new_version, rom, resource_open_r, resource_open_w, resource_delete) + old_version + 1, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) diff --git a/coilsnake/modules/eb/FontModule.py b/coilsnake/modules/eb/FontModule.py index 80c845a9..5aa3be3f 100644 --- a/coilsnake/modules/eb/FontModule.py +++ b/coilsnake/modules/eb/FontModule.py @@ -101,7 +101,7 @@ def read_credits_font_from_project(self, resource_open): with resource_open("Fonts/credits", "png") as image_file: self.credits_font.from_files(image_file, "png") - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version == new_version: return elif old_version == 5: @@ -139,12 +139,12 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour with resource_open_w(widths_resource_name, "yml", True) as widths_file: yml_dump(widths_dict, widths_file, default_flow_style=False) - self.upgrade_project(6, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(6, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) elif old_version <= 2: # The credits font was a new feature in version 3 self.read_credits_font_from_rom(rom) self.write_credits_font_to_project(resource_open_w) - self.upgrade_project(3, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(3, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) else: - self.upgrade_project(old_version + 1, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(old_version + 1, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) diff --git a/coilsnake/modules/eb/MapEventModule.py b/coilsnake/modules/eb/MapEventModule.py index 55e67af5..37132a7e 100644 --- a/coilsnake/modules/eb/MapEventModule.py +++ b/coilsnake/modules/eb/MapEventModule.py @@ -54,7 +54,7 @@ def read_from_project(self, resource_open): with resource_open("map_changes", "yml", True) as f: self.pointer_table.from_yml_file(f) - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version == new_version: return elif old_version < 5: @@ -78,8 +78,8 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour convert_values_to_hex_repr_in_yml_file("map_changes", resource_open_r, resource_open_w, ["Event Flag"], default_flow_style=None) - self.upgrade_project(5, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(5, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) else: - self.upgrade_project(old_version + 1, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(old_version + 1, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) diff --git a/coilsnake/modules/eb/MapModule.py b/coilsnake/modules/eb/MapModule.py index cb181cc7..700e85b5 100644 --- a/coilsnake/modules/eb/MapModule.py +++ b/coilsnake/modules/eb/MapModule.py @@ -204,7 +204,7 @@ def read_from_project(self, resource_open): townmap_x, townmap_y] - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version == new_version: return elif old_version <= 2: @@ -226,6 +226,6 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour with resource_open_w("map_sectors", 'yml', True) as f: yaml.dump(data, f, Dumper=yaml.CSafeDumper, default_flow_style=False) - self.upgrade_project(3, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(3, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) else: - self.upgrade_project(old_version + 1, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(old_version + 1, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) diff --git a/coilsnake/modules/eb/MiscTablesModule.py b/coilsnake/modules/eb/MiscTablesModule.py index d922c229..cb5f1f21 100644 --- a/coilsnake/modules/eb/MiscTablesModule.py +++ b/coilsnake/modules/eb/MiscTablesModule.py @@ -56,7 +56,7 @@ def write_to_project(self, resource_open): with resource_open(table.name.lower(), "yml", True) as f: table.to_yml_file(f) - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version == new_version: return elif old_version == 3: @@ -100,6 +100,7 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour self.upgrade_project(old_version=old_version + 1, new_version=new_version, rom=rom, + old_compiled_rom=old_compiled_rom, resource_open_r=resource_open_r, resource_open_w=resource_open_w, resource_delete=resource_delete) @@ -127,6 +128,7 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour self.upgrade_project(old_version=old_version + 1, new_version=new_version, rom=rom, + old_compiled_rom=old_compiled_rom, resource_open_r=resource_open_r, resource_open_w=resource_open_w, resource_delete=resource_delete) @@ -149,8 +151,9 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour self.upgrade_project(old_version=old_version + 1, new_version=new_version, rom=rom, + old_compiled_rom=old_compiled_rom, resource_open_r=resource_open_r, resource_open_w=resource_open_w, resource_delete=resource_delete) else: - self.upgrade_project(old_version + 1, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(old_version + 1, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) diff --git a/coilsnake/modules/eb/MiscTextModule.py b/coilsnake/modules/eb/MiscTextModule.py index 11fc1399..343bf185 100644 --- a/coilsnake/modules/eb/MiscTextModule.py +++ b/coilsnake/modules/eb/MiscTextModule.py @@ -218,7 +218,7 @@ def write_to_project(self, resource_open): with resource_open("text_misc", "yml", True) as f: yml_dump(self.data, f, default_flow_style=False) - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version == new_version: return elif old_version == 4: @@ -229,10 +229,10 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour self.write_to_project(resource_open_w) - self.upgrade_project(old_version + 1, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(old_version + 1, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) elif old_version <= 2: self.read_from_rom(rom) self.write_to_project(resource_open_w) - self.upgrade_project(3, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(3, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) else: - self.upgrade_project(old_version + 1, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(old_version + 1, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) diff --git a/coilsnake/modules/eb/MusicModule.py b/coilsnake/modules/eb/MusicModule.py index 5c416edb..91813390 100644 --- a/coilsnake/modules/eb/MusicModule.py +++ b/coilsnake/modules/eb/MusicModule.py @@ -287,7 +287,7 @@ def get_patch(self, rom): ips.load(get_ips_filename(rom.type, 'gas_station_pack_fix'), 0) return ips - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version == new_version: return if old_version == 11: @@ -298,6 +298,7 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour 11 if old_version < 11 else old_version + 1, new_version, rom, + old_compiled_rom, resource_open_r, resource_open_w, resource_delete) diff --git a/coilsnake/modules/eb/SoundStoneModule.py b/coilsnake/modules/eb/SoundStoneModule.py index fc019c68..7165b8d8 100644 --- a/coilsnake/modules/eb/SoundStoneModule.py +++ b/coilsnake/modules/eb/SoundStoneModule.py @@ -80,7 +80,7 @@ def write_to_project(self, resource_open): with resource_open("Logos/SoundStone", "png") as image_file: image.save(image_file, "png") - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version < 8: self.read_from_rom(rom) self.write_to_project(resource_open_w) \ No newline at end of file diff --git a/coilsnake/modules/eb/SpriteGroupModule.py b/coilsnake/modules/eb/SpriteGroupModule.py index 5111b67d..d22cec76 100644 --- a/coilsnake/modules/eb/SpriteGroupModule.py +++ b/coilsnake/modules/eb/SpriteGroupModule.py @@ -120,7 +120,7 @@ def write_to_rom(self, rom): self.group_pointer_table.to_block(block=rom, offset=new_table_offset) self.palette_table.to_block(block=rom, offset=from_snes_address(PALETTE_TABLE_OFFSET)) - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version == new_version: return elif old_version == 4: @@ -136,7 +136,7 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour del entry["Collision Settings"] with resource_open_w("sprite_groups", "yml", True) as f: yml_dump(data, f) - self.upgrade_project(old_version + 1, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(old_version + 1, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) elif old_version == 2: replace_field_in_yml(resource_name="sprite_groups", resource_open_r=resource_open_r, @@ -149,6 +149,6 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour resource_open_w=resource_open_w, key="Unknown B", new_key="Collision Settings") - self.upgrade_project(old_version + 1, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(old_version + 1, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) else: - self.upgrade_project(old_version + 1, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(old_version + 1, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) diff --git a/coilsnake/modules/eb/StaffModule.py b/coilsnake/modules/eb/StaffModule.py index b5c2e567..dc838b6d 100644 --- a/coilsnake/modules/eb/StaffModule.py +++ b/coilsnake/modules/eb/StaffModule.py @@ -266,7 +266,7 @@ def write_to_project(self, resource_open): else: mode = self.print_char(f, byte, mode, invbig, invsmall) - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version < 10: self.read_from_rom(rom) self.write_to_project(resource_open_w) diff --git a/coilsnake/modules/eb/SwirlModule.py b/coilsnake/modules/eb/SwirlModule.py index 0dbbf4e7..5382b8c0 100644 --- a/coilsnake/modules/eb/SwirlModule.py +++ b/coilsnake/modules/eb/SwirlModule.py @@ -144,7 +144,7 @@ def write_to_project(self, resource_open): with resource_open("Swirls/swirls", "yml", True) as f: yml_dump(swirl_data, f, default_flow_style=False) - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version == new_version: return elif old_version <= 6: @@ -154,6 +154,7 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour 7, new_version, rom, + old_compiled_rom, resource_open_r, resource_open_w, resource_delete) @@ -162,6 +163,7 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour old_version + 1, new_version, rom, + old_compiled_rom, resource_open_r, resource_open_w, resource_delete) \ No newline at end of file diff --git a/coilsnake/modules/eb/TilesetModule.py b/coilsnake/modules/eb/TilesetModule.py index 5d7c0afd..f522b9c0 100644 --- a/coilsnake/modules/eb/TilesetModule.py +++ b/coilsnake/modules/eb/TilesetModule.py @@ -209,7 +209,7 @@ def read_from_project(self, resource_open): entry = yml_rep[map_tileset][palette_id] palette.settings_from_yml_rep(entry) - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version == new_version: return elif old_version <= 6: @@ -227,7 +227,7 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour self.write_map_palette_settings(yml_rep, resource_open_w) self.upgrade_project( - 7, new_version, rom, resource_open_r, resource_open_w, resource_delete) + 7, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) else: self.upgrade_project( - old_version + 1, new_version, rom, resource_open_r, resource_open_w, resource_delete) + old_version + 1, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) diff --git a/coilsnake/modules/eb/TitleScreenModule.py b/coilsnake/modules/eb/TitleScreenModule.py index bdd64bda..697aa53c 100644 --- a/coilsnake/modules/eb/TitleScreenModule.py +++ b/coilsnake/modules/eb/TitleScreenModule.py @@ -599,8 +599,8 @@ def write_chars_data_to_project(self, resource_open): yml_dump(chars_positions, f, False) def upgrade_project( - self, old_version, new_version, rom, resource_open_r, - resource_open_w, resource_delete): + self, old_version, new_version, rom, old_compiled_rom, + resource_open_r, resource_open_w, resource_delete): if old_version < 9: self.read_from_rom(rom) self.write_to_project(resource_open_w) diff --git a/coilsnake/modules/eb/TownMapIconModule.py b/coilsnake/modules/eb/TownMapIconModule.py index 8f03b027..2467a30e 100644 --- a/coilsnake/modules/eb/TownMapIconModule.py +++ b/coilsnake/modules/eb/TownMapIconModule.py @@ -37,7 +37,7 @@ def write_to_project(self, resource_open): with resource_open("TownMaps/icon_positions", "yml", True) as f: self.table.to_yml_file(f) - def upgrade_project(self, old_version, new_version, rom, resource_open_r, resource_open_w, resource_delete): + def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version == new_version: return elif old_version == 4: @@ -54,10 +54,10 @@ def upgrade_project(self, old_version, new_version, rom, resource_open_r, resour convert_values_to_hex_repr_in_yml_file("TownMaps/icon_positions", resource_open_r, resource_open_w, ["Event Flag"]) - self.upgrade_project(5, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(5, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) elif old_version <= 2: self.read_from_rom(rom) self.write_to_project(resource_open_w) - self.upgrade_project(new_version, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(new_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) else: - self.upgrade_project(old_version + 1, new_version, rom, resource_open_r, resource_open_w, resource_delete) + self.upgrade_project(old_version + 1, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete) diff --git a/coilsnake/ui/cli.py b/coilsnake/ui/cli.py index fea1659c..08cf5c9e 100755 --- a/coilsnake/ui/cli.py +++ b/coilsnake/ui/cli.py @@ -32,6 +32,7 @@ def main(): upgrade_parser = subparsers.add_parser("upgrade", help="upgrade a project which was created by an older version of CoilSnake") upgrade_parser.add_argument("base_rom") + upgrade_parser.add_argument("old_compiled_rom") upgrade_parser.add_argument("project_directory") upgrade_parser.set_defaults(func=_upgrade) @@ -98,6 +99,7 @@ def _decompile(args): def _upgrade(args): upgrade_project(base_rom_filename=args.base_rom, + old_compiled_rom_filename=args.old_compiled_rom, project_path=args.project_directory) def _scriptdump(args): diff --git a/coilsnake/ui/common.py b/coilsnake/ui/common.py index e7b78c56..f69c0f60 100644 --- a/coilsnake/ui/common.py +++ b/coilsnake/ui/common.py @@ -40,11 +40,13 @@ def setup_logging(quiet=False, verbose=False, stream=None): logging.root.addHandler(handler) -def upgrade_project(project_path, base_rom_filename, progress_bar=None): +def upgrade_project(project_path, base_rom_filename, old_compiled_rom_filename, progress_bar=None): if not os.path.isdir(project_path): raise RuntimeError("Project directory \"" + project_path + "\" is not a directory.") if not os.path.isfile(base_rom_filename): raise RuntimeError("Base Rom \"" + base_rom_filename + "\" is not a file.") + if not os.path.isfile(old_compiled_rom_filename): + raise RuntimeError("Old compiled Rom \"" + old_compiled_rom_filename + "\" is not a file.") modules = load_modules() @@ -67,6 +69,10 @@ def upgrade_project(project_path, base_rom_filename, progress_bar=None): rom = Rom() rom.from_file(base_rom_filename) check_if_types_match(project=project, rom=rom) + + old_rom = Rom() + old_rom.from_file(old_compiled_rom_filename) + check_if_types_match(project=project, rom=old_rom) compatible_modules = [(name, clazz) for name, clazz in modules if clazz.is_compatible_with_romtype(rom.type)] tick_amount = 1.0/len(compatible_modules) @@ -75,7 +81,7 @@ def upgrade_project(project_path, base_rom_filename, progress_bar=None): log.info("Upgrading {}...".format(module_class.NAME)) start_time = time.time() with module_class() as module: - module.upgrade_project(project.version, FORMAT_VERSION, rom, + module.upgrade_project(project.version, FORMAT_VERSION, rom, old_rom, lambda x, y, astext=False : project.get_resource(module_name, x, y, 'rt' if astext else 'rb', 'utf-8' if astext else None), lambda x, y, astext=False: project.get_resource(module_name, x, y, diff --git a/coilsnake/ui/gui.py b/coilsnake/ui/gui.py index 319c18b5..04612f8b 100644 --- a/coilsnake/ui/gui.py +++ b/coilsnake/ui/gui.py @@ -311,8 +311,9 @@ def _do_compile_help(self, project, base_rom, rom): self.progress_bar.clear() self.enable_all_components() - def do_upgrade(self, rom_entry, project_entry): + def do_upgrade(self, rom_entry, old_rom_entry, project_entry): rom = rom_entry.get() + old_rom = old_rom_entry.get() project = project_entry.get() if rom and project: @@ -331,12 +332,12 @@ def do_upgrade(self, rom_entry, project_entry): self.disable_all_components() self.progress_bar.clear() - thread = Thread(target=self._do_upgrade_help, args=(rom, project)) + thread = Thread(target=self._do_upgrade_help, args=(rom, old_rom, project)) thread.start() - def _do_upgrade_help(self, rom, project): + def _do_upgrade_help(self, rom, old_rom, project): try: - upgrade_project(project_path=project, base_rom_filename=rom, progress_bar=self.progress_bar) + upgrade_project(project_path=project, base_rom_filename=rom, old_compiled_rom_filename=old_rom, progress_bar=self.progress_bar) except Exception as inst: log.debug(format_exc()) log.error(inst) @@ -697,13 +698,15 @@ def create_upgrade_frame(self, notebook): frame=upgrade_frame) rom_entry = self.add_rom_fields_to_frame(name="Clean ROM", frame=upgrade_frame) + old_rom_entry = self.add_rom_fields_to_frame(name="Existing compiled ROM", frame=upgrade_frame) project_entry = self.add_project_fields_to_frame(name="Project", frame=upgrade_frame) def upgrade_tmp(): self.preferences["default upgrade rom"] = rom_entry.get() + self.preferences["default upgrade old rom"] = old_rom_entry.get() self.preferences["default upgrade project"] = project_entry.get() self.preferences.save() - self.do_upgrade(rom_entry, project_entry) + self.do_upgrade(rom_entry, old_rom_entry, project_entry) self.upgrade_button = Button(upgrade_frame, text="Upgrade", command=upgrade_tmp) self.upgrade_button.pack(fill=X, expand=1) @@ -712,6 +715,10 @@ def upgrade_tmp(): if self.preferences["default upgrade rom"]: set_entry_text(entry=rom_entry, text=self.preferences["default upgrade rom"]) + + if self.preferences["default upgrade old rom"]: + set_entry_text(entry=old_rom_entry, + text=self.preferences["default upgrade old rom"]) if self.preferences["default upgrade project"]: set_entry_text(entry=project_entry, From 3ac1f903c85a87d6179bba40ca80b8cce0485121 Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:46:17 +0930 Subject: [PATCH 06/15] Add error checking for too many frames. 64 is max; 65536 bytes uncompressed --- coilsnake/modules/eb/BattleAnimationModule.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/coilsnake/modules/eb/BattleAnimationModule.py b/coilsnake/modules/eb/BattleAnimationModule.py index fab09c8b..5690d644 100644 --- a/coilsnake/modules/eb/BattleAnimationModule.py +++ b/coilsnake/modules/eb/BattleAnimationModule.py @@ -101,6 +101,9 @@ def arrangements_from_map(self, map_file): arrangement.arrangement = [[EbOneByteTileArrangementItem(int(x, 16)) for x in y.split()] for y in text_frame.split("\n")] self.arrangements.append(arrangement) self.frame_count += 1 + + if self.frame_count > 64: + raise Exception("Frame count cannot exceed 64 frames.") class BattleAnimationModule(EbModule): From 67066c381c6f6f8f02eb9b12c53ea51401989e37 Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:31:35 +0930 Subject: [PATCH 07/15] Trim tilesets based on highest used tile ID --- coilsnake/modules/eb/BattleAnimationModule.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/coilsnake/modules/eb/BattleAnimationModule.py b/coilsnake/modules/eb/BattleAnimationModule.py index 5690d644..27db37ff 100644 --- a/coilsnake/modules/eb/BattleAnimationModule.py +++ b/coilsnake/modules/eb/BattleAnimationModule.py @@ -332,6 +332,7 @@ def read_from_project(self, resource_open): # For deduplication known_tilesets = {} + tilesets_max_ID_used = [] for animation_id in range(self.battle_animation_table.num_rows): try: @@ -358,16 +359,28 @@ def read_from_project(self, resource_open): if tileset_hash not in known_tilesets.keys(): known_tilesets[tileset_hash] = len(self.tilesets) self.tilesets.append(tileset) + tilesets_max_ID_used.append(0) # Populate later animation.tileset = self.tilesets[known_tilesets[tileset_hash]] with resource_open("BattleAnimations/{:02d}/arrangement".format(animation_id), "map", True) as map_f: animation.arrangements_from_map(map_f) # Frame count is filled in now # Will fill in the arrangement pointers when we actually have the arrangements in the ROM + # Find max tile ID used + tilesets_max_ID_used[known_tilesets[tileset_hash]] = max( + tilesets_max_ID_used[known_tilesets[tileset_hash]], + max(max(max(tile.tile for tile in row) for row in arrangement.arrangement) for arrangement in animation.arrangements) + ) + except Exception as e: message = "Encountered an error while reading battle animation #{}.".format(animation_id) raise CoilSnakeTraceableError(message, e) + # Trim tilesets past max ID + for index, tileset in enumerate(self.tilesets): + tileset.num_tiles_maximum = tilesets_max_ID_used[index]+1 + tileset.tiles = tileset.tiles[:tileset.num_tiles_maximum] + def write_to_project(self, resource_open): for i, animation in enumerate(self.battle_animations): # Write arrangements (tilemaps) From ca0b67256bc38b7d728213a1299598a9beffc1ca Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:31:53 +0930 Subject: [PATCH 08/15] Remove TODO notice --- coilsnake/modules/eb/BattleAnimationModule.py | 1 - 1 file changed, 1 deletion(-) diff --git a/coilsnake/modules/eb/BattleAnimationModule.py b/coilsnake/modules/eb/BattleAnimationModule.py index 27db37ff..5a8db518 100644 --- a/coilsnake/modules/eb/BattleAnimationModule.py +++ b/coilsnake/modules/eb/BattleAnimationModule.py @@ -352,7 +352,6 @@ def read_from_project(self, resource_open): self.palette_table[animation_id] = [palette] # Dedup identical tilesets - # TODO -- trim end of tilesets past the max used tile ID tileset = EbGraphicTileset(256) tileset.from_image(tileset_image, TILESET_IMAGE_ARRANGEMENT, palette) tileset_hash = tileset.hash() From 9606080107025c53cde63131e5d6f615cc4c9436 Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:34:14 +0930 Subject: [PATCH 09/15] Remove unused imports --- coilsnake/modules/eb/BattleAnimationModule.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coilsnake/modules/eb/BattleAnimationModule.py b/coilsnake/modules/eb/BattleAnimationModule.py index 5a8db518..d5a59e4f 100644 --- a/coilsnake/modules/eb/BattleAnimationModule.py +++ b/coilsnake/modules/eb/BattleAnimationModule.py @@ -6,8 +6,7 @@ from coilsnake.modules.eb.EbModule import EbModule from coilsnake.util.eb.pointer import from_snes_address, to_snes_address, AsmPointerReference, XlPointerReference from coilsnake.util.common.image import open_indexed_image -from coilsnake.util.common.yml import yml_dump, yml_load -from coilsnake.util.common.type import enum_class_from_name_list +from coilsnake.util.common.yml import yml_load import logging From d7bf8d5fceb13fc49f32aecea40774b98e3ea938 Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:31:29 +0930 Subject: [PATCH 10/15] Battle animation expansion patch --- .../ips/Earthbound/battle_animation_expand.ips | Bin 0 -> 53 bytes .../ips/Earthbound/battle_animation_expand.yml | 6 ++++++ coilsnake/modules/eb/BattleAnimationModule.py | 17 +++++++++++++++++ 3 files changed, 23 insertions(+) create mode 100644 coilsnake/assets/ips/Earthbound/battle_animation_expand.ips create mode 100644 coilsnake/assets/ips/Earthbound/battle_animation_expand.yml diff --git a/coilsnake/assets/ips/Earthbound/battle_animation_expand.ips b/coilsnake/assets/ips/Earthbound/battle_animation_expand.ips new file mode 100644 index 0000000000000000000000000000000000000000..8632aa78e0ef038002dc373e8d1ee6359fcf22fc GIT binary patch literal 53 zcmV-50LuSRK~zIX1Nn^r155z^1O5O2Aj$v#@EFM>0FVpGHUO{#Oq==X6aa++r2--r L;lfPE`bAGhB83$_ literal 0 HcmV?d00001 diff --git a/coilsnake/assets/ips/Earthbound/battle_animation_expand.yml b/coilsnake/assets/ips/Earthbound/battle_animation_expand.yml new file mode 100644 index 00000000..9080d3c4 --- /dev/null +++ b/coilsnake/assets/ips/Earthbound/battle_animation_expand.yml @@ -0,0 +1,6 @@ +Title: Modify battle animation functions to play expanded animations +Author: Jeffman (IPS format conversion by SupremeKirb) +Source: https://github.com/jeffman/EbPsiAnimationEditor/blob/master/additional-animations.md +Hidden: True +Header: 0x200 +Ranges: [] \ No newline at end of file diff --git a/coilsnake/modules/eb/BattleAnimationModule.py b/coilsnake/modules/eb/BattleAnimationModule.py index d5a59e4f..8800cc07 100644 --- a/coilsnake/modules/eb/BattleAnimationModule.py +++ b/coilsnake/modules/eb/BattleAnimationModule.py @@ -1,8 +1,10 @@ from coilsnake.exceptions.common.exceptions import CoilSnakeTraceableError +from coilsnake.model.common.ips import IpsPatch from coilsnake.model.eb.blocks import EbCompressibleBlock from coilsnake.model.eb.graphics import EbGraphicTileset, EbOneByteTileArrangement, EbTileArrangement, EbOneByteTileArrangementItem from coilsnake.model.eb.palettes import EbPalette from coilsnake.model.eb.table import eb_table_from_offset +from coilsnake.modules.common.PatchModule import get_ips_filename from coilsnake.modules.eb.EbModule import EbModule from coilsnake.util.eb.pointer import from_snes_address, to_snes_address, AsmPointerReference, XlPointerReference from coilsnake.util.common.image import open_indexed_image @@ -12,6 +14,8 @@ log = logging.getLogger(__name__) +DEFAULT_ANIMATION_COUNT = 34 + BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS = 0xCCF04D BATTLE_ANIMATION_PALETTES_DEFAULT_ADDRESS = 0xCCF47F BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS = 0xCCF58F @@ -234,6 +238,19 @@ def read_from_rom(self, rom): self.battle_animations.append(battle_animation) def write_to_rom(self, rom): + # If necessary, apply the battle animation expansion patch. + # The range this patch covers is not currently marked as free, so it's OK to use. + # But if that ever changes then this may break. + # (Ranges: 3F98D - 3F98F, 3FE00 - 3FE20) + if len(self.battle_animations) > DEFAULT_ANIMATION_COUNT: + # The patch causes battle animations #34 and up, when called via script (or the function at C3F981), + # to require being called starting at ID 55 (56 in CCScript) instead of ID 34. + # This is because battle animations and the HDMA-based enemy-attack animations share the same ID space. + log.info("Applying battle animation expansion patch") + patch = IpsPatch() + patch.load(get_ips_filename(rom.type, "battle_animation_expand")) + patch.apply(rom) + # Write palette table palette_table_offset = rom.allocate(size=self.palette_table.size) self.palette_table.to_block(rom, palette_table_offset) From 52a96d2b08b3562e2bc5570cbbb79a7e3025f70b Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:34:22 +0930 Subject: [PATCH 11/15] Couple of suggestions from JTolmar + a little tidyup --- coilsnake/modules/eb/BattleAnimationModule.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/coilsnake/modules/eb/BattleAnimationModule.py b/coilsnake/modules/eb/BattleAnimationModule.py index 8800cc07..158ce7f7 100644 --- a/coilsnake/modules/eb/BattleAnimationModule.py +++ b/coilsnake/modules/eb/BattleAnimationModule.py @@ -19,7 +19,7 @@ BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS = 0xCCF04D BATTLE_ANIMATION_PALETTES_DEFAULT_ADDRESS = 0xCCF47F BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS = 0xCCF58F -BATTLE_ANIMATIONS_TILESET_BANK_DEFAULT_ADDRESS = 0xCC0000 +BATTLE_ANIMATION_TILESET_BANK_DEFAULT_ADDRESS = 0xCC0000 BATTLE_ANIMATION_TABLE_REFERENCES = ( AsmPointerReference(0x02E34F), @@ -33,8 +33,8 @@ BATTLE_ANIMATION_ARRANGEMENT_PTRS_REFERENCES = ( AsmPointerReference(0x02E461), ) -BATTLE_ANIMATION_TILESETS_REFERENCES = ( - # Pointer to the bank which the compressed tilesets are in +BATTLE_ANIMATION_TILESET_BANK_REFERENCES = ( + # 32-bit pointer to the bank which the compressed tilesets are in; then the game adds the short tileset pointer after AsmPointerReference(0x02E13F), AsmPointerReference(0x02E1A6), ) @@ -146,10 +146,10 @@ def read_from_rom(self, rom): log.warning("Code-read battle animation arrangement pointer table reference starting at ${:06X} was invalid, defaulting to vanilla address".format(BATTLE_ANIMATION_ARRANGEMENT_PTRS_REFERENCES[0].offset)) arrangements_ptrs_ptr = BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS - tilesets_bank_ptr = BATTLE_ANIMATION_TILESETS_REFERENCES[0].read(rom) + tilesets_bank_ptr = BATTLE_ANIMATION_TILESET_BANK_REFERENCES[0].read(rom) if not tilesets_bank_ptr or tilesets_bank_ptr & 0xFFFF != 0: # Pointer should only include the bank byte - log.warning("Code-read battle animation tileset bank reference starting at ${:06X} was invalid, defaulting to vanilla address".format(BATTLE_ANIMATION_TILESETS_REFERENCES[0].offset)) - tilesets_bank_ptr = BATTLE_ANIMATIONS_TILESET_BANK_DEFAULT_ADDRESS + log.warning("Code-read battle animation tileset bank reference starting at ${:06X} was invalid, defaulting to vanilla address".format(BATTLE_ANIMATION_TILESET_BANK_REFERENCES[0].offset)) + tilesets_bank_ptr = BATTLE_ANIMATION_TILESET_BANK_DEFAULT_ADDRESS log.info("Found battle animation pointers:\n Config table: ${:06X}\n Palettes: ${:06X}\n Arrangement ptrs: ${:06X}\n Tileset bank: ${:06X}".format( config_ptr, palettes_ptr, arrangements_ptrs_ptr, tilesets_bank_ptr @@ -162,7 +162,6 @@ def read_from_rom(self, rom): # - Arrangement ptr is invalid, null, or goes over bank boundary # - Config table goes over bank boundary # - Decompressed arrangement data is not a multiple of the frame size - # - Decompressed tileset length is incorrect # Other things which could be added: # - Check for data overlap @@ -295,12 +294,11 @@ def write_to_rom(self, rom): # Write the compressed tilesets for compressed in tilesets_compressed: - # compressed.to_block(rom, current_tileset_offset) rom.to_block(compressed, current_tileset_offset) tileset_short_ptrs.append(current_tileset_offset & 0xFFFF) current_tileset_offset += compressed.size # Repoint them - for reference in BATTLE_ANIMATION_TILESETS_REFERENCES: + for reference in BATTLE_ANIMATION_TILESET_BANK_REFERENCES: if reference.validate_structure(rom): reference.write(rom, to_snes_address(tilesets_bank_only)) else: @@ -402,7 +400,6 @@ def write_to_project(self, resource_open): # This is called ".map" just like the overworld map but it is a little different. # - Tile indexes are 2-digit instead of 3-digit (we can only have 256 tiles) # - The data is arranged into a series of rectangles representing a frame each - # - Maybe we need a different file extension... with resource_open("BattleAnimations/{:02d}/arrangement".format(i), "map", True) as f: for frame in animation.arrangements: for row in range(frame.height): From c20da811bf6b6b48d09f6a95d29f20b7a57e495b Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:05:43 +0930 Subject: [PATCH 12/15] Make old compiled ROM when upgrading optional and a dialogue box explaining things --- coilsnake/ui/cli.py | 5 +++-- coilsnake/ui/gui.py | 35 +++++++++++++++++++++++++---------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/coilsnake/ui/cli.py b/coilsnake/ui/cli.py index 08cf5c9e..8b5d910d 100755 --- a/coilsnake/ui/cli.py +++ b/coilsnake/ui/cli.py @@ -32,8 +32,8 @@ def main(): upgrade_parser = subparsers.add_parser("upgrade", help="upgrade a project which was created by an older version of CoilSnake") upgrade_parser.add_argument("base_rom") - upgrade_parser.add_argument("old_compiled_rom") upgrade_parser.add_argument("project_directory") + upgrade_parser.add_argument("--existing_rom", "-e", required=False, help="a compiled ROM of this project, if you have one. Otherwise uses the base ROM") upgrade_parser.set_defaults(func=_upgrade) decomp_script_parser = subparsers.add_parser("scriptdump", help="Decompile a ROM's script to an already existing project.") @@ -98,8 +98,9 @@ def _decompile(args): def _upgrade(args): + existing_rom = args.existing_rom if args.existing_rom else args.base_rom upgrade_project(base_rom_filename=args.base_rom, - old_compiled_rom_filename=args.old_compiled_rom, + old_compiled_rom_filename=existing_rom, project_path=args.project_directory) def _scriptdump(args): diff --git a/coilsnake/ui/gui.py b/coilsnake/ui/gui.py index 04612f8b..766c6e59 100644 --- a/coilsnake/ui/gui.py +++ b/coilsnake/ui/gui.py @@ -25,7 +25,7 @@ patch_rom, create_patch from coilsnake.ui.gui_preferences import CoilSnakePreferences from coilsnake.ui.gui_util import browse_for_patch, browse_for_rom, browse_for_project, open_folder, set_entry_text, \ - find_system_java_exe + find_system_java_exe, ROM_FILETYPES from coilsnake.ui.information import coilsnake_about from coilsnake.ui.widgets import ThreadSafeConsole, CoilSnakeGuiProgressBar from coilsnake.util.common.project import PROJECT_FILENAME @@ -311,9 +311,8 @@ def _do_compile_help(self, project, base_rom, rom): self.progress_bar.clear() self.enable_all_components() - def do_upgrade(self, rom_entry, old_rom_entry, project_entry): + def do_upgrade(self, rom_entry, project_entry): rom = rom_entry.get() - old_rom = old_rom_entry.get() project = project_entry.get() if rom and project: @@ -324,6 +323,27 @@ def do_upgrade(self, rom_entry, old_rom_entry, project_entry): icon='warning') if confirm != "yes": return + + has_old = tkinter.messagebox.askyesnocancel("Do you have expanded battle animations?", + "Does this project have expanded battle animations via Jeffman's PSI Animation Editor?\n\n" + + "If it does, select \"Yes\" and then provide the ROM that was produced after compiling.\n" + + "After the upgrade, remove the relevant entries in used_ranges.yml and delete the CCScript file that repoints the data.\n\n" + + "If you don't have expanded animations, or don't mind losing them in the upgrade, select \"No\".") + if has_old is True: + old_rom = tkinter.filedialog.askopenfilename(parent=self.root, + initialdir=os.path.expanduser("~"), + title="Select existing compiled ROM", + filetypes=ROM_FILETYPES) + if not old_rom: + # User cancalled the file picker + return + elif has_old is False: + # Use the same as the base ROM if the user selects "No" + old_rom = rom + else: + # User selected "Cancel" when prompted for a modified base ROM + return + self.save_default_tab() @@ -698,15 +718,13 @@ def create_upgrade_frame(self, notebook): frame=upgrade_frame) rom_entry = self.add_rom_fields_to_frame(name="Clean ROM", frame=upgrade_frame) - old_rom_entry = self.add_rom_fields_to_frame(name="Existing compiled ROM", frame=upgrade_frame) project_entry = self.add_project_fields_to_frame(name="Project", frame=upgrade_frame) def upgrade_tmp(): self.preferences["default upgrade rom"] = rom_entry.get() - self.preferences["default upgrade old rom"] = old_rom_entry.get() self.preferences["default upgrade project"] = project_entry.get() self.preferences.save() - self.do_upgrade(rom_entry, old_rom_entry, project_entry) + self.do_upgrade(rom_entry, project_entry) self.upgrade_button = Button(upgrade_frame, text="Upgrade", command=upgrade_tmp) self.upgrade_button.pack(fill=X, expand=1) @@ -715,10 +733,7 @@ def upgrade_tmp(): if self.preferences["default upgrade rom"]: set_entry_text(entry=rom_entry, text=self.preferences["default upgrade rom"]) - - if self.preferences["default upgrade old rom"]: - set_entry_text(entry=old_rom_entry, - text=self.preferences["default upgrade old rom"]) + if self.preferences["default upgrade project"]: set_entry_text(entry=project_entry, From c830c4c8ae0f86ef797e5aa4366bd49348b9a902 Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:00:59 +0930 Subject: [PATCH 13/15] Change dumping format after discussion. Tilesets are no longer duplicated per-animation --- coilsnake/modules/eb/BattleAnimationModule.py | 72 +++++++++++-------- 1 file changed, 41 insertions(+), 31 deletions(-) diff --git a/coilsnake/modules/eb/BattleAnimationModule.py b/coilsnake/modules/eb/BattleAnimationModule.py index 158ce7f7..fcfe6921 100644 --- a/coilsnake/modules/eb/BattleAnimationModule.py +++ b/coilsnake/modules/eb/BattleAnimationModule.py @@ -8,7 +8,7 @@ from coilsnake.modules.eb.EbModule import EbModule from coilsnake.util.eb.pointer import from_snes_address, to_snes_address, AsmPointerReference, XlPointerReference from coilsnake.util.common.image import open_indexed_image -from coilsnake.util.common.yml import yml_load +from coilsnake.util.common.yml import yml_load, yml_dump import logging @@ -49,6 +49,10 @@ tile_id_to_write += 1 del tile_id_to_write +# Common EbPalette used to render the tileset to an image +# Grayscale +TILESET_IMAGE_PALETTE = EbPalette(1, 4, (0, 0, 0, 64, 64, 64, 128, 128, 128, 255, 255, 255)) + class BattleAnimation: def __init__(self, tileset_pointer_short, frame_duration, palette_cycle_duration, @@ -124,7 +128,7 @@ def __init__(self): self.arrangement_ptr_table = eb_table_from_offset(BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS) self.battle_animations: list[BattleAnimation] = [] - self.tilesets: list[EbGraphicTileset] = [] # List of tilesets, for deduplication purposes + self.tilesets: list[EbGraphicTileset] = [] def read_from_rom(self, rom): # Before this module was added, PSI animations were expanded manually @@ -344,8 +348,8 @@ def read_from_project(self, resource_open): self.palette_table.recreate(num_rows=self.battle_animation_table.num_rows) self.arrangement_ptr_table.recreate(num_rows=self.battle_animation_table.num_rows) - # For deduplication - known_tilesets = {} + # For trimming data + known_tilesets = {} # Key: ID from yaml | val: Index into self.tilesets tilesets_max_ID_used = [] for animation_id in range(self.battle_animation_table.num_rows): @@ -357,31 +361,31 @@ def read_from_project(self, resource_open): # We'll fill in frame count here after we read the map files, # and tileset pointer after we figure out where it's going in the ROM - with resource_open("BattleAnimations/{:02d}/tileset".format(animation_id), "png") as tileset_f: - tileset_image = open_indexed_image(tileset_f) - - palette = EbPalette(1, 4) - palette.from_image(tileset_image) - animation.palette = palette - self.palette_table[animation_id] = [palette] - - # Dedup identical tilesets - tileset = EbGraphicTileset(256) - tileset.from_image(tileset_image, TILESET_IMAGE_ARRANGEMENT, palette) - tileset_hash = tileset.hash() - if tileset_hash not in known_tilesets.keys(): - known_tilesets[tileset_hash] = len(self.tilesets) - self.tilesets.append(tileset) - tilesets_max_ID_used.append(0) # Populate later - animation.tileset = self.tilesets[known_tilesets[tileset_hash]] + # Get palette + animation.palette = EbPalette(1, 4) + animation.palette.from_yml_rep(yml_rep[animation_id]["Palette"]) + self.palette_table[animation_id] = [animation.palette] + + # Create tileset if this is the first time we've seen it. + # This is nicer than globbing and prevents unused tilesets from being compiled + tileset_id = int(yml_rep[animation_id]["Tileset"]) + if tileset_id not in known_tilesets: + with resource_open("BattleAnimations/Tilesets/{:02d}".format(tileset_id), "png") as tileset_f: + tileset_image = open_indexed_image(tileset_f) + tileset = EbGraphicTileset(256) + tileset.from_image(tileset_image, TILESET_IMAGE_ARRANGEMENT, TILESET_IMAGE_PALETTE) + known_tilesets[tileset_id] = len(self.tilesets) + self.tilesets.append(tileset) + tilesets_max_ID_used.append(0) # Populate later + animation.tileset = self.tilesets[known_tilesets[tileset_id]] - with resource_open("BattleAnimations/{:02d}/arrangement".format(animation_id), "map", True) as map_f: + with resource_open("BattleAnimations/Arrangements/{:02d}".format(animation_id), "map", True) as map_f: animation.arrangements_from_map(map_f) # Frame count is filled in now # Will fill in the arrangement pointers when we actually have the arrangements in the ROM # Find max tile ID used - tilesets_max_ID_used[known_tilesets[tileset_hash]] = max( - tilesets_max_ID_used[known_tilesets[tileset_hash]], + tilesets_max_ID_used[known_tilesets[tileset_id]] = max( + tilesets_max_ID_used[known_tilesets[tileset_id]], max(max(max(tile.tile for tile in row) for row in arrangement.arrangement) for arrangement in animation.arrangements) ) @@ -395,12 +399,17 @@ def read_from_project(self, resource_open): tileset.tiles = tileset.tiles[:tileset.num_tiles_maximum] def write_to_project(self, resource_open): + battle_animation_yml = self.battle_animation_table.to_yml_rep() for i, animation in enumerate(self.battle_animations): + # Add extra fields to the battle animation yml + battle_animation_yml[i]["Tileset"] = self.tilesets.index(animation.tileset) + battle_animation_yml[i]["Palette"] = animation.palette.yml_rep() + # Write arrangements (tilemaps) # This is called ".map" just like the overworld map but it is a little different. # - Tile indexes are 2-digit instead of 3-digit (we can only have 256 tiles) # - The data is arranged into a series of rectangles representing a frame each - with resource_open("BattleAnimations/{:02d}/arrangement".format(i), "map", True) as f: + with resource_open("BattleAnimations/Arrangements/{:02d}".format(i), "map", True) as f: for frame in animation.arrangements: for row in range(frame.height): for col in range(frame.width): @@ -408,14 +417,15 @@ def write_to_project(self, resource_open): f.write(" ") f.write("\n") f.write("\n") - - # Write tileset image - with resource_open("BattleAnimations/{:02d}/tileset".format(i), "png") as f: - image = TILESET_IMAGE_ARRANGEMENT.image(animation.tileset, animation.palette, True) - image.save(f, "png") with resource_open("BattleAnimations/battle_animations", "yml", True) as f: - self.battle_animation_table.to_yml_file(f) + yml_dump(battle_animation_yml, f, False) + + # Write tileset images + for i, tileset in enumerate(self.tilesets): + with resource_open("BattleAnimations/Tilesets/{:02d}".format(i), "png") as f: + image = TILESET_IMAGE_ARRANGEMENT.image(tileset, TILESET_IMAGE_PALETTE, True) + image.save(f, "png") def upgrade_project(self, old_version, new_version, rom, old_compiled_rom, resource_open_r, resource_open_w, resource_delete): if old_version < 14: From fad43e444897f437e1cd355bad4963f8eaf2d0bb Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:54:42 +0930 Subject: [PATCH 14/15] Fix grayscale palette and remove unused tileset hash functionality --- coilsnake/model/eb/graphics.py | 10 ---------- coilsnake/modules/eb/BattleAnimationModule.py | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/coilsnake/model/eb/graphics.py b/coilsnake/model/eb/graphics.py index 9f7e5245..210e2514 100644 --- a/coilsnake/model/eb/graphics.py +++ b/coilsnake/model/eb/graphics.py @@ -3,8 +3,6 @@ from PIL import Image -from zlib import crc32 - from coilsnake.exceptions.common.exceptions import InvalidArgumentError, OutOfBoundsError, InvalidUserDataError from coilsnake.model.eb.blocks import EbCompressibleBlock from coilsnake.model.eb.palettes import EbPalette, EbColor @@ -211,14 +209,6 @@ def __eq__(self, other): def __getitem__(self, key): return self.tiles[key] - def hash(self): - csum = 0 - for tile in self.tiles: - for row in tile: - csum = crc32(bytes(row), csum) & 0xffffffff - return csum - - class EbTileArrangementItem(EqualityMixin, StringRepresentationMixin): def __init__(self, tile=0, subpalette=0, is_vertically_flipped=False, is_horizontally_flipped=False, is_priority=False): diff --git a/coilsnake/modules/eb/BattleAnimationModule.py b/coilsnake/modules/eb/BattleAnimationModule.py index fcfe6921..9cf60346 100644 --- a/coilsnake/modules/eb/BattleAnimationModule.py +++ b/coilsnake/modules/eb/BattleAnimationModule.py @@ -51,7 +51,7 @@ # Common EbPalette used to render the tileset to an image # Grayscale -TILESET_IMAGE_PALETTE = EbPalette(1, 4, (0, 0, 0, 64, 64, 64, 128, 128, 128, 255, 255, 255)) +TILESET_IMAGE_PALETTE = EbPalette(1, 4, (0, 0, 0, 85, 85, 85, 170, 170, 170, 255, 255, 255)) class BattleAnimation: def __init__(self, From c609c0a5ae7d698550d32eb6b74cfe242687e908 Mon Sep 17 00:00:00 2001 From: Supremekirb <99102603+Supremekirb@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:33:34 +0930 Subject: [PATCH 15/15] More code review suggestions --- coilsnake/modules/eb/BattleAnimationModule.py | 74 +++++++++---------- coilsnake/util/eb/pointer.py | 4 +- 2 files changed, 36 insertions(+), 42 deletions(-) diff --git a/coilsnake/modules/eb/BattleAnimationModule.py b/coilsnake/modules/eb/BattleAnimationModule.py index 9cf60346..475295cd 100644 --- a/coilsnake/modules/eb/BattleAnimationModule.py +++ b/coilsnake/modules/eb/BattleAnimationModule.py @@ -1,4 +1,4 @@ -from coilsnake.exceptions.common.exceptions import CoilSnakeTraceableError +from coilsnake.exceptions.common.exceptions import CoilSnakeTraceableError, CoilSnakeUserError from coilsnake.model.common.ips import IpsPatch from coilsnake.model.eb.blocks import EbCompressibleBlock from coilsnake.model.eb.graphics import EbGraphicTileset, EbOneByteTileArrangement, EbTileArrangement, EbOneByteTileArrangementItem @@ -53,6 +53,14 @@ # Grayscale TILESET_IMAGE_PALETTE = EbPalette(1, 4, (0, 0, 0, 85, 85, 85, 170, 170, 170, 255, 255, 255)) +# Helper function for reading pointers from code +def read_pointer_ref_or_default(ref, rom, default_addr): + addr = ref.read(rom) + if not addr: + log.warning("Failed to validate code-read pointer reference starting at offset ${:06X}; defaulting to vanilla address ${:06X}".format(ref.offset, default_addr)) + return default_addr + return addr + class BattleAnimation: def __init__(self, tileset_pointer_short, frame_duration, palette_cycle_duration, @@ -101,11 +109,24 @@ def arrangements_from_map(self, map_file): self.arrangements = [] self.frame_count = 0 raw: str = map_file.read() - text_frames = raw.split("\n\n")[:-1] # Last split is the trailing gap + text_frames = raw.split("\n\n") + if text_frames and not text_frames[-1]: + # The last split is from the trailing \n\n gap + text_frames.pop() + if not text_frames: + raise CoilSnakeUserError("Battle animation has no frames") - for text_frame in text_frames: + for i, text_frame in enumerate(text_frames): arrangement = EbOneByteTileArrangement(32, 32) - arrangement.arrangement = [[EbOneByteTileArrangementItem(int(x, 16)) for x in y.split()] for y in text_frame.split("\n")] + rows = text_frame.split("\n") + if len(rows) != arrangement.height: + raise CoilSnakeUserError("Frame {} has {} rows of tiles (expected {})".format(i, len(rows), arrangement.height)) + for j, row in enumerate(rows): + tiles = row.split() + if len(tiles) != arrangement.width: + raise CoilSnakeUserError("Frame {} row {} has {} tiles (expected {})".format(i, j, len(tiles), arrangement.width)) + for k, tile in enumerate(tiles): + arrangement.arrangement[j][k] = EbOneByteTileArrangementItem(int(tile, 16)) self.arrangements.append(arrangement) self.frame_count += 1 @@ -118,7 +139,7 @@ class BattleAnimationModule(EbModule): NAME = "Battle Animations" # Animations config, arrangements, arrangement pointers, tilesets, and palettes - FREE_RANGES = [(0x0C2E19, 0x0CF617)] + FREE_RANGES = [(0x0C2E19, 0x0CF616)] def __init__(self): super(BattleAnimationModule, self).__init__() @@ -135,25 +156,10 @@ def read_from_rom(self, rom): # and applied to a base ROM for future compilations. # That means we need to read the code to find pointers to potentially relocated # tables, and do some guesswork (fancy programmers call it "heuristics") to find the length. - config_ptr = BATTLE_ANIMATION_TABLE_REFERENCES[0].read(rom) - if not config_ptr: - log.warning("Code-read battle animation table reference starting at ${:06X} was invalid, defaulting to vanilla address".format(BATTLE_ANIMATION_TABLE_REFERENCES[0].offset)) - config_ptr = BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS - - palettes_ptr = BATTLE_ANIMATION_PALETTE_TABLE_REFERENCES[0].read(rom) - if not palettes_ptr: - log.warning("Code-read battle animation palettes reference starting at ${:06X} was invalid, defaulting to vanilla address".format(BATTLE_ANIMATION_PALETTE_TABLE_REFERENCES[0].offset)) - palettes_ptr = BATTLE_ANIMATION_PALETTES_DEFAULT_ADDRESS - - arrangements_ptrs_ptr = BATTLE_ANIMATION_ARRANGEMENT_PTRS_REFERENCES[0].read(rom) - if not arrangements_ptrs_ptr: - log.warning("Code-read battle animation arrangement pointer table reference starting at ${:06X} was invalid, defaulting to vanilla address".format(BATTLE_ANIMATION_ARRANGEMENT_PTRS_REFERENCES[0].offset)) - arrangements_ptrs_ptr = BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS - - tilesets_bank_ptr = BATTLE_ANIMATION_TILESET_BANK_REFERENCES[0].read(rom) - if not tilesets_bank_ptr or tilesets_bank_ptr & 0xFFFF != 0: # Pointer should only include the bank byte - log.warning("Code-read battle animation tileset bank reference starting at ${:06X} was invalid, defaulting to vanilla address".format(BATTLE_ANIMATION_TILESET_BANK_REFERENCES[0].offset)) - tilesets_bank_ptr = BATTLE_ANIMATION_TILESET_BANK_DEFAULT_ADDRESS + config_ptr = read_pointer_ref_or_default(BATTLE_ANIMATION_TABLE_REFERENCES[0], rom, BATTLE_ANIMATION_TABLE_DEFAULT_ADDRESS) + palettes_ptr = read_pointer_ref_or_default(BATTLE_ANIMATION_PALETTE_TABLE_REFERENCES[0], rom, BATTLE_ANIMATION_PALETTES_DEFAULT_ADDRESS) + arrangements_ptrs_ptr = read_pointer_ref_or_default(BATTLE_ANIMATION_ARRANGEMENT_PTRS_REFERENCES[0], rom, BATTLE_ANIMATION_ARRANGEMENT_PTRS_DEFAULT_ADDRESS) + tilesets_bank_ptr = read_pointer_ref_or_default(BATTLE_ANIMATION_TILESET_BANK_REFERENCES[0], rom, BATTLE_ANIMATION_TILESET_BANK_DEFAULT_ADDRESS) log.info("Found battle animation pointers:\n Config table: ${:06X}\n Palettes: ${:06X}\n Arrangement ptrs: ${:06X}\n Tileset bank: ${:06X}".format( config_ptr, palettes_ptr, arrangements_ptrs_ptr, tilesets_bank_ptr @@ -228,7 +234,7 @@ def read_from_rom(self, rom): tileset_ptr = from_snes_address(battle_animation.tileset_pointer_short | tilesets_bank_ptr) # Keep track of tilesets we've already seen and skip duplicating them # Not as crucial here as it is when writing ... - if not tileset_ptr in known_tilesets: + if tileset_ptr not in known_tilesets: with EbCompressibleBlock() as compressed_block: tileset = EbGraphicTileset(256) compressed_block.from_compressed_block(rom, tileset_ptr) @@ -310,24 +316,10 @@ def write_to_rom(self, rom): # Write the config table. Fill in the final field: the short pointer to the tileset - self.battle_animation_table.recreate(len(self.battle_animations)) for id, animation in enumerate(self.battle_animations): # This is slower, but better than keeping an index into the self.tilesets list in the # BattleAnimation object, because that keeps it more separate. - animation.tileset_pointer_short = tileset_short_ptrs[self.tilesets.index(animation.tileset)] - # Now fill in the fields - self.battle_animation_table[id] = [ - animation.tileset_pointer_short, - animation.frame_duration, - animation.palette_cycle_duration, - animation.palette_cycle_lower_index, - animation.palette_cycle_upper_index, - animation.frame_count, - animation.targetting, - animation.enemy_color_delay, - animation.enemy_color_duration, - animation.enemy_color - ] + self.battle_animation_table[id][0] = tileset_short_ptrs[self.tilesets.index(animation.tileset)] battle_animation_table_offset = rom.allocate(size=self.battle_animation_table.size) self.battle_animation_table.to_block(rom, battle_animation_table_offset) # And repoint @@ -381,6 +373,8 @@ def read_from_project(self, resource_open): with resource_open("BattleAnimations/Arrangements/{:02d}".format(animation_id), "map", True) as map_f: animation.arrangements_from_map(map_f) # Frame count is filled in now + # Put frame count in the table also + self.battle_animation_table[animation_id][5] = animation.frame_count # Will fill in the arrangement pointers when we actually have the arrangements in the ROM # Find max tile ID used diff --git a/coilsnake/util/eb/pointer.py b/coilsnake/util/eb/pointer.py index ed217da5..b9f91b31 100644 --- a/coilsnake/util/eb/pointer.py +++ b/coilsnake/util/eb/pointer.py @@ -73,7 +73,7 @@ def read(self, rom): if self.validate_structure(rom): return (rom.read_multi(self.offset+6, 2) << 16) | rom.read_multi(self.offset+1, 2) else: - return False + return None class XlPointerReference(object): def __init__(self, offset): @@ -93,4 +93,4 @@ def read(self, rom): if self.validate_structure(rom): return rom.read_multi(self.offset+1, 3) else: - return False + return None