Make launchers standalone, remove xorriso dependency

This commit is contained in:
spicyjpeg 2024-05-02 09:06:11 +02:00
parent 176eb07915
commit b921d6e7b4
No known key found for this signature in database
GPG Key ID: 5CC87404C01DF393
14 changed files with 732 additions and 136 deletions

View File

@ -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"
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}
"$<TARGET_FILE:${name}>"
"${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(
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
"${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
)
)
list(APPEND packageContents "${releaseName}.iso")
if(EXISTS "${CHDMAN_PATH}")
if(EXISTS "${CHDMAN_PATH}")
add_custom_command(
COMMAND
"${CHDMAN_PATH}" createcd -f
-i "${releaseName}.iso"
-o "${releaseName}.chd"
OUTPUT "${releaseName}.chd"
DEPENDS "${releaseName}.iso"
-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}.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")

126
cd.json Normal file
View File

@ -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
}
]
}

View File

@ -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"
},
{

View File

@ -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
}
}

View File

@ -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
);
}

View File

@ -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.

View File

@ -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;

280
tools/buildCDImage.py Executable file
View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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
@ -53,7 +53,8 @@ def createParser() -> ArgumentParser:
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:

View File

@ -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}.")

View File

@ -1,10 +1,16 @@
# -*- coding: utf-8 -*-
import logging, re
from collections import deque
from hashlib import md5
from io import SEEK_SET, SEEK_END
from typing import BinaryIO, ByteString, Iterable, Iterator, Sequence, TextIO
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"

View File

@ -5,3 +5,4 @@
lz4 >= 4.3.2
numpy >= 1.19.4
Pillow >= 8.2.0
pycdlib >= 1.14.0