2023-06-07 21:39:02 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
2024-05-02 09:06:11 +02:00
|
|
|
__version__ = "0.4.2"
|
2023-06-07 21:39:02 +02:00
|
|
|
__author__ = "spicyjpeg"
|
|
|
|
|
|
|
|
import json, logging, os, re
|
2023-09-23 10:13:09 +02:00
|
|
|
from argparse import ArgumentParser, FileType, Namespace
|
2023-06-07 21:39:02 +02:00
|
|
|
from collections import Counter, defaultdict
|
|
|
|
from pathlib import Path
|
|
|
|
from struct import Struct
|
2024-04-20 07:36:39 +02:00
|
|
|
from typing import Any, Mapping, Sequence, TextIO
|
2023-06-07 21:39:02 +02:00
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
from common.cart import CartDump, DumpFlag
|
|
|
|
from common.cartdata import *
|
|
|
|
from common.games import GameDB, GameDBEntry
|
2024-05-02 09:06:11 +02:00
|
|
|
from common.util import setupLogger
|
2023-06-07 21:39:02 +02:00
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
## MAME NVRAM file parser
|
2023-06-07 21:39:02 +02:00
|
|
|
|
|
|
|
_MAME_X76F041_STRUCT: Struct = Struct("< 4x 8s 8s 8s 8s 512s")
|
|
|
|
_MAME_X76F100_STRUCT: Struct = Struct("< 4x 8s 8s 112s")
|
|
|
|
_MAME_ZS01_STRUCT: Struct = Struct("< 4x 8s 8s 8s 112s")
|
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
def parseMAMEDump(dump: bytes) -> CartDump:
|
2023-06-07 21:39:02 +02:00
|
|
|
systemID: bytes = bytes(8)
|
|
|
|
cartID: bytes = bytes(8)
|
|
|
|
zsID: bytes = bytes(8)
|
|
|
|
config: bytes = bytes(8)
|
|
|
|
|
|
|
|
flags: DumpFlag = \
|
|
|
|
DumpFlag.DUMP_PUBLIC_DATA_OK | DumpFlag.DUMP_PRIVATE_DATA_OK
|
|
|
|
|
|
|
|
match int.from_bytes(dump[0:4], "big"):
|
|
|
|
case 0x1955aa55:
|
|
|
|
chipType: ChipType = ChipType.X76F041
|
|
|
|
_, _, dataKey, config, data = _MAME_X76F041_STRUCT.unpack(dump)
|
|
|
|
|
|
|
|
flags |= DumpFlag.DUMP_CONFIG_OK
|
|
|
|
|
|
|
|
case 0x1900aa55:
|
|
|
|
chipType: ChipType = ChipType.X76F100
|
|
|
|
dataKey, readKey, data = _MAME_X76F100_STRUCT.unpack(dump)
|
|
|
|
|
|
|
|
if dataKey != readKey:
|
2024-04-20 07:36:39 +02:00
|
|
|
raise RuntimeError(
|
|
|
|
chipType,
|
|
|
|
"X76F100 dumps with different read/write keys are not "
|
|
|
|
"supported"
|
|
|
|
)
|
2023-06-07 21:39:02 +02:00
|
|
|
|
|
|
|
case 0x5a530001:
|
|
|
|
chipType: ChipType = ChipType.ZS01
|
|
|
|
_, dataKey, config, data = _MAME_ZS01_STRUCT.unpack(dump)
|
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
#zsID = _MAME_ZS_ID
|
2023-06-07 21:39:02 +02:00
|
|
|
flags |= DumpFlag.DUMP_CONFIG_OK | DumpFlag.DUMP_ZS_ID_OK
|
|
|
|
|
|
|
|
case _id:
|
2024-04-20 07:36:39 +02:00
|
|
|
raise RuntimeError(
|
|
|
|
ChipType.NONE, f"unrecognized chip ID: 0x{_id:08x}"
|
|
|
|
)
|
2023-06-07 21:39:02 +02:00
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
#if data.find(_MAME_CART_ID) >= 0:
|
|
|
|
#cartID = _MAME_CART_ID
|
2023-06-07 21:39:02 +02:00
|
|
|
#flags |= DumpFlag.DUMP_HAS_CART_ID | DumpFlag.DUMP_CART_ID_OK
|
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
#if data.find(_MAME_SYSTEM_ID) >= 0:
|
|
|
|
#systemID = _MAME_SYSTEM_ID
|
2023-06-07 21:39:02 +02:00
|
|
|
#flags |= DumpFlag.DUMP_HAS_SYSTEM_ID | DumpFlag.DUMP_SYSTEM_ID_OK
|
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
return CartDump(
|
|
|
|
chipType, flags, systemID, cartID, zsID, dataKey, config, data
|
2023-06-07 21:39:02 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
## Dump processing
|
|
|
|
|
|
|
|
def processDump(
|
2024-04-20 07:36:39 +02:00
|
|
|
dump: CartDump, gameDB: GameDB, nameHints: Sequence[str] = [],
|
|
|
|
exportFile: TextIO | None = None
|
|
|
|
) -> CartDBEntry:
|
|
|
|
parser: CartParser = newCartParser(dump)
|
2023-06-07 21:39:02 +02:00
|
|
|
|
|
|
|
# If the parser could not find a valid game code in the dump, attempt to
|
2024-04-20 07:36:39 +02:00
|
|
|
# parse it from the provided hints.
|
2023-06-07 21:39:02 +02:00
|
|
|
if parser.region is None:
|
|
|
|
raise RuntimeError("can't parse game region from dump")
|
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
if parser.code is None:
|
|
|
|
for hint in nameHints:
|
|
|
|
code: re.Match | None = GAME_CODE_REGEX.search(
|
|
|
|
hint.upper().encode("ascii")
|
|
|
|
)
|
|
|
|
|
|
|
|
if code is not None:
|
|
|
|
parser.code = code.group().decode("ascii")
|
|
|
|
break
|
|
|
|
|
|
|
|
if parser.code is None:
|
|
|
|
raise RuntimeError(
|
|
|
|
"can't parse game code from dump nor from filename"
|
|
|
|
)
|
|
|
|
|
|
|
|
matches: list[GameDBEntry] = sorted(
|
|
|
|
gameDB.lookupByCode(parser.code, parser.region)
|
|
|
|
)
|
2023-06-07 21:39:02 +02:00
|
|
|
|
2023-09-23 10:13:09 +02:00
|
|
|
if exportFile:
|
|
|
|
_, flags = str(parser.flags).split(".", 1)
|
2023-09-23 18:37:55 +02:00
|
|
|
matchList: str = " ".join(
|
|
|
|
(game.mameID or f"[{game}]") for game in matches
|
|
|
|
)
|
2023-09-23 10:13:09 +02:00
|
|
|
|
|
|
|
exportFile.write(
|
2024-04-20 07:36:39 +02:00
|
|
|
f"{dump.chipType.name},"
|
|
|
|
f"{' '.join(nameHints)},"
|
|
|
|
f"{parser.code},"
|
|
|
|
f"{parser.region},"
|
|
|
|
f"{matchList},"
|
|
|
|
f"{parser.getFormatType().name},"
|
|
|
|
f"{flags}\n"
|
2023-09-23 10:13:09 +02:00
|
|
|
)
|
2023-06-07 21:39:02 +02:00
|
|
|
if not matches:
|
2024-04-20 07:36:39 +02:00
|
|
|
raise RuntimeError(
|
|
|
|
f"{parser.code} {parser.region} not found in game list"
|
|
|
|
)
|
2023-06-07 21:39:02 +02:00
|
|
|
|
2023-09-23 10:13:09 +02:00
|
|
|
# If more than one match is found, use the first result.
|
2024-04-20 07:36:39 +02:00
|
|
|
game: GameDBEntry = matches[0]
|
2023-06-07 21:39:02 +02:00
|
|
|
|
2023-09-23 10:13:09 +02:00
|
|
|
if game.hasCartID():
|
|
|
|
if not (parser.flags & DataFlag.DATA_HAS_CART_ID):
|
2023-09-23 18:37:55 +02:00
|
|
|
raise RuntimeError("game has a cartridge ID but dump does not")
|
2023-09-23 10:13:09 +02:00
|
|
|
else:
|
|
|
|
if parser.flags & DataFlag.DATA_HAS_CART_ID:
|
2023-09-23 18:37:55 +02:00
|
|
|
raise RuntimeError("dump has a cartridge ID but game does not")
|
2023-06-25 09:56:47 +02:00
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
if game.hasSystemID() and game.cartLockedToIOBoard:
|
2023-09-23 18:37:55 +02:00
|
|
|
if not (parser.flags & DataFlag.DATA_HAS_SYSTEM_ID):
|
|
|
|
raise RuntimeError("game has a system ID but dump does not")
|
2023-09-23 10:13:09 +02:00
|
|
|
else:
|
2023-09-23 18:37:55 +02:00
|
|
|
if parser.flags & DataFlag.DATA_HAS_SYSTEM_ID:
|
|
|
|
raise RuntimeError("dump has a system ID but game does not")
|
2023-06-25 09:56:47 +02:00
|
|
|
|
2023-09-23 10:13:09 +02:00
|
|
|
logging.info(f"imported {dump.chipType.name}: {game.getFullName()}")
|
2024-04-20 07:36:39 +02:00
|
|
|
return CartDBEntry(parser.code, parser.region, game.name, dump, parser)
|
2023-06-07 21:39:02 +02:00
|
|
|
|
|
|
|
## Main
|
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
_MAME_DUMP_SIZES: Sequence[int] = (
|
|
|
|
_MAME_X76F041_STRUCT.size,
|
|
|
|
_MAME_X76F100_STRUCT.size,
|
|
|
|
_MAME_ZS01_STRUCT.size
|
|
|
|
)
|
|
|
|
|
2023-06-07 21:39:02 +02:00
|
|
|
def createParser() -> ArgumentParser:
|
|
|
|
parser = ArgumentParser(
|
2023-09-15 00:26:47 +02:00
|
|
|
description = \
|
|
|
|
"Recursively scans a directory for MAME dumps of X76F041 and ZS01 "
|
2024-04-20 07:36:39 +02:00
|
|
|
"cartridges, analyzes them and generates .db files.",
|
2023-09-15 00:26:47 +02:00
|
|
|
add_help = False
|
2023-06-07 21:39:02 +02:00
|
|
|
)
|
2023-09-15 00:26:47 +02:00
|
|
|
|
|
|
|
group = parser.add_argument_group("Tool options")
|
|
|
|
group.add_argument(
|
|
|
|
"-h", "--help",
|
|
|
|
action = "help",
|
|
|
|
help = "Show this help message and exit"
|
|
|
|
)
|
|
|
|
group.add_argument(
|
2023-06-07 21:39:02 +02:00
|
|
|
"-v", "--verbose",
|
|
|
|
action = "count",
|
2023-09-15 00:26:47 +02:00
|
|
|
help = "Enable additional logging levels"
|
2023-06-07 21:39:02 +02:00
|
|
|
)
|
2023-09-15 00:26:47 +02:00
|
|
|
|
|
|
|
group = parser.add_argument_group("File paths")
|
|
|
|
group.add_argument(
|
2023-06-07 21:39:02 +02:00
|
|
|
"-o", "--output",
|
|
|
|
type = Path,
|
|
|
|
default = os.curdir,
|
2023-09-15 00:26:47 +02:00
|
|
|
help = "Path to output directory (current directory by default)",
|
2023-06-07 21:39:02 +02:00
|
|
|
metavar = "dir"
|
|
|
|
)
|
2023-09-23 10:13:09 +02:00
|
|
|
group.add_argument(
|
|
|
|
"-e", "--export",
|
|
|
|
type = FileType("wt"),
|
|
|
|
help = "Export CSV table of all dumps parsed to specified path",
|
|
|
|
metavar = "file"
|
|
|
|
)
|
2023-09-15 00:26:47 +02:00
|
|
|
group.add_argument(
|
2023-06-07 21:39:02 +02:00
|
|
|
"gameList",
|
|
|
|
type = Path,
|
2023-09-15 00:26:47 +02:00
|
|
|
help = "Path to JSON file containing game list"
|
2023-06-07 21:39:02 +02:00
|
|
|
)
|
2023-09-15 00:26:47 +02:00
|
|
|
group.add_argument(
|
2023-06-07 21:39:02 +02:00
|
|
|
"input",
|
|
|
|
type = Path,
|
|
|
|
nargs = "+",
|
2023-09-15 00:26:47 +02:00
|
|
|
help = "Paths to input directories"
|
2023-06-07 21:39:02 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
return parser
|
|
|
|
|
|
|
|
def main():
|
|
|
|
parser: ArgumentParser = createParser()
|
|
|
|
args: Namespace = parser.parse_args()
|
|
|
|
setupLogger(args.verbose)
|
|
|
|
|
|
|
|
with args.gameList.open("rt") as _file:
|
|
|
|
gameList: Sequence[Mapping[str, Any]] = json.load(_file)
|
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
gameDB: GameDB = GameDB(gameList)
|
|
|
|
|
|
|
|
failures: Counter[ChipType] = Counter()
|
|
|
|
entries: defaultdict[ChipType, list[CartDBEntry]] = defaultdict(list)
|
|
|
|
|
|
|
|
if args.export:
|
|
|
|
args.export.write(
|
|
|
|
"# chipType,nameHints,code,region,matchList,formatType,flags\n"
|
|
|
|
)
|
2023-06-07 21:39:02 +02:00
|
|
|
|
|
|
|
for inputPath in args.input:
|
|
|
|
for rootDir, _, files in os.walk(inputPath):
|
2024-04-20 07:36:39 +02:00
|
|
|
root: Path = Path(rootDir)
|
|
|
|
|
2023-06-07 21:39:02 +02:00
|
|
|
for dumpName in files:
|
2024-04-20 07:36:39 +02:00
|
|
|
path: Path = root / dumpName
|
|
|
|
size: int = os.stat(path).st_size
|
2023-06-07 21:39:02 +02:00
|
|
|
|
|
|
|
# Skip files whose size does not match any of the known dump
|
|
|
|
# formats.
|
2024-04-20 07:36:39 +02:00
|
|
|
if size not in _MAME_DUMP_SIZES:
|
|
|
|
logging.warning(f"ignoring: {dumpName}, invalid size")
|
2023-06-07 21:39:02 +02:00
|
|
|
continue
|
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
with open(path, "rb") as _file:
|
|
|
|
data: bytes = _file.read()
|
|
|
|
|
2023-06-07 21:39:02 +02:00
|
|
|
try:
|
2024-04-20 07:36:39 +02:00
|
|
|
dump: CartDump = parseMAMEDump(data)
|
2023-09-23 10:13:09 +02:00
|
|
|
except RuntimeError as exc:
|
2024-04-20 07:36:39 +02:00
|
|
|
logging.error(f"failed to parse: {path}, {exc}")
|
2023-09-23 10:13:09 +02:00
|
|
|
continue
|
2023-06-07 21:39:02 +02:00
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
hints: Sequence[str] = dumpName, root.name
|
|
|
|
|
2023-09-23 10:13:09 +02:00
|
|
|
try:
|
|
|
|
entries[dump.chipType].append(
|
2024-04-20 07:36:39 +02:00
|
|
|
processDump(dump, gameDB, hints, args.export)
|
2023-06-07 21:39:02 +02:00
|
|
|
)
|
|
|
|
except RuntimeError as exc:
|
2024-04-20 07:36:39 +02:00
|
|
|
logging.error(
|
|
|
|
f"failed to import {dump.chipType.name}: {path}, {exc}"
|
|
|
|
)
|
2023-09-23 10:13:09 +02:00
|
|
|
failures[dump.chipType] += 1
|
|
|
|
|
|
|
|
if args.export:
|
|
|
|
args.export.close()
|
2023-06-07 21:39:02 +02:00
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
# Sort all entries and generate the .db files.
|
|
|
|
for chipType, _entries in entries.items():
|
|
|
|
if not _entries:
|
|
|
|
logging.warning(f"no entries generated for {chipType.name}")
|
2023-06-07 21:39:02 +02:00
|
|
|
continue
|
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
_entries.sort()
|
2023-06-07 21:39:02 +02:00
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
with open(args.output / f"{chipType.name.lower()}.db", "wb") as _file:
|
|
|
|
for entry in _entries:
|
2023-06-07 21:39:02 +02:00
|
|
|
_file.write(entry.serialize())
|
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
logging.info(
|
|
|
|
f"{chipType.name}: {len(_entries)} entries saved, "
|
|
|
|
f"{failures[chipType]} failures"
|
|
|
|
)
|
2023-06-07 21:39:02 +02:00
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
main()
|