Skip to content

Instantly share code, notes, and snippets.

@ssokolow
Last active March 11, 2021 19:22
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ssokolow/550c2e825a9073de39653314db2b8cb0 to your computer and use it in GitHub Desktop.
Save ssokolow/550c2e825a9073de39653314db2b8cb0 to your computer and use it in GitHub Desktop.
Rough prototype frontend for dumping cartridges with an INL Retro with minimal hassle
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Streamlined Wizard for dumping games with the INL Retro cartridge dumper"""
__author__ = "Stephan Sokolow (deitarion/SSokolow)"
__appname__ = "INL Retro Dumping Helper"
__version__ = "0.1"
__license__ = "MIT"
# pylint: disable=bad-builtin
import csv, hashlib, itertools, math, os, re, shlex, subprocess, sys
from xml.etree import ElementTree as ET
if sys.version_info.major < 3:
print("This script requires Python 3.x")
sys.exit(1)
# Get width of the terminal window on Linux. Fall back to assuming 80 columns.
try:
TERM_COLUMNS = int(os.environ.get('COLUMNS'))
except (TypeError, ValueError):
TERM_COLUMNS = 80
# Used for hashing
CHUNK_SIZE = 2**16
basedir = os.path.dirname(os.path.abspath(__file__))
# TODO: Make the info_db_path values at least somewhat configurable
# -- Helper Functions --
def col_menu(prompt, options, label_getter=str):
"""Code to generate an arbitrary columnar menu using provided choices
TODO: On Linux, this can be inelegantly wide and short. Add support for
a `min_height` option which restricts the maximum number of columns
to satisfy a constraint on the minimum number of rows.
"""
# Don't bother displaying a menu if there's only one choice
if len(options) == 1:
return options[0]
# Calculate the maximum length of an option name
labels = [label_getter(x) for x in options]
opt_width = max(len(x) for x in labels)
# Calculate how many columns will fit per row, and how wide they should be
col_width = len('00) ') + opt_width + len(' ')
col_count = TERM_COLUMNS // col_width
while True:
# Calculate how many rows there will be, given that number of columns
# FIXME: Less confusing variable names for this math here
row_len = math.ceil(len(labels) / col_count)
rows = math.ceil(len(labels) / row_len)
# Adjust for aesthetics (avoid very wide one- or two-row menus)
if col_count < 2 or row_len > 2:
break
else:
col_count -= 1
# Convert the list of names into a list of (number, name) tuples
enumerated = [pair for pair in enumerate(labels)]
# Break the list of pairs up into column-sized chunks
chunked = [enumerated[i:i + row_len]
for i in range(0, len(enumerated), row_len)]
# Use a matrix transpose (rotate the 2D array about its diagonal axis)
# to switch from row-major order ("1,2,3" runs along the first row) to
# column-major order ("1,2,3" runs down the first column).
#
# (missing cells will be filled with `None`)
rows = itertools.zip_longest(*chunked)
# Render the menu
print("")
for row in rows:
row_formatted = []
for pair in row:
if not pair:
continue
row_formatted.append(
"{:>2}) {:<{width}}".format(pair[0] + 1, pair[1],
width=opt_width))
print(' '.join(row_formatted))
# Prompt for input
return options[prompt_for(prompt,
convert=int,
test=lambda x: 1 <= x <= len(options)) - 1]
def hash_file(path, hasher=hashlib.sha1, seek=0):
"""Generate a hash for a potentially long file.
Accepts paths and file-like objects.
Digesting will obey CHUNK_SIZE to conserve memory.
"""
with open(path, 'rb') as fobj:
fhash = hasher()
fobj.seek(seek)
# Chunked digest generation (conserve memory)
for block in iter(lambda: fobj.read(CHUNK_SIZE), b''):
fhash.update(block)
return fhash.hexdigest()
def make_filename(title):
"""Generate a filename that's valid on Windows from a title"""
win32_special_names = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3',
'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3',
'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9']
# Replace disallowed characters with underscores
fname = re.sub(r'[\x00-\x1f<>:|?*"/\\]+', '_', title)
# Append an underscore to the name portion if it's a reserved name
base, ext = os.path.splitext(fname)
if base in win32_special_names:
fname = base + '_' + ext
return fname
def make_search_slug(substr):
"""Lazy attempt at fuzzy matching preprocessing"""
return re.sub(r"\W+", "", substr).lower()
def mappers_for(system, inlretro_dir):
"""Convenience wrapper for querying supported mappers
(Hides the actual mechanism from code elsewhere for easy improvement)
NOTE: This heuristic isn't *guaranteed* to keep working, but actually
extracting the list of valid named would require parsing Lua and,
at the moment, there's a 1-to-1 correlation between valid mapper names
and the names of the scripts which implement them.
"""
# system.lower() because Linux filesystems are case-sensitive and the
# scripts/... folders are lowercase but proper capitalizations like "NES"
# should be valid `system` values.
# TODO: Redesign so the script can inventory its dependencies on start
script_dir = os.path.join(inlretro_dir, 'scripts', system.lower())
scripts = [os.path.splitext(fname)[0] for fname in os.listdir(script_dir)]
scripts.sort()
# Omit the template for writing new mapper scripts
# It's neither a valid choice nor functional
if "blank" in scripts:
scripts.remove("blank")
return scripts
def prompt_for(prompt, convert=lambda x: x, test=lambda x: x, err_msg=None):
"""Convenience helper for validity-checking input prompts
After leading/trailing whitespace has been stripped, `convert(string)`
will be called to produce the value that will be returned.
It may raise TypeError or ValueError to trigger redisplay of the prompt.
`test(converted)` must return a truthy value for the prompt loop to exit
but, otherwise, has no effect on what gets returned.
"""
prompt += " "
choice, converted = None, None
while not choice:
try:
converted = convert(input('\n' + prompt).strip())
except (TypeError, ValueError):
pass
if converted is not None and test(converted):
return converted
elif err_msg:
print(err_msg)
def prompt_yn(prompt):
"""DRY wrapper for asking yes/no questions
Adds ` (y/n)` for you.
"""
return prompt_for(prompt + ' (y/n)',
lambda x: x.lower(), lambda x: x[:1] in 'yn')[0] == 'y'
# -- System Definitions --
class System(object):
"""Unified definition of behaviour for a dumpable system"""
name = None # Human-friendly name for the system
system_id = None # The name used for the scripts/ folder and -c argument
# The extensions for the ROM and save file, without leading period
rom_ext = None
save_ext = None # Optional. Leave on `None` to omit `-a` flag to inlretro
# Set these to false to skip prompting
auto_mapper = False
auto_size = False
# Set this to enable semi-automatic lookup of required metadata
info_db_path = None
info_source = None
unhashed_header_len = 0 # Length of header to skip before hashing ROM
def __init__(self, **kwargs):
"""Shorthand for setting member variables"""
for key, value in kwargs.items():
setattr(self, key, value)
if not self.rom_ext:
self.rom_ext = self.system_id
self.meta_db = None
def __str__(self):
return self.name
def parse_database(self): # pylint: disable=no-self-use
"""Override this to automate retrieval of mapper/size info"""
return None
def get_size(self, meta_entry=None): # pylint: disable=R0201,W0613
"""Override to customize prompting for size.
Return a set of arguments to be appended to inlretro's command line.
"""
# TODO: Research more stringent validity checks for non-NES sizes
return ['-k', prompt_for("Size of ROM to be dumped (in KiB):",
int, lambda x: x > 0,
"Value must be a positive integer.")]
def get_meta(self):
"""Prompt for the name and attempt to look up metadata from it"""
if self.info_db_path and not self.meta_db:
if os.path.exists(self.info_db_path):
self.meta_db = self.parse_database()
else:
print("\nCannot find %r to automatically query metadata" %
os.path.normpath(self.info_db_path))
if self.info_source:
print("You may download it from %s\n" % self.info_source)
if not self.meta_db:
return {'name': prompt_for("Name of cartridge to be dumped: ",
err_msg="Please type a name")}
while True:
slug = prompt_for("Please type part of the cartridge's name:",
make_search_slug, lambda x: x, "Please type a name")
matches = [x for x in self.meta_db
if slug in make_search_slug(x['name']) or
slug == make_search_slug(x.get('catalog', ''))]
if matches:
def formatter(x):
"""Callback for formatting col_menu entries"""
if 'catalog' in x:
return "{} ({})".format(x['name'], x['catalog'])
else:
return x['name']
matches.append({'name': '← Revise Search', 'go_back': True})
result = col_menu("Please select your cartridge:", matches,
formatter)
if not result.get('go_back'):
return result
else:
# TODO: Support falling back to all-manual details
print("No matches found")
def choose_mapper(self, meta, inlretro_dir):
"""Split out the logic for resolving any ambiguity in mapper choice"""
# If we get a single mapper from meta, use it.
# Otherwise, if not doing auto-detection, fallback to the list provided
# by the database and then to the list of supported mappers
mappers = meta.get('mappers', [])
if mappers and len(mappers) == 1:
return ['-m', meta['mappers'][0]]
elif not self.auto_mapper:
if mappers and len(mappers) > 1:
mapper_id = meta.get('mapper_id')
if mapper_id:
msg = "Select script to use for mapper %r:" % mapper_id
else:
msg = "Select script to use for this mapper:"
return ['-m', col_menu(msg, mappers)]
else:
return ['-m', col_menu("Select mapper used by this cart:",
mappers_for(self.system_id, inlretro_dir))]
return []
def dump_game(self, path_base, inlretro_path):
"""Dump a cartridge for the specified system.
`path_base` should be the path the ROM and, if applicable, the save
file should be written to, minus extension.
"""
args = []
meta = self.get_meta()
inlretro_dir, inlretro_cmd = os.path.split(inlretro_path)
if meta.get('size'):
args += ['-k', str(math.ceil(meta['size'] / 1024))]
elif not self.auto_size:
args += self.get_size(meta)
args += self.choose_mapper(meta, inlretro_dir)
rom_path = os.path.join(path_base,
make_filename('{}.{}'.format(meta['name'], self.rom_ext)))
cmd = [os.path.join(os.curdir, inlretro_cmd),
"-c", self.system_id,
"-s", os.path.join('scripts', 'inlretro2.lua'),
"-d", rom_path,
] + list(args)
if self.save_ext:
cmd.extend(["-a", make_filename('{}{}{}.{}'.format(path_base,
os.sep, meta['name'], self.save_ext))])
while True:
try:
print("\nRunning: " + ' '.join(shlex.quote(x) for x in cmd))
print("With working directory: {}\n".format(inlretro_dir))
subprocess.check_call(cmd, cwd=inlretro_dir)
except subprocess.CalledProcessError:
if prompt_yn("inlretro returned an error. Try again?"):
continue
else:
return
else:
if not os.path.exists(rom_path):
if prompt_yn("No ROM file was produced. Try again?"):
continue
else:
return
# TODO: Have an option to dump the save file multiple times and
# compare to catch corruption introduced in the dumping
# process.
retry = None
for hash_type in ('sha1', 'md5'):
if hash_type in meta and meta[hash_type]:
hash_got = self.hash_rom(rom_path, hash_type).lower()
hash_expected = meta[hash_type]
if isinstance(hash_expected, str):
hash_expected = [hash_expected]
if any(hash_got == x.lower() for x in hash_expected):
print("Success! Dump matches expected hash.")
return
else:
print("ROM doesn't match %s hash on file. (%s != %s)"
"" % (hash_type, hash_got, meta[hash_type]))
retry = prompt_yn("Try again?")
break
if retry is True:
continue
elif retry is False:
return
print("No good hash on file. Cannot check dump success.")
return
def hash_rom(self, path, hash_type):
return hash_file(path,
hasher=getattr(hashlib, hash_type),
seek=self.unhashed_header_len)
class GameboySystem(System):
"""Definition for dumping Gameboy cartridges"""
name = 'Gameboy'
system_id = 'gb'
info_db_path = os.path.join(basedir, os.pardir, 'docs',
'CartridgeList.csv')
info_source = (
"https://github.com/gbdev/awesome-gbdev/blob/master/CartridgeList.csv")
# TODO: Look into processing the data from
# https://gbhwdb.gekkio.fi/cartridges/ into a datfile with mapper
# information AND nice titles
# (See https://github.com/Gekkio/gb-hardware-db)
MAPPERS = {
'MBC1': ['mbc1'],
'ROM': ['romonly', 'romonly_paul'],
}
def parse_database(self):
meta_db = []
with open(self.info_db_path) as fobj:
for line in csv.reader(fobj.readlines()):
try:
mapper_id = line[0]
meta_db.append({
'mapper_id': mapper_id,
'mappers': self.MAPPERS.get(mapper_id, None),
'size': int(line[1]) * 1024,
'name': line[5],
})
except (TypeError, ValueError):
pass # TODO: Report failure somehow
return meta_db
class GbaSystem(System):
"""Definition for dumping Gameboy Advance cartridges"""
name = 'Gameboy Advance'
system_id = 'gba'
auto_mapper = True
# TODO: Try to find a source with Nintendo catalogue numbers
info_source = "https://datomatic.no-intro.org/?page=download"
info_db_path = os.path.join(basedir, os.pardir, 'docs',
'Nintendo - Game Boy Advance (20190815-231257).dat')
# FIXME: Don't depend on a specific version's filename
def parse_database(self):
meta_db = []
old_meta = None
tree = ET.parse(self.info_db_path)
for game in tree.iterfind('game'):
for rom in game.iterfind('.//rom'):
meta = {
'name': game.get('name'),
'md5': rom.get('md5'),
'sha1': rom.get('sha1'),
'size': int(rom.get('size')),
}
# Ensure no duplicate entries show up
if old_meta != meta:
meta_db.append(meta)
old_meta = meta
return meta_db
class N64System(System):
"""Definition for dumping Nintendo 64 cartridges"""
name = 'Nintendo 64'
system_id = 'n64'
auto_mapper = True
info_db_path = os.path.join(basedir, os.pardir, 'docs',
'Nintendo - Nintendo 64 - Dump Status (BigEndian) (2019-07-24).csv')
def parse_database(self):
meta_db = []
with open(self.info_db_path) as fobj:
for line in csv.reader(fobj.readlines()[2:], delimiter=';'):
if len(line) >= 3:
try:
meta_db.append({
'name': line[1],
'size': int(line[2]),
'md5': line[4],
'catalog': line[5],
'mappers': None,
})
except (TypeError, ValueError):
pass # TODO: Report failure somehow
return meta_db
class NesSystem(System):
"""Definition for dumping NES and Famicom cartridges"""
name = 'NES/Famicom'
system_id = 'nes'
# FIXME: Don't depend on a specific version's filename
info_db_path = os.path.join(basedir, os.pardir, 'docs',
'NesCarts (2017-08-21).xml')
info_source = 'http://bootgod.dyndns.org:7777/xml.php'
# Thanks to https://forums.nesdev.com/viewtopic.php?f=2&t=9425
# for pointing out why the hashes weren't matching
unhashed_header_len = 16
MAPPERS = {
0: ['nrom'], # Based on a "Popeye no Eigo Asobi" comment in nrom.lua
1: ['mmc1'],
# unrom_tsop is excluded from the results for mapper 2 because I don't
# want to inconvenience and potentially confuse 99.999%+ use cases
# with the off-chance that somebody is trying to dump an unlicensed
# clone of a legit cart and would benefit from datfile-based detection
2: ['unrom'],
3: ['cnrom'],
4: ['mmc3'],
5: ['mmc5'],
9: ['mmc9'],
10: ['mmc4'],
11: ['cdream'],
28: ['action53', 'action53_tsop'],
30: ['mapper30', 'mapper30v2'], # TODO: Check that this is what I want
# I *think* easyNSF is #31: wiki.nesdev.com/w/index.php/INES_Mapper_031
34: ['bnrom'],
69: ['fme7'],
111: ['gtrom'],
# TODO: Figure out what the cninja and dualport mappers are for.
}
def parse_database(self):
meta_db = []
tree = ET.parse(self.info_db_path)
old_meta = None
for game in tree.iterfind('game'):
seen_hashes = {
'crc': [],
'sha1': [],
}
for cartridge in game.iterfind('cartridge'):
for hash_type in ('crc', 'sha1'):
hash_str = cartridge.get(hash_type, None)
if hash_str and hash_str not in seen_hashes[hash_type]:
seen_hashes[hash_type].append(hash_str)
for board in game.iterfind('.//board'):
meta = {
'name': game.get('name'),
'catalog': game.get('catalog'),
'mappers': None,
'prg': 0,
'chr': 0,
'wram': None,
}
meta['mapper_id'] = board.get('mapper')
try:
m_type = int(meta['mapper_id'])
except (TypeError, ValueError):
m_type = None
if m_type in self.MAPPERS:
meta['mappers'] = self.MAPPERS[m_type]
for ctype in ('prg', 'chr', 'wram'):
for rom in board.iterfind('.//' + ctype):
rom_size = rom.get('size', '')
assert re.match(r'\d+k', rom_size)
meta[ctype] = int(rom_size[:-1])
# Skip duplicate entries show up
if old_meta != meta:
tmp_meta = {}
tmp_meta.update(meta)
tmp_meta.update(seen_hashes)
meta_db.append(tmp_meta)
old_meta = meta
return meta_db
def get_size(self, meta_entry=None):
args, meta_entry = [], meta_entry or {}
for key, arg in (('prg', '-x'), ('chr', '-y')):
value = meta_entry.get(key)
if not value:
value = prompt_for(
"Enter %s ROM size in KiB (0 for none):" % key.upper(),
int, lambda x: x == 0 or (x >= 8 and x % 4 == 0),
"ROM size must be at least 8 and a multiple of 4.")
if value:
args.extend([arg, str(value)])
if 'wram' in meta_entry:
has_save = meta_entry.get('wram')
else:
has_save = prompt_yn(
"Does this game have battery-backed saves (WRAM)?")
if has_save:
args.extend(['-w', '8'])
self.save_ext = 'sav'
else:
self.save_ext = None
return args
SYSTEMS = [
NesSystem(),
System(name='SNES/Super Famicom',
system_id='snes', rom_ext='sfc', save_ext='srm',
auto_mapper=True, auto_size=True),
N64System(),
GameboySystem(),
GbaSystem(),
System(name='Sega Genesis/Mega Drive', system_id='genesis', rom_ext='bin',
auto_mapper=True, auto_size=True),
]
# -- Code Here --
def main():
"""The main entry point, compatible with setuptools entry points."""
default_inlretro_path = os.path.join(basedir, 'inlretro')
if os.name == 'nt':
default_inlretro_path += '.exe'
from argparse import ArgumentParser, RawDescriptionHelpFormatter
parser = ArgumentParser(formatter_class=RawDescriptionHelpFormatter,
description=__doc__.replace('\r\n', '\n').split('\n--snip--\n')[0])
parser.add_argument('--version', action='version',
version="%%(prog)s v%s" % __version__)
parser.add_argument('--inlretro-path', action='store', metavar="PATH",
default=os.path.relpath(default_inlretro_path),
help="Path to the `inlretro` binary (default: %(default)s)")
parser.add_argument('output_dir', nargs='?',
default=os.path.relpath(os.path.join(basedir, 'ignore')),
help="Directory to write dumps to (default: %(default)s)")
args = parser.parse_args()
if not os.path.exists(args.inlretro_path):
print("Could not find %r. Exiting." % args.inlretro_path)
sys.exit(1)
if not os.path.exists(args.output_dir):
os.makedirs(args.output_dir)
while True:
print(__appname__)
system = col_menu("Select cartridge type:", SYSTEMS)
system.dump_game(args.output_dir, os.path.abspath(args.inlretro_path))
if not prompt_yn("Dump another?"):
break
if __name__ == '__main__':
main()
# vim: set sw=4 sts=4 expandtab :
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment