From 6287bcee056f0f56f4f148e33ad63048585ed54d Mon Sep 17 00:00:00 2001 From: Luke Hubmayer-Werner Date: Sun, 30 Jun 2024 21:39:02 +0930 Subject: [PATCH] [WIP] Laying some foundations for multi-ROM support --- .gitignore | 2 + includes/helpers.py | 7 +- includes/rom_serde.py | 17 +++-- tabcomp.py | 159 +++++++++++++++++++++++++----------------- 4 files changed, 114 insertions(+), 71 deletions(-) diff --git a/.gitignore b/.gitignore index 8fdf497..fb315f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ *.sfc +*.gba +__pycache__ diff --git a/includes/helpers.py b/includes/helpers.py index 59128d2..204d399 100644 --- a/includes/helpers.py +++ b/includes/helpers.py @@ -107,7 +107,7 @@ def unflatten_table(headers: list[str], entries: list): return entries # This could be an array of an array of an array of an... id0 = entries[0]['ID'] - if '.' not in id0 and ':' not in id0: + if isinstance(id0, int) or ('.' not in id0 and ':' not in id0): return entries # Treat this as a nested array table = {tuple(decode_nested_ids(entry['ID'])): entry for entry in entries} @@ -147,13 +147,16 @@ def dump_tsv(filename, table, id_column=True) -> None: file.write('\t'.join([str(entry[key]) for key in headers]) + '\n') -def load_tsv(filename) -> list: +def load_tsv(filename: str, unflatten: bool = True) -> list: with open(filename, 'r') as file: lines = file.read().rstrip().split('\n') if len(lines) < 2: return [] headers = lines[0].split('\t') + if not unflatten: + return [{key: try_int(value) for key, value in zip(headers, line.split('\t'))} for line in lines[1:]] + # Simple line-by-line unflatten entries = [] for line in lines[1:]: diff --git a/includes/rom_serde.py b/includes/rom_serde.py index e9b5d46..fca41c7 100644 --- a/includes/rom_serde.py +++ b/includes/rom_serde.py @@ -28,16 +28,21 @@ class ROMHandler: self.build(table, existing_data, out_buffer) -def load_ff5_snes_struct_definitions() -> dict: +def load_struct_definitions(*filenames) -> dict: existing_structs = get_base_structarraytypes() - parse_struct_definitions_from_tsv_filename('ChocolateBirdData/structs_SNES_stubs.tsv', existing_structs) - parse_struct_definitions_from_tsv_filename('ChocolateBirdData/5/structs/SNES_stubs.tsv', existing_structs) - parse_struct_definitions_from_tsv_filename('ChocolateBirdData/5/structs/SNES.tsv', existing_structs) - parse_struct_definitions_from_tsv_filename('ChocolateBirdData/5/structs/SNES_save.tsv', existing_structs) + for filename in filenames: + parse_struct_definitions_from_tsv_filename(filename, existing_structs) return existing_structs class FF5SNESHandler(ROMHandler): offset_key: str = 'SNES' - struct_definitions: dict = load_ff5_snes_struct_definitions() + struct_definitions: dict = load_struct_definitions('ChocolateBirdData/structs_SNES_stubs.tsv', 'ChocolateBirdData/5/structs/SNES_stubs.tsv', 'ChocolateBirdData/5/structs/SNES.tsv', 'ChocolateBirdData/5/structs/SNES_save.tsv') addresses: dict = {entry['Label']: entry for entry in load_tsv('ChocolateBirdData/5/addresses_SNES_PSX.tsv')} + + +class FF5GBAHandler(ROMHandler): + def __init__(self, region: str) -> None: + self.offset_key = region + struct_definitions: dict = load_struct_definitions('ChocolateBirdData/structs_SNES_stubs.tsv', 'ChocolateBirdData/5/structs/SNES_stubs.tsv', 'ChocolateBirdData/5/structs/GBA.tsv', 'ChocolateBirdData/5/structs/SNES_save.tsv') + addresses: dict = {entry['Label']: entry for entry in load_tsv('ChocolateBirdData/5/addresses_GBA.tsv')} diff --git a/tabcomp.py b/tabcomp.py index 63bafa0..2f5aade 100644 --- a/tabcomp.py +++ b/tabcomp.py @@ -1,71 +1,104 @@ from includes.helpers import load_tsv, dump_tsv -from includes.rom_serde import FF5SNESHandler +from includes.rom_serde import FF5SNESHandler, FF5GBAHandler +from argparse import ArgumentParser +from configparser import ConfigParser +from glob import glob +import re -if __name__ == '__main__': - from argparse import ArgumentParser - parser = ArgumentParser(description='The ROMhacking Table Compiler.') - parser.add_argument('action', choices=['extract', 'build']) - parser.add_argument('rom', help='The ROM to use as a basis for extracting data.') - parser.add_argument('project', help='The project folder to extract data to, or compile data from.') - parser.add_argument('tables', nargs='*', help='Specify which tables to extract or compile, separated by spaces. If left empty, nothing will be extracted, or all tables in a project will be compiled. See the labels in https://git.ufeff.net/birdulon/ChocolateBirdData/src/branch/master/5/addresses_SNES_PSX.tsv for a list of values which may be used, though bear in mind things such as graphics and maps are currently not supported in a sensible way.') + +parser = ArgumentParser(description='The ROMhacking Table Compiler.') +parser.add_argument('action', choices=['extract', 'build']) +parser.add_argument('rom', help='The ROM to use as a basis for extracting data.') +parser.add_argument('project', help='The project folder to extract data to, or compile data from.') +parser.add_argument('tables', nargs='*', help='Specify which tables to extract or compile, separated by spaces. If left empty, nothing will be extracted, or all tables in a project will be compiled. See the labels in https://git.ufeff.net/birdulon/ChocolateBirdData/src/branch/master/5/addresses_SNES_PSX.tsv for a list of values which may be used, though bear in mind things such as graphics and maps are currently not supported in a sensible way.') + + +known_roms = { + 'Final Fantasy V': { + 'SNES': { + 'any': {'filename': r'.*Final Fantasy [V5].*\.sfc', 'handler': FF5SNESHandler()}, + }, + 'GBA': { + 'U': {'filename': r'2564 - .*\.gba', 'handler': FF5GBAHandler('U')}, + 'E': {'filename': r'2727 - .*\.gba', 'handler': FF5GBAHandler('E')}, + }, + }, +} + + +def guess_rom(filename: str) -> dict: + for game, gd in known_roms.items(): + for platform, pd in gd.items(): + for region, rd in pd.items(): + if re.fullmatch(rd['filename'], filename): + return {'Game': game, 'Platform': platform, 'Region': region} + + +def main(): args = parser.parse_args() - if args.project: - project_folder = args.project.rstrip('/') + '/' - project_folder_len = len(project_folder) + if not args.rom: + print('No ROM specified!') + return + if not args.project: + return - from glob import glob - from configparser import ConfigParser - config = ConfigParser() - config['TabComp.Project'] = {'Game': 'Final Fantasy V', 'Platform': 'SNES', 'Region': 'any'} - try: - with open(f'{project_folder}project.ini', 'r') as configfile: - config.read_file(configfile) - except FileNotFoundError: - pass - with open(f'{project_folder}project.ini', 'w') as configfile: - config.write(configfile) + rom_id = guess_rom(args.rom) - def run(): - game = config['TabComp.Project']['Game'] - platform = config['TabComp.Project']['Platform'] - if game != 'Final Fantasy V' or platform != 'SNES': - print(f'Unsupported ROM for project - "{game}" on "{platform}"') + project_folder = args.project.rstrip('/') + '/' + project_folder_len = len(project_folder) + config = ConfigParser() + config['TabComp.Project'] = rom_id # {'Game': 'Final Fantasy V', 'Platform': 'SNES', 'Region': 'any'} + try: + with open(f'{project_folder}project.ini', 'r') as configfile: + config.read_file(configfile) + except FileNotFoundError: + pass + with open(f'{project_folder}project.ini', 'w') as configfile: + config.write(configfile) + + game = config['TabComp.Project']['Game'] + platform = config['TabComp.Project']['Platform'] + region = config['TabComp.Project']['Region'] + try: + handler = known_roms[game][platform][region]['handler'] + except IndexError: + print(f'Unsupported ROM for project - "{game}" on "{platform}" with region "{region}"') + return + + with open(args.rom, 'rb') as file: + rom_bytes = file.read() + in_buffer = bytearray(rom_bytes) + match args.action: + case 'extract': + if not args.tables: + print('Must specify tables to extract!') return - handler = FF5SNESHandler() - if not args.rom: - print('No ROM specified!') - return - with open(args.rom, 'rb') as file: - rom_bytes = file.read() - in_buffer = bytearray(rom_bytes) - match args.action: - case 'extract': - if not args.tables: - print('Must specify tables to extract!') - return - tables = [table for table in args.tables] - print(f'Attempting to extract tables {tables}') - for table in tables: - data = handler.extract(table, in_buffer) - dump_tsv(f'{project_folder}{table}.tsv', data) - print('Done extracting!') + tables = [table for table in args.tables] + print(f'Attempting to extract tables {tables}') + for table in tables: + data = handler.extract(table, in_buffer) + dump_tsv(f'{project_folder}{table}.tsv', data) + print('Done extracting!') - case 'build': - tables = [table for table in args.tables] - if not args.tables: - # Find all .tsv files in project folder - tables = [file[project_folder_len:-4] for file in glob(f'{project_folder}*.tsv')] - print(f'Attempting to build tables {tables}') - out_buffer = bytearray(rom_bytes) - for table in tables: - data = load_tsv(f'{project_folder}{table}.tsv') - handler.build_partial(table, data, in_buffer, out_buffer) - out_filename = f'{project_folder}rom.sfc' - with open(out_filename, 'wb') as file: - file.write(out_buffer) - print(f'Compiled to "{out_filename}", make your own .ips from this') - case _: - 'Invalid action!' - return - run() + case 'build': + tables = [table for table in args.tables] + if not args.tables: + # Find all .tsv files in project folder + tables = [file[project_folder_len:-4] for file in glob(f'{project_folder}*.tsv')] + print(f'Attempting to build tables {tables}') + out_buffer = bytearray(rom_bytes) + for table in tables: + data = load_tsv(f'{project_folder}{table}.tsv') + handler.build_partial(table, data, in_buffer, out_buffer) + out_filename = f'{project_folder}rom.sfc' + with open(out_filename, 'wb') as file: + file.write(out_buffer) + print(f'Compiled to "{out_filename}", make your own .ips from this') + case _: + 'Invalid action!' + return + + +if __name__ == '__main__': + main()