2023-05-30 18:08:52 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
|
|
"""ELF executable to PlayStation 1 .EXE converter
|
|
|
|
|
|
|
|
A very simple script to convert ELF files compiled for the PS1 to the executable
|
|
|
|
format used by the BIOS, with support for setting initial $sp/$gp values and
|
|
|
|
customizing the region string (used by some emulators to determine whether they
|
|
|
|
should start in PAL or NTSC mode by default). Requires no external dependencies.
|
|
|
|
"""
|
|
|
|
|
2024-06-10 18:43:57 +02:00
|
|
|
__version__ = "0.1.2"
|
2023-05-30 18:08:52 +02:00
|
|
|
__author__ = "spicyjpeg"
|
|
|
|
|
2023-06-03 08:25:41 +02:00
|
|
|
from argparse import ArgumentParser, FileType, Namespace
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from enum import IntEnum, IntFlag
|
|
|
|
from struct import Struct
|
2024-01-08 19:02:16 +01:00
|
|
|
from typing import BinaryIO, Generator
|
2023-05-30 18:08:52 +02:00
|
|
|
|
|
|
|
## Utilities
|
|
|
|
|
|
|
|
def alignToMultiple(data: bytearray, alignment: int):
|
|
|
|
padAmount: int = alignment - (len(data) % alignment)
|
|
|
|
|
|
|
|
if padAmount < alignment:
|
|
|
|
data.extend(b"\0" * padAmount)
|
|
|
|
|
2024-06-10 18:43:57 +02:00
|
|
|
def parseStructFromFile(file: BinaryIO, _struct: Struct) -> tuple:
|
|
|
|
return _struct.unpack(file.read(_struct.size))
|
2023-05-30 18:08:52 +02:00
|
|
|
|
|
|
|
def parseStructsFromFile(
|
2024-06-10 18:43:57 +02:00
|
|
|
file: BinaryIO, _struct: Struct, count: int
|
2023-05-30 18:08:52 +02:00
|
|
|
) -> Generator[tuple, None, None]:
|
2024-06-10 18:43:57 +02:00
|
|
|
data: bytes = file.read(_struct.size * count)
|
2023-05-30 18:08:52 +02:00
|
|
|
|
|
|
|
for offset in range(0, len(data), _struct.size):
|
|
|
|
yield _struct.unpack(data[offset:offset + _struct.size])
|
|
|
|
|
|
|
|
## ELF file parser
|
|
|
|
|
|
|
|
ELF_HEADER_STRUCT: Struct = Struct("< 4s 4B 8x 2H 5I 6H")
|
|
|
|
ELF_HEADER_MAGIC: bytes = b"\x7fELF"
|
|
|
|
PROG_HEADER_STRUCT: Struct = Struct("< 8I")
|
|
|
|
|
|
|
|
class ELFType(IntEnum):
|
|
|
|
RELOCATABLE = 1
|
|
|
|
EXECUTABLE = 2
|
|
|
|
SHARED = 3
|
|
|
|
CORE = 4
|
|
|
|
|
|
|
|
class ELFArchitecture(IntEnum):
|
|
|
|
MIPS = 8
|
|
|
|
|
|
|
|
class ELFEndianness(IntEnum):
|
|
|
|
LITTLE = 1
|
|
|
|
BIG = 2
|
|
|
|
|
|
|
|
class ProgHeaderType(IntEnum):
|
|
|
|
NONE = 0
|
|
|
|
LOAD = 1
|
|
|
|
DYNAMIC = 2
|
|
|
|
INTERPRETER = 3
|
|
|
|
NOTE = 4
|
|
|
|
|
|
|
|
class ProgHeaderFlag(IntFlag):
|
|
|
|
EXECUTE = 1 << 0
|
|
|
|
WRITE = 1 << 1
|
|
|
|
READ = 1 << 2
|
|
|
|
|
2023-06-03 08:25:41 +02:00
|
|
|
@dataclass
|
2023-05-30 18:08:52 +02:00
|
|
|
class Segment:
|
2023-06-03 08:25:41 +02:00
|
|
|
address: int
|
|
|
|
data: bytes
|
|
|
|
flags: ProgHeaderFlag
|
2023-05-30 18:08:52 +02:00
|
|
|
|
|
|
|
def isReadOnly(self) -> bool:
|
|
|
|
return not \
|
|
|
|
(self.flags & (ProgHeaderFlag.WRITE | ProgHeaderFlag.EXECUTE))
|
|
|
|
|
|
|
|
class ELF:
|
2024-06-10 18:43:57 +02:00
|
|
|
def __init__(self, file: BinaryIO):
|
2023-05-30 18:08:52 +02:00
|
|
|
# Parse the file header and perform some minimal validation.
|
2024-06-10 18:43:57 +02:00
|
|
|
file.seek(0)
|
2023-05-30 18:08:52 +02:00
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
(
|
|
|
|
magic,
|
|
|
|
wordSize,
|
|
|
|
endianness,
|
|
|
|
_,
|
|
|
|
abi,
|
|
|
|
elfType,
|
|
|
|
architecture,
|
|
|
|
_,
|
|
|
|
entryPoint,
|
|
|
|
progHeaderOffset,
|
|
|
|
secHeaderOffset,
|
|
|
|
flags,
|
|
|
|
elfHeaderSize,
|
|
|
|
progHeaderSize,
|
|
|
|
progHeaderCount,
|
|
|
|
secHeaderSize,
|
|
|
|
secHeaderCount,
|
|
|
|
_
|
|
|
|
) = \
|
2024-06-10 18:43:57 +02:00
|
|
|
parseStructFromFile(file, ELF_HEADER_STRUCT)
|
2023-05-30 18:08:52 +02:00
|
|
|
|
|
|
|
if magic != ELF_HEADER_MAGIC:
|
|
|
|
raise RuntimeError("file is not a valid ELF")
|
|
|
|
if wordSize != 1 or endianness != ELFEndianness.LITTLE:
|
|
|
|
raise RuntimeError("ELF file must be 32-bit little-endian")
|
|
|
|
if (
|
2023-06-03 08:25:41 +02:00
|
|
|
elfHeaderSize != ELF_HEADER_STRUCT.size or
|
|
|
|
progHeaderSize != PROG_HEADER_STRUCT.size
|
2023-05-30 18:08:52 +02:00
|
|
|
):
|
|
|
|
raise RuntimeError("unsupported ELF format")
|
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
self.type: ELFType = ELFType(elfType)
|
|
|
|
self.architecture: int = architecture
|
|
|
|
self.abi: int = abi
|
|
|
|
self.entryPoint: int = entryPoint
|
|
|
|
self.flags: int = flags
|
|
|
|
|
2023-05-30 18:08:52 +02:00
|
|
|
# Parse the program headers and extract all loadable segments.
|
2023-06-03 08:25:41 +02:00
|
|
|
self.segments: list[Segment] = []
|
|
|
|
|
2024-06-10 18:43:57 +02:00
|
|
|
file.seek(progHeaderOffset)
|
2023-05-30 18:08:52 +02:00
|
|
|
|
|
|
|
for (
|
2024-04-20 07:36:39 +02:00
|
|
|
headerType,
|
|
|
|
fileOffset,
|
|
|
|
address,
|
|
|
|
_,
|
|
|
|
fileLength,
|
|
|
|
length,
|
|
|
|
flags,
|
|
|
|
_
|
2024-06-10 18:43:57 +02:00
|
|
|
) in parseStructsFromFile(file, PROG_HEADER_STRUCT, progHeaderCount):
|
2023-05-30 18:08:52 +02:00
|
|
|
if headerType != ProgHeaderType.LOAD:
|
|
|
|
continue
|
|
|
|
|
|
|
|
# Retrieve the segment and trim or pad it if necessary.
|
2024-06-10 18:43:57 +02:00
|
|
|
file.seek(fileOffset)
|
|
|
|
data: bytes = file.read(fileLength)
|
2023-05-30 18:08:52 +02:00
|
|
|
|
|
|
|
if length > len(data):
|
|
|
|
data = data.ljust(length, b"\0")
|
|
|
|
else:
|
|
|
|
data = data[0:length]
|
|
|
|
|
|
|
|
self.segments.append(Segment(address, data, flags))
|
|
|
|
|
2024-06-10 18:43:57 +02:00
|
|
|
#file.close()
|
2023-05-30 18:08:52 +02:00
|
|
|
|
|
|
|
def flatten(self, stripReadOnly: bool = False) -> tuple[int, bytearray]:
|
|
|
|
# Find the lower and upper boundaries of the segments' address space.
|
|
|
|
startAddress: int = min(
|
|
|
|
seg.address for seg in self.segments
|
|
|
|
)
|
|
|
|
endAddress: int = max(
|
|
|
|
(seg.address + len(seg.data)) for seg in self.segments
|
|
|
|
)
|
|
|
|
|
|
|
|
# Copy all segments into a new byte array at their respective offsets.
|
|
|
|
data: bytearray = bytearray(endAddress - startAddress)
|
|
|
|
|
|
|
|
for seg in self.segments:
|
|
|
|
if stripReadOnly and seg.isReadOnly():
|
|
|
|
continue
|
|
|
|
|
|
|
|
offset: int = seg.address - startAddress
|
|
|
|
data[offset:offset + len(seg.data)] = seg.data
|
|
|
|
|
|
|
|
return startAddress, data
|
|
|
|
|
|
|
|
## Main
|
|
|
|
|
2024-04-20 07:36:39 +02:00
|
|
|
EXE_HEADER_STRUCT: Struct = Struct("< 8s 8x 4I 16x 2I 20x 1972s")
|
2023-05-30 18:08:52 +02:00
|
|
|
EXE_HEADER_MAGIC: bytes = b"PS-X EXE"
|
|
|
|
EXE_ALIGNMENT: int = 2048
|
|
|
|
|
|
|
|
def createParser() -> ArgumentParser:
|
|
|
|
parser = ArgumentParser(
|
|
|
|
description = \
|
|
|
|
"Converts an ELF executable into a PlayStation 1 .EXE file.",
|
|
|
|
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 = parser.add_argument_group("Conversion options")
|
|
|
|
group.add_argument(
|
|
|
|
"-r", "--region-str",
|
|
|
|
type = str,
|
|
|
|
default = "",
|
|
|
|
help = "Add a custom region string to the header",
|
|
|
|
metavar = "string"
|
|
|
|
)
|
|
|
|
group.add_argument(
|
|
|
|
"-s", "--set-sp",
|
|
|
|
type = lambda value: int(value, 0),
|
|
|
|
default = 0,
|
|
|
|
help = "Add an initial value for the stack pointer to the header",
|
|
|
|
metavar = "value"
|
|
|
|
)
|
|
|
|
group.add_argument(
|
|
|
|
"-g", "--set-gp",
|
|
|
|
type = lambda value: int(value, 0),
|
|
|
|
default = 0,
|
|
|
|
help = "Add an initial value for the global pointer to the header",
|
|
|
|
metavar = "value"
|
|
|
|
)
|
|
|
|
group.add_argument(
|
|
|
|
"-S", "--strip-read-only",
|
|
|
|
action = "store_true",
|
|
|
|
help = \
|
|
|
|
"Remove all ELF segments not marked writable nor executable from "
|
|
|
|
"the output file"
|
|
|
|
)
|
|
|
|
|
|
|
|
group = parser.add_argument_group("File paths")
|
|
|
|
group.add_argument(
|
|
|
|
"input",
|
|
|
|
type = FileType("rb"),
|
|
|
|
help = "Path to ELF input executable",
|
|
|
|
)
|
|
|
|
group.add_argument(
|
|
|
|
"output",
|
|
|
|
type = FileType("wb"),
|
|
|
|
help = "Path to PS1 executable to generate"
|
|
|
|
)
|
|
|
|
|
|
|
|
return parser
|
|
|
|
|
|
|
|
def main():
|
|
|
|
parser: ArgumentParser = createParser()
|
|
|
|
args: Namespace = parser.parse_args()
|
|
|
|
|
2024-06-10 18:43:57 +02:00
|
|
|
with args.input as file:
|
2023-05-30 18:08:52 +02:00
|
|
|
try:
|
2024-06-10 18:43:57 +02:00
|
|
|
elf: ELF = ELF(file)
|
2023-05-30 18:08:52 +02:00
|
|
|
except RuntimeError as err:
|
|
|
|
parser.error(err.args[0])
|
|
|
|
|
|
|
|
if elf.type != ELFType.EXECUTABLE:
|
|
|
|
parser.error("ELF file must be an executable")
|
|
|
|
if elf.architecture != ELFArchitecture.MIPS:
|
|
|
|
parser.error("ELF architecture must be MIPS")
|
|
|
|
if not elf.segments:
|
|
|
|
parser.error("ELF file must contain at least one segment")
|
|
|
|
|
|
|
|
startAddress, data = elf.flatten(args.strip_read_only)
|
|
|
|
alignToMultiple(data, EXE_ALIGNMENT)
|
|
|
|
|
|
|
|
region: bytes = args.region_str.strip().encode("ascii")
|
|
|
|
header: bytes = EXE_HEADER_STRUCT.pack(
|
|
|
|
EXE_HEADER_MAGIC, # Magic
|
|
|
|
elf.entryPoint, # Entry point
|
|
|
|
args.set_gp, # Initial global pointer
|
|
|
|
startAddress, # Data load address
|
|
|
|
len(data), # Data size
|
|
|
|
args.set_sp, # Stack offset
|
|
|
|
0, # Stack size
|
|
|
|
region # Region string
|
|
|
|
)
|
|
|
|
|
2024-06-10 18:43:57 +02:00
|
|
|
with args.output as file:
|
|
|
|
file.write(header)
|
|
|
|
file.write(data)
|
2023-05-30 18:08:52 +02:00
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
main()
|