[WIP] Laying some foundations for multi-ROM support

This commit is contained in:
Luke Hubmayer-Werner 2024-06-30 21:39:02 +09:30
parent f0c76049bb
commit 6287bcee05
4 changed files with 114 additions and 71 deletions

2
.gitignore vendored
View File

@ -1 +1,3 @@
*.sfc *.sfc
*.gba
__pycache__

View File

@ -107,7 +107,7 @@ def unflatten_table(headers: list[str], entries: list):
return entries return entries
# This could be an array of an array of an array of an... # This could be an array of an array of an array of an...
id0 = entries[0]['ID'] 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 return entries
# Treat this as a nested array # Treat this as a nested array
table = {tuple(decode_nested_ids(entry['ID'])): entry for entry in entries} 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') 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: with open(filename, 'r') as file:
lines = file.read().rstrip().split('\n') lines = file.read().rstrip().split('\n')
if len(lines) < 2: if len(lines) < 2:
return [] return []
headers = lines[0].split('\t') 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 # Simple line-by-line unflatten
entries = [] entries = []
for line in lines[1:]: for line in lines[1:]:

View File

@ -28,16 +28,21 @@ class ROMHandler:
self.build(table, existing_data, out_buffer) 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() existing_structs = get_base_structarraytypes()
parse_struct_definitions_from_tsv_filename('ChocolateBirdData/structs_SNES_stubs.tsv', existing_structs) for filename in filenames:
parse_struct_definitions_from_tsv_filename('ChocolateBirdData/5/structs/SNES_stubs.tsv', existing_structs) parse_struct_definitions_from_tsv_filename(filename, 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)
return existing_structs return existing_structs
class FF5SNESHandler(ROMHandler): class FF5SNESHandler(ROMHandler):
offset_key: str = 'SNES' 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')} 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')}

View File

@ -1,71 +1,104 @@
from includes.helpers import load_tsv, dump_tsv 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 = ArgumentParser(description='The ROMhacking Table Compiler.') parser.add_argument('action', choices=['extract', 'build'])
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('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('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.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() args = parser.parse_args()
if args.project: if not args.rom:
project_folder = args.project.rstrip('/') + '/' print('No ROM specified!')
project_folder_len = len(project_folder) return
if not args.project:
return
from glob import glob rom_id = guess_rom(args.rom)
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)
def run(): project_folder = args.project.rstrip('/') + '/'
game = config['TabComp.Project']['Game'] project_folder_len = len(project_folder)
platform = config['TabComp.Project']['Platform'] config = ConfigParser()
if game != 'Final Fantasy V' or platform != 'SNES': config['TabComp.Project'] = rom_id # {'Game': 'Final Fantasy V', 'Platform': 'SNES', 'Region': 'any'}
print(f'Unsupported ROM for project - "{game}" on "{platform}"') 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 return
handler = FF5SNESHandler() tables = [table for table in args.tables]
if not args.rom: print(f'Attempting to extract tables {tables}')
print('No ROM specified!') for table in tables:
return data = handler.extract(table, in_buffer)
with open(args.rom, 'rb') as file: dump_tsv(f'{project_folder}{table}.tsv', data)
rom_bytes = file.read() print('Done extracting!')
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!')
case 'build': case 'build':
tables = [table for table in args.tables] tables = [table for table in args.tables]
if not args.tables: if not args.tables:
# Find all .tsv files in project folder # Find all .tsv files in project folder
tables = [file[project_folder_len:-4] for file in glob(f'{project_folder}*.tsv')] tables = [file[project_folder_len:-4] for file in glob(f'{project_folder}*.tsv')]
print(f'Attempting to build tables {tables}') print(f'Attempting to build tables {tables}')
out_buffer = bytearray(rom_bytes) out_buffer = bytearray(rom_bytes)
for table in tables: for table in tables:
data = load_tsv(f'{project_folder}{table}.tsv') data = load_tsv(f'{project_folder}{table}.tsv')
handler.build_partial(table, data, in_buffer, out_buffer) handler.build_partial(table, data, in_buffer, out_buffer)
out_filename = f'{project_folder}rom.sfc' out_filename = f'{project_folder}rom.sfc'
with open(out_filename, 'wb') as file: with open(out_filename, 'wb') as file:
file.write(out_buffer) file.write(out_buffer)
print(f'Compiled to "{out_filename}", make your own .ips from this') print(f'Compiled to "{out_filename}", make your own .ips from this')
case _: case _:
'Invalid action!' 'Invalid action!'
return return
run()
if __name__ == '__main__':
main()