Broken initial implementation of new game analyzer

This commit is contained in:
spicyjpeg 2024-12-08 03:32:56 +01:00
parent 5e70889394
commit 5a79a630e2
No known key found for this signature in database
GPG Key ID: 5CC87404C01DF393
10 changed files with 1652 additions and 61 deletions

172
tools/analyzeGameDumps.py Executable file
View File

@ -0,0 +1,172 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# 573in1 - Copyright (C) 2022-2024 spicyjpeg
#
# 573in1 is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# 573in1 is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# 573in1. If not, see <https://www.gnu.org/licenses/>.
__version__ = "1.0.3"
__author__ = "spicyjpeg"
import json, logging
from argparse import ArgumentParser, FileType, Namespace
from pathlib import Path
from typing import Any
from common.analysis import MAMENVRAMDump, getBootloaderVersion
from common.cartparser import parseCartHeader, parseROMHeader
from common.decompile import AnalysisError
from common.gamedb import GameInfo
from common.util import \
JSONFormatter, JSONGroupedArray, JSONGroupedObject, setupLogger
## Game analysis
def analyzeGame(game: GameInfo, nvramDir: Path, reanalyze: bool = False):
dump: MAMENVRAMDump = MAMENVRAMDump(nvramDir)
if (reanalyze or game.bootloaderVersion is None) and dump.bootloader:
try:
game.bootloaderVersion = getBootloaderVersion(dump.bootloader)
except AnalysisError:
pass
if (reanalyze or game.rtcHeader is None) and dump.rtcHeader:
game.rtcHeader = parseROMHeader(dump.rtcHeader)
if (reanalyze or game.flashHeader is None) and dump.flashHeader:
game.flashHeader = parseROMHeader(dump.flashHeader)
if (reanalyze or game.installCart is None) and dump.installCart:
game.installCart = parseCartHeader(dump.installCart)
if (reanalyze or game.gameCart is None) and dump.gameCart:
game.gameCart = parseCartHeader(dump.gameCart)
## Main
def createParser() -> ArgumentParser:
parser = ArgumentParser(
description = \
"Parses a list of games in JSON format and generates a new JSON "
"file with additional information about each game extracted from "
"MAME ROM and NVRAM dumps.",
add_help = False
)
group = parser.add_argument_group("Tool options")
group.add_argument(
"-h", "--help",
action = "help",
help = "Show this help message and exit"
)
group.add_argument(
"-v", "--verbose",
action = "count",
help = "Enable additional logging levels"
)
group = parser.add_argument_group("Analysis options")
group.add_argument(
"-r", "--reanalyze",
action = "store_true",
help = \
"Discard any existing analysis information from the input file and "
"rebuild it by reanalyzing the game whenever possible"
)
group.add_argument(
"-k", "--keep-unanalyzed",
action = "store_true",
help = \
"Do not remove entries for games that have not been analyzed from "
"output file"
)
group = parser.add_argument_group("Output options")
group.add_argument(
"-m", "--minify",
action = "store_true",
help = "Do not pretty print output file"
)
group = parser.add_argument_group("File paths")
group.add_argument(
"dumpDir",
type = Path,
help = "Path to MAME NVRAM directory"
)
group.add_argument(
"gameInfo",
type = FileType("rt", encoding = "utf-8"),
help = "Path to JSON file containing initial game list"
)
group.add_argument(
"output",
type = FileType("wt", encoding = "utf-8"),
help = "Path to JSON file to generate"
)
return parser
def main():
parser: ArgumentParser = createParser()
args: Namespace = parser.parse_args()
setupLogger(args.verbose)
with args.gameInfo as file:
gameInfo: dict[str, Any] = json.load(file)
games: list[JSONGroupedObject] = []
for initialInfo in gameInfo["games"]:
game: GameInfo = GameInfo.fromJSONObject(initialInfo)
code: str = f"{game.code} {"/".join(set(game.regions))}"
# Each entry in the initial game list may be associated with one or more
# region codes (and thus MAME dumps). This script only analyzes one dump
# per entry, assuming all its dumps are functionally identical and only
# differ in the region code.
analyzed: bool = False
for identifier in game.identifiers:
nvramDir: Path = args.dumpDir / identifier
if not identifier or not nvramDir.exists():
continue
logging.info(f"analyzing {identifier} ({code})")
analyzeGame(game, nvramDir, args.reanalyze)
analyzed = True
break
if analyzed or args.keep_unanalyzed:
games.append(game.toJSONObject())
if not analyzed:
logging.error(f"no dump found for {game.name} ({code})")
logging.info(f"saving {len(games)} entries out of {len(gameInfo["games"])}")
# Generate the output file, carrying over the schema path (if any) from the
# initial game list.
root: JSONGroupedObject = JSONGroupedObject()
if "$schema" in gameInfo:
root.groups.append({ "$schema": gameInfo["$schema"] })
root.groups.append({ "games": JSONGroupedArray([ games ]) })
with args.output as file:
for string in JSONFormatter(args.minify).serialize(root):
file.write(string)
if __name__ == "__main__":
main()

0
tools/buildResourcePackage.py Normal file → Executable file
View File

View File

@ -18,11 +18,12 @@ __version__ = "1.0.3"
__author__ = "spicyjpeg" __author__ = "spicyjpeg"
__all__ = ( __all__ = (
"analysis",
"assets", "assets",
"cart", "cart",
"cartdata", "cartparser",
"decompile", "decompile",
"games", "gamedb",
"mips", "mips",
"util" "util"
) )

229
tools/common/analysis.py Normal file
View File

