diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 0000000..ab1033b --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c9c0f88 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[tool.poetry] +name = "elfish-tools" +version = "0.0.1" +description = "Tools for handling fish data from the 1993 game EL-Fish." +authors = ["Josh Washburne "] +readme = "README.md" +packages = [{include = "elfish", from = "src"}] + +[tool.poetry.dependencies] +python = ">=3.10" + +[tool.poetry.group.dev.dependencies] +pylint = ">=3.0.3" +black = ">=24.1.1" +mypy = ">=1.8.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/src/elfish/__init__.py b/src/elfish/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/elfish/exceptions.py b/src/elfish/exceptions.py new file mode 100644 index 0000000..260a476 --- /dev/null +++ b/src/elfish/exceptions.py @@ -0,0 +1,15 @@ +"""Custom exceptions for fish parsing.""" + +class AuthorError(ValueError): + """ + The author must be no more than 15 characters in length and may only + contain a subset of special characters. + """ + + +class InvalidFSHFileError(ValueError): + """The FSH file provided is not valid and cannot be parsed safely.""" + + +class InvalidROEFileError(ValueError): + """The ROE file provided is not valid and cannot be parsed safely.""" diff --git a/src/elfish/roe.py b/src/elfish/roe.py new file mode 100644 index 0000000..ed5d053 --- /dev/null +++ b/src/elfish/roe.py @@ -0,0 +1,48 @@ +"""Tools for parsing a ROE file.""" + +from os import SEEK_CUR, SEEK_SET +from os.path import basename, splitext +from struct import unpack + +from .exceptions import InvalidROEFileError +from .utils import nibble_flip + + +class Roe: + """The DNA of an electronic fish with an embedded icon.""" + def __init__(self, name: str = "", author: str | None = None) -> None: + self.name: str = name + self.author: str | None = author + self.is_mutant: bool = False + self.unknowns: dict[str, bytes] = {} + + @classmethod + def from_file(cls, filename: str) -> "Roe": + """Factory for importing roe from a .ROE file.""" + + # Unlike .FSH files, the name comes from the filename itself. Since + # DOS was limited to the 8.3 naming convention, a fish's name has a + # forced limit of only eight characters. + name = splitext(basename(filename))[0][0:8] + roe = cls(name) + + with open(filename, "rb") as rfile: + # First byte of file determines mutant status. Non-zero = mutant. + # Second byte is padding. + roe.is_mutant = unpack("?x", rfile.read(2))[0] + + # Next two bytes are unknown. + roe.unknowns["00000002"] = unpack("2s", rfile.read(2))[0] + + # Next sixteen bytes is a nibble-flipped encoding of the original + # author with a zero null termination byte. + author = "" + flipped_author = unpack("16s", rfile.read(16))[0] + for letter in flipped_author: + code = nibble_flip(letter) + if code == 0: + break + author += chr(code) + roe.author = author.upper() + + return roe diff --git a/src/elfish/utils.py b/src/elfish/utils.py new file mode 100644 index 0000000..d17bb00 --- /dev/null +++ b/src/elfish/utils.py @@ -0,0 +1,5 @@ +"""Miscellaneous functions commonly used by all classes.""" + +def nibble_flip(num: int) -> int: + """Swaps the high and low nibbles in a byte. (Ex: 0x1a -> 0xa1)""" + return (num >> 4) | (num << 4) & 0xFF