diff --git a/CMakeLists.txt b/CMakeLists.txt index 35f68bd..d65fc3f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,28 +4,37 @@ cmake_minimum_required(VERSION 3.25) set(CMAKE_TOOLCHAIN_FILE "${CMAKE_CURRENT_LIST_DIR}/cmake/toolchain.cmake") project( - cart_tool_private + cart_tool LANGUAGES C CXX ASM VERSION 0.4.2 DESCRIPTION "Konami System 573 security cartridge tool" ) find_package(Python3 REQUIRED COMPONENTS Interpreter) -find_program( - XORRISO_PATH xorriso - PATHS "C:/msys64/usr/bin" - DOC "Path to xorriso tool (optional)" -) find_program( CHDMAN_PATH chdman - PATHS "C:/Program Files/MAME" "C:/Program Files (x86)/MAME" - DOC "Path to MAME chdman tool (optional)" + PATHS + "C:/Program Files/MAME" + "C:/Program Files (x86)/MAME" + "/opt/mame" + DOC "Path to MAME chdman tool (optional)" ) +string(TOUPPER "${PROJECT_NAME}" _name) string(REPLACE "." "_" _version "${PROJECT_VERSION}") -set(releaseInfo "cart_tool build ${PROJECT_VERSION} - (C) 2022-2024 spicyjpeg") -set(releaseName "cart_tool_${PROJECT_VERSION}") -set(cdVolumeName "CART_TOOL_${_version}") + +set( + RELEASE_INFO "${PROJECT_NAME} ${PROJECT_VERSION} - (C) 2022-2024 spicyjpeg" + CACHE STRING "Executable description and version string (optional)" +) +set( + RELEASE_NAME "${PROJECT_NAME}_${PROJECT_VERSION}" + CACHE STRING "CD-ROM image and release package file name" +) +set( + CD_VOLUME_NAME "${_name}_${_version}" + CACHE STRING "CD-ROM image volume label" +) ## Files common to all executables @@ -61,7 +70,7 @@ function(addExecutable name address stackTop) COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tools/convertExecutable.py" - -r "${releaseInfo}" + -r "${RELEASE_INFO}" -s 0x${stackTop} "$" "${name}.psexe" @@ -157,8 +166,6 @@ addExecutable( ) target_link_libraries(boot PRIVATE bootFlags) -list(APPEND packageContents boot.psexe) - function(addLauncher address stackTop) addExecutable( launcher${address} ${address} ${stackTop} @@ -236,9 +243,9 @@ add_custom_command( resources.json assets/app.palette.json assets/app.strings.json - main - launcher801f8000 - launcher803f8000 + main.psexe + launcher801f8000.psexe + launcher803f8000.psexe COMMENT "Building resource archive" VERBATIM ) @@ -249,80 +256,57 @@ addBinaryFile( ## CD-ROM image +configure_file(cd.json cd.json ESCAPE_QUOTES) configure_file(assets/cdreadme.txt readme.txt NEWLINE_STYLE CRLF) -if(EXISTS "${XORRISO_PATH}") +add_custom_command( + COMMAND + "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/tools/buildCDImage.py" + cd.json + "${RELEASE_NAME}.iso" + OUTPUT "${RELEASE_NAME}.iso" + DEPENDS + cd.json + boot.psexe + COMMENT "Building CD-ROM image" + VERBATIM +) + +if(EXISTS "${CHDMAN_PATH}") add_custom_command( COMMAND - "${XORRISO_PATH}" - -outdev "${releaseName}.iso" - -blank all - -rockridge off - -joliet off - -padding 2M - -volid "${cdVolumeName}" - -volset_id "${cdVolumeName}" - -publisher "SPICYJPEG" - -application_id "PLAYSTATION" - -system_id "PLAYSTATION" - -preparer_id "CART_TOOL BUILD SCRIPT" - -map readme.txt README.TXT - -map boot.psexe PSX.EXE - -clone PSX.EXE GSE.NXX - -clone PSX.EXE NSE.GXX - -clone PSX.EXE OSE.FXX - -clone PSX.EXE QSU.DXH - -clone PSX.EXE QSX.DXE - -clone PSX.EXE QSY.DXD - -clone PSX.EXE QSZ.DXC - -clone PSX.EXE RSU.CXH - -clone PSX.EXE RSV.CXG - -clone PSX.EXE RSW.CXF - -clone PSX.EXE RSZ.CXC - -clone PSX.EXE SSW.BXF - -clone PSX.EXE SSX.BXE - -clone PSX.EXE SSY.BXD - -clone PSX.EXE TSV.AXG - -clone PSX.EXE TSW.AXF - -clone PSX.EXE TSX.AXE - -clone PSX.EXE TSY.AXD - -clone PSX.EXE TSZ.AXC - OUTPUT "${releaseName}.iso" - DEPENDS boot - COMMENT "Building CD-ROM image" + "${CHDMAN_PATH}" createcd -f + -i "${RELEASE_NAME}.iso" + -o "${RELEASE_NAME}.chd" + OUTPUT "${RELEASE_NAME}.chd" + DEPENDS "${RELEASE_NAME}.iso" + COMMENT "Building MAME CHD image" VERBATIM ) - list(APPEND packageContents "${releaseName}.iso") - - if(EXISTS "${CHDMAN_PATH}") - add_custom_command( - COMMAND - "${CHDMAN_PATH}" createcd -f - -i "${releaseName}.iso" - -o "${releaseName}.chd" - OUTPUT "${releaseName}.chd" - DEPENDS "${releaseName}.iso" - COMMENT "Building MAME CHD image" - VERBATIM - ) - - list(APPEND packageContents "${releaseName}.chd") - endif() + list(APPEND packageFiles "${RELEASE_NAME}.chd") endif() ## Release package +list( + APPEND packageFiles + readme.txt + boot.psexe + "${RELEASE_NAME}.iso" +) + add_custom_command( COMMAND "${CMAKE_COMMAND}" -E tar cf - "${releaseName}.zip" + "${RELEASE_NAME}.zip" --format=zip - ${packageContents} readme.txt - OUTPUT "${releaseName}.zip" - DEPENDS ${packageContents} + ${packageFiles} + OUTPUT "${RELEASE_NAME}.zip" + DEPENDS ${packageFiles} COMMENT "Packaging built files" VERBATIM ) -add_custom_target(package ALL DEPENDS "${releaseName}.zip") +add_custom_target(package ALL DEPENDS "${RELEASE_NAME}.zip") diff --git a/cd.json b/cd.json new file mode 100644 index 0000000..d28e8b2 --- /dev/null +++ b/cd.json @@ -0,0 +1,126 @@ +{ + "identifiers": { + "system": "PLAYSTATION", + "volume": "${CD_VOLUME_NAME}", + "volumeSet": "${CD_VOLUME_NAME}", + "publisher": "SPICYJPEG", + "dataPreparer": "GCC ${CMAKE_CXX_COMPILER_VERSION}, CMAKE ${CMAKE_VERSION}, PYTHON ${Python3_VERSION}", + "application": "PLAYSTATION", + "copyright": "README.TXT;1" + }, + + "entries": [ + { + "type": "file", + "name": "README.TXT", + "source": "${PROJECT_BINARY_DIR}/readme.txt" + }, + + { + "type": "file", + "name": "PSX.EXE", + "source": "${PROJECT_BINARY_DIR}/boot.psexe" + }, + { + "type": "fileAlias", + "name": "GSE.NXX", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "NSE.GXX", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "OSE.FXX", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "QSU.DXH", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "QSX.DXE", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "QSY.DXD", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "QSZ.DXC", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "RSU.CXH", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "RSV.CXG", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "RSW.CXF", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "RSZ.CXC", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "SSW.BXF", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "SSX.BXE", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "SSY.BXD", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "TSV.AXG", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "TSW.AXF", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "TSX.AXE", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "TSY.AXD", + "source": "PSX.EXE" + }, + { + "type": "fileAlias", + "name": "TSZ.AXC", + "source": "PSX.EXE" + }, + + { + "type": "padding", + "name": "PADDING.BIN", + "size": 1048576 + } + ] +} diff --git a/resources.json b/resources.json index 37ce16a..e1cec1f 100644 --- a/resources.json +++ b/resources.json @@ -2,18 +2,18 @@ { "type": "binary", "name": "binaries/main.psexe.lz4", - "source": "main.psexe", + "source": "${PROJECT_BINARY_DIR}/main.psexe", "compression": "lz4" }, { "type": "binary", "name": "binaries/launcher801f8000.psexe", - "source": "launcher801f8000.psexe" + "source": "${PROJECT_BINARY_DIR}/launcher801f8000.psexe" }, { "type": "binary", "name": "binaries/launcher803f8000.psexe", - "source": "launcher803f8000.psexe" + "source": "${PROJECT_BINARY_DIR}/launcher803f8000.psexe" }, { diff --git a/src/common/args.cpp b/src/common/args.cpp index af7c703..70a6ca0 100644 --- a/src/common/args.cpp +++ b/src/common/args.cpp @@ -87,7 +87,12 @@ bool ExecutableLauncherArgs::parseArgument(const char *arg) { return true; default: +#if 0 return CommonArgs::parseArgument(arg); +#else + // Avoid pulling in strtol(). + return false; +#endif } } diff --git a/src/common/io.hpp b/src/common/io.hpp index 7271789..3751b7d 100644 --- a/src/common/io.hpp +++ b/src/common/io.hpp @@ -144,7 +144,7 @@ static inline void setMiscOutput(MiscOutputPin pin, bool value) { static inline bool isDigitalIOPresent(void) { return ( (SYS573D_CPLD_STAT & (SYS573D_CPLD_STAT_ID1 | SYS573D_CPLD_STAT_ID2)) == - SYS573D_CPLD_STAT_ID1 + SYS573D_CPLD_STAT_ID2 ); } diff --git a/src/launcher/launcher.cpp b/src/launcher/launcher.cpp index 654bc99..6865426 100644 --- a/src/launcher/launcher.cpp +++ b/src/launcher/launcher.cpp @@ -7,10 +7,17 @@ #include "vendor/ff.h" LauncherError ExecutableLauncher::openFile(void) { +#if 0 if (!args.drive || !args.path) { LOG("required arguments missing"); return INVALID_ARGS; } +#else + if (!args.drive) + args.drive = "1:"; + if (!args.path) + args.path = "psx.exe"; +#endif // The drive index is always a single digit, so there is no need to pull in // strtol() here. diff --git a/src/main/app/romactions.cpp b/src/main/app/romactions.cpp index 51e615c..6e4996e 100644 --- a/src/main/app/romactions.cpp +++ b/src/main/app/romactions.cpp @@ -302,7 +302,7 @@ void StorageActionsScreen::update(ui::Context &ctx) { ctx.show(APP->_storageInfoScreen, true, true); } else { if (action.region.isPresent()) { - this->selectedRegion = &(action.region); + selectedRegion = &(action.region); if (action.region.regionLength > 0x1000000) { APP->_cardSizeScreen.callback = action.target; diff --git a/tools/buildCDImage.py b/tools/buildCDImage.py new file mode 100755 index 0000000..1a6d183 --- /dev/null +++ b/tools/buildCDImage.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""ISO9660 CD-ROM image generator + +A simple tool for building ISO9660 CD-ROM images with 2048-byte sectors, as used +by the PlayStation 1 and other machines, from a JSON configuration file +describing their contents. Unlike most other ISO9660 authoring tools, files and +directories are placed in the image in the same order they are specified in the +JSON file, allowing for customization of the disc's layout. Requires pycdlib to +be installed. +""" + +__version__ = "0.1.0" +__author__ = "spicyjpeg" + +import json, re, sys +from argparse import ArgumentParser, FileType, Namespace +from pathlib import Path +from typing import Any, BinaryIO, Iterable + +from pycdlib.dr import DirectoryRecord +from pycdlib.inode import Inode +from pycdlib.pycdlib import PyCdlib + +## Utilities + +PATH_IGNORE_REGEX: re.Pattern = re.compile(r";[0-9]+$") +PATH_INVALID_REGEX: re.Pattern = re.compile(r"[^0-9A-Z_./]") + +def normalizePath(path: str, isDirectory: bool = False) -> str: + path = PATH_IGNORE_REGEX.sub("", path.upper()) + path = PATH_INVALID_REGEX.sub("_", path) + + if not path.startswith("/"): + path = f"/{path}" + + if isDirectory: + path = path.replace(".", "_") + else: + # The ISO9660 specification requires file names to always contain + # exactly one period (even if no extension is present) and be terminated + # with a version number. + numPeriods: int = path.count(".") + + if numPeriods: + path = path.replace(".", "_", numPeriods - 1) + else: + path += "." + + path += ";1" + + return path + +def restoreISOFileOrder( + iso: PyCdlib, isoEntries: Iterable[DirectoryRecord | Inode], + printLayout: bool = False +): + # By default, when calling force_consistency(), pycdlib allocates files in + # the same order as their respective directory records, which are in turn + # sorted alphabetically as required by the ISO9660 specification. We're + # going to "undo" this sorting by iterating through the provided array and + # reassigning an LBA to each file and directory manually to restore the + # intended order. + iso.force_consistency() + + sectorLength: int = iso.logical_block_size + sectorOffset: int = \ + iso.pvd.path_table_location_be + iso.pvd.path_table_num_extents + + if printLayout: + sys.stderr.write("CD-ROM image layout:\n") + + # Allocate the indices (arrays of directory records) for the root directory + # and each subfolder. Note that these will always be placed before any file + # data, regardless of where they are listed in the configuration file. + for entry in isoEntries: + names: list[str] = [] + length: int = \ + (entry.data_length + sectorLength - 1) // sectorLength + + if isinstance(entry, DirectoryRecord): + entry.set_data_location(sectorOffset, 0) + names.append(entry.file_identifier().decode("ascii")) + else: + entry.set_extent_location(sectorOffset) + + for record, _ in entry.linked_records: + if not isinstance(record, DirectoryRecord): + continue + + record.set_data_location(sectorOffset, 0) + names.append(record.file_identifier().decode("ascii")) + + if printLayout: + nameList: str = ", ".join(names) + + sys.stderr.write( + f" [{sectorOffset:6d}-{sectorOffset + length - 1:6d}] " + f"{nameList}\n" + ) + + sectorOffset += length + + if printLayout: + sys.stderr.write(f"Total image size: {sectorOffset} sectors\n") + +def showProgress(part: int, total: int, _: Any): + if part >= total: + sys.stderr.write("\rPacking finished.\n") + else: + sys.stderr.write(f"\rPacking: {part / total * 100.0:5.1f}%") + +class PaddingFile(BinaryIO): + mode: str = "rb" + + def read(self, length: int) -> bytes: + return bytes(length) + +## Main + +def createParser() -> ArgumentParser: + parser = ArgumentParser( + description = \ + "Parses a JSON file containing a list of ISO9660 identifiers, file " + "and directory entries and generates a CD-ROM image.", + 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( + "-q", "--quiet", + action = "store_true", + help = "Suppress all non-error output" + ) + + group = parser.add_argument_group("CD-ROM image options") + group.add_argument( + "-l", "--level", + type = lambda value: int(value, 0), + default = 1, + help = \ + "Set ISO9660 interchange level and maximum file name length " + "(default 1)", + metavar = "1-4" + ) + group.add_argument( + "-x", "--xa", + action = "store_true", + help = "Add CD-XA header and metadata tags" + ) + group.add_argument( + "-S", "--system-area", + type = FileType("rb"), + help = \ + "Insert specified file (in 2048-bytes-per-sector format, up to 32 " + "KB) into the image's system area", + metavar = "file" + ) + + group = parser.add_argument_group("File paths") + group.add_argument( + "-s", "--source-dir", + type = Path, + help = \ + "Set path to directory containing source files (same directory as " + "configuration file by default)", + metavar = "dir" + ) + group.add_argument( + "configFile", + type = FileType("rt"), + help = "Path to JSON configuration file", + ) + group.add_argument( + "output", + type = FileType("wb"), + help = "Path to ISO9660 CD-ROM image to generate" + ) + + return parser + +def main(): + parser: ArgumentParser = createParser() + args: Namespace = parser.parse_args() + + with args.configFile as _file: + configFile: dict[str, Any] = json.load(_file) + sourceDir: Path = \ + args.source_dir or Path(_file.name).parent + + iso: PyCdlib = PyCdlib() + paddingFile: PaddingFile = PaddingFile() + + identifiers: dict[str, str] = configFile.get("identifiers", {}) + + iso.new( + interchange_level = args.level, + sys_ident = identifiers.get("system", ""), + vol_ident = identifiers.get("volume", ""), + vol_set_ident = identifiers.get("volumeSet", ""), + pub_ident_str = identifiers.get("publisher", ""), + preparer_ident_str = identifiers.get("dataPreparer", ""), + app_ident_str = identifiers.get("application", ""), + copyright_file = identifiers.get("copyright", ""), + abstract_file = identifiers.get("abstract", ""), + bibli_file = identifiers.get("bibliographic", ""), + joliet = None, + rock_ridge = None, + xa = args.xa, + udf = None + ) + + entryList: list[dict[str, Any]] = configFile["entries"] + isoEntries: list[DirectoryRecord | Inode] = [ + iso.pvd.root_directory_record() + ] + + for entry in entryList: + match entry.get("type", "file").strip(): + case "padding": + name: str = normalizePath(entry["name"]) + + iso.add_fp( + fp = paddingFile, + length = int(entry["size"]), + iso_path = name + ) + iso.set_hidden(iso_path = name) + isoEntries.append(iso.inodes[-1]) + + case "file": + iso.add_file( + filename = sourceDir / entry["source"], + iso_path = normalizePath(entry["name"]) + ) + isoEntries.append(iso.inodes[-1]) + + case "fileAlias": + iso.add_hard_link( + iso_old_path = normalizePath(entry["source"]), + iso_new_path = normalizePath(entry["name"]) + ) + + case "directoryAlias": + iso.add_hard_link( + iso_old_path = normalizePath(entry["source"], True), + iso_new_path = normalizePath(entry["name"], True) + ) + + case "directory": + name: str = normalizePath(entry["name"], True) + + iso.add_directory(iso_path = name) + isoEntries.append(iso.get_record(iso_path = name)) + + case _type: + raise KeyError(f"unsupported entry type '{_type}'") + + restoreISOFileOrder(iso, isoEntries, not args.quiet) + + with args.output as _file: + iso.write_fp( + outfp = _file, + progress_cb = None if args.quiet else showProgress + ) + iso.close() + + if args.system_area: + with args.system_area as inputFile: + _file.seek(0) + _file.write(inputFile.read(iso.logical_block_size * 16)) + +if __name__ == "__main__": + main() diff --git a/tools/buildCartDB.py b/tools/buildCartDB.py index a32d26c..43a550c 100755 --- a/tools/buildCartDB.py +++ b/tools/buildCartDB.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -__version__ = "0.4.1" +__version__ = "0.4.2" __author__ = "spicyjpeg" import json, logging, os, re @@ -14,6 +14,7 @@ from typing import Any, Mapping, Sequence, TextIO from common.cart import CartDump, DumpFlag from common.cartdata import * from common.games import GameDB, GameDBEntry +from common.util import setupLogger ## MAME NVRAM file parser @@ -200,17 +201,6 @@ def createParser() -> ArgumentParser: return parser -def setupLogger(level: int | None): - logging.basicConfig( - format = "[{levelname:8s}] {message}", - style = "{", - level = ( - logging.WARNING, - logging.INFO, - logging.DEBUG - )[min(level or 0, 2)] - ) - def main(): parser: ArgumentParser = createParser() args: Namespace = parser.parse_args() diff --git a/tools/buildFlashDB.py b/tools/buildFlashDB.py index eea5eb0..a3fb8f5 100755 --- a/tools/buildFlashDB.py +++ b/tools/buildFlashDB.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -__version__ = "0.4.1" +__version__ = "0.4.2" __author__ = "spicyjpeg" import json, logging, os, re @@ -12,7 +12,7 @@ from typing import ByteString, Mapping, TextIO from common.cart import DumpFlag, ROMHeaderDump from common.cartdata import * from common.games import GameDB, GameDBEntry -from common.util import InterleavedFile +from common.util import InterleavedFile, setupLogger ## Flash dump "parser" @@ -143,17 +143,6 @@ def createParser() -> ArgumentParser: return parser -def setupLogger(level: int | None): - logging.basicConfig( - format = "[{levelname:8s}] {message}", - style = "{", - level = ( - logging.WARNING, - logging.INFO, - logging.DEBUG - )[min(level or 0, 2)] - ) - def main(): parser: ArgumentParser = createParser() args: Namespace = parser.parse_args() diff --git a/tools/buildResourceArchive.py b/tools/buildResourceArchive.py index 1047855..e31f0e2 100755 --- a/tools/buildResourceArchive.py +++ b/tools/buildResourceArchive.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -__version__ = "0.4.1" +__version__ = "0.4.2" __author__ = "spicyjpeg" import json from argparse import ArgumentParser, FileType, Namespace from pathlib import Path -from typing import ByteString +from typing import Any, ByteString from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile import lz4.block @@ -50,10 +50,11 @@ def createParser() -> ArgumentParser: group = parser.add_argument_group("File paths") group.add_argument( "-s", "--source-dir", - type = Path, - help = \ + type = Path, + help = \ "Set path to directory containing source files (same directory as " - "resource list by default)" + "resource list by default)", + metavar = "dir" ) group.add_argument( "resourceList", @@ -73,8 +74,9 @@ def main(): args: Namespace = parser.parse_args() with args.resourceList as _file: - assetList: list = json.load(_file) - sourceDir: Path = args.source_dir or Path(_file.name).parent + assetList: list[dict[str, Any]] = json.load(_file) + sourceDir: Path = \ + args.source_dir or Path(_file.name).parent with ZipFile(args.output, "w", allowZip64 = False) as _zip: for asset in assetList: diff --git a/tools/common/assets.py b/tools/common/assets.py index 17a15ca..9828b64 100644 --- a/tools/common/assets.py +++ b/tools/common/assets.py @@ -9,7 +9,7 @@ from typing import Any, Generator, Mapping, Sequence import numpy from numpy import ndarray from PIL import Image -from .util import hashData +from .util import colorFromString, hashData ## .TIM image converter @@ -155,8 +155,9 @@ def generateFontMetrics(metrics: Mapping[str, Any]) -> bytearray: ## Color palette generator -_PALETTE_COLOR_REGEX: re.Pattern = re.compile(r"^#?([0-9A-Fa-f]{6})$") -_PALETTE_COLORS: Sequence[str] = ( +_PALETTE_ENTRY_STRUCT: Struct = Struct("< 3B x") + +_PALETTE_ENTRIES: Sequence[str] = ( "default", "shadow", "backdrop", @@ -177,25 +178,22 @@ _PALETTE_COLORS: Sequence[str] = ( "subtitle" ) -_PALETTE_ENTRY_STRUCT: Struct = Struct("< 3s x") - -def generateColorPalette(palette: Mapping[str, str]) -> bytearray: +def generateColorPalette( + palette: Mapping[str, str | Sequence[int]] +) -> bytearray: data: bytearray = bytearray() - for entry in _PALETTE_COLORS: - color: str | None = palette.get(entry, None) + for entry in _PALETTE_ENTRIES: + color: str | Sequence[int] | None = palette.get(entry, None) if color is None: raise ValueError(f"no entry found for {entry}") + if isinstance(color, str): + r, g, b = colorFromString(color) + else: + r, g, b = color - matched: re.Match | None = _PALETTE_COLOR_REGEX.match(color) - - if matched is None: - raise ValueError(f"invalid color value: {color}") - - colorValue: bytes = bytes.fromhex(matched.group(1)) - - data.extend(_PALETTE_ENTRY_STRUCT.pack(colorValue)) + data.extend(_PALETTE_ENTRY_STRUCT.pack(r, g, b)) return data @@ -241,7 +239,7 @@ def _walkStringTree( if value is None: yield hashData(fullKey.encode("ascii")), None - elif type(value) is str: + elif isinstance(value, str): yield hashData(fullKey.encode("ascii")), _convertString(value) else: yield from _walkStringTree(value, f"{fullKey}.") diff --git a/tools/common/util.py b/tools/common/util.py index 71a2511..ce38697 100644 --- a/tools/common/util.py +++ b/tools/common/util.py @@ -1,10 +1,16 @@ # -*- coding: utf-8 -*- -from hashlib import md5 -from io import SEEK_SET, SEEK_END -from typing import BinaryIO, ByteString, Iterable, Iterator, Sequence, TextIO +import logging, re +from collections import deque +from hashlib import md5 +from io import SEEK_END, SEEK_SET +from typing import \ + BinaryIO, ByteString, Iterable, Iterator, Mapping, MutableSequence, \ + Sequence, TextIO -## Misc. utilities +import numpy + +## Value and array manipulation def signExtend(value: int, bitLength: int) -> int: signMask: int = 1 << (bitLength - 1) @@ -12,12 +18,31 @@ def signExtend(value: int, bitLength: int) -> int: return (value & valueMask) - (value & signMask) +def blitArray( + source: numpy.ndarray, dest: numpy.ndarray, position: Sequence[int] +): + pos: map[int | None] = map(lambda x: x if x >= 0 else None, position) + neg: map[int | None] = map(lambda x: -x if x < 0 else None, position) + + destView: numpy.ndarray = dest[tuple( + slice(start, None) for start in pos + )] + sourceView: numpy.ndarray = source[tuple( + slice(start, end) for start, end in zip(neg, destView.shape) + )] + + destView[tuple( + slice(None, end) for end in source.shape + )] = sourceView + ## String manipulation # This encoding is similar to standard base45, but with some problematic # characters (' ', '$', '%', '*') excluded. _BASE41_CHARSET: str = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./:" +_COLOR_REGEX: re.Pattern = re.compile(r"^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$") + def toPrintableChar(value: int) -> str: if (value < 0x20) or (value > 0x7e): return "." @@ -54,6 +79,27 @@ def decodeBase41(data: str) -> bytearray: return output +def colorFromString(value: str) -> tuple[int, int, int]: + matched: re.Match | None = _COLOR_REGEX.match(value) + + if matched is None: + raise ValueError(f"invalid color value '{value}'") + + digits: str = matched.group(1) + + if len(digits) == 3: + return ( + int(digits[0], 16) * 0x11, + int(digits[1], 16) * 0x11, + int(digits[2], 16) * 0x11 + ) + else: + return ( + int(digits[0:2], 16), + int(digits[2:4], 16), + int(digits[4:6], 16) + ) + ## Hashes and checksums def hashData(data: Iterable[int]) -> int: @@ -87,6 +133,19 @@ def shortenedMD5(data: ByteString) -> bytearray: return output +## Logging + +def setupLogger(level: int | None): + logging.basicConfig( + format = "[{levelname:8s}] {message}", + style = "{", + level = ( + logging.WARNING, + logging.INFO, + logging.DEBUG + )[min(level or 0, 2)] + ) + ## Odd/even interleaved file reader class InterleavedFile(BinaryIO): @@ -139,3 +198,158 @@ class InterleavedFile(BinaryIO): self._offset += _length return output + +## Boolean algebra expression parser + +class BooleanOperator: + precedence: int = 1 + operands: int = 2 + + @staticmethod + def execute(stack: MutableSequence[bool]): + pass + +class AndOperator(BooleanOperator): + precedence: int = 2 + + @staticmethod + def execute(stack: MutableSequence[bool]): + a: bool = stack.pop() + b: bool = stack.pop() + + stack.append(a and b) + +class OrOperator(BooleanOperator): + @staticmethod + def execute(stack: MutableSequence[bool]): + a: bool = stack.pop() + b: bool = stack.pop() + + stack.append(a or b) + +class XorOperator(BooleanOperator): + @staticmethod + def execute(stack: MutableSequence[bool]): + a: bool = stack.pop() + b: bool = stack.pop() + + stack.append(a != b) + +class NotOperator(BooleanOperator): + precedence: int = 3 + operands: int = 1 + + @staticmethod + def execute(stack: MutableSequence): + stack.append(not stack.pop()) + +_OPERATORS: Mapping[str, type[BooleanOperator]] = { + "*": AndOperator, + "+": OrOperator, + "@": XorOperator, + "~": NotOperator +} + +class BooleanFunction: + def __init__(self, expression: str): + # "Compile" the expression to its respective RPN representation using + # the shunting yard algorithm. + self.expression: list[str | type[BooleanOperator]] = [] + + operators: deque[str] = deque() + tokenBuffer: str = "" + + for char in expression: + if char not in "*+@~()": + tokenBuffer += char + continue + + # Flush the non-operator token buffer when an operator is + # encountered. + if tokenBuffer: + self.expression.append(tokenBuffer) + tokenBuffer = "" + + match char: + case "(": + operators.append(char) + case ")": + if "(" not in operators: + raise RuntimeError("mismatched parentheses in expression") + while (op := operators.pop()) != "(": + self.expression.append(_OPERATORS[op]) + case _: + precedence: int = _OPERATORS[char].precedence + + while operators: + op: str = operators[-1] + + if op == "(": + break + if _OPERATORS[op].precedence < precedence: + break + + self.expression.append(_OPERATORS[op]) + operators.pop() + + operators.append(char) + + if tokenBuffer: + self.expression.append(tokenBuffer) + tokenBuffer = "" + + if "(" in operators: + raise RuntimeError("mismatched parentheses in expression") + while operators: + self.expression.append(_OPERATORS[operators.pop()]) + + def evaluate(self, variables: Mapping[str, bool]) -> bool: + values: dict[str, bool] = { "0": False, "1": True, **variables } + stack: deque[bool] = deque() + + for token in self.expression: + if isinstance(token, str): + value: bool | None = values.get(token) + + if value is None: + raise RuntimeError(f"unknown variable '{token}'") + + stack.append(value) + else: + token.execute(stack) + + if len(stack) != 1: + raise RuntimeError("invalid or malformed expression") + + return stack[0] + +## Logic lookup table conversion + +def generateLUTFromExpression(expression: str, inputs: Sequence[str]) -> int: + lut: int = 0 + + function: BooleanFunction = BooleanFunction(expression) + variables: dict[str, bool] = {} + + for index in range(1 << len(inputs)): + for bit, name in enumerate(inputs): + variables[name] = bool((index >> bit) & 1) + + if function.evaluate(variables): + lut |= 1 << index # LSB-first + + return lut + +def generateExpressionFromLUT(lut: int, inputs: Sequence[str]) -> str: + products: list[str] = [] + + for index in range(1 << len(inputs)): + values: str = "*".join( + (value if (index >> bit) & 1 else f"~{value}") + for bit, value in enumerate(inputs) + ) + + if (lut >> index) & 1: + products.append(f"({values})") + + return "+".join(products) or "0" diff --git a/tools/requirements.txt b/tools/requirements.txt index 334576e..2eb8864 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -2,6 +2,7 @@ # py -m pip install -r tools/requirements.txt (Windows) # sudo pip install -r tools/requirements.txt (Linux/macOS) -lz4 >= 4.3.2 -numpy >= 1.19.4 -Pillow >= 8.2.0 +lz4 >= 4.3.2 +numpy >= 1.19.4 +Pillow >= 8.2.0 +pycdlib >= 1.14.0