@ -0,0 +1,229 @@
# -*- coding: utf-8 -*-
# 573in1 - Copyright (C) 2022-2024 spicyjpeg
#
# 573in1 is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# 573in1 is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# 573in1. If not, see <https://www.gnu.org/licenses/>.
import logging, re
from collections.abc import Sequence
from pathlib import Path
from .cart import *
from .decompile import AnalysisError, PSEXEAnalyzer
from .mips import ImmInstruction, Opcode, Register, encodeADDIU, encodeJR
from .util import InterleavedFile
## MAME NVRAM directory reader
class MAMENVRAMDump:
def __init__(self, nvramDir: Path):
try:
with InterleavedFile(
open(nvramDir / "29f016a.31m", "rb"),
open(nvramDir / "29f016a.27m", "rb")
) as file:
file.seek(FLASH_HEADER_OFFSET)
self.flashHeader: ROMHeaderDump | None = ROMHeaderDump(
DumpFlag.DUMP_PUBLIC_DATA_OK,
b"",
file.read(FLASH_HEADER_LENGTH)
)
# FIXME: the executable's CRC32 should probably be validated
file.seek(FLASH_EXECUTABLE_OFFSET)
try:
self.bootloader: PSEXEAnalyzer | None = PSEXEAnalyzer(file)
except AnalysisError:
self.bootloader: PSEXEAnalyzer | None = None
except FileNotFoundError:
self.flashHeader: ROMHeaderDump | None = None
self.bootloader: PSEXEAnalyzer | None = None
try:
with open(nvramDir / "m48t58", "rb") as file:
file.seek(RTC_HEADER_OFFSET)
self.rtcHeader: ROMHeaderDump | None = ROMHeaderDump(
DumpFlag.DUMP_PUBLIC_DATA_OK,
b"",
file.read(RTC_HEADER_LENGTH)
)
except FileNotFoundError:
self.rtcHeader: ROMHeaderDump | None = None
self.installCart: CartDump | None = \
self._loadCartDump(nvramDir / "cassette_install_eeprom")
self.gameCart: CartDump | None = \
self._loadCartDump(nvramDir / "cassette_game_eeprom")
def _loadCartDump(self, path: Path) -> CartDump | None:
try:
with open(path, "rb") as file:
return parseMAMECartDump(file.read())
except FileNotFoundError:
return None
## Bootloader executable analysis
_BOOT_VERSION_REGEX: re.Pattern = \
re.compile(rb"\0BOOT VER[. ]*(1\.[0-9A-Z]+)\0")
def getBootloaderVersion(exe: PSEXEAnalyzer) -> str:
for matched in _BOOT_VERSION_REGEX.finditer(exe.body):
version: bytes = matched.group(1)
argString: bytes = b"\0" + version + b"\0"
# A copy of the version string with no "BOOT VER" prefix is always
# present in the launcher and passed to the game's command line.
if argString not in exe.body:
logging.warning("found version string with no prefix-less copy")
return version.decode("ascii")
raise AnalysisError("could not find version string")
## Game executable analysis
# In order to support chips from multiple manufacturers, Konami's flash and
# security cartridge drivers use vtable arrays to dispatch API calls to the
# appropriate driver. The following arrays are present in the binary:
#
# struct {
# int (*eraseChip)(const uint8_t *dataKey);
# int (*setDataKey)(
# uint8_t type, const uint8_t *oldKey, const uint8_t *newKey
# );
# int (*readData)(
# const uint8_t *dataKey, uint32_t offset, void *output, size_t length
# );
# int (*writeData)(
# const uint8_t *dataKey, uint32_t offset, const void *data, size_t length
# );
# int (*readConfig)(const uint8_t *dataKey, void *output);
# int (*writeConfig)(const uint8_t *dataKey, const void *config);
# int (*readDS2401)(void *output);
# int chipType, capacity;
# } CART_DRIVERS[4];
#
# struct {
# int (*eraseSector)(void *ptr);
# int (*flushErase)(void);
# int (*flushEraseLower)(void);
# int (*flushEraseUpper)(void);
# int (*writeHalfword)(void *ptr, uint16_t value);
# int (*writeHalfwordAsync)(void *ptr, uint16_t value);
# int (*flushWrite)(void *ptr, uint16_t value);
# int (*flushWriteLower)(void *ptr, uint16_t value);
# int (*flushWriteUpper)(void *ptr, uint16_t value);
# int (*resetChip)(void *ptr);
# } FLASH_DRIVERS[4];
def _findDriverTableCalls(
exe: PSEXEAnalyzer,
dummyErrorCode: int,
functionNames: Sequence[str] = (),
valueNames: Sequence[str] = ()
) -> dict[str, int]:
# The first entry of each array is always a dummy driver containing pointers
# to a function that returns an error code. The table can thus be found by
# locating the dummy function and all contiguous references to it.
table: int = 0
for dummy in exe.findBytes(
encodeJR(Register.RA) +
encodeADDIU(Register.V0, Register.ZERO, dummyErrorCode)
):
try:
table = exe.findSingleMatch(
(dummy.to_bytes(4, "little") * len(functionNames)) +
bytes(4 * len(valueNames))
)
break
except StopIteration:
continue
if not table:
raise AnalysisError(
"could not locate any valid table referenced by a dummy function"
)
logging.debug(f"table found at {table:#010x}")
# Search the binary for functions that are wrappers around the driver table.
memberNames: Sequence[str] = functionNames + valueNames
functions: dict[str, int] = {}
for offset in exe.findFunctionReturns():
match (
exe.disassembleAt(offset + 4),
exe.disassembleAt(offset + 16),
exe.disassembleAt(offset + 40)
):
case (
ImmInstruction(
opcode = Opcode.LUI, rt = Register.V1, value = msb
), ImmInstruction(
opcode = Opcode.ADDIU, rt = Register.V1, value = lsb
), ImmInstruction(
opcode = Opcode.LW, rt = Register.V0, value = index
)
) if ((msb << 16) + lsb) == table:
index //= 4
if (index < 0) or (index >= len(memberNames)):
logging.debug(
f"ignoring candidate at {offset:#010x} due to "
f"out-of-bounds index {index}"
)
continue
name: str = memberNames[index]
functions[name] = offset
logging.debug(f"found {name} at {offset:#010x}")
return functions
def findCartFunctions(exe: PSEXEAnalyzer) -> dict[str, int]:
return _findDriverTableCalls(
exe, -2, (
"eraseChip",
"setDataKey",
"readSector",
"writeSector",
"readConfig",
"writeConfig",
"readCartID",
), (
"chipType",
"capacity"
)
)
def findFlashFunctions(exe: PSEXEAnalyzer) -> dict[str, int]:
return _findDriverTableCalls(
exe, -1, (
"eraseSector",
"flushErase",
"flushEraseLower",
"flushEraseUpper",
"writeHalfword",
"writeHalfwordAsync",
"flushWrite",
"flushWriteLower",
"flushWriteUpper",
"resetChip"
)
)

View File

@ -14,10 +14,12 @@
# You should have received a copy of the GNU General Public License along with # You should have received a copy of the GNU General Public License along with
# 573in1. If not, see <https://www.gnu.org/licenses/>. # 573in1. If not, see <https://www.gnu.org/licenses/>.
from collections.abc import Mapping import re
from collections.abc import ByteString
from dataclasses import dataclass from dataclasses import dataclass
from enum import IntEnum, IntFlag from enum import IntEnum, IntFlag
from struct import Struct from struct import Struct
from typing import Self
from zlib import decompress from zlib import decompress
from .util import decodeBase41 from .util import decodeBase41
@ -40,19 +42,37 @@ class DumpFlag(IntFlag):
DUMP_PUBLIC_DATA_OK = 1 << 6 DUMP_PUBLIC_DATA_OK = 1 << 6
DUMP_PRIVATE_DATA_OK = 1 << 7 DUMP_PRIVATE_DATA_OK = 1 << 7
@dataclass
class ChipSize:
privateDataOffset: int
privateDataLength: int
publicDataOffset: int
publicDataLength: int
def getLength(self) -> int:
return self.privateDataLength + self.publicDataLength
RTC_HEADER_OFFSET: int = 0x00
RTC_HEADER_LENGTH: int = 0x20
FLASH_HEADER_OFFSET: int = 0x00
FLASH_HEADER_LENGTH: int = 0x20
FLASH_CRC_OFFSET: int = 0x20
FLASH_EXECUTABLE_OFFSET: int = 0x24
## Cartridge dump structure ## Cartridge dump structure
_CART_DUMP_HEADER_STRUCT: Struct = Struct("< H 2B 8s 8s 8s 8s 8s") _CART_DUMP_HEADER_STRUCT: Struct = Struct("< H 2B 8s 8s 8s 8s 8s")
_CART_DUMP_HEADER_MAGIC: int = 0x573d _CART_DUMP_HEADER_MAGIC: int = 0x573d
_CHIP_SIZES: Mapping[ChipType, tuple[int, int, int]] = { _CHIP_SIZES: dict[ChipType, ChipSize] = {
ChipType.X76F041: ( 512, 384, 128 ), ChipType.X76F041: ChipSize( 0, 384, 384, 128),
ChipType.X76F100: ( 112, 0, 0 ), ChipType.X76F100: ChipSize( 0, 112, 0, 0),
ChipType.ZS01: ( 112, 0, 32 ) ChipType.ZS01: ChipSize(32, 80, 0, 32)
} }
_QR_STRING_START: str = "573::" _QR_STRING_REGEX: re.Pattern = \
_QR_STRING_END: str = "::" re.compile(r"573::([0-9A-Z+-./:]+)::", re.IGNORECASE)
@dataclass @dataclass
class CartDump: class CartDump:
@ -66,10 +86,52 @@ class CartDump:
config: bytes config: bytes
data: bytes data: bytes
def getChipSize(self) -> tuple[int, int, int]: def getChipSize(self) -> ChipSize:
return _CHIP_SIZES[self.chipType] return _CHIP_SIZES[self.chipType]
def serialize(self) -> bytes: @staticmethod
def fromQRString(data: str) -> Self:
qrString: re.Match | None = _QR_STRING_REGEX.search(data)
if qrString is None:
raise ValueError("not a valid 573in1 QR code string")
dump: bytearray = decodeBase41(qrString.group(1).upper())
return CartDump.fromBinary(decompress(dump))
@staticmethod
def fromBinary(data: ByteString) -> Self:
(
magic,
chipType,
flags,
systemID,
cartID,
zsID,
dataKey,
config
) = \
_CART_DUMP_HEADER_STRUCT.unpack_from(data, 0)
if magic != _CART_DUMP_HEADER_MAGIC:
raise ValueError(f"invalid or unsupported dump format: {magic:#06x}")
offset: int = _CART_DUMP_HEADER_STRUCT.size
length: int = _CHIP_SIZES[chipType].getLength()
return CartDump(
chipType,
flags,
systemID,
cartID,
zsID,
dataKey,
config,
data[offset:offset + length]
)
def toBinary(self) -> bytes:
return _CART_DUMP_HEADER_STRUCT.pack( return _CART_DUMP_HEADER_STRUCT.pack(
_CART_DUMP_HEADER_MAGIC, _CART_DUMP_HEADER_MAGIC,
self.chipType, self.chipType,
@ -81,31 +143,80 @@ class CartDump:
self.config self.config
) + self.data ) + self.data
def parseCartDump(data: bytes) -> CartDump: ## MAME NVRAM cartridge dump parser
magic, chipType, flags, systemID, cartID, zsID, dataKey, config = \
_CART_DUMP_HEADER_STRUCT.unpack(data[0:_CART_DUMP_HEADER_STRUCT.size])
if magic != _CART_DUMP_HEADER_MAGIC: _MAME_X76F041_DUMP_STRUCT: Struct = Struct("< 4x 8s 8s 8s 8s 512s")
raise ValueError(f"invalid or unsupported dump format: {magic:#04x}") _MAME_X76F100_DUMP_STRUCT: Struct = Struct("< 4x 8s 8s 112s")
_MAME_ZS01_DUMP_STRUCT: Struct = Struct("< 4x 8s 8s 8s 112s")
_MAME_ZS01_OLD_DUMP_STRUCT1: Struct = Struct("< 4x 8s 8s 8s 112s 3984x")
_MAME_ZS01_OLD_DUMP_STRUCT2: Struct = Struct("< 4x 8s 8s 112s 3984x")
length, _, _ = _CHIP_SIZES[chipType] def parseMAMECartDump(dump: ByteString) -> CartDump:
match int.from_bytes(dump[0:4], "big"), len(dump):
case 0x1955aa55, _MAME_X76F041_DUMP_STRUCT.size:
writeKey, readKey, configKey, config, data = \
_MAME_X76F041_DUMP_STRUCT.unpack(dump)
chipType: ChipType = ChipType.X76F041
dataKey: bytes = configKey
case 0x1900aa55, _MAME_X76F100_DUMP_STRUCT.size:
writeKey, readKey, data = \
_MAME_X76F100_DUMP_STRUCT.unpack(dump)
if writeKey != readKey:
raise RuntimeError(
"X76F100 dumps with different read and write keys are not "
"supported"
)
chipType: ChipType = ChipType.X76F100
dataKey: bytes = writeKey
config: bytes | None = None
# Even though older versions of MAME emulate X76F100 cartridges for
# games that support them, no actual X76F100 cartridges seem to
# exist.
raise RuntimeError("X76F100 cartridge dumps are not supported")
case 0x5a530001, _MAME_ZS01_DUMP_STRUCT.size:
commandKey, dataKey, config, data = \
_MAME_ZS01_DUMP_STRUCT.unpack(dump)
chipType: ChipType = ChipType.ZS01
case 0x5a530001, _MAME_ZS01_OLD_DUMP_STRUCT1.size:
commandKey, dataKey, config, data = \
_MAME_ZS01_OLD_DUMP_STRUCT1.unpack(dump)
chipType: ChipType = ChipType.ZS01
case 0x5a530001, _MAME_ZS01_OLD_DUMP_STRUCT2.size:
commandKey, dataKey, data = \
_MAME_ZS01_OLD_DUMP_STRUCT2.unpack(dump)
chipType: ChipType = ChipType.ZS01
config: bytes | None = None
case magic, length:
raise RuntimeError(
f"unknown chip type {magic:#010x}, dump length {length:#x}"
)
return CartDump( return CartDump(
chipType, flags, systemID, cartID, zsID, dataKey, config, chipType,
data[_CART_DUMP_HEADER_STRUCT.size:_CART_DUMP_HEADER_STRUCT.size + length] 0
| (DumpFlag.DUMP_CONFIG_OK if config else 0)
| DumpFlag.DUMP_PUBLIC_DATA_OK
| DumpFlag.DUMP_PRIVATE_DATA_OK,
b"",
b"",
b"",
dataKey,
config or b"",
data
) )
def parseCartQRString(data: str) -> CartDump:
_data: str = data.strip().upper()
if not _data.startswith(_QR_STRING_START):
raise ValueError(f"dump string does not begin with '{_QR_STRING_START}'")
if not _data.endswith(_QR_STRING_END):
raise ValueError(f"dump string does not end with '{_QR_STRING_END}'")
_data = _data[len(_QR_STRING_START):-len(_QR_STRING_END)]
return parseCartDump(decompress(decodeBase41(_data)))
## Flash and RTC header dump structure ## Flash and RTC header dump structure

