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") set(CMAKE_TOOLCHAIN_FILE "${CMAKE_CURRENT_LIST_DIR}/cmake/toolchain.cmake")
project( project(
cart_tool_private cart_tool
LANGUAGES C CXX ASM LANGUAGES C CXX ASM
VERSION 0.4.2 VERSION 0.4.2
DESCRIPTION "Konami System 573 security cartridge tool" DESCRIPTION "Konami System 573 security cartridge tool"
) )
find_package(Python3 REQUIRED COMPONENTS Interpreter) find_package(Python3 REQUIRED COMPONENTS Interpreter)
find_program(
XORRISO_PATH xorriso
PATHS "C:/msys64/usr/bin"
DOC "Path to xorriso tool (optional)"
)
find_program( find_program(
CHDMAN_PATH chdman CHDMAN_PATH chdman
PATHS "C:/Program Files/MAME" "C:/Program Files (x86)/MAME" PATHS
DOC "Path to MAME chdman tool (optional)" "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}") string(REPLACE "." "_" _version "${PROJECT_VERSION}")
set(releaseInfo "cart_tool build ${PROJECT_VERSION} - (C) 2022-2024 spicyjpeg")
set(releaseName "cart_tool_${PROJECT_VERSION}") set(
set(cdVolumeName "CART_TOOL_${_version}") 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 ## Files common to all executables
@ -61,7 +70,7 @@ function(addExecutable name address stackTop)
COMMAND COMMAND
"${Python3_EXECUTABLE}" "${Python3_EXECUTABLE}"
"${PROJECT_SOURCE_DIR}/tools/convertExecutable.py" "${PROJECT_SOURCE_DIR}/tools/convertExecutable.py"
-r "${releaseInfo}" -r "${RELEASE_INFO}"
-s 0x${stackTop} -s 0x${stackTop}
"$<TARGET_FILE:${name}>" "$<TARGET_FILE:${name}>"
"${name}.psexe" "${name}.psexe"
@ -157,8 +166,6 @@ addExecutable(
) )
target_link_libraries(boot PRIVATE bootFlags) target_link_libraries(boot PRIVATE bootFlags)
list(APPEND packageContents boot.psexe)
function(addLauncher address stackTop) function(addLauncher address stackTop)
addExecutable( addExecutable(
launcher${address} ${address} ${stackTop} launcher${address} ${address} ${stackTop}
@ -236,9 +243,9 @@ add_custom_command(
resources.json resources.json
assets/app.palette.json assets/app.palette.json
assets/app.strings.json assets/app.strings.json
main main.psexe
launcher801f8000 launcher801f8000.psexe
launcher803f8000 launcher803f8000.psexe
COMMENT "Building resource archive" COMMENT "Building resource archive"
VERBATIM VERBATIM
) )
@ -249,80 +256,57 @@ addBinaryFile(
## CD-ROM image ## CD-ROM image
configure_file(cd.json cd.json ESCAPE_QUOTES)
configure_file(assets/cdreadme.txt readme.txt NEWLINE_STYLE CRLF) 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( add_custom_command(
COMMAND COMMAND
"${XORRISO_PATH}" "${CHDMAN_PATH}" createcd -f
-outdev "${releaseName}.iso" -i "${RELEASE_NAME}.iso"
-blank all -o "${RELEASE_NAME}.chd"
-rockridge off OUTPUT "${RELEASE_NAME}.chd"
-joliet off DEPENDS "${RELEASE_NAME}.iso"
-padding 2M COMMENT "Building MAME CHD image"
-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"
VERBATIM VERBATIM
) )
list(APPEND packageContents "${releaseName}.iso") list(APPEND packageFiles "${RELEASE_NAME}.chd")
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()
endif() endif()
## Release package ## Release package
list(
APPEND packageFiles
readme.txt
boot.psexe
"${RELEASE_NAME}.iso"
)
add_custom_command( add_custom_command(
COMMAND COMMAND
"${CMAKE_COMMAND}" -E tar cf "${CMAKE_COMMAND}" -E tar cf
"${releaseName}.zip" "${RELEASE_NAME}.zip"
--format=zip --format=zip
${packageContents} readme.txt ${packageFiles}
OUTPUT "${releaseName}.zip" OUTPUT "${RELEASE_NAME}.zip"
DEPENDS ${packageContents} DEPENDS ${packageFiles}
COMMENT "Packaging built files" COMMENT "Packaging built files"
VERBATIM 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", "type": "binary",
"name": "binaries/main.psexe.lz4", "name": "binaries/main.psexe.lz4",
"source": "main.psexe", "source": "${PROJECT_BINARY_DIR}/main.psexe",
"compression": "lz4" "compression": "lz4"
}, },
{ {
"type": "binary", "type": "binary",
"name": "binaries/launcher801f8000.psexe", "name": "binaries/launcher801f8000.psexe",
"source": "launcher801f8000.psexe" "source": "${PROJECT_BINARY_DIR}/launcher801f8000.psexe"
}, },
{ {
"type": "binary", "type": "binary",
"name": "binaries/launcher803f8000.psexe", "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; return true;
default: default:
#if 0
return CommonArgs::parseArgument(arg); 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) { static inline bool isDigitalIOPresent(void) {
return ( return (
(SYS573D_CPLD_STAT & (SYS573D_CPLD_STAT_ID1 | SYS573D_CPLD_STAT_ID2)) == (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" #include "vendor/ff.h"
LauncherError ExecutableLauncher::openFile(void) { LauncherError ExecutableLauncher::openFile(void) {
#if 0
if (!args.drive || !args.path) { if (!args.drive || !args.path) {
LOG("required arguments missing"); LOG("required arguments missing");
return INVALID_ARGS; 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 // The drive index is always a single digit, so there is no need to pull in
// strtol() here. // strtol() here.

View File

@ -302,7 +302,7 @@ void StorageActionsScreen::update(ui::Context &ctx) {
ctx.show(APP->_storageInfoScreen, true, true); ctx.show(APP->_storageInfoScreen, true, true);
} else { } else {
if (action.region.isPresent()) { if (action.region.isPresent()) {
this->selectedRegion = &(action.region); selectedRegion = &(action.region);
if (action.region.regionLength > 0x1000000) { if (action.region.regionLength > 0x1000000) {
APP->_cardSizeScreen.callback = action.target; 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 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
__version__ = "0.4.1" __version__ = "0.4.2"
__author__ = "spicyjpeg" __author__ = "spicyjpeg"
import json, logging, os, re import json, logging, os, re
@ -14,6 +14,7 @@ from typing import Any, Mapping, Sequence, TextIO
from common.cart import CartDump, DumpFlag from common.cart import CartDump, DumpFlag
from common.cartdata import * from common.cartdata import *
from common.games import GameDB, GameDBEntry from common.games import GameDB, GameDBEntry
from common.util import setupLogger
## MAME NVRAM file parser ## MAME NVRAM file parser
@ -200,17 +201,6 @@ def createParser() -> ArgumentParser:
return parser 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(): def main():
parser: ArgumentParser = createParser() parser: ArgumentParser = createParser()
args: Namespace = parser.parse_args() args: Namespace = parser.parse_args()

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
__version__ = "0.4.1" __version__ = "0.4.2"
__author__ = "spicyjpeg" __author__ = "spicyjpeg"
import json, logging, os, re import json, logging, os, re
@ -12,7 +12,7 @@ from typing import ByteString, Mapping, TextIO
from common.cart import DumpFlag, ROMHeaderDump from common.cart import DumpFlag, ROMHeaderDump
from common.cartdata import * from common.cartdata import *
from common.games import GameDB, GameDBEntry from common.games import GameDB, GameDBEntry
from common.util import InterleavedFile from common.util import InterleavedFile, setupLogger
## Flash dump "parser" ## Flash dump "parser"
@ -143,17 +143,6 @@ def createParser() -> ArgumentParser:
return parser 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(): def main():
parser: ArgumentParser = createParser() parser: ArgumentParser = createParser()
args: Namespace = parser.parse_args() args: Namespace = parser.parse_args()

View File

@ -1,13 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
__version__ = "0.4.1" __version__ = "0.4.2"
__author__ = "spicyjpeg" __author__ = "spicyjpeg"
import json import json
from argparse import ArgumentParser, FileType, Namespace from argparse import ArgumentParser, FileType, Namespace
from pathlib import Path from pathlib import Path
from typing import ByteString from typing import Any, ByteString
from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile
import lz4.block import lz4.block
@ -50,10 +50,11 @@ def createParser() -> ArgumentParser:
group = parser.add_argument_group("File paths") group = parser.add_argument_group("File paths")
group.add_argument( group.add_argument(
"-s", "--source-dir", "-s", "--source-dir",
type = Path, type = Path,
help = \ help = \
"Set path to directory containing source files (same directory as " "Set path to directory containing source files (same directory as "
"resource list by default)" "resource list by default)",
metavar = "dir"
) )
group.add_argument( group.add_argument(
"resourceList", "resourceList",
@ -73,8 +74,9 @@ def main():
args: Namespace = parser.parse_args() args: Namespace = parser.parse_args()
with args.resourceList as _file: with args.resourceList as _file:
assetList: list = json.load(_file) assetList: list[dict[str, Any]] = json.load(_file)
sourceDir: Path = args.source_dir or Path(_file.name).parent sourceDir: Path = \
args.source_dir or Path(_file.name).parent
with ZipFile(args.output, "w", allowZip64 = False) as _zip: with ZipFile(args.output, "w", allowZip64 = False) as _zip:
for asset in assetList: for asset in assetList:

View File

@ -9,7 +9,7 @@ from typing import Any, Generator, Mapping, Sequence
import numpy import numpy
from numpy import ndarray from numpy import ndarray
from PIL import Image from PIL import Image
from .util import hashData from .util import colorFromString, hashData
## .TIM image converter ## .TIM image converter
@ -155,8 +155,9 @@ def generateFontMetrics(metrics: Mapping[str, Any]) -> bytearray:
## Color palette generator ## Color palette generator
_PALETTE_COLOR_REGEX: re.Pattern = re.compile(r"^#?([0-9A-Fa-f]{6})$") _PALETTE_ENTRY_STRUCT: Struct = Struct("< 3B x")
_PALETTE_COLORS: Sequence[str] = (
_PALETTE_ENTRIES: Sequence[str] = (
"default", "default",
"shadow", "shadow",
"backdrop", "backdrop",
@ -177,25 +178,22 @@ _PALETTE_COLORS: Sequence[str] = (
"subtitle" "subtitle"
) )
_PALETTE_ENTRY_STRUCT: Struct = Struct("< 3s x") def generateColorPalette(
palette: Mapping[str, str | Sequence[int]]
def generateColorPalette(palette: Mapping[str, str]) -> bytearray: ) -> bytearray:
data: bytearray = bytearray() data: bytearray = bytearray()
for entry in _PALETTE_COLORS: for entry in _PALETTE_ENTRIES:
color: str | None = palette.get(entry, None) color: str | Sequence[int] | None = palette.get(entry, None)
if color is None: if color is None:
raise ValueError(f"no entry found for {entry}") 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) data.extend(_PALETTE_ENTRY_STRUCT.pack(r, g, b))
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))
return data return data
@ -241,7 +239,7 @@ def _walkStringTree(
if value is None: if value is None:
yield hashData(fullKey.encode("ascii")), None yield hashData(fullKey.encode("ascii")), None
elif type(value) is str: elif isinstance(value, str):
yield hashData(fullKey.encode("ascii")), _convertString(value) yield hashData(fullKey.encode("ascii")), _convertString(value)
else: else:
yield from _walkStringTree(value, f"{fullKey}.") yield from _walkStringTree(value, f"{fullKey}.")

View File

@ -1,10 +1,16 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from hashlib import md5 import logging, re
from io import SEEK_SET, SEEK_END from collections import deque
from typing import BinaryIO, ByteString, Iterable, Iterator, Sequence, TextIO 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: def signExtend(value: int, bitLength: int) -> int:
signMask: int = 1 << (bitLength - 1) signMask: int = 1 << (bitLength - 1)
@ -12,12 +18,31 @@ def signExtend(value: int, bitLength: int) -> int:
return (value & valueMask) - (value & signMask) 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 ## String manipulation
# This encoding is similar to standard base45, but with some problematic # This encoding is similar to standard base45, but with some problematic
# characters (' ', '$', '%', '*') excluded. # characters (' ', '$', '%', '*') excluded.
_BASE41_CHARSET: str = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./:" _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: def toPrintableChar(value: int) -> str:
if (value < 0x20) or (value > 0x7e): if (value < 0x20) or (value > 0x7e):
return "." return "."
@ -54,6 +79,27 @@ def decodeBase41(data: str) -> bytearray:
return output 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 ## Hashes and checksums
def hashData(data: Iterable[int]) -> int: def hashData(data: Iterable[int]) -> int:
@ -87,6 +133,19 @@ def shortenedMD5(data: ByteString) -> bytearray:
return output 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 ## Odd/even interleaved file reader
class InterleavedFile(BinaryIO): class InterleavedFile(BinaryIO):
@ -139,3 +198,158 @@ class InterleavedFile(BinaryIO):
self._offset += _length self._offset += _length
return output 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

@ -2,6 +2,7 @@
# py -m pip install -r tools/requirements.txt (Windows) # py -m pip install -r tools/requirements.txt (Windows)
# sudo pip install -r tools/requirements.txt (Linux/macOS) # sudo pip install -r tools/requirements.txt (Linux/macOS)
lz4 >= 4.3.2 lz4 >= 4.3.2
numpy >= 1.19.4 numpy >= 1.19.4
Pillow >= 8.2.0 Pillow >= 8.2.0
pycdlib >= 1.14.0