415
tools/common/cartparser.py Normal file
View File

@ -0,0 +1,415 @@
# -*- coding: utf-8 -*-
# 573in1 - Copyright (C) 2022-2024 spicyjpeg
#
# 573in1 is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# 573in1 is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# 573in1. If not, see <https://www.gnu.org/licenses/>.
import logging, re
from collections.abc import Sequence
from dataclasses import dataclass
from itertools import product
from struct import Struct
from typing import ByteString
from .cart import *
from .gamedb import *
from .util import checksum8, checksum16, dsCRC8, sidCRC16
## Utilities
class ParserError(Exception):
pass
def _unscrambleRTCRAM(data: ByteString) -> bytearray:
# Some early games "scramble" RTC RAM by (possibly accidentally?)
# interpreting the data to be written as an array of 16-bit big endian
# values, then expanding them to 32-bit little endian.
output: bytearray = bytearray(len(data) // 2)
for i in range(0, len(output), 2):
#if data[(i * 2) + 2] or data[(i * 2) + 3]:
#raise ParserError("data does not seem to be scrambled")
output[i + 0] = data[(i * 2) + 1]
output[i + 1] = data[(i * 2) + 0]
return output
def _validateCustomID(data: ByteString) -> bool:
if not sum(data):
return False
checksum: int = checksum8(data[0:7], True)
if checksum == data[7]:
return True
raise ParserError(
f"checksum mismatch: expected {checksum:#04x}, got {data[7]:#04x}"
)
def _validateDS2401ID(data: ByteString) -> bool:
if not sum(data):
return False
if not data[0] or (data[0] == 0xff):
raise ParserError(f"invalid 1-wire prefix {data[0]:#04x}")
crc: int = dsCRC8(data[0:7])
if crc == data[7]:
return True
raise ParserError(f"CRC8 mismatch: expected {crc:#04x}, got {data[7]:#04x}")
## Header checksum detection
def detectChecksum(data: ByteString, checksum: int) -> ChecksumFlag:
buffer: bytearray = bytearray(data)
bigEndianSum: int = (0
| ((checksum << 8) & 0xff00)
| ((checksum >> 8) & 0x00ff)
)
for unit, bigEndian, inverted, forceGXSpec in product(
(
ChecksumFlag.CHECKSUM_UNIT_BYTE,
ChecksumFlag.CHECKSUM_UNIT_WORD_LITTLE,
ChecksumFlag.CHECKSUM_UNIT_WORD_BIG
),
( 0, ChecksumFlag.CHECKSUM_BIG_ENDIAN ),
( 0, ChecksumFlag.CHECKSUM_INVERTED ),
( 0, ChecksumFlag.CHECKSUM_FORCE_GX_SPEC )
):
checksumFlags: ChecksumFlag = \
ChecksumFlag(unit | bigEndian | inverted | forceGXSpec)
flagList: str = \
"|".join(flag.name for flag in checksumFlags) or "0"
# Dark Horse Legend sets the game code to GE706, but mistakenly computes
# the checksum as if the specification were GX.
actual: int = bigEndianSum if bigEndian else checksum
buffer[0:2] = b"GX" if forceGXSpec else data[0:2]
match unit:
case ChecksumFlag.CHECKSUM_UNIT_BYTE:
expected: int = checksum8(buffer, bool(inverted))
case ChecksumFlag.CHECKSUM_UNIT_WORD_LITTLE:
expected: int = checksum16(buffer, "little", bool(inverted))
case ChecksumFlag.CHECKSUM_UNIT_WORD_BIG:
expected: int = checksum16(buffer, "big", bool(inverted))
if expected == actual:
return checksumFlags
else:
logging.debug(
f" <{flagList}>: expected {expected:#06x}, got {actual:#06x}"
)
raise ParserError("could not find any valid header checksum format")
## Header format detection
# spec[0]: always G
# spec[1]: product type (B, C, E, K, L, N, Q, U, X, *=wildcard)
# code[0:2]: game code (700-999 or A00-D99)
# region[0]: region code
# (A=Asia, E=Europe, J=Japan, K=Korea, S=Singapore?, U=US)
# region[1]: major version code (A-F=regular, R-W=e-Amusement, X-Z=?)
# region[2:4]: minor version code (A-D or Z00-Z99, optional)
_SPECIFICATION_REGEX: re.Pattern = re.compile(rb"G[A-Z*]")
_CODE_REGEX: re.Pattern = re.compile(rb"[0-9A-D][0-9][0-9]")
_REGION_REGEX: re.Pattern = \
re.compile(rb"[AEJKSU][A-FR-WX-Z]([A-D]|Z[0-9][0-9])?", re.IGNORECASE)
_BASIC_HEADER_STRUCT: Struct = Struct("< 2s 2s B 3x")
_EXTENDED_HEADER_STRUCT: Struct = Struct("< 2s 6s 2s 4s H")
_PRIVATE_ID_STRUCT: Struct = Struct("< 8s 8s 8s 8s")
_PUBLIC_ID_STRUCT: Struct = Struct("< 8s 8s")
@dataclass
class DetectedHeader:
yearField: int = 0
headerFlags: HeaderFlag = HeaderFlag(0)
checksumFlags: ChecksumFlag = ChecksumFlag(0)
privateIDOffset: int | None = None
publicIDOffset: int | None = None
def detectHeader(
data: ByteString,
privateOffset: int,
publicOffset: int
) -> DetectedHeader:
unscrambledData: bytearray = _unscrambleRTCRAM(data)
for formatType, scrambled, usesPublicArea in product(
(
HeaderFlag.FORMAT_SIMPLE,
HeaderFlag.FORMAT_BASIC,
HeaderFlag.FORMAT_EXTENDED
),
( HeaderFlag(0), HeaderFlag.HEADER_SCRAMBLED ),
( HeaderFlag(0), HeaderFlag.HEADER_IN_PUBLIC_AREA )
):
header: DetectedHeader = DetectedHeader()
header.headerFlags = formatType | scrambled | usesPublicArea
flagList: str = \
"|".join(flag.name for flag in header.headerFlags) or "0"
buffer: ByteString = unscrambledData if scrambled else data
offset: int | None = publicOffset if usesPublicArea else privateOffset
if (offset < 0) or (offset >= len(buffer)):
logging.debug(f" <{flagList}>: header offset out of bounds")
continue
match formatType:
case HeaderFlag.FORMAT_SIMPLE:
region: bytes = buffer[offset:offset + 4]
specification: bytes = b""
case HeaderFlag.FORMAT_BASIC:
region, specification, checksum = \
_BASIC_HEADER_STRUCT.unpack_from(buffer, offset)
header.privateIDOffset = offset + _BASIC_HEADER_STRUCT.size
try:
header.checksumFlags = \
detectChecksum(buffer[offset:offset + 4], checksum)
except ParserError as err:
logging.debug(f" <{flagList}>: {err}")
continue
case HeaderFlag.FORMAT_EXTENDED:
(
specification,
code,
header.yearField,
region,
checksum
) = \
_EXTENDED_HEADER_STRUCT.unpack_from(buffer, offset)
header.publicIDOffset = offset + _EXTENDED_HEADER_STRUCT.size
header.privateIDOffset = \
header.publicIDOffset + _PUBLIC_ID_STRUCT.size
if (
not _SPECIFICATION_REGEX.match(specification) or
not _CODE_REGEX.match(code)
):
logging.debug(f" <{flagList}>: invalid game code")
continue
try:
header.checksumFlags = \
detectChecksum(buffer[offset:offset + 14], checksum)
except ParserError as err:
logging.debug(f" <{flagList}>: {err}")
continue
if not _REGION_REGEX.match(region):
logging.debug(f" <{flagList}>: invalid game region")
continue
if region == region.lower():
header.headerFlags |= HeaderFlag.REGION_LOWERCASE
if _SPECIFICATION_REGEX.match(specification):
if specification[1] == "*":
header.headerFlags |= HeaderFlag.SPEC_TYPE_WILDCARD
else:
header.headerFlags |= HeaderFlag.SPEC_TYPE_ACTUAL
return header
raise ParserError("could not find any valid header data format")
## Identifier detection
_TID_WIDTHS: Sequence[int] = 16, 14
@dataclass
class DetectedIdentifiers:
tidWidth: int = 0
midValue: int = 0
idFlags: IdentifierFlag = IdentifierFlag(0)
def detectPrivateIDs(
data: ByteString,
privateOffset: int,
dummyAreaOffset: int
) -> DetectedIdentifiers:
ids: DetectedIdentifiers = DetectedIdentifiers()
# Dancing Stage EuroMIX uses an X76F041 cartridge but adopts the same data
# layout as ZS01 games (32-byte public header/IDs + 32-byte private IDs).
# However, as the X76F041 does not support leaving only the first 32 bytes
# unprotected, the public area is instead relocated to the chip's last
# 128-byte sector (which is then configured to be unprotected). This has to
# be taken into account here as the private IDs are *not* moved to the
# beginning of the first sector; the space that would otherwise .
offset: int = privateOffset
if (dummyAreaOffset >= 0) and (dummyAreaOffset < len(data)):
dummyArea: ByteString = \
data[dummyAreaOffset:dummyAreaOffset + _PRIVATE_ID_STRUCT.size]
if sum(dummyArea):
offset = dummyAreaOffset
ids.idFlags = IdentifierFlag.ALLOCATE_DUMMY_PUBLIC_AREA
tid, sid, mid, xid = _PRIVATE_ID_STRUCT.unpack_from(data, offset)
if _validateCustomID(tid):
match tid[0]:
case 0x81:
ids.idFlags |= IdentifierFlag.PRIVATE_TID_TYPE_STATIC
case 0x82:
littleEndianCRC: int = int.from_bytes(tid[1:3], "little")
bigEndianCRC: int = int.from_bytes(tid[1:3], "big")
for width in _TID_WIDTHS:
crc: int = sidCRC16(sid[1:7], width)
if crc == littleEndianCRC:
ids.tidWidth = width
ids.idFlags |= \
IdentifierFlag.PRIVATE_TID_TYPE_SID_HASH_LITTLE
break
elif crc == bigEndianCRC:
ids.tidWidth = width
ids.idFlags |= \
IdentifierFlag.PRIVATE_TID_TYPE_SID_HASH_BIG
break
raise ParserError("could not determine trace ID bit width")
case _:
raise ParserError(f"unknown trace ID prefix: {tid[0]:#04x}")
if _validateDS2401ID(sid):
ids.idFlags |= IdentifierFlag.PRIVATE_SID_PRESENT
if _validateCustomID(mid):
ids.midValue = mid[0]
ids.idFlags |= IdentifierFlag.PRIVATE_MID_PRESENT
if _validateDS2401ID(xid):
ids.idFlags |= IdentifierFlag.PRIVATE_XID_PRESENT
return ids
def detectPublicIDs(data: ByteString, publicOffset: int) -> DetectedIdentifiers:
ids: DetectedIdentifiers = DetectedIdentifiers()
mid, xid = _PUBLIC_ID_STRUCT.unpack_from(data, publicOffset)
if _validateCustomID(mid):
ids.midValue = mid[0]
ids.idFlags |= IdentifierFlag.PUBLIC_MID_PRESENT
if _validateDS2401ID(xid):
ids.idFlags |= IdentifierFlag.PUBLIC_XID_PRESENT
return ids
## Installation signature detection
_SIGNATURE_STRUCT: Struct = Struct("< 8s 8s")
def detectSignature(data: ByteString, publicOffset: int) -> SignatureFlag:
signatureFlags: SignatureFlag = SignatureFlag(0)
installSig, dummy = _SIGNATURE_STRUCT.unpack_from(data, publicOffset)
# TODO: implement
return signatureFlags
## Parsing API
def parseCartHeader(dump: CartDump, pcb: CartPCBType | None = None) -> CartInfo:
if pcb is None:
match dump.chipType, bool(dump.flags & DumpFlag.DUMP_HAS_CART_ID):
case ChipType.X76F041, False:
pcb = CartPCBType.CART_UNKNOWN_X76F041
case ChipType.X76F041, True:
pcb = CartPCBType.CART_UNKNOWN_X76F041_DS2401
case ChipType.ZS01, True:
pcb = CartPCBType.CART_UNKNOWN_ZS01
case _, _:
raise ParserError("unsupported cartridge type")
chipSize: ChipSize = dump.getChipSize()
header: DetectedHeader = detectHeader(
dump.data,
chipSize.privateDataOffset,
chipSize.publicDataOffset
)
if header.privateIDOffset is None:
privateIDs: DetectedIdentifiers = DetectedIdentifiers()
else:
privateIDs: DetectedIdentifiers = detectPrivateIDs(
dump.data,
header.privateIDOffset,
header.privateIDOffset
- chipSize.publicDataOffset
+ chipSize.privateDataOffset
)
if header.publicIDOffset is None:
publicIDs: DetectedIdentifiers = DetectedIdentifiers()
else:
publicIDs: DetectedIdentifiers = \
detectPublicIDs(dump.data, header.publicIDOffset)
if (
(IdentifierFlag.PRIVATE_MID_PRESENT in privateIDs.idFlags) and
(IdentifierFlag.PUBLIC_MID_PRESENT in publicIDs.idFlags)
):
if privateIDs.midValue != publicIDs.midValue:
raise ParserError("private and public MID values do not match")
return CartInfo(
pcb,
dump.dataKey,
header.yearField,
privateIDs.tidWidth,
privateIDs.midValue,
header.headerFlags,
header.checksumFlags,
privateIDs.idFlags | publicIDs.idFlags
)
def parseROMHeader(dump: ROMHeaderDump) -> ROMHeaderInfo:
header: DetectedHeader = detectHeader(dump.data, -1, FLASH_HEADER_OFFSET)
if header.publicIDOffset is None:
signatureFlags: SignatureFlag = SignatureFlag(0)
else:
signatureFlags: SignatureFlag = \
detectSignature(dump.data, header.publicIDOffset)
return ROMHeaderInfo(
header.yearField,
header.headerFlags,
header.checksumFlags,
signatureFlags
)

View File

@ -24,12 +24,12 @@ from .mips import \
## Executable analyzer ## Executable analyzer
def parseStructFromFile(file: BinaryIO, _struct: Struct) -> tuple:
return _struct.unpack(file.read(_struct.size))
_EXE_HEADER_STRUCT: Struct = Struct("< 8s 8x 4I 16x 2I 20x 1972s") _EXE_HEADER_STRUCT: Struct = Struct("< 8s 8x 4I 16x 2I 20x 1972s")
_EXE_HEADER_MAGIC: bytes = b"PS-X EXE" _EXE_HEADER_MAGIC: bytes = b"PS-X EXE"
class AnalysisError(Exception):
pass
class PSEXEAnalyzer: class PSEXEAnalyzer:
def __init__(self, file: BinaryIO): def __init__(self, file: BinaryIO):
( (
@ -40,12 +40,12 @@ class PSEXEAnalyzer:
length, length,
stackOffset, stackOffset,
stackLength, stackLength,
_ region
) = \ ) = \
parseStructFromFile(file, _EXE_HEADER_STRUCT) _EXE_HEADER_STRUCT.unpack(file.read(_EXE_HEADER_STRUCT.size))
if magic != _EXE_HEADER_MAGIC: if magic != _EXE_HEADER_MAGIC:
raise RuntimeError("file is not a valid PS1 executable") raise AnalysisError("file is not a valid PS1 executable")
self.entryPoint: int = entryPoint self.entryPoint: int = entryPoint
self.startAddress: int = startAddress self.startAddress: int = startAddress
@ -61,7 +61,10 @@ class PSEXEAnalyzer:
return self.body[key - self.startAddress] return self.body[key - self.startAddress]
def _makeSlice( def _makeSlice(
self, start: int | None = None, stop: int | None = None, step: int = 1 self,
start: int | None = None,
stop: int | None = None,
step: int = 1
) -> slice: ) -> slice:
_start: int = \ _start: int = \
0 if (start is None) else (start - self.startAddress) 0 if (start is None) else (start - self.startAddress)
@ -86,7 +89,9 @@ class PSEXEAnalyzer:
return None return None
def disassemble( def disassemble(
self, start: int | None = None, stop: int | None = None self,
start: int | None = None,
stop: int | None = None
) -> Generator[Instruction | None, None, None]: ) -> Generator[Instruction | None, None, None]:
area: slice = self._makeSlice(start, stop, 4) area: slice = self._makeSlice(start, stop, 4)
offset: int = area.start offset: int = area.start
@ -105,15 +110,21 @@ class PSEXEAnalyzer:
offset += area.step offset += area.step
def dumpDisassembly( def dumpDisassembly(
self, output: TextIO, start: int | None = None, stop: int | None = None self,
output: TextIO,
start: int | None = None,
stop: int | None = None
): ):
for inst in self.disassemble(start, stop): for inst in self.disassemble(start, stop):
if inst is not None: if inst is not None:
output.write(f"{inst.address:08x}: {inst.toString()}\n") output.write(f"{inst.address:08x}: {inst.toString()}\n")
def findBytes( def findBytes(
self, data: ByteString, start: int | None = None, self,
stop: int | None = None, alignment: int = 4 data: ByteString,
start: int | None = None,
stop: int | None = None,
alignment: int = 4
) -> Generator[int, None, None]: ) -> Generator[int, None, None]:
area: slice = self._makeSlice(start, stop) area: slice = self._makeSlice(start, stop)
offset: int = area.start offset: int = area.start
@ -138,8 +149,31 @@ class PSEXEAnalyzer:
offset += step offset += step
def findSingleMatch(
self,
data: ByteString,
start: int | None = None,
stop: int | None = None,
alignment: int = 4
) -> int:
matches: Generator[int, None, None] = \
self.findBytes(data, start, stop, alignment)
try:
firstMatch: int = next(matches)
except StopIteration:
raise AnalysisError("no match found")
try:
next(matches)
raise AnalysisError("more than one match found")
except StopIteration:
return firstMatch
def findFunctionReturns( def findFunctionReturns(
self, start: int | None = None, stop: int | None = None self,
start: int | None = None,
stop: int | None = None
) -> Generator[int, None, None]: ) -> Generator[int, None, None]:
inst: bytes = encodeJR(Register.RA) inst: bytes = encodeJR(Register.RA)
@ -151,15 +185,21 @@ class PSEXEAnalyzer:
yield offset + 8 yield offset + 8
def findCalls( def findCalls(
self, target: int, start: int | None = None, stop: int | None = None self,
target: int,
start: int | None = None,
stop: int | None = None
) -> Generator[int, None, None]: ) -> Generator[int, None, None]:
inst: bytes = encodeJAL(target) inst: bytes = encodeJAL(target)
yield from self.findBytes(inst, start, stop, 4) yield from self.findBytes(inst, start, stop, 4)
def findValueLoads( def findValueLoads(
self, value: int, start: int | None = None, stop: int | None = None, self,
maxDisplacement: int = 1 value: int,
start: int | None = None,
stop: int | None = None,
maxDistance: int = 1
) -> Generator[ImmInstruction, None, None]: ) -> Generator[ImmInstruction, None, None]:
# 32-bit loads are typically encoded as a LUI followed by either ORI or # 32-bit loads are typically encoded as a LUI followed by either ORI or
# ADDIU. Due to ADDIU only supporting signed immediates, the LUI's # ADDIU. Due to ADDIU only supporting signed immediates, the LUI's
@ -169,7 +209,7 @@ class PSEXEAnalyzer:
if inst is None: if inst is None:
continue continue
for offset in range(4, (maxDisplacement + 1) * 4, 4): for offset in range(4, (maxDistance + 1) * 4, 4):
nextInst: Instruction | None = \ nextInst: Instruction | None = \
self.disassembleAt(inst.address + offset) self.disassembleAt(inst.address + offset)

583
tools/common/gamedb.py Normal file
View File

@ -0,0 +1,583 @@
# -*- coding: utf-8 -*-
# 573in1 - Copyright (C) 2022-2024 spicyjpeg
#
# 573in1 is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# 573in1 is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# 573in1. If not, see <https://www.gnu.org/licenses/>.
from collections.abc import ByteString, Iterable, Mapping
from dataclasses import dataclass
from enum import IntEnum, IntFlag
from struct import Struct
from typing import Any, Self
from .util import JSONGroupedObject
## Utilities
def _toJSONObject(value: Any) -> Any:
if hasattr(value, "toJSONObject"):
value = value.toJSONObject()
elif isinstance(value, ByteString):
value = value.hex("-")
elif isinstance(value, IntFlag):
value = [ flag.name for flag in value ]
if (value == 0) or (value == False) or (value == ""):
return None
else:
return value
def _makeJSONObject(*groups: Mapping[str, Any]) -> JSONGroupedObject:
jsonObj: JSONGroupedObject = JSONGroupedObject()
for group in groups:
dest: dict[str, Any] = {}
for key, value in group.items():
jsonValue: Any = _toJSONObject(value)
if jsonValue is not None:
dest[key] = jsonValue
if dest:
jsonObj.groups.append(dest)
return jsonObj
def _groupJSONObject(obj: Any, *groups: Iterable[str]) -> JSONGroupedObject:
jsonObj: JSONGroupedObject = JSONGroupedObject()
for group in groups:
dest: dict[str, Any] = {}
for key in group:
jsonValue: Any = _toJSONObject(getattr(obj, key, None))
if jsonValue is not None:
dest[key] = jsonValue
if dest:
jsonObj.groups.append(dest)
return jsonObj
## Flags
class CartPCBType(IntEnum):
CART_UNKNOWN_X76F041 = 1
CART_UNKNOWN_X76F041_DS2401 = 2
CART_UNKNOWN_ZS01 = 3
CART_GX700_PWB_D = 4
CART_GX700_PWB_E = 5
CART_GX700_PWB_J = 6
CART_GX883_PWB_D = 7
CART_GX894_PWB_D = 8
CART_GX896_PWB_A_A = 9
CART_GE949_PWB_D_A = 10
CART_GE949_PWB_D_B = 12
CART_PWB0000068819 = 12
CART_PWB0000088954 = 13
@staticmethod
def fromJSONObject(obj: str) -> Self:
return {
"unknown-x76f041": CartPCBType.CART_UNKNOWN_X76F041,
"unknown-x76f041-ds2401": CartPCBType.CART_UNKNOWN_X76F041_DS2401,
"unknown-zs01": CartPCBType.CART_UNKNOWN_ZS01,
"GX700-PWB(D)": CartPCBType.CART_GX700_PWB_D,
"GX700-PWB(E)": CartPCBType.CART_GX700_PWB_E,
"GX700-PWB(J)": CartPCBType.CART_GX700_PWB_J,
"GX883-PWB(D)": CartPCBType.CART_GX883_PWB_D,
"GX894-PWB(D)": CartPCBType.CART_GX894_PWB_D,
"GX896-PWB(A)A": CartPCBType.CART_GX896_PWB_A_A,
"GE949-PWB(D)A": CartPCBType.CART_GE949_PWB_D_A,
"GE949-PWB(D)B": CartPCBType.CART_GE949_PWB_D_B,
"PWB0000068819": CartPCBType.CART_PWB0000068819,
"PWB0000088954": CartPCBType.CART_PWB0000088954
}[obj]
def toJSONObject(self) -> str:
return {
CartPCBType.CART_UNKNOWN_X76F041: "unknown-x76f041",
CartPCBType.CART_UNKNOWN_X76F041_DS2401: "unknown-x76f041-ds2401",
CartPCBType.CART_UNKNOWN_ZS01: "unknown-zs01",
CartPCBType.CART_GX700_PWB_D: "GX700-PWB(D)",
CartPCBType.CART_GX700_PWB_E: "GX700-PWB(E)",
CartPCBType.CART_GX700_PWB_J: "GX700-PWB(J)",
CartPCBType.CART_GX883_PWB_D: "GX883-PWB(D)",
CartPCBType.CART_GX894_PWB_D: "GX894-PWB(D)",
CartPCBType.CART_GX896_PWB_A_A: "GX896-PWB(A)A",
CartPCBType.CART_GE949_PWB_D_A: "GE949-PWB(D)A",
CartPCBType.CART_GE949_PWB_D_B: "GE949-PWB(D)B",
CartPCBType.CART_PWB0000068819: "PWB0000068819",
CartPCBType.CART_PWB0000088954: "PWB0000088954"
}[self]
class HeaderFlag(IntFlag):
FORMAT_BITMASK = 3 << 0
FORMAT_SIMPLE = 0 << 0
FORMAT_BASIC = 1 << 0
FORMAT_EXTENDED = 2 << 0
SPEC_TYPE_BITMASK = 3 << 2
SPEC_TYPE_NONE = 0 << 2
SPEC_TYPE_ACTUAL = 1 << 2
SPEC_TYPE_WILDCARD = 2 << 2
HEADER_SCRAMBLED = 1 << 4
HEADER_IN_PUBLIC_AREA = 1 << 5
REGION_LOWERCASE = 1 << 6
@staticmethod
def fromJSONObject(obj: Mapping[str, Any]) -> Self:
flags: HeaderFlag = 0
flags |= {
"simple": HeaderFlag.FORMAT_SIMPLE,
"basic": HeaderFlag.FORMAT_BASIC,
"extended": HeaderFlag.FORMAT_EXTENDED
}[obj.get("format", None)]
flags |= {
None: HeaderFlag.SPEC_TYPE_NONE,
"actual": HeaderFlag.SPEC_TYPE_ACTUAL,
"wildcard": HeaderFlag.SPEC_TYPE_WILDCARD
}[obj.get("specType", None)]
for key, flag in {
"scrambled": HeaderFlag.HEADER_SCRAMBLED,
"usesPublicArea": HeaderFlag.HEADER_IN_PUBLIC_AREA,
"lowercaseRegion": HeaderFlag.REGION_LOWERCASE
}.items():
if obj.get(key, False):
flags |= flag
return flags
def toJSONObject(self) -> JSONGroupedObject:
return _makeJSONObject(
{
"format": {
HeaderFlag.FORMAT_SIMPLE: "simple",
HeaderFlag.FORMAT_BASIC: "basic",
HeaderFlag.FORMAT_EXTENDED: "extended"
}[self & HeaderFlag.FORMAT_BITMASK],
"specType": {
HeaderFlag.SPEC_TYPE_NONE: None,
HeaderFlag.SPEC_TYPE_ACTUAL: "actual",
HeaderFlag.SPEC_TYPE_WILDCARD: "wildcard"
}[self & HeaderFlag.SPEC_TYPE_BITMASK],
"scrambled": (HeaderFlag.HEADER_SCRAMBLED in self),
"usesPublicArea": (HeaderFlag.HEADER_IN_PUBLIC_AREA in self),
"lowercaseRegion": (HeaderFlag.REGION_LOWERCASE in self)
}
)
class ChecksumFlag(IntFlag):
CHECKSUM_UNIT_BITMASK = 3 << 0
CHECKSUM_UNIT_BYTE = 0 << 0
CHECKSUM_UNIT_WORD_LITTLE = 1 << 0
CHECKSUM_UNIT_WORD_BIG = 2 << 0
CHECKSUM_BIG_ENDIAN = 1 << 2
CHECKSUM_INVERTED = 1 << 3
CHECKSUM_FORCE_GX_SPEC = 1 << 4
@staticmethod
def fromJSONObject(obj: Mapping[str, Any]) -> Self:
flags: ChecksumFlag = 0
flags |= {
"byte": ChecksumFlag.CHECKSUM_UNIT_BYTE,
"littleEndianWord": ChecksumFlag.CHECKSUM_UNIT_WORD_LITTLE,
"bigEndianWord": ChecksumFlag.CHECKSUM_UNIT_WORD_BIG
}[obj.get("unit", None)]
for key, flag in {
"bigEndian": ChecksumFlag.CHECKSUM_BIG_ENDIAN,
"inverted": ChecksumFlag.CHECKSUM_INVERTED,
"forceGXSpec": ChecksumFlag.CHECKSUM_FORCE_GX_SPEC
}.items():
if obj.get(key, False):
flags |= flag
return flags
def toJSONObject(self) -> JSONGroupedObject:
return _makeJSONObject(
{
"unit": {
ChecksumFlag.CHECKSUM_UNIT_BYTE: "byte",
ChecksumFlag.CHECKSUM_UNIT_WORD_LITTLE: "littleEndianWord",
ChecksumFlag.CHECKSUM_UNIT_WORD_BIG: "bigEndianWord"
}[self & ChecksumFlag.CHECKSUM_UNIT_BITMASK],
"bigEndian": (ChecksumFlag.CHECKSUM_BIG_ENDIAN in self),
"inverted": (ChecksumFlag.CHECKSUM_INVERTED in self),
"forceGXSpec": (ChecksumFlag.CHECKSUM_FORCE_GX_SPEC in self)
}
)
class IdentifierFlag(IntFlag):
PRIVATE_TID_TYPE_BITMASK = 3 << 0
PRIVATE_TID_TYPE_NONE = 0 << 0
PRIVATE_TID_TYPE_STATIC = 1 << 0
PRIVATE_TID_TYPE_SID_HASH_LITTLE = 2 << 0
PRIVATE_TID_TYPE_SID_HASH_BIG = 3 << 0
PRIVATE_SID_PRESENT = 1 << 2
PRIVATE_MID_PRESENT = 1 << 3
PRIVATE_XID_PRESENT = 1 << 4
ALLOCATE_DUMMY_PUBLIC_AREA = 1 << 5
PUBLIC_MID_PRESENT = 1 << 6
PUBLIC_XID_PRESENT = 1 << 7
@staticmethod
def fromJSONObject(obj: Mapping[str, Any]) -> Self:
flags: IdentifierFlag = 0
flags |= {
None: IdentifierFlag.PRIVATE_TID_TYPE_NONE,
"static": IdentifierFlag.PRIVATE_TID_TYPE_STATIC,
"littleEndianSIDHash": IdentifierFlag.PRIVATE_TID_TYPE_SID_HASH_LITTLE,
"bigEndianSIDHash": IdentifierFlag.PRIVATE_TID_TYPE_SID_HASH_BIG
}[obj.get("privateTID", None)]
for key, flag in {
"privateSID": IdentifierFlag.PRIVATE_SID_PRESENT,
"privateMID": IdentifierFlag.PRIVATE_MID_PRESENT,
"privateXID": IdentifierFlag.PRIVATE_XID_PRESENT,
"dummyPublicArea": IdentifierFlag.ALLOCATE_DUMMY_PUBLIC_AREA,
"publicMID": IdentifierFlag.PUBLIC_MID_PRESENT,
"publicXID": IdentifierFlag.PUBLIC_XID_PRESENT
}.items():
if obj.get(key, False):
flags |= flag
return flags
def toJSONObject(self) -> JSONGroupedObject:
return _makeJSONObject(
{
"privateTID": {
IdentifierFlag.PRIVATE_TID_TYPE_NONE: None,
IdentifierFlag.PRIVATE_TID_TYPE_STATIC: "static",
IdentifierFlag.PRIVATE_TID_TYPE_SID_HASH_LITTLE: "littleEndianSIDHash",
IdentifierFlag.PRIVATE_TID_TYPE_SID_HASH_BIG: "bigEndianSIDHash"
}[self & IdentifierFlag.PRIVATE_TID_TYPE_BITMASK],
"privateSID": (IdentifierFlag.PRIVATE_SID_PRESENT in self),
"privateMID": (IdentifierFlag.PRIVATE_MID_PRESENT in self),
"privateXID": (IdentifierFlag.PRIVATE_XID_PRESENT in self)
}, {
"dummyPublicArea":
(IdentifierFlag.ALLOCATE_DUMMY_PUBLIC_AREA in self),
"publicMID": (IdentifierFlag.PUBLIC_MID_PRESENT in self),
"publicXID": (IdentifierFlag.PUBLIC_XID_PRESENT in self)
}
)
class SignatureFlag(IntFlag):
SIGNATURE_TYPE_BITMASK = 3 << 0
SIGNATURE_TYPE_NONE = 0 << 0
SIGNATURE_TYPE_CHECKSUM = 1 << 0
SIGNATURE_TYPE_MD5 = 2 << 0
SIGNATURE_DUMMY_01 = 1 << 2
SIGNATURE_PAD_WITH_FF = 1 << 3
@staticmethod
def fromJSONObject(obj: Mapping[str, Any]) -> Self:
flags: SignatureFlag = 0
flags |= {
None: SignatureFlag.SIGNATURE_TYPE_NONE,
"checksum": SignatureFlag.SIGNATURE_TYPE_CHECKSUM,
"md5": SignatureFlag.SIGNATURE_TYPE_MD5
}[obj.get("type", None)]
for key, flag in {
"dummy01": SignatureFlag.SIGNATURE_DUMMY_01,
"padWithFF": SignatureFlag.SIGNATURE_PAD_WITH_FF
}.items():
if obj.get(key, False):
flags |= flag
return flags
def toJSONObject(self) -> JSONGroupedObject:
return _makeJSONObject(
{
"type": {
SignatureFlag.SIGNATURE_TYPE_NONE: None,
SignatureFlag.SIGNATURE_TYPE_CHECKSUM: "checksum",
SignatureFlag.SIGNATURE_TYPE_MD5: "md5"
}[self & SignatureFlag.SIGNATURE_TYPE_BITMASK],
"dummy01": (SignatureFlag.SIGNATURE_DUMMY_01 in self),
"padWithFF": (SignatureFlag.SIGNATURE_PAD_WITH_FF in self)
}
)
class GameFlag(IntFlag):
GAME_IO_BOARD_BITMASK = 7 << 0
GAME_IO_BOARD_NONE = 0 << 0
GAME_IO_BOARD_ANALOG = 1 << 0
GAME_IO_BOARD_KICK = 2 << 0
GAME_IO_BOARD_FISHING_REEL = 3 << 0
GAME_IO_BOARD_DIGITAL = 4 << 0
GAME_IO_BOARD_DDR_KARAOKE = 5 << 0
GAME_IO_BOARD_GUNMANIA = 6 << 0
GAME_INSTALL_RTC_HEADER_REQUIRED = 1 << 3
GAME_RTC_HEADER_REQUIRED = 1 << 4
@staticmethod
def fromJSONObject(obj: Mapping[str, Any]) -> Self:
flags: GameFlag = 0
flags |= {
None: GameFlag.GAME_IO_BOARD_NONE,
"GX700-PWB(F)": GameFlag.GAME_IO_BOARD_ANALOG,
"GX700-PWB(K)": GameFlag.GAME_IO_BOARD_KICK,
"GE765-PWB(B)A": GameFlag.GAME_IO_BOARD_FISHING_REEL,
"GX894-PWB(B)A": GameFlag.GAME_IO_BOARD_DIGITAL,
"GX921-PWB(B)": GameFlag.GAME_IO_BOARD_DDR_KARAOKE,
"PWB0000073070": GameFlag.GAME_IO_BOARD_GUNMANIA
}[obj.get("ioBoard", None)]
for key, flag in {
"installRequiresRTCHeader": GameFlag.GAME_INSTALL_RTC_HEADER_REQUIRED,
"requiresRTCHeader": GameFlag.GAME_RTC_HEADER_REQUIRED
}.items():
if obj.get(key, False):
flags |= flag
return flags
def toJSONObject(self) -> JSONGroupedObject:
return _makeJSONObject(
{
"ioBoard": {
GameFlag.GAME_IO_BOARD_NONE: None,
GameFlag.GAME_IO_BOARD_ANALOG: "GX700-PWB(F)",
GameFlag.GAME_IO_BOARD_KICK: "GX700-PWB(K)",
GameFlag.GAME_IO_BOARD_FISHING_REEL: "GE765-PWB(B)A",
GameFlag.GAME_IO_BOARD_DIGITAL: "GX894-PWB(B)A",
GameFlag.GAME_IO_BOARD_DDR_KARAOKE: "GX921-PWB(B)",
GameFlag.GAME_IO_BOARD_GUNMANIA: "PWB0000073070"
}[self & GameFlag.GAME_IO_BOARD_BITMASK]
}, {
"installRequiresRTCHeader":
(GameFlag.GAME_INSTALL_RTC_HEADER_REQUIRED in self),
"requiresRTCHeader": (GameFlag.GAME_RTC_HEADER_REQUIRED in self)
}
)
## Data structures
_ROM_HEADER_INFO_STRUCT: Struct = Struct("< 2s 3B x")
_CART_INFO_STRUCT: Struct = Struct("< 8s 2s 6B")
_GAME_INFO_STRUCT: Struct = Struct("< 8s 36s 3s B 2H 6s 6s 16s 16s")
_MAX_SPECIFICATIONS: int = 4
_MAX_REGIONS: int = 12
@dataclass
class ROMHeaderInfo:
yearField: bytes
headerFlags: HeaderFlag
checksumFlags: ChecksumFlag
signatureFlags: SignatureFlag
@staticmethod
def fromJSONObject(obj: Mapping[str, Any]) -> Self:
return ROMHeaderInfo(
bytes.fromhex(obj["yearField"].replace("-", " ")),
HeaderFlag .fromJSONObject(obj.get("headerFlags", {})),
ChecksumFlag .fromJSONObject(obj.get("checksumFlags", {})),
SignatureFlag.fromJSONObject(obj.get("signatureFlags", {}))
)
def toJSONObject(self) -> JSONGroupedObject:
return _groupJSONObject(
self, (
"yearField"
), (
"headerFlags",
"checksumFlags",
"signatureFlags"
)
)
def toBinary(self) -> bytes:
return _ROM_HEADER_INFO_STRUCT.pack(
self.yearField,
self.headerFlags,
self.checksumFlags,
self.signatureFlags
)
@dataclass
class CartInfo:
pcb: CartPCBType
dataKey: bytes
yearField: bytes
tidWidth: int
midValue: int
headerFlags: HeaderFlag
checksumFlags: ChecksumFlag
idFlags: IdentifierFlag
@staticmethod
def fromJSONObject(obj: Mapping[str, Any]) -> Self:
return CartInfo(
CartPCBType.fromJSONObject(obj["pcb"]),
bytes.fromhex(obj["dataKey"] .replace("-", " ")),
bytes.fromhex(obj["yearField"].replace("-", " ")),
int(obj.get("tidWidth", 0)),
int(obj.get("midValue", 0)),
HeaderFlag .fromJSONObject(obj.get("headerFlags", {})),
ChecksumFlag .fromJSONObject(obj.get("checksumFlags", {})),
IdentifierFlag.fromJSONObject(obj.get("idFlags", {}))
)
def toJSONObject(self) -> JSONGroupedObject:
return _groupJSONObject(
self, (
"pcb",
), (
"dataKey",
"yearField",
"tidWidth",
"midValue"
), (
"headerFlags",
"checksumFlags",
"idFlags",
"tidWidth",
"midValue"
)
)
def toBinary(self) -> bytes:
return _CART_INFO_STRUCT.pack(
self.dataKey,
self.yearField,
self.pcb,
self.tidWidth,
self.midValue,
self.headerFlags,
self.checksumFlags,
self.idFlags
)
@dataclass
class GameInfo:
specifications: list[str]
code: str
regions: list[str]
identifiers: list[str | None]
name: str
series: str | None
year: int
flags: GameFlag
bootloaderVersion: str | None = None
rtcHeader: ROMHeaderInfo | None = None
flashHeader: ROMHeaderInfo | None = None
installCart: CartInfo | None = None
gameCart: CartInfo | None = None
@staticmethod
def fromJSONObject(obj: Mapping[str, Any]) -> Self:
rtcHeader: Mapping[str, Any] | None = obj.get("rtcHeader", None)
flashHeader: Mapping[str, Any] | None = obj.get("flashHeader", None)
installCart: Mapping[str, Any] | None = obj.get("installCart", None)
gameCart: Mapping[str, Any] | None = obj.get("gameCart", None)
return GameInfo(
obj["specifications"],
obj["code"],
obj["regions"],
obj["identifiers"],
obj["name"],
obj.get("series", None),
obj["year"],
GameFlag.fromJSONObject(obj.get("flags", {})),
obj.get("bootloaderVersion", None),
ROMHeaderInfo.fromJSONObject(rtcHeader) if rtcHeader else None,
ROMHeaderInfo.fromJSONObject(flashHeader) if flashHeader else None,
CartInfo .fromJSONObject(installCart) if installCart else None,
CartInfo .fromJSONObject(gameCart) if gameCart else None
)
def toJSONObject(self) -> JSONGroupedObject:
return _groupJSONObject(
self, (
"specifications",
"code",
"regions",
"identifiers"
), (
"name",
"series",
"year"
), (
"flags",
), (
"bootloaderVersion",
), (
"rtcHeader",
"flashHeader",
"installCart",
"gameCart"
)
)
def toBinary(self, nameOffset: int) -> bytes:
if len(self.specifications) > _MAX_SPECIFICATIONS:
raise ValueError(
f"entry can only have up to {_MAX_SPECIFICATIONS} "
f"specification codes"
)
if len(self.regions) > _MAX_REGIONS:
raise ValueError(
f"entry can only have up to {_MAX_REGIONS} region codes"
)
# FIXME: identifiers, series and bootloaderVersion are not currently
# included in the binary format
return _GAME_INFO_STRUCT.pack(
b"".join(sorted(
spec.encode("ascii").ljust(2, b"\0")
for spec in self.specifications
)),
b"".join(sorted(
region.encode("ascii").ljust(3, b"\0")
for region in self.regions
)),
self.code.encode("ascii"),
self.flags,
nameOffset,
self.year,
self.rtcHeader .toBinary(),
self.flashHeader.toBinary(),
self.installCart.toBinary(),
self.gameCart .toBinary()
)

View File

@ -125,9 +125,11 @@ def hashData(data: Iterable[int]) -> int:
def checksum8(data: Iterable[int], invert: bool = False) -> int: def checksum8(data: Iterable[int], invert: bool = False) -> int:
return (sum(data) & 0xff) ^ (0xff if invert else 0) return (sum(data) & 0xff) ^ (0xff if invert else 0)
def checksum16(data: Iterable[int], invert: bool = False) -> int: def checksum16(
data: Iterable[int], endianness: str = "little", invert: bool = False
) -> int:
it: Iterator = iter(data) it: Iterator = iter(data)
values: map[int] = map(lambda x: x[0] | (x[1] << 8), zip(it, it)) values: map[int] = map(lambda x: int.from_bytes(x, endianness), zip(it, it))
return (sum(values) & 0xffff) ^ (0xffff if invert else 0) return (sum(values) & 0xffff) ^ (0xffff if invert else 0)
@ -140,6 +142,37 @@ def shortenedMD5(data: ByteString) -> bytearray:
return output return output
## CRC calculation
_CRC8_POLY: int = 0x8c
def dsCRC8(data: ByteString) -> int:
crc: int = 0
for byte in data:
for _ in range(8):
temp: int = crc ^ byte
byte >>= 1
crc >>= 1
if temp & 1:
crc ^= _CRC8_POLY
return crc & 0xff
def sidCRC16(data: ByteString, width: int = 16) -> int:
crc: int = 0
for i, byte in enumerate(data):
for j in range(i * 8, (i + 1) * 8):
if byte & 1:
crc ^= 1 << (j % width)
byte >>= 1
return crc & 0xffff
## Logging ## Logging
def setupLogger(level: int | None): def setupLogger(level: int | None):
@ -228,7 +261,7 @@ class JSONFormatter:
lastIndex: int = len(obj) - 1 lastIndex: int = len(obj) - 1
for index, ( key, value ) in obj.items(): for index, ( key, value ) in enumerate(obj.items()):
yield from self.serialize(key) yield from self.serialize(key)
yield self._inlineSep(":") yield self._inlineSep(":")
yield from self.serialize(value) yield from self.serialize(value)
@ -252,6 +285,9 @@ class JSONFormatter:
lastGroupIndex: int = len(groups) - 1 lastGroupIndex: int = len(groups) - 1
for groupIndex, obj in enumerate(groups): for groupIndex, obj in enumerate(groups):
if not obj:
raise ValueError("empty groups are not allowed")
lastIndex: int = len(obj) - 1 lastIndex: int = len(obj) - 1
for index, item in enumerate(obj): for index, item in enumerate(obj):
@ -279,6 +315,9 @@ class JSONFormatter:
lastGroupIndex: int = len(groups) - 1 lastGroupIndex: int = len(groups) - 1
for groupIndex, obj in enumerate(groups): for groupIndex, obj in enumerate(groups):
if not obj:
raise ValueError("empty groups are not allowed")
keys: list[str] = [ keys: list[str] = [
("".join(self.serialize(key)) + self._inlineSep(":")) ("".join(self.serialize(key)) + self._inlineSep(":"))
for key in obj.keys() for key in obj.keys()
@ -317,9 +356,9 @@ class JSONFormatter:
case JSONGroupedObject() if not groupedOnSingleLine: case JSONGroupedObject() if not groupedOnSingleLine:
yield from self._groupedObject(obj.groups) yield from self._groupedObject(obj.groups)
case list() | tuple() if ungroupedOnSingleLine: case (list() | tuple()) if ungroupedOnSingleLine:
yield from self._singleLineArray(obj) yield from self._singleLineArray(obj)
case list() | tuple() if not ungroupedOnSingleLine: case (list() | tuple()) if not ungroupedOnSingleLine:
yield from self._groupedArray(( obj, )) yield from self._groupedArray(( obj, ))
case Mapping() if ungroupedOnSingleLine: case Mapping() if ungroupedOnSingleLine:
@ -355,7 +394,7 @@ class HashTableBuilder:
self.entries[index] = entry self.entries[index] = entry
return index return index
if bucket.fullHash == fullHash: if bucket.fullHash == fullHash:
raise KeyError(f"collision detected, hash={fullHash:#010x}") raise KeyError(f"hash collision detected ({fullHash:#010x})")
# Otherwise, follow the buckets's chain, find the last chained item and # Otherwise, follow the buckets's chain, find the last chained item and
# link the new entry to it. # link the new entry to it.
@ -363,7 +402,7 @@ class HashTableBuilder:
bucket = self.entries[bucket.chainIndex] bucket = self.entries[bucket.chainIndex]
if bucket.fullHash == fullHash: if bucket.fullHash == fullHash:
raise KeyError(f"collision detected, hash={fullHash:#010x}") raise KeyError(f"hash collision detected, ({fullHash:#010x})")
bucket.chainIndex = len(self.entries) bucket.chainIndex = len(self.entries)
self.entries.append(entry) self.entries.append(entry)

View File

@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License along with # You should have received a copy of the GNU General Public License along with
# 573in1. If not, see <https://www.gnu.org/licenses/>. # 573in1. If not, see <https://www.gnu.org/licenses/>.
__version__ = "0.4.1" __version__ = "1.0.3"
__author__ = "spicyjpeg" __author__ = "spicyjpeg"
import sys import sys
@ -63,7 +63,7 @@ def createParser() -> ArgumentParser:
parser = ArgumentParser( parser = ArgumentParser(
description = \ description = \
"Decodes and displays or saves the contents of a QR code cartridge " "Decodes and displays or saves the contents of a QR code cartridge "
"dump generated by the tool.", "dump generated by 573in1.",
add_help = False add_help = False
) )
@ -78,7 +78,7 @@ def createParser() -> ArgumentParser:
group.add_argument( group.add_argument(
"-i", "--input", "-i", "--input",
type = FileType("rb"), type = FileType("rb"),
help = "Read dump (.dmp file) or QR string from specified path", help = "Read dump (.dmp file) or QR code string from specified path",
metavar = "file" metavar = "file"
) )
group.add_argument( group.add_argument(
@ -100,7 +100,7 @@ def createParser() -> ArgumentParser:
"data", "data",
type = str, type = str,
nargs = "?", nargs = "?",
help = "QR string to decode (if -i was not passed)" help = "QR code string to decode (if -i was not passed)"
) )
return parser return parser
@ -114,19 +114,20 @@ def main():
data: bytes = file.read() data: bytes = file.read()
try: try:
dump: CartDump = parseCartDump(data) dump: CartDump = CartDump.fromBinary(data)
except: except:
dump: CartDump = parseCartQRString(data.decode("ascii")) dump: CartDump = CartDump.fromQRString(data.decode("utf-8"))
elif args.data: elif args.data:
dump: CartDump = parseCartQRString(args.data) dump: CartDump = CartDump.fromQRString(args.data)
else: else:
parser.error("a dump must be passed on the command line or using -i") parser.error("a dump must be passed on the command line or using -i")
if args.log: if args.log:
printDumpInfo(dump, args.log) printDumpInfo(dump, args.log)
if args.export: if args.export:
with args.export as file: with args.export as file:
file.write(dump.serialize()) file.write(dump.toBinary())
if __name__ == "__main__": if __name__ == "__main__":
main() main()