A Python library for parsing Bethesda game files, and a Qt-based GUI to go with it.
import abc
import dataclasses
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator, BinaryIO


@dataclasses.dataclass
class AbstractFile:
    path: str
    size: int

    # Container-specific metadata.
    meta: dataclasses.field(default_factory=dict)
    # The parent container for this file.
    container: "ArchiveContainer"

    @contextmanager
    def open(self):
        with self.container.open(self) as io:
            yield io

    def extract_into(self, directory: Path, *, overwrite: bool = False):
        """
        Extracts the file into a directory.

        :param directory: The directory to extract into.
        :param overwrite: Whether to overwrite existing files.
        """
        self.container.extract_into(self, directory, overwrite=overwrite)


class ArchiveContainer(abc.ABC):
    """
    Provides a base class for working with files that contain other files in
    a virtual filesystem, such as a .bas or .ba2 archive.
    """

    @abc.abstractmethod
    def read_from_file(self, file: BinaryIO):
        """
        Reads the archive from a file.
        """

    @abc.abstractmethod
    def files(self) -> Iterator[AbstractFile]:
        """
        Returns an iterator over the files in the archive.
        """

    @abc.abstractmethod
    @contextmanager
    def open(self, file: AbstractFile):
        """
        Opens a file in the archive.
        """

    def extract_into(
        self, file: AbstractFile, directory: Path, *, overwrite: bool = False
    ):
        """
        Extracts a file into a directory.

        :param file: The file to extract.
        :param directory: The directory to extract into.
        :param overwrite: Whether to overwrite existing files.
        """
        if not directory.is_dir():
            raise ValueError(f"{directory} is not a directory")

        final_path = directory / Path(file.path)
        if final_path.exists() and not overwrite:
            raise FileExistsError(f"{final_path} already exists")

        # Naive implementation that should be replaced by optimized methods
        # in subclasses. This will result in the entire thing getting read
        # into memory.
        with file.open() as io:
            with final_path.open("wb") as out:
                out.write(io.read())

    def add(self, file: AbstractFile, content: bytes):
        """
        Adds a file to the archive. If the file already exists, it will be
        overwritten.
        """
        raise NotImplementedError()

    def remove(self, file: AbstractFile):
        """
        Removes a file from the archive.
        """
        raise NotImplementedError()

    def save(self, io: BinaryIO):
        """
        Saves the archive to a file.
        """
        raise NotImplementedError()

starhopper/formats/archive.py
TkTech/starhopper
import contextlib
import zlib
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
from typing import BinaryIO, Iterator

import lz4.frame
import lz4.block

from starhopper.formats.archive import ArchiveContainer, AbstractFile
from starhopper.formats.common import Location
from starhopper.io import BinaryReader


@dataclass
class GeneralFile:
    hash_: int
    ext: str
    directory_hash: int
    unknown_0: int
    offset: int
    packed_size: int
    unpacked_size: int
    unknown_1: int
    path: bytes
    loc: Location
    reader: BinaryReader | None = None


class BA2Container(ArchiveContainer):
    """
    Parser for Bethesda .ba2 files.

    .. note::

        Currently, this only supports BTDX-versioned .ba2 files, such as those
        used in Starfield.
    """

    def __init__(self, file: BinaryIO | None = None):
        self._files: list[AbstractFile] = []

        if file is not None:
            self.read_from_file(file)

    def read_from_file(self, file: BinaryIO):
        """
        Reads a .ba2 file from a file-like object.

        :param file: Any file-like object supporting read().
        """
        io = BinaryReader(file)

        header = self.parse_header(io)
        name_table = self.parse_name_table(io, header)
        for file in self.parse_file_index(io, header, name_table):
            self._files.append(
                AbstractFile(
                    path=file.path.decode("ascii"),
                    container=self,
                    size=file.unpacked_size,
                    meta={
                        "_original": file,
                    },
                )
            )

    def files(self) -> Iterator[AbstractFile]:
        yield from self._files

    @contextlib.contextmanager
    def open(self, file: AbstractFile):
        """
        Opens a file in the archive.

        :param file: The file to open.
        :return: A file-like object.
        """
        # If there's no original metadata, then this file was added after
        # the archive was loaded and hasn't actually been written anywhere.
        with BytesIO() as destination:
            self._write_to_io(file, destination)
            destination.seek(0)
            yield destination

    def extract_into(
        self, file: AbstractFile, directory: Path, *, overwrite: bool = False
    ):
        if not directory.is_dir():
            raise ValueError(f"{directory} is not a directory")

        final_path = directory / Path(file.path)
        if final_path.exists() and not overwrite:
            raise FileExistsError(f"{final_path} already exists")

        final_path.parent.mkdir(parents=True, exist_ok=True)
        with open(final_path, "wb") as destination:
            self._write_to_io(file, destination)

    @staticmethod
    def _write_to_io(file: AbstractFile, destination: BinaryIO):
        original: GeneralFile | None = file.meta.get("_original")
        if original is None:
            destination.write(file.meta["_content"])
        else:
            reader = original.reader
            reader.seek(original.offset)
            if original.packed_size > 0:
                # This file is compressed.
                # TODO: We should see if these support chunked decoding to
                #       minimize memory usage.
                zlib_check = reader.read(2)
                reader.seek(original.offset)

                if zlib_check == b"\x78\xDA":
                    # Zlib-compressed.
                    unpacked = zlib.decompress(
                        reader.read(original.packed_size),
                        bufsize=original.unpacked_size,
                    )
                else:
                    unpacked = lz4.frame.decompress(
                        reader.read(original.packed_size)
                    )

                if len(unpacked) != original.unpacked_size:
                    raise ValueError(
                        f"Unpacked size mismatch: expected "
                        f"{original.unpacked_size}, got {len(unpacked)}"
                    )

                destination.write(unpacked)
            else:
                end = original.offset + original.unpacked_size
                for chunk in range(original.offset, end, 4096):
                    chunk = reader.read(4096)
                    if not chunk:
                        break
                    destination.write(chunk)

    @staticmethod
    def parse_header(reader: BinaryReader):
        """
        Parses the header of a .ba2 file.
        """
        with reader as header:
            header.bytes("file_id", 4).ensure("file_id", b"BTDX").uint32(
                "version"
            ).string("type", 4).change(lambda t: t.rstrip()).uint32(
                "file_count"
            ).uint64(
                "names_offset"
            )

            if header["version"] in (2, 3):
                header.uint32("unknown_1").uint32("unknown2_2")

            if header["version"] == 3:
                header.uint32("unknown_3")

            header.set(
                "loc",
                Location(header.start_pos, header.pos),
            )

            return header.data

    @staticmethod
    def parse_name_table(reader: BinaryReader, header: dict):
        reader.seek(header["names_offset"])
        result = [
            reader.read(reader.uint16()) for _ in range(header["file_count"])
        ]
        reader.seek(header["loc"].end)
        return result

    @staticmethod
    def parse_file_index(
        reader: BinaryReader, header: dict, name_table: list[bytes]
    ) -> list[GeneralFile]:
        if header["type"] == "GNRL":
            for index in range(header["file_count"]):
                with reader as file:
                    yield (
                        GeneralFile(
                            **file.uint32("hash_")
                            .string("ext", 4)
                            .change(lambda t: t.rstrip())
                            .uint32("directory_hash")
                            .uint32("unknown_0")
                            .uint64("offset")
                            .uint32("packed_size")
                            .uint32("unpacked_size")
                            .uint32("unknown_1")
                            .set(
                                "loc",
                                Location(
                                    file.start_pos,
                                    file.pos,
                                ),
                            )
                            .data,
                            reader=reader,
                            path=name_table[index],
                        )
                    )

starhopper/formats/btdx/file.py
TkTech/starhopper
import dataclasses
from typing import BinaryIO

from starhopper.io import BinaryReader, BinaryWriter


@dataclasses.dataclass
class Record:
    type_: bytes
    size: int
    data: bytes


class CompiledDBContainer:
    """
    Parser for a Bethesda CompiledDB file (.cdb).

    This file format is not yet fully understood. It appears to be a
    serialization of in-memory data structures used by the game engine and
    may change with each release.
    """

    def __init__(self, file: BinaryIO | None = None):
        self.records = []
        if file:
            self.read_from_file(file)

    def read_from_file(self, file: BinaryIO):
        io = BinaryReader(file)
        with io as header:
            header.bytes("magic", 4).ensure("magic", b"BETH").uint32(
                "size"
            ).ensure("size", 8).uint32("version").uint32("record_count")
            for i in range(header["record_count"] - 1):
                with io as record:
                    self.records.append(
                        Record(
                            **record.bytes("type_", 4)
                            .uint32("size")
                            .bytes("data", record["size"])
                            .data
                        )
                    )

    def save(self, destination: BinaryIO, *, version: int = 4):
        io = BinaryWriter(destination)
        io.write(b"BETH").uint32(8).uint32(version).uint32(
            len(self.records) + 1
        )
        for record in self.records:
            io.write(record.type_).uint32(len(record.data)).write(record.data)

starhopper/formats/cdb.py
TkTech/starhopper
import dataclasses


@dataclasses.dataclass
class Location:
    """
    Stores the location of a piece of data in a file.
    """

    start: int
    end: int

    @property
    def size(self):
        return self.end - self.start

starhopper/formats/common.py
TkTech/starhopper
import dataclasses
import enum
import zlib
from io import BytesIO
from typing import BinaryIO, Iterator, Any

from starhopper.formats.common import Location
from starhopper.io import BinaryReader


class RecordFlag(enum.IntFlag):
    Master = 0x01
    Deleted = 0x20
    Localized = 0x80
    Shadows = 0x200
    Persistent = 0x400
    Ignored = 0x1000
    VisibleWhenDistant = 0x8000
    Dangerous = 0x20000
    Compressed = 0x40000
    CantWait = 0x80000


class GroupType(enum.IntEnum):
    # GitHub Copilot crapped out this enum practically verbatim from the
    # ESMSharp project...
    Top = 0x00
    WorldChildren = 0x01
    InteriorCellBlock = 0x02
    InteriorCellSubBlock = 0x03
    ExteriorCellBlock = 0x04
    ExteriorCellSubBlock = 0x05
    CellChildren = 0x06
    TopicChildren = 0x07
    CellPersistentChildren = 0x08
    CellTemporaryChildren = 0x09
    CellVisibleDistantChildren = 0x0A


@dataclasses.dataclass
class Group:
    type: bytes
    size: int
    label: bytes
    group_type: int
    version: int
    loc: Location
    file: "ESMContainer"

    def get_friendly_label(self) -> str:
        """
        Get a human-friendly name for display.

        Only top-level GRUPs have a readable label. The rest must be resolved.
        """
        io = BinaryReader(BytesIO(self.label))
        match self.group_type:
            case GroupType.Top:
                return self.label.decode("ascii")
            case GroupType.WorldChildren:
                return f"World Children [{io.uint32():08X}]"
            case GroupType.InteriorCellBlock:
                return f"Interior Cell Block [{io.uint32():08X}]"
            case GroupType.InteriorCellSubBlock:
                return f"Interior Cell Sub-Block [{io.uint32():08X}]"
            case GroupType.ExteriorCellBlock:
                return f"Exterior Cell Block {io.uint16()}, {io.uint16()}"
            case GroupType.ExteriorCellSubBlock:
                return f"Exterior Cell Sub-Block {io.uint16()}, {io.uint16()}"
            case GroupType.CellChildren:
                return f"Cell Children [{io.uint32():08X}]"
            case GroupType.TopicChildren:
                return f"Topic Children [{io.uint32():08X}]"
            case GroupType.CellPersistentChildren:
                return f"Cell Persistent Children [{io.uint32():08X}]"
            case GroupType.CellTemporaryChildren:
                return f"Cell Temporary Children [{io.uint32():08X}]"
            case GroupType.CellVisibleDistantChildren:
                return f"Cell Visible Distant Children [{io.uint32():08X}]"
            case _:
                return f"Unknown (T:{self.group_type}, V:{self.label.hex()})"

    @property
    def metadata(self) -> dict[str, Any]:
        return {}

    def children(self):
        self.file.io.seek(self.loc.start + 24)
        while self.file.io.pos < self.loc.end:
            peek = self.file.io.read(4)
            self.file.io.seek(self.file.io.pos - 4)

            if peek == b"GRUP":
                group = self.file.parse_group(self.file.io)
                self.file.io.seek(group.loc.end)
                yield group
            else:
                record = self.file.parse_record()
                self.file.io.seek(record.loc.end)
                yield record


@dataclasses.dataclass
class Record:
    type: bytes
    size: int
    flags: RecordFlag
    form_id: int
    revision: int
    version: int
    loc: Location
    file: "ESMContainer"

    def fields(self):
        self.file.io.seek(self.loc.start + 24)
        if self.flags & RecordFlag.Compressed:
            decompressed_size = self.file.io.uint32()
            with BytesIO(
                zlib.decompress(
                    self.file.io.read(self.size - 4),
                    bufsize=decompressed_size,
                )
            ) as data:
                compressed_io = BinaryReader(data)
                while compressed_io.pos < decompressed_size:
                    field = self.file.parse_field(compressed_io)
                    yield field
        else:
            while self.file.io.pos < self.loc.end:
                field = self.file.parse_field()
                yield field


@dataclasses.dataclass
class Field:
    type: bytes
    size: int
    file: "ESMContainer"
    data: bytes


class ESMContainer:
    """
    Parser for a Bethesda ESM file.
    """

    def __init__(self, file: BinaryIO):
        self.file = file
        self.io = BinaryReader(file)
        self.header = self.parse_header(self.io)
        self.io.seek(self.header["loc"].end)
        self._groups = list(self.parse_groups(self.io))

    @staticmethod
    def parse_header(reader: BinaryReader):
        with reader as header:
            return (
                header.bytes("type", 4)
                .ensure("type", b"TES4")
                .uint32("size")
                .uint32("flags")
                .change(lambda f: RecordFlag(f))
                .uint32("form_id")
                .ensure("form_id", 0)
                .uint32("revision")
                .uint16("version")
                .skip(2)
                .set(
                    "loc",
                    Location(
                        header.start_pos,
                        header.start_pos + header["size"] + 24,
                    ),
                )
                .data
            )

    def parse_group(self, stream: BinaryReader):
        with stream as group:
            return Group(
                **group.bytes("type", 4)
                .uint32("size")
                .bytes("label", 4)
                .uint32("group_type")
                .skip(2)
                .skip(2)
                .uint16("version")
                .skip(2)
                .set(
                    "loc",
                    Location(
                        group.start_pos,
                        group.start_pos + group["size"],
                    ),
                )
                .data,
                file=self,
            )

    def parse_groups(self, reader: BinaryReader) -> Iterator[Group]:
        while True:
            try:
                group = self.parse_group(reader)
                reader.seek(group.loc.end)
                yield group
            except EOFError:
                break

    def parse_record(self) -> Record:
        with self.io as record:
            return Record(
                **record.bytes("type", 4)
                .uint32("size")
                .uint32("flags")
                .change(lambda f: RecordFlag(f))
                .uint32("form_id")
                .uint32("revision")
                .uint16("version")
                .skip(2)
                .set(
                    "loc",
                    Location(
                        record.start_pos,
                        record.start_pos + record["size"] + 24,
                    ),
                )
                .data,
                file=self,
            )

    def parse_field(self, using: BinaryReader | None = None) -> Field:
        with using or self.io as field:
            field.bytes("type", 4).uint16("size")

            if field["type"] == b"XXXX":
                # This took a really long time to figure out. The XXXX field
                # would parse fine, but a couple fields later parsing would
                # break. XXXX is used to indicate that the next field uses a
                # 32bit size instead of the usual 16bit size. The XXXX field
                # itself is not included in the size.
                field.uint32("size").bytes("type", 4).skip(2)
                if field["size"] == 0:
                    field.set("data", b"")
                else:
                    field.bytes("data", field["size"])
            else:
                if field["size"] == 0:
                    field.set("data", b"")
                else:
                    field.bytes("data", field["size"])

            return Field(
                **field.data,
                file=self,
            )

    @property
    def groups(self):
        return self._groups

starhopper/formats/esm/file.py
TkTech/starhopper
import abc
import enum
import importlib
import inspect
import pkgutil
from functools import cache
from io import BytesIO
from typing import Any

from starhopper.formats.esm.file import Record, Field
from starhopper.io import BinaryReader


class Missing:
    pass


class BasicType(enum.IntEnum):
    String: int = 0
    Int: int = 1
    Float: int = 2
    Bool: int = 3
    Unknown: int = 4


class RecordField(abc.ABC):
    """
    Base class for an ESM field contained in a record.
    """

    def __init__(self, name: str):
        self.name = name

    def __repr__(self):
        return f"<{self.__class__.__name__}({self.name!r})>"

    @staticmethod
    @abc.abstractmethod
    def label() -> str:
        pass

    def read_from_field(self, record: Record, field: Field):
        with BytesIO(field.data) as src:
            io = BinaryReader(src)
            return self.read(record, field, io)

    @abc.abstractmethod
    def read(
        self, record: Record, field: Field, io: BinaryReader
    ) -> dict[str, Any]:
        pass


class UnknownField(RecordField):
    @staticmethod
    def label():
        return "Unknown"

    def read(self, record: "HighLevelRecord", field: Field, io: BinaryReader):
        with io as unknown:
            return (
                unknown.set("size", field.size).bytes("data", field.size).data
            )


class BaseRecord(abc.ABC):
    @staticmethod
    @abc.abstractmethod
    def label():
        pass

    @staticmethod
    @abc.abstractmethod
    def structure(record: Record):
        pass


class UnknownRecord(BaseRecord):
    @staticmethod
    def label():
        return "An Unknown Record"

    @staticmethod
    def structure(record: Record):
        return [UnknownField("Unknown")]


@cache
def get_all_records() -> dict[bytes, type[BaseRecord]]:
    """
    Returns a dictionary of all implemented record fields.
    """
    import starhopper.formats.esm.records

    scan_path = starhopper.formats.esm.records.__path__

    result = {}
    for item in pkgutil.iter_modules(scan_path):
        module = importlib.import_module(
            f"starhopper.formats.esm.records.{item.name}"
        )

        for _, class_ in inspect.getmembers(module, inspect.isclass):
            if issubclass(class_, BaseRecord) and class_ is not BaseRecord:
                result[class_.__name__.encode("ascii")] = class_

    return result


class HighLevelRecord:
    """
    A high-level representation of a record.

    When the complete structure of a Record is known, this class can be used to
    parse the record into a high-level representation with individual fields
    parsed.
    """

    def __init__(self, record: Record):
        self.results: list[RecordField, dict[str, Any]] = []
        self.record = record

    def first(self, type_: type[RecordField], default=Missing):
        """
        Returns the first result of the given type.

        :param type_: A subclass of RecordField.
        :param default: A default value to return if the type is not found.
        :return: The first result of the given type.
        """
        for result, data in self.results:
            if isinstance(result, type_):
                return data

        if default is not Missing:
            return default

        raise ValueError(f"Could not find {type_}")

    @staticmethod
    def can_be_handled(record: Record):
        """
        Returns True if this record can be handled by a handler.
        """
        return record.type in get_all_records()

    @staticmethod
    def handler(record: Record) -> type[BaseRecord]:
        return get_all_records().get(record.type, UnknownRecord)

    def read(self):
        for structure, field in zip(
            self.handler(self.record).structure(self.record),
            self.record.fields(),
        ):
            self.results.append(
                (structure, structure.read_from_field(self, field))
            )

starhopper/formats/esm/records/base.py
TkTech/starhopper
from typing import Callable

from starhopper.formats.esm.file import Field
from starhopper.formats.esm.records.base import (
    RecordField,
    HighLevelRecord,
    BasicType,
)
from starhopper.formats.esm.records.types import (
    UInt32,
    Float,
    Bool,
    Bytes,
    Translatable
)
from starhopper.io import BinaryReader


class DATA(RecordField):
    def __init__(
        self, name: str, type_: Callable[[HighLevelRecord], BasicType]
    ):
        super().__init__(name)
        self.type_ = type_

    @staticmethod
    def label():
        return "Arbitrary Data"

    def read(self, record: HighLevelRecord, field: Field, io: BinaryReader):
        with io as data:
            type_ = self.type_(record)
            match type_:
                case BasicType.String:
                    data.uint32("value").change(Translatable)
                case BasicType.Int:
                    data.int32("value").change(UInt32)
                case BasicType.Float:
                    data.float_("value").change(Float)
                case BasicType.Bool:
                    data.uint8("value").change(Bool)
                case BasicType.Unknown:
                    data.bytes("value", field.size).change(Bytes)
                case _:
                    raise ValueError(f"Unknown type {self.type_}")

            return data.data

starhopper/formats/esm/records/data.py
TkTech/starhopper
from starhopper.formats.esm.file import Field
from starhopper.formats.esm.records.base import RecordField, HighLevelRecord
from starhopper.formats.esm.records.types import String
from starhopper.io import BinaryReader


class EDID(RecordField):
    @staticmethod
    def label():
        return "Editor ID"

    def read(self, record: HighLevelRecord, field: Field, io: BinaryReader):
        with io as edid:
            return edid.cstring("name").change(String).data

starhopper/formats/esm/records/edid.py
TkTech/starhopper
from starhopper.formats.esm.file import Field
from starhopper.formats.esm.records.base import RecordField, HighLevelRecord
from starhopper.formats.esm.records.types import Float
from starhopper.io import BinaryReader


class FLTV(RecordField):
    @staticmethod
    def label():
        return "Float Value"

    def read(self, record: HighLevelRecord, field: Field, io: BinaryReader):
        with io as fltv:
            return fltv.float_("value").change(Float).data

starhopper/formats/esm/records/fltv.py
TkTech/starhopper
from starhopper.formats.esm.file import Record
from starhopper.formats.esm.records.base import (
    BaseRecord,
)
from starhopper.formats.esm.records.edid import EDID
from starhopper.formats.esm.records.fltv import FLTV


class GLOB(BaseRecord):
    @staticmethod
    def label():
        return "Global"

    @staticmethod
    def structure(record: Record):
        return [
            EDID("Editor ID"),
            FLTV("Value"),
        ]

starhopper/formats/esm/records/glob.py
TkTech/starhopper
from starhopper.formats.esm.file import Record
from starhopper.formats.esm.records.base import (
    BasicType,
    BaseRecord,
)
from starhopper.formats.esm.records.data import DATA
from starhopper.formats.esm.records.edid import EDID


class GMST(BaseRecord):
    @staticmethod
    def label():
        return "Game Setting"

    @staticmethod
    def structure(record: Record):
        return [
            EDID("Editor ID"),
            DATA(
                "value",
                type_=lambda r: {
                    "s": BasicType.String,
                    "i": BasicType.Int,
                    "f": BasicType.Float,
                    "b": BasicType.Bool,
                }.get(r.first(EDID)["name"].value[0]),
            ),
        ]

starhopper/formats/esm/records/gmst.py
TkTech/starhopper
"""
Primitive types for high-level ESM records.
"""


class BaseType:
    def __init__(self, value):
        self.value = value

    def __repr__(self):
        return f"<{self.__class__.__name__}({self.value!r})>"


class UInt8(BaseType):
    pass


class UInt16(BaseType):
    pass


class UInt32(BaseType):
    pass


class UInt64(BaseType):
    pass


class Int8(BaseType):
    pass


class Int16(BaseType):
    pass


class Int32(BaseType):
    pass


class Int64(BaseType):
    pass


class Float(BaseType):
    pass


class Bool(BaseType):
    pass


class String(BaseType):
    pass


class Bytes(BaseType):
    pass


class Unknown(BaseType):
    pass


class Translatable(BaseType):
    pass

starhopper/formats/esm/records/types.py
TkTech/starhopper
import dataclasses
from typing import BinaryIO

from starhopper.formats.common import Location
from starhopper.io import BinaryReader


@dataclasses.dataclass
class Mesh:
    version: int
    triangle_count: int
    triangle_data: Location
    coordinate_scale: float
    weights_per_vertex: int
    vertex_count: int
    vertex_data: Location
    uv_count: int
    uv_data: Location
    unknown_count: int
    unknown_data: Location
    color_count: int
    color_data: Location
    normal_count: int
    normal_data: Location
    tangents_count: int
    tangents_data: Location


def parse_mesh(file: BinaryIO) -> Mesh:
    """
    Parses a mesh file.

    .. note::

        This file format is described here:
        https://github.com/fo76utils/ce2utils/blob/main/src/meshfile.cpp#L2
    """
    reader = BinaryReader(file)
    with reader as header:
        return Mesh(
            **header.uint32("version")
            .ensure("version", 1)
            .uint32("triangle_count")
            .set(
                "triangle_data",
                Location(
                    header.pos,
                    header.pos + (2 * header["triangle_count"]),
                ),
            )
            .skip(header["triangle_data"].size)
            .float_("coordinate_scale")
            # .change(lambda cs: cs / 32767)
            .uint32("weights_per_vertex")
            .uint32("vertex_count")
            .set(
                "vertex_data",
                Location(
                    header.pos,
                    header.pos + (2 * header["vertex_count"] * 3),
                ),
            )
            .skip(header["vertex_data"].size)
            .uint32("uv_count")
            .set(
                "uv_data",
                Location(
                    header.pos,
                    header.pos + (2 * header["uv_count"] * 2),
                ),
            )
            .skip(header["uv_data"].size)
            .uint32("unknown_count")
            .set(
                "unknown_data",
                Location(
                    header.pos,
                    header.pos + (4 * header["unknown_count"]),
                ),
            )
            .skip(header["unknown_data"].size)
            .uint32("color_count")
            .set(
                "color_data",
                Location(
                    header.pos,
                    header.pos + (4 * header["color_count"]),
                ),
            )
            .skip(header["color_data"].size)
            .uint32("normal_count")
            .set(
                "normal_data",
                Location(
                    header.pos,
                    header.pos + (2 * header["normal_count"]),
                ),
            )
            .skip(header["normal_data"].size)
            .uint32("tangents_count")
            .set(
                "tangents_data",
                Location(
                    header.pos,
                    header.pos + (4 * header["tangents_count"]),
                ),
            )
            .skip(header["tangents_data"].size)
            .data
        )


def mesh_to_obj(file: BinaryIO, destination: BinaryIO):
    """
    Converts a mesh file to an OBJ file.
    """
    # https://en.wikipedia.org/wiki/Wavefront_.obj_file
    # https://blender.stackexchange.com/a/32502
    header = parse_mesh(file)
    reader = BinaryReader(file)

    destination.write("# Exported using StarHopper :)\n".encode("ascii"))
    destination.write(b"\n")

    # List of geometric vertices, with (x, y, z [,w]) coordinates, w is optional
    reader.seek(header.vertex_data.start)
    for i in range(header.vertex_count):
        x = reader.int16() * header.coordinate_scale / 32767
        y = reader.int16() * header.coordinate_scale / 32767
        z = reader.int16() * header.coordinate_scale / 32767

        destination.write(f"v {x:.8f} {y:.8f} {z:.8f}\n".encode("ascii"))

    # List of texture coordinates, in (u, v [,w]) coordinates, these will vary
    # between 0 and 1
    reader.seek(header.uv_data.start)
    for i in range(header.uv_count):
        u = reader.half()
        v = reader.half()

        destination.write(f"vt {u} {1.0 - v}\n".encode("ascii"))

    # List of vertex normals in (x,y,z) form
    # uint32 * vCnt        Normals (Z axis of tangent space) in X10Y10Z10 format:
    # b0  to b9  = (X + 1.0) * 511.5
    # b10 to b19 = (Y + 1.0) * 511.5
    # b20 to b29 = (Z + 1.0) * 511.5
    # b30 to b31 = unknown, either 00 or 01
    reader.seek(header.normal_data.start)
    for i in range(header.normal_count):
        # X10Y10Z10W2 format
        normal = reader.uint32()
        x = ((normal & 1023) / 511.5) - 1.0
        y = (((normal >> 10) & 1023) / 511.5) - 1.0
        z = (((normal >> 20) & 1023) / 511.5) - 1.0

        destination.write(f"vn {x:.8f} {y:.8f} {z:.8f}\n".encode("ascii"))

    # List of triangle indices (3 per face), each index repeated three times separated by slashes
    reader.seek(header.triangle_data.start)
    for i in range(header.triangle_count // 3):
        # I don't honestly know _why_ we always add +1 (maybe to ensure the
        # value is never less than 0?) but it's what all the blender examples
        # do, so c'est la vie.
        a = reader.uint16() + 1
        b = reader.uint16() + 1
        c = reader.uint16() + 1

        destination.write(
            f"f {a:d}/{a:d}/{a:d} {b:d}/{b:d}/{b:d} {c:d}/{c:d}/{c:d}\n".encode("ascii")
        )


starhopper/formats/mesh.py
TkTech/starhopper
import enum
from typing import BinaryIO

from starhopper.formats.common import Location
from starhopper.io import BinaryReader, BinaryWriter


class StringContainerType(enum.IntEnum):
    Strings = 0
    DLStrings = 1
    ILStrings = 2


class StringContainer:
    def __init__(self, file: BinaryIO, type_: StringContainerType):
        """
        Parser for a Bethesda .strings, .dlstrings, and ilstring files.

        The type of string container is typically determined by the file
        extension and must be passed into the parser. The structure of the
        header is the same for all three types, but the strings themselves
        are encoded differently.

        :param file: The file to parse.
        :param type_: The type of string container.
        """
        self.file = file
        self.io = BinaryReader(file)
        self.type_ = type_
        self.header = self.parse_header(self.io)
        self._strings = {}

    @staticmethod
    def parse_header(reader: BinaryReader):
        with reader as header:
            header.uint32("count").uint32("size").set(
                "directory",
                [
                    (reader.uint32(), reader.uint32())
                    for _ in range(header["count"])
                ],
            )
            header.set(
                "loc",
                Location(header.start_pos, header.pos),
            )
            return header.data

    @property
    def strings(self):
        """
        Returns a dictionary of string IDs to strings.

        :return:
        """
        if self._strings:
            return self._strings

        self._strings = strings = {}

        for string_id, offset in self.header["directory"]:
            self.io.seek(self.header["loc"].end + offset)
            match self.type_:
                case StringContainerType.Strings:
                    strings[string_id] = self.io.cstring(None)
                    try:
                        strings[string_id] = strings[string_id].decode("utf-8")
                    except UnicodeDecodeError:
                        strings[string_id] = strings[string_id].decode("cp1252")
                case StringContainerType.DLStrings | StringContainerType.ILStrings:
                    size = self.io.uint32()
                    strings[string_id] = self.io.read(size)
                    try:
                        strings[string_id] = strings[string_id].decode("utf-8")
                    except UnicodeDecodeError:
                        strings[string_id] = strings[string_id].decode("cp1252")
                case _:
                    raise ValueError(
                        f"Unknown string container type: {self.type_}"
                    )

        return self._strings

    def save(self, file: BinaryIO):
        """
        Saves the string container to a file.

        :param file: Any file-like object supporting write() and seek().
        """
        writer = BinaryWriter(file)
        writer.uint32(len(self.strings))
        # This is the size of the string data, we'll have to come back to write
        # this.
        writer.uint32(0)

        # Where to skip backwards to when writing offsets.
        offsets: dict[int, int] = {}

        for string_id in self.strings.values():
            writer.uint32(string_id)
            offsets[string_id] = writer.pos
            writer.uint32(0)

        # Write the string data.
        for string_id, string in self.strings.items():
            start = writer.pos
            match self.type_:
                case StringContainerType.Strings:
                    writer.cstring(string)
                case StringContainerType.DLStrings | StringContainerType.ILStrings:
                    encoded = string.encode("utf-8")
                    writer.uint32(len(encoded))
                    writer.write(string)
                    writer.write(b"\x00")

            return_pos = writer.pos
            writer.seek(offsets[string_id])
            writer.uint32(start)
            writer.seek(return_pos)

        # Write the size of the string data.
        writer.seek(4)
        writer.uint32(writer.pos - 8)

starhopper/formats/strings.py
TkTech/starhopper
import sys
from pathlib import Path

from PySide6 import QtCore
from PySide6.QtCore import (
    QTranslator,
    QCoreApplication,
    Signal,
    QThreadPool,
)
from PySide6.QtGui import QKeySequence, Qt
from PySide6.QtWidgets import (
    QApplication,
    QWidget,
    QVBoxLayout,
    QMainWindow,
    QSplitter,
    QFileDialog,
    QScrollArea,
    QHBoxLayout,
    QLayout,
    QSizePolicy,
    QLineEdit,
    QProgressBar,
    QMessageBox,
    QLabel,
    QPushButton,
)

from starhopper.gui.common import tr
from starhopper.gui.navigation import Navigation, ESMFileNode, ArchiveFileNode
from starhopper.gui.settings import HasSettings
from starhopper.gui.viewers.search_viewer import SearchViewer, SearchIndexThread


class SearchBox(QWidget):
    onTyping = Signal(str)

    def __init__(self):
        super().__init__()

        self.query = QLineEdit()
        self.query.setMinimumWidth(300)
        self.query.setPlaceholderText(tr("Search", "Search...", None))
        self.query.textChanged.connect(self.onTyping)

        self.progress = QProgressBar()
        self.progress.setTextVisible(True)
        self.progress.setMinimumWidth(300)
        self.progress.setFormat(tr("Search", "Indexing...", None))
        self.progress.setAlignment(QtCore.Qt.AlignCenter)
        self.progress.setMaximum(100)
        self.progress.setValue(50)

        self.layout = QVBoxLayout(self)
        self.layout.addWidget(self.query)
        self.layout.setContentsMargins(0, 0, 0, 0)


class Details(QWidget):
    def __init__(self):
        super().__init__()

        self.layout = QVBoxLayout(self)
        self.layout.setContentsMargins(0, 0, 0, 0)

        self.setLayout(self.layout)


class StarterWidget(QWidget):
    """
    Shown in the main window when no other content has been loaded yet to
    provide an easy way to get started.
    """

    openFile = Signal()

    def __init__(self):
        super().__init__()

        self.layout = QVBoxLayout(self)
        self.layout.setContentsMargins(0, 0, 0, 0)

        self.layout.setAlignment(Qt.AlignCenter)
        self.layout.addWidget(
            QLabel(tr("MainWindow", "Welcome to StarHopper!", None))
        )
        self.layout.addWidget(
            QLabel(
                tr(
                    "MainWindow",
                    "To get started, open a file using the button below.",
                    None,
                )
            )
        )
        btn = QPushButton(
            tr("MainWindow", "Open File", None),
        )
        btn.clicked.connect(self.openFile.emit)
        self.layout.addWidget(btn)

        self.setLayout(self.layout)


class MainWindow(HasSettings, QMainWindow):
    fileAdded = Signal(str)

    def __init__(self):
        super().__init__()
        self.search_index_threads = []
        self.setWindowTitle("StarHopper")

        self.search_box = SearchBox()

        self.menu = self.menuBar()
        self.menu.setCornerWidget(
            self.search_box, QtCore.Qt.TopRightCorner  # noqa
        )
        self.menu_file = self.menu.addMenu(tr("MainWindow", "File", None))

        open_action = self.menu_file.addAction(tr("MainWindow", "Open", None))
        open_action.setShortcut(QKeySequence.Open)
        open_action.triggered.connect(self.on_open_file)

        self.menu_file.addSeparator()

        file_exit = self.menu_file.addAction(tr("MainWindow", "Exit", None))
        file_exit.setShortcut(QKeySequence.Quit)
        file_exit.triggered.connect(self.on_close)

        self.panel_scroll_container = QScrollArea()
        self.panel_scroll_container.setWidgetResizable(True)
        self.panel_scroll_container.setSizePolicy(
            QSizePolicy.MinimumExpanding, QSizePolicy.Expanding
        )

        self.panel_container = QWidget()
        self.panel_container.setSizePolicy(
            QSizePolicy.Minimum, QSizePolicy.Minimum
        )
        self.panel_container.setMinimumWidth(800)
        self.panel_container_layout = QHBoxLayout(self.panel_container)
        self.panel_container_layout.setContentsMargins(0, 0, 0, 0)
        self.panel_container_layout.setSizeConstraint(QLayout.SetMinimumSize)
        self.panel_scroll_container.setWidget(self.panel_container)

        self.search_box.onTyping.connect(self.on_search)

        self.navigation = Navigation(working_area=self.panel_container_layout)
        self.navigation.setMinimumWidth(300)
        self.navigation.setSizePolicy(
            QSizePolicy.Minimum, QSizePolicy.Expanding
        )
        self.navigation.addedNewPanel.connect(
            self.on_added_new_panel, QtCore.Qt.QueuedConnection  # noqa
        )
        self.navigation.hide()

        self.search_view = SearchViewer(self.panel_container_layout)
        self.search_view.hide()
        self.search_view.onSearchSelected.connect(self.navigation.navigate)
        self.search_view.onSearchSelected.connect(self.close_search)

        starter = StarterWidget()
        starter.openFile.connect(self.on_open_file)
        self.fileAdded.connect(starter.close)
        self.panel_container_layout.addWidget(starter)

        splitter = QSplitter()
        splitter.addWidget(self.navigation)
        splitter.addWidget(self.panel_scroll_container)
        splitter.addWidget(self.search_view)
        splitter.setSizes([300, 800, 800])

        self.setCentralWidget(splitter)

    def settings_group_name(self) -> str:
        return "main"

    def on_close(self):
        QCoreApplication.quit()

    def on_open_file(self):
        fnames, _ = QFileDialog.getOpenFileNames(
            self,
            tr("MainWindow", "Open File", None),
            self.settings.value("last_open_dir", "."),
            tr("MainWindow", "Bethesda Files (*.esm *.ba2)", None),
        )
        if not fnames:
            return

        for fname in fnames:
            path = Path(fname)
            self.settings.setValue("last_open_dir", str(path.parent))
            match path.suffix:
                case ".esm":
                    item = ESMFileNode(fname)
                case ".ba2":
                    item = ArchiveFileNode(fname)
                case _:
                    QMessageBox.warning(
                        None,  # noqa
                        "StarHopper",
                        tr(
                            "MainWindow",
                            "Tried to open an unknown filetype :(",
                            None,
                        ),
                    )
                    continue

            self.navigation.tree.addTopLevelItem(item)
            if self.navigation.tree.topLevelItemCount() > 1:
                self.navigation.show()
            else:
                self.navigation.on_item_double_clicked(item, 0)

            self.fileAdded.emit(fname)

            loader = SearchIndexThread(fname)
            QThreadPool.globalInstance().start(loader)

    def close_search(self):
        self.search_box.query.clear()
        self.search_view.hide()
        self.panel_scroll_container.show()

    def on_search(self, query: str):
        if not query:
            self.close_search()
            return

        self.panel_scroll_container.hide()
        self.search_view.show()
        self.search_view.update_search_results(query)

    def on_added_new_panel(self, panel: QWidget):
        self.panel_scroll_container.ensureWidgetVisible(panel)

    def settings_load(self):
        self.restoreGeometry(self.settings.value("geometry"))
        self.restoreState(self.settings.value("state"))

    def settings_save(self):
        self.settings.setValue("geometry", self.saveGeometry())
        self.settings.setValue("state", self.saveState())


def create_app():
    app = QApplication([])
    app.setApplicationName("StarHopper")
    app.setOrganizationName("tktech")
    app.setOrganizationDomain("tkte.ch")

    translator = QTranslator()
    QCoreApplication.installTranslator(translator)

    window = MainWindow()
    window.show()

    sys.exit(app.exec())


if __name__ == "__main__":
    create_app()

starhopper/gui/app.py
TkTech/starhopper
from functools import cache

from PySide6 import QtGui
from PySide6.QtCore import QCoreApplication
from PySide6.QtGui import QFont

tr = QCoreApplication.translate
ColorGray = QtGui.QColor(122, 122, 122)
ColorPurple = QtGui.QColor(122, 122, 255)
ColorRed = QtGui.QColor(255, 122, 122)
ColorGreen = QtGui.QColor(122, 255, 122)
ColorTeal = QtGui.QColor(20, 240, 255)
ColorYellow = QtGui.QColor(255, 255, 122)
ColorOrange = QtGui.QColor(255, 122, 20)


@cache
def monospace():
    font = QFont("Monospace")
    font.setStyleHint(QFont.Monospace)
    return font

starhopper/gui/common.py
TkTech/starhopper
from pathlib import Path

from PySide6 import QtGui, QtCore
from PySide6.QtCore import Signal, Qt
from PySide6.QtWidgets import (
    QWidget,
    QTreeWidget,
    QVBoxLayout,
    QTreeWidgetItem,
    QLayout,
)

from starhopper.formats.btdx.file import BA2Container
from starhopper.formats.esm.file import ESMContainer

from starhopper.gui.common import (
    ColorTeal,
    ColorOrange,
)
from starhopper.gui.viewers.archive_viewer import ArchiveViewer
from starhopper.gui.viewers.esm_viewer import ESMViewer
from starhopper.gui.viewers.viewer import Viewer


class Navigation(QWidget):
    addedNewPanel = Signal(QWidget)

    def __init__(self, working_area: QLayout):
        super().__init__()

        self.working_area = working_area

        self.tree = QTreeWidget()
        self.tree.setColumnCount(1)
        self.tree.setHeaderHidden(True)
        self.tree.itemDoubleClicked.connect(self.on_item_double_clicked)

        self.layout = QVBoxLayout(self)
        self.layout.addWidget(self.tree)
        self.layout.setContentsMargins(0, 0, 0, 0)

        self.viewer: QWidget | None = None

    def set_viewer(self, viewer: Viewer):
        if self.viewer is not None and viewer != self.viewer:
            self.viewer.close()

        self.viewer = viewer
        self.viewer.addedNewPanel.connect(
            self.addedNewPanel.emit, QtCore.Qt.QueuedConnection  # noqa
        )
        self.working_area.addWidget(self.viewer)
        self.addedNewPanel.emit(self.viewer)

    def on_item_double_clicked(self, item: QTreeWidgetItem, column: int):
        self.open_item(item)

    def open_item(self, item: QTreeWidgetItem) -> Viewer | None:
        if not isinstance(item, HandledChildNode):
            return

        viewer = item.get_viewer(self.working_area)
        if viewer is None:
            return

        self.set_viewer(viewer)
        return viewer

    def navigate(self, path: list[str]):
        top_level = Path(path.pop(0))

        items = self.tree.findItems(
            top_level.name, Qt.MatchExactly | Qt.MatchRecursive
        )

        for item in items:
            if item.file == top_level:
                self.tree.setCurrentItem(item)
                viewer = self.open_item(item)
                if viewer is not None:
                    viewer.navigate(path)
                break


class HandledChildNode:
    def get_viewer(self, working_area: QLayout) -> Viewer | None:
        return None


class ESMFileNode(HandledChildNode, QTreeWidgetItem):
    def __init__(self, file: str):
        super().__init__()
        self.file = Path(file)
        self.handle = open(file, "rb")
        self.esm = ESMContainer(self.handle)

        self.setText(0, self.file.name)
        self.setForeground(0, QtGui.QBrush(ColorOrange))

    def get_viewer(self, working_area: QLayout) -> Viewer | None:
        return ESMViewer(self.esm, working_area)


class ArchiveFileNode(HandledChildNode, QTreeWidgetItem):
    def __init__(self, file: str):
        super().__init__()
        self.file = Path(file)
        self.handle = open(file, "rb")
        self.container = BA2Container(self.handle)

        self.setText(0, self.file.name)
        self.setForeground(0, QtGui.QBrush(ColorTeal))

    def get_viewer(self, working_area: QLayout) -> Viewer | None:
        return ArchiveViewer(self.container, working_area)

starhopper/gui/navigation.py
TkTech/starhopper
import dataclasses
import enum
import json
import sqlite3


class ItemType(enum.IntEnum):
    UNKNOWN = 0
    FILE = 10
    EDID = 20
    FORM_ID = 30


@dataclasses.dataclass
class SearchResult:
    file_path: str
    navigation_path: list[str | int | list[int, int]]
    stored_text: str
    item_type: ItemType


connection = sqlite3.connect(":memory:", check_same_thread=False)
connection.executescript(
    """
    CREATE TABLE IF NOT EXISTS search_index (
        id INTEGER PRIMARY KEY,
        /* The path on disk where the match is located */
        file_path TEXT NOT NULL,
        /* A viewer-specific path to the match */
        navigation_path TEXT NOT NULL,
        /* Stored text to be retrieved */
        stored_text TEXT NOT NULL,
        /* The text to be searched against*/
        search_text TEXT NOT NULL,
        /* The type of item this is */
        item_type INTEGER NOT NULL
    );
    CREATE UNIQUE INDEX IF NOT EXISTS search_index_unique ON search_index (
        file_path,
        navigation_path,
        stored_text
    );
    CREATE INDEX IF NOT EXISTS search_index_search_text ON search_index (
        search_text
    );
    CREATE INDEX IF NOT EXISTS search_index_item_type ON search_index (
        item_type
    );
    """
)
connection.commit()


def add_to_index(
    file_path: str,
    navigation_path: list[str],
    search_text: str,
    item_type: ItemType,
):
    connection.execute(
        """
        INSERT INTO search_index (
            file_path,
            navigation_path,
            stored_text,
            search_text,
            item_type
        )
        VALUES (?, ?, ?, lower(?), ?)
        ON CONFLICT (file_path, navigation_path, stored_text) DO UPDATE SET
            search_text = excluded.search_text
        """,
        (
            file_path,
            json.dumps(navigation_path),
            search_text,
            search_text,
            item_type.value,
        ),
    )


def search_index(query: str) -> list[SearchResult]:
    results = connection.execute(
        """
        SELECT file_path, navigation_path, stored_text, item_type
        FROM search_index
        WHERE search_text LIKE ?
        ORDER BY file_path ASC
        LIMIT 100
        """,
        (query,),
    ).fetchall()
    for result in results:
        yield SearchResult(
            result[0], json.loads(result[1]), result[2], ItemType(result[3])
        )

starhopper/gui/search.py
TkTech/starhopper
from PySide6.QtCore import QSettings


class HasSettings:
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.settings = QSettings()
        self.settings.beginGroup(self.settings_group_name())
        try:
            self.settings_load()
        finally:
            self.settings.endGroup()

    def settings_group_name(self) -> str:
        raise NotImplementedError()

    def settings_load(self):
        pass

    def settings_save(self):
        pass

    def closeEvent(self, event):
        super().closeEvent(event)  # type: ignore

        self.settings.beginGroup(self.settings_group_name())
        try:
            self.settings_save()
        finally:
            self.settings.endGroup()
        event.accept()

starhopper/gui/settings.py
TkTech/starhopper
from pathlib import Path

from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
    QLayout,
    QTreeWidget,
    QTreeWidgetItem,
    QPushButton,
    QHeaderView,
    QFileDialog,
)

from starhopper.formats.archive import ArchiveContainer, AbstractFile
from starhopper.gui.common import tr, monospace
from starhopper.gui.settings import HasSettings
from starhopper.gui.viewers.model_viewer import ModelViewer
from starhopper.gui.viewers.string_viewer import StringViewer
from starhopper.gui.viewers.viewer import Viewer


class ArchiveViewerNode(QTreeWidgetItem):
    def __init__(self, container: ArchiveContainer, file: AbstractFile):
        super().__init__()
        self.container = container
        self.file = file

        self.setText(0, file.path)
        self.setText(1, f"{file.size} bytes")
        self.setFont(1, monospace())
        self.setTextAlignment(1, Qt.AlignRight)
        self.setFlags(self.flags() | Qt.ItemIsUserCheckable)
        self.setCheckState(0, Qt.Unchecked)


class ArchiveViewer(HasSettings, Viewer):
    def __init__(self, container: ArchiveContainer, working_area: QLayout):
        super().__init__(working_area=working_area)
        self.container = container

        self.browser = QTreeWidget()
        self.browser.setColumnCount(2)
        self.browser.setUniformRowHeights(True)
        self.browser.setAlternatingRowColors(True)
        header = self.browser.header()
        header.setStretchLastSection(False)
        header.setSectionResizeMode(0, QHeaderView.Stretch)
        header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
        self.browser.setHeaderLabels(
            (
                tr("ArchiveViewer", "Path", None),
                tr("ArchiveViewer", "Size", None),
            )
        )
        self.browser.itemDoubleClicked.connect(self.on_item_double_clicked)

        # TODO: Build up a directory tree here instead of lumping it all into
        #       the root.
        for file in container.files():
            item = ArchiveViewerNode(self.container, file)
            self.browser.addTopLevelItem(item)

        self.export_button = QPushButton(tr("ArchiveViewer", "&Export", None))
        self.export_button.clicked.connect(self.on_export_button_clicked)

        self.layout.insertWidget(0, self.browser)
        self.layout.insertWidget(1, self.export_button)

    def on_item_double_clicked(self, item: QTreeWidgetItem, column: int):
        if not isinstance(item, ArchiveViewerNode):
            return

        p = Path(item.file.path)
        match p.suffix:
            case ".strings" | ".dlstrings" | ".ilstrings":
                self.add_panel(
                    "child", StringViewer(item.file, self.working_area)
                )
            case ".mesh":
                self.add_panel(
                    "child", ModelViewer(item.file, self.working_area)
                )

    def on_export_button_clicked(self):
        items_to_extract = [
            item
            for item in self.browser.findItems(
                "", Qt.MatchContains | Qt.MatchRecursive
            )
            if isinstance(item, ArchiveViewerNode)
            and item.checkState(0) == Qt.Checked
        ]

        if not items_to_extract:
            return

        directory = QFileDialog.getExistingDirectory(
            self,
            tr("ArchiveViewer", "Export to", None),
            self.settings.value("last_open_dir", "."),
            QFileDialog.ShowDirsOnly,
        )

        # Should probably be moved into a thread.
        for item in items_to_extract:
            item.file.extract_into(Path(directory), overwrite=True)

    def settings_group_name(self) -> str:
        return "archive_viewer"

    def navigate(self, path: list[str]):
        component = path.pop(0)

        items = self.browser.findItems(
            component, Qt.MatchExactly | Qt.MatchRecursive
        )

        for item in items:
            if item.file.path == component:
                self.browser.setCurrentItem(item)
                self.on_item_double_clicked(item, 0)
                break

starhopper/gui/viewers/archive_viewer.py
TkTech/starhopper
import struct
from io import BytesIO

from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
    QLayout,
    QWidget,
    QVBoxLayout,
    QTreeWidget,
    QTreeWidgetItem,
    QHeaderView,
    QSizePolicy,
)

from starhopper.gui.common import tr, ColorGray
from starhopper.gui.viewers.viewer import Viewer
from starhopper.io import BinaryReader


class TypeGuesser(QWidget):
    def __init__(self, data: bytes):
        super().__init__()
        self.data = data

        self.layout = QVBoxLayout()
        self.layout.setContentsMargins(0, 0, 0, 0)
        self.tree = QTreeWidget()

        self.tree.setColumnCount(2)
        self.tree.setHeaderLabels(
            (
                tr("BinaryViewer", "Type", None),
                tr("BinaryViewer", "Value", None),
            )
        )
        header = self.tree.header()
        header.setStretchLastSection(False)
        header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
        header.setSectionResizeMode(1, QHeaderView.Stretch)
        self.tree.setAlternatingRowColors(True)
        self.tree.setSizePolicy(
            QSizePolicy.Minimum, QSizePolicy.MinimumExpanding
        )

        with BytesIO(self.data) as stream:
            io = BinaryReader(stream)
            for offset in range(0, 3):
                item = QTreeWidgetItem((f"{offset} byte offset",))
                item.setFirstColumnSpanned(2)
                item.setForeground(0, ColorGray)
                f = self.tree.font()
                f.setItalic(True)
                item.setFont(0, f)
                self.tree.addTopLevelItem(item)

                to_try = (
                    ("uint8", io.uint8, 1),
                    ("uint16", io.uint16, 2),
                    ("uint32", io.uint32, 4),
                    ("uint64", io.uint64, 8),
                    ("int8", io.int8, 1),
                    ("int16", io.int16, 2),
                    ("int32", io.int32, 4),
                    ("int64", io.int64, 8),
                    ("float", io.float_, 4),
                    ("double", io.double, 8),
                    ("string", lambda: io.read(16), None),
                )

                stream.seek(offset)
                for label, reader, size in to_try:
                    try:
                        value = reader()
                    except (EOFError, struct.error):
                        value = None
                    finally:
                        stream.seek(offset)

                    item = QTreeWidgetItem((label, repr(value)))
                    item.setTextAlignment(0, Qt.AlignLeft)
                    item.setTextAlignment(1, Qt.AlignRight)
                    if value is None:
                        item.setForeground(1, ColorGray)
                    self.tree.addTopLevelItem(item)

        self.layout.addWidget(self.tree)
        self.setLayout(self.layout)


class BinaryViewer(Viewer):
    """
    Simplistic binary viewer.

    Not optimized at all for large files. Should be replaced with a better
    implementation that can use memoryviews() or IO.
    """

    def __init__(self, data: bytes, working_area: QLayout):
        super().__init__(working_area=working_area)

        self.data = data

        # If the data is small enough, it's _probably_ a simple type, so lets
        # just try showing it as _everything_.
        self.type_guesser = TypeGuesser(data)
        self.layout.insertWidget(0, self.type_guesser)
        self.setMinimumWidth(0)

starhopper/gui/viewers/binary_viewer.py
TkTech/starhopper
from PySide6.QtCore import Qt
from PySide6.QtGui import QBrush
from PySide6.QtWidgets import QLayout, QTreeWidget, QTreeWidgetItem, QSizePolicy

from starhopper.formats.esm.file import ESMContainer, Group
from starhopper.formats.esm.records.base import get_all_records
from starhopper.gui.common import ColorGreen, monospace
from starhopper.gui.viewers.group_viewer import GroupViewer
from starhopper.gui.viewers.viewer import Viewer


class ESMViewerNode(QTreeWidgetItem):
    def __init__(self, esm: ESMContainer, group: Group):
        super().__init__()
        self.esm = esm
        self.group = group

        self.setText(0, group.label.decode("ascii"))

        handler = get_all_records().get(group.label)
        if handler:
            self.setText(0, handler.label())
            self.setForeground(0, QBrush(ColorGreen))
        else:
            self.setFont(0, monospace())


class ESMViewer(Viewer):
    def __init__(self, esm: ESMContainer, working_area: QLayout):
        super().__init__(working_area=working_area)
        self.esm = esm

        self.tree = QTreeWidget()
        self.tree.setColumnCount(1)
        self.tree.setHeaderHidden(True)
        self.tree.setAlternatingRowColors(True)
        self.tree.itemDoubleClicked.connect(self.on_item_double_clicked)
        self.tree.setSizePolicy(
            QSizePolicy.Minimum, QSizePolicy.MinimumExpanding
        )

        for group in self.esm.groups:
            item = ESMViewerNode(self.esm, group)
            self.tree.addTopLevelItem(item)

        self.layout.insertWidget(0, self.tree)
        self.setMinimumWidth(0)

    def on_item_double_clicked(self, item: QTreeWidgetItem, column: int):
        self.open_item(item)

    def open_item(self, item: QTreeWidgetItem) -> Viewer | None:
        if not isinstance(item, ESMViewerNode):
            return

        viewer = GroupViewer(item.group, working_area=self.working_area)
        self.add_panel("child", viewer)
        return viewer

    def navigate(self, path: list[str]):
        try:
            component = path.pop(0)
        except IndexError:
            return

        items = self.tree.findItems(
            component, Qt.MatchExactly | Qt.MatchRecursive
        )

        for item in items:
            if not isinstance(item, ESMViewerNode):
                continue

            if item.group.label.decode("ascii") == component:
                self.tree.setCurrentItem(item)
                viewer = self.open_item(item)
                viewer.navigate(path)

starhopper/gui/viewers/esm_viewer.py
TkTech/starhopper
import dataclasses
from io import BytesIO

from PySide6 import QtGui, QtCore
from PySide6.QtCore import Signal, QRunnable, QThreadPool, QObject, Qt
from PySide6.QtWidgets import (
    QTreeWidgetItem,
    QTreeWidget,
    QLayout,
)

from starhopper.formats.esm.file import Group, ESMContainer, Record, RecordFlag
from starhopper.gui.common import (
    tr,
    ColorGray,
    ColorPurple,
    monospace,
    ColorRed,
    ColorOrange,
)
from starhopper.gui.viewers.record_viewer import RecordViewer
from starhopper.gui.viewers.viewer import Viewer
from starhopper.io import BinaryReader


class RecordChild(QTreeWidgetItem):
    def __init__(self, record: Record):
        super().__init__()
        self.record = record


class GroupChild(QTreeWidgetItem):
    def __init__(self, group: Group):
        super().__init__()
        self.group = group


class GroupLoaderSignals(QObject):
    progressStart = Signal(int)
    progressDone = Signal()


class GroupLoaderThread(QRunnable):
    """
    Loads a group in the background and populates the details view.
    """

    def __init__(self, viewer: "GroupViewer", group: Group):
        super().__init__()
        self.viewer = viewer
        self.group = group
        self.s = GroupLoaderSignals()

    def run(self):
        self.s.progressStart.emit(self.group.loc.size)

        item = QTreeWidgetItem(self.viewer.details)
        item.setText(0, tr("GroupViewer", "Details", None))
        item.addChildren(
            [
                QTreeWidgetItem(["Type", self.group.type.decode("ascii")]),
                QTreeWidgetItem(["Size", f"{self.group.size} bytes"]),
                QTreeWidgetItem(["Label", repr(self.group.label)]),
                QTreeWidgetItem(["Group Type", str(self.group.group_type)]),
                QTreeWidgetItem(["Version", str(self.group.version)]),
                QTreeWidgetItem(["Start", f"{self.group.loc.start:08x}"]),
                QTreeWidgetItem(["End", f"0x{self.group.loc.end:08x}"]),
                QTreeWidgetItem(["Size", f"0x{self.group.loc.size:08x}"]),
            ]
        )
        item.setExpanded(True)

        for child in self.group.children():
            if isinstance(child, Group):
                item = GroupChild(group=child)
                item.setText(0, child.type.decode("ascii"))
                item.setText(2, child.get_friendly_label())
                item.setForeground(2, QtGui.QBrush(ColorPurple))
                self.group.file.io.seek(child.loc.end)
                self.viewer.details.addTopLevelItem(item)
                continue

            item = RecordChild(record=child)
            item.setText(0, child.type.decode("ascii"))
            item.setText(1, f"0x{child.form_id:08X}")
            item.setToolTip(1, tr("GroupViewer", "Form ID", None))
            item.setFont(1, monospace())
            item.setForeground(1, ColorOrange)

            if child.flags & RecordFlag.Deleted:
                item.setForeground(0, QtGui.QBrush(ColorRed))

            for field in child.fields():
                # As a special case, we pull up any EDID fields to the top
                # level of the tree as a label for the record.
                if field.type == b"EDID":
                    with BytesIO(field.data) as data:
                        io = BinaryReader(data)
                        with io as edid:
                            edid.cstring("name")
                            item.setText(2, edid.data["name"])
                            item.setToolTip(
                                2, tr("GroupViewer", "Editor ID", None)
                            )
                            item.setForeground(
                                2,
                                QtGui.QBrush(ColorPurple),
                            )

                    break

            self.viewer.details.addTopLevelItem(item)
            self.group.file.io.seek(child.loc.end)

        self.s.progressDone.emit()


class GroupViewer(Viewer):
    """
    Displays a single group in a tree view.

    Uses a background thread to load the group and populate the tree without
    blocking the UI thread.
    """

    def __init__(self, group: Group, working_area: QLayout):
        super().__init__(working_area=working_area)

        self.group = group
        self.file = group.file.file
        self.file.seek(0)
        self.group = dataclasses.replace(
            self.group, file=ESMContainer(self.file)
        )

        self.details = QTreeWidget(self)
        self.details.hide()
        self.details.setUniformRowHeights(True)
        self.details.setColumnCount(3)
        self.details.setHeaderLabels(
            (
                tr("GroupViewer", "Type", None),
                tr("GroupViewer", "Form ID", None),
                tr("GroupViewer", "EDID", None),
            )
        )
        self.details.itemDoubleClicked.connect(self.on_item_double_clicked)

        loader = GroupLoaderThread(self, group)
        loader.s.progressStart.connect(
            self.on_loading_start, QtCore.Qt.QueuedConnection  # noqa
        )
        loader.s.progressDone.connect(
            self.on_loading_complete, QtCore.Qt.QueuedConnection  # noqa
        )
        QThreadPool.globalInstance().start(loader)

        self.layout.insertWidget(0, self.details)

    def on_loading_complete(self):
        super().on_loading_complete()
        self.details.show()

    def on_item_double_clicked(self, item: QTreeWidgetItem, column: int):
        self.open_item(item)

    def open_item(self, item: QTreeWidgetItem) -> Viewer | None:
        if isinstance(item, GroupChild):
            viewer = GroupViewer(item.group, self.working_area)
            self.add_panel("child", viewer)
            return viewer
        elif isinstance(item, RecordChild):
            viewer = RecordViewer(item.record, self.working_area)
            self.add_panel("child", viewer)
            return viewer

    def navigate(self, path: list[str]):
        pass

starhopper/gui/viewers/group_viewer.py
TkTech/starhopper
from PySide6.Qt3DCore import Qt3DCore
from PySide6.QtCore import QUrl, QObject, Signal, Property, Qt
from PySide6.QtGui import QVector3D, QMatrix4x4
from PySide6.QtWidgets import QLayout, QWidget, QPushButton, QFileDialog, QLabel

from PySide6.Qt3DExtras import Qt3DExtras
from PySide6.Qt3DRender import Qt3DRender

from starhopper.formats.archive import AbstractFile
from starhopper.formats.mesh import mesh_to_obj
from starhopper.gui.common import tr, ColorGray
from starhopper.gui.viewers.viewer import Viewer


class ModelViewer(Viewer):
    def __init__(self, file: AbstractFile, working_area: QLayout):
        super().__init__(working_area=working_area)

        self.file = file

        # Don't show the 3D preview under Nuitka since it'll just crash for
        # who-knows-why (using the debugger just crashes it sooner)
        if "__compiled__" not in globals():
            self.preview = Qt3DExtras.Qt3DWindow()
            self.preview.defaultFrameGraph().setClearColor(ColorGray)
            self.container = QWidget.createWindowContainer(self.preview)
            self.scene = Qt3DCore.QEntity()

            camera = self.preview.camera()
            # camera.lens().setPerspectiveProjection(45, 16 / 9, 0.1, 1000)
            camera.setPosition(QVector3D(0, 0, 10))
            camera.setViewCenter(QVector3D(0, 0, 0))

            self.camera_controller = Qt3DExtras.QFirstPersonCameraController(
                self.scene
            )
            self.camera_controller.setLinearSpeed(25)
            self.camera_controller.setLookSpeed(180)
            self.camera_controller.setCamera(camera)

            self.material = Qt3DExtras.QPhongMaterial(self.scene)

            with file.open() as f:
                # For whatever reason, Qt3D doesn't like it when we pass it
                # a NamedTemporaryFile. Maybe it doesn't load until after a
                # ProcessEvents call, in which case it would already be deleted?
                # Who knows.
                # with NamedTemporaryFile("wb", suffix=".obj") as destination:
                with open("preview.obj", "wb") as destination:
                    mesh_to_obj(f, destination)
                    destination.flush()

                    # This is a hack, we should create the QGeometry directly from
                    # the .mesh rather than this export-and-import shenanigans.
                    # But, this is also just 2 lines, sooooo...
                    self.mesh = Qt3DRender.QMesh()
                    self.mesh.setSource(QUrl.fromLocalFile(destination.name))

                    self.transform = Qt3DCore.QTransform()
                    self.entity = Qt3DCore.QEntity(self.scene)
                    self.entity.addComponent(self.mesh)
                    self.entity.addComponent(self.material)
                    self.entity.addComponent(self.transform)
                    self.transform.setTranslation(QVector3D(0, 0, 0))

            self.preview.setRootEntity(self.scene)
            self.layout.insertWidget(0, self.container)
        else:
            self.warning = QLabel(
                tr(
                    "ModelViewer",
                    "3D preview is disabled under Nuitka.",
                    None,
                )
            )
            self.warning.setAlignment(Qt.AlignCenter)
            self.layout.insertWidget(0, self.warning)

        self.export_button = QPushButton(
            tr("ModelViewer", "E&xport As .obj", None)
        )
        self.export_button.clicked.connect(self.on_export)

        # self.layout.insertWidget(0, self.container)
        self.layout.insertWidget(1, self.export_button)

    def on_export(self):
        fname, _ = QFileDialog.getSaveFileName(
            self,
            tr("ModelViewer", "Export As", None),
            filter=tr("ModelViewer", "Wavefront OBJ (*.obj)", None),
        )

        if not fname:
            return

        with open(fname, "wb") as f:
            with self.file.open() as source:
                mesh_to_obj(source, f)

starhopper/gui/viewers/model_viewer.py
TkTech/starhopper
import dataclasses

from PySide6.QtCore import Signal, QObject, QRunnable, QThreadPool
from PySide6.QtWidgets import (
    QTreeWidget,
    QTreeWidgetItem,
    QLayout,
    QHeaderView,
)

from starhopper.formats.esm.file import Record, ESMContainer, RecordFlag, Field
from starhopper.formats.esm.records.base import HighLevelRecord
from starhopper.gui.common import tr, ColorPurple, ColorGray
from starhopper.gui.viewers.binary_viewer import BinaryViewer
from starhopper.gui.viewers.viewer import Viewer


class FieldChild(QTreeWidgetItem):
    def __init__(self, record, field):
        super().__init__()
        self.record = record
        self.field = field


class RecordLoaderSignals(QObject):
    progressStart = Signal(int)
    progressDone = Signal()


class RecordLoaderThread(QRunnable):
    def __init__(self, viewer: "RecordViewer", record: Record):
        super().__init__()
        self.record = record
        self.viewer = viewer
        self.s = RecordLoaderSignals()

    def run(self):
        self.s.progressStart.emit(self.record.loc.size)

        item = QTreeWidgetItem(self.viewer.details)
        item.setText(0, "Details")

        item_flags = QTreeWidgetItem(["Flags", str(self.record.flags)])
        item_flags.addChildren(
            [
                QTreeWidgetItem([str(flag.name), str(True)])
                for flag in RecordFlag
                if flag in self.record.flags
            ]
        )

        item.addChildren(
            [
                QTreeWidgetItem(["Type", self.record.type.decode("ascii")]),
                QTreeWidgetItem(["Size", str(self.record.size)]),
                item_flags,
                QTreeWidgetItem(["Form ID", f"0x{self.record.form_id:08X}"]),
                QTreeWidgetItem(["Revision", str(self.record.revision)]),
                QTreeWidgetItem(["Version", str(self.record.version)]),
                QTreeWidgetItem(["Start", f"{self.record.loc.start:08x}"]),
                QTreeWidgetItem(["End", f"0x{self.record.loc.end:08x}"]),
                QTreeWidgetItem(["Size", f"0x{self.record.loc.size:08x}"]),
            ]
        )

        if HighLevelRecord.can_be_handled(self.record):
            hlr = HighLevelRecord(self.record)
            hlr.read()

            for field, values in hlr.results:
                field_item = FieldChild(self.record, field)
                field_item.setText(0, field.label())
                if len(values) == 1:
                    # If there's only a single value, we'll just display it
                    # inline.
                    value = next(iter(values.values()))
                    field_item.setText(1, str(value.value))
                    field_item.setForeground(1, ColorPurple)
                    field_item.setText(2, value.__class__.__name__)
                    field_item.setForeground(2, ColorGray)
                else:
                    field_item.addChildren(
                        [
                            QTreeWidgetItem([str(k), str(v)])
                            for k, v in values.items()
                        ]
                    )

                self.viewer.details.addTopLevelItem(field_item)

            # TODO: Actually track progress
            self.s.progressDone.emit()
            return

        # Fallback for unknown types of records.
        for field in self.record.fields():
            field_item = FieldChild(self.record, field)
            field_item.setText(0, field.type.decode("ascii"))
            field_item.setText(1, f"{field.size} bytes")
            field_item.setForeground(1, ColorGray)

            self.viewer.details.addTopLevelItem(field_item)

        self.s.progressDone.emit()


class RecordViewer(Viewer):
    """
    Generic record viewer.
    """

    def __init__(self, record: Record, working_area: QLayout):
        super().__init__(working_area=working_area)
        self.file = record.file.file
        self.file.seek(0)
        self.record = dataclasses.replace(record, file=ESMContainer(self.file))

        self.details = QTreeWidget()
        self.details.setColumnCount(3)
        self.details.setHeaderLabels(
            (
                tr("RecordViewer", "Field Type", None),
                tr("RecordViewer", "Value", None),
                tr("RecordViewer", "Data Type", None),
            )
        )
        header = self.details.header()
        header.setStretchLastSection(False)
        header.setSectionResizeMode(1, QHeaderView.Stretch)

        self.details.currentItemChanged.connect(self.on_item_changed)

        loader = RecordLoaderThread(self, record)
        loader.s.progressStart.connect(self.on_loading_start)
        loader.s.progressDone.connect(self.on_loading_complete)
        QThreadPool.globalInstance().start(loader)

        self.layout.insertWidget(0, self.details)

    def on_loading_complete(self):
        self.details.expandAll()
        super().on_loading_complete()

    def on_item_changed(
        self, current: QTreeWidgetItem, previous: QTreeWidgetItem
    ):
        if not isinstance(current, FieldChild):
            return

        if not isinstance(current.field, Field):
            # If we have a high-level field, we already know what the contents
            # are.
            return

        self.add_panel(
            "child",
            BinaryViewer(
                current.field.data,
                working_area=self.working_area,
            ),
        )

starhopper/gui/viewers/record_viewer.py
TkTech/starhopper
import itertools
from io import BytesIO
from pathlib import Path

from PySide6.QtCore import QRunnable, Signal
from PySide6.QtWidgets import (
    QLayout,
    QTreeWidget,
    QTreeWidgetItem,
    QHeaderView,
    QSizePolicy,
    QWidget,
)

from starhopper.formats.btdx.file import BA2Container
from starhopper.formats.esm.file import ESMContainer, Group
from starhopper.gui import search
from starhopper.gui.common import (
    ColorPurple,
    tr,
    ColorGray,
    ColorOrange,
    ColorGreen,
    monospace,
)
from starhopper.gui.search import SearchResult

from starhopper.gui.viewers.viewer import Viewer
from starhopper.io import BinaryReader


class SearchResultNode(QTreeWidgetItem):
    def __init__(self, result: SearchResult):
        super().__init__()

        self.result = result
        self.setText(0, Path(result.file_path).name)
        self.setText(1, result.stored_text)
        self.setText(2, result.item_type.name)
        self.setForeground(0, ColorGray)

        match result.item_type:
            case search.ItemType.EDID:
                self.setForeground(1, ColorPurple)
            case search.ItemType.FORM_ID:
                self.setText(1, f"0x{result.stored_text}")
                self.setFont(1, monospace())
                self.setForeground(1, ColorOrange)
            case search.ItemType.FILE:
                self.setForeground(1, ColorGreen)


class SearchViewer(Viewer):
    """
    A viewer for search results.
    """

    onSearchSelected = Signal(list)

    def __init__(self, working_area: QLayout):
        super().__init__(working_area=working_area)

        self.tree = QTreeWidget()
        self.tree.setAlternatingRowColors(True)
        self.tree.setUniformRowHeights(True)
        self.tree.setColumnCount(3)
        self.tree.setHeaderLabels(
            (
                tr("Search", "File", None),
                tr("Search", "Text", None),
                tr("Search", "Type", None),
            )
        )
        self.tree.itemDoubleClicked.connect(self.on_item_double_clicked)

        header = self.tree.header()
        header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
        header.setSectionResizeMode(1, QHeaderView.Stretch)
        header.setStretchLastSection(False)

        self.layout.insertWidget(0, self.tree)
        self.setSizePolicy(
            QSizePolicy.MinimumExpanding,
            QSizePolicy.MinimumExpanding,
        )

    def update_search_results(self, text: str):
        self.tree.clear()

        items = []
        for result in search.search_index(f"%{text}%"):
            item = SearchResultNode(result)
            items.append(item)

        self.tree.addTopLevelItems(items)

    def on_item_double_clicked(self, item: QTreeWidgetItem, column: int):
        if not isinstance(item, SearchResultNode):
            return

        self.onSearchSelected.emit(item.result.navigation_path)


class SearchIndexThread(QRunnable):
    """
    Loads a file in the background, scanning it to populate a search index.
    """

    def __init__(self, file: str):
        super().__init__()
        self.file = Path(file)

    def run(self):
        match self.file.suffix:
            case ".esm":
                with open(self.file, "rb") as handle:
                    item = ESMContainer(handle)
                    for top_level_group in item.groups:
                        path = [top_level_group]
                        while path:
                            parent = path.pop()
                            for record in parent.children():
                                if isinstance(record, Group):
                                    path.append(record)
                                    continue

                                search.add_to_index(
                                    str(self.file),
                                    [
                                        str(self.file),
                                        top_level_group.label.decode("ascii"),
                                        f"{record.form_id:08X}",
                                    ],
                                    f"{record.form_id:08X}",
                                    search.ItemType.FORM_ID,
                                )

                                for field in record.fields():
                                    if field.type != b"EDID":
                                        continue

                                    with BytesIO(field.data) as data:
                                        io = BinaryReader(data)
                                        label = io.cstring()
                                        search.add_to_index(
                                            str(self.file),
                                            [
                                                str(self.file),
                                                top_level_group.label.decode(
                                                    "ascii"
                                                ),
                                                label,
                                            ],
                                            label,
                                            search.ItemType.EDID,
                                        )

            case ".ba2":
                with open(self.file, "rb") as handle:
                    item = BA2Container()
                    item.read_from_file(handle)
                    for file in item.files():
                        search.add_to_index(
                            str(self.file),
                            [str(self.file), file.path],
                            file.path,
                            search.ItemType.FILE,
                        )
            case _:
                return

starhopper/gui/viewers/search_viewer.py
TkTech/starhopper
import csv
from pathlib import Path

from PySide6.QtWidgets import (
    QLayout,
    QTreeWidget,
    QTreeWidgetItem,
    QFileDialog,
    QPushButton,
)

from starhopper.formats.archive import AbstractFile
from starhopper.formats.strings import StringContainer, StringContainerType
from starhopper.gui.common import tr
from starhopper.gui.viewers.viewer import Viewer


class StringViewer(Viewer):
    """
    Viewer for Bethesda .strings, .dlstrings, and .ilstrings files, typically
    containing translations.
    """

    def __init__(self, file: AbstractFile, working_area: QLayout):
        super().__init__(working_area)

        self.file = file

        match Path(self.file.path).suffix.lower():
            case ".strings":
                self._type = StringContainerType.Strings
            case ".dlstrings":
                self._type = StringContainerType.DLStrings
            case ".ilstrings":
                self._type = StringContainerType.ILStrings
            case _:
                raise ValueError("Invalid file type")

        self.details = QTreeWidget()
        self.details.setUniformRowHeights(True)
        self.details.setColumnCount(2)
        self.details.setAlternatingRowColors(True)
        self.details.setHeaderLabels(
            (
                tr("StringViewer", "ID", None),
                tr("StringViewer", "String", None),
            )
        )

        with self.file.open() as data:
            self.strings = StringContainer(data, self._type)
            for string_id, string in self.strings.strings.items():
                item = QTreeWidgetItem()
                item.setText(0, str(string_id))
                item.setText(1, string)
                self.details.addTopLevelItem(item)

        self.export_button = QPushButton(
            tr("StringViewer", "E&xport as CSV", None)
        )
        self.export_button.clicked.connect(self.on_export_button_clicked)

        self.layout.insertWidget(0, self.details)
        self.layout.insertWidget(1, self.export_button)

    def on_export_button_clicked(self):
        fname, _ = QFileDialog.getSaveFileName(
            self,
            tr("ModelViewer", "Export As", None),
            filter=tr("ModelViewer", "CSV (*.csv)", None),
        )

        if not fname:
            return

        with open(fname, "w") as f:
            writer = csv.writer(f)
            writer.writerow(("ID", "String"))
            for string_id, string in self.strings.strings.items():
                writer.writerow((str(string_id), string))

starhopper/gui/viewers/string_viewer.py
TkTech/starhopper
from typing import Any

from PySide6 import QtCore, QtGui
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
    QVBoxLayout,
    QProgressBar,
    QWidget,
    QSizePolicy,
    QLayout,
)

from starhopper.gui.common import ColorPurple
from starhopper.gui.widgets.spinner import QtWaitingSpinner


class Viewer(QWidget):
    """
    Base class for all viewers.

    Viewers are widgets that are displayed in the main window and typically
    contain Record or Field information.
    """

    addedNewPanel = Signal(QWidget)

    def __init__(self, working_area: QLayout):
        super().__init__()

        self.working_area = working_area
        self.child_panels = {}

        self.layout = QVBoxLayout()
        self.layout.setContentsMargins(0, 0, 0, 0)

        self.spinner = QtWaitingSpinner(self)
        self.spinner.setColor(ColorPurple)
        self.spinner.hide()

        p = QtGui.QPalette(ColorPurple)
        p.setColor(QtGui.QPalette.ColorRole.Highlight, ColorPurple)

        self.layout.addWidget(self.spinner)

        self.setSizePolicy(
            QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
        )
        self.setMinimumWidth(600)
        self.setLayout(self.layout)

    def on_loading_start(self):
        self.spinner.start()
        self.spinner.show()

    def on_loading_complete(self):
        self.spinner.close()

    def add_panel(self, key: Any, panel: QWidget):
        self.remove_panel(key)

        self.working_area.addWidget(panel)
        self.addedNewPanel.emit(panel)
        self.child_panels[key] = panel

        if isinstance(panel, Viewer):
            panel.addedNewPanel.connect(
                # No type information for QueuedConnection.
                self.addedNewPanel.emit,
                QtCore.Qt.QueuedConnection,  # noqa
            )

    def remove_panel(self, key: Any):
        if key not in self.child_panels:
            return

        panel = self.child_panels[key]
        panel.close()
        self.working_area.removeWidget(panel)
        del self.child_panels[key]

    def closeEvent(self, event: QtGui.QCloseEvent) -> None:
        for panel in self.child_panels.values():
            panel.close()

        super().closeEvent(event)

    def navigate(self, path: list[str]):
        pass

starhopper/gui/viewers/viewer.py
TkTech/starhopper
"""
The MIT License (MIT)

Copyright (c) 2012-2014 Alexander Turkin
Copyright (c) 2014 William Hallatt
Copyright (c) 2015 Jacob Dawid
Copyright (c) 2016 Luca Weiss

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""

import math

from PySide6.QtCore import Qt, QTimer, QRect
from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import QWidget


class QtWaitingSpinner(QWidget):
    def __init__(
        self,
        parent,
        centerOnParent=True,
        disableParentWhenSpinning=False,
        modality=Qt.WindowModality.NonModal,
    ):
        super().__init__(parent)

        self._centerOnParent = centerOnParent
        self._disableParentWhenSpinning = disableParentWhenSpinning

        # WAS IN initialize()
        self._color = QColor(Qt.GlobalColor.black)
        self._roundness = 100.0
        self._minimumTrailOpacity = 3.14159265358979323846
        self._trailFadePercentage = 80.0
        self._revolutionsPerSecond = 1.57079632679489661923
        self._numberOfLines = 20
        self._lineLength = 10
        self._lineWidth = 2
        self._innerRadius = 10
        self._currentCounter = 0
        self._isSpinning = False

        self._timer = QTimer(self)
        self._timer.timeout.connect(self.rotate)
        self.updateSize()
        self.updateTimer()
        self.hide()
        # END initialize()

        self.setWindowModality(modality)
        self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)

    def paintEvent(self, QPaintEvent):
        self.updatePosition()
        painter = QPainter(self)
        painter.fillRect(self.rect(), Qt.GlobalColor.transparent)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)

        if self._currentCounter >= self._numberOfLines:
            self._currentCounter = 0

        painter.setPen(Qt.PenStyle.NoPen)
        for i in range(0, self._numberOfLines):
            painter.save()
            painter.translate(
                self._innerRadius + self._lineLength,
                self._innerRadius + self._lineLength,
            )
            rotateAngle = float(360 * i) / float(self._numberOfLines)
            painter.rotate(rotateAngle)
            painter.translate(self._innerRadius, 0)
            distance = self.lineCountDistanceFromPrimary(
                i, self._currentCounter, self._numberOfLines
            )
            color = self.currentLineColor(
                distance,
                self._numberOfLines,
                self._trailFadePercentage,
                self._minimumTrailOpacity,
                self._color,
            )
            painter.setBrush(color)
            rect = QRect(
                0,
                int(-self._lineWidth / 2),
                int(self._lineLength),
                int(self._lineWidth),
            )
            painter.drawRoundedRect(
                rect, self._roundness, self._roundness, Qt.SizeMode.RelativeSize
            )
            painter.restore()

    def start(self):
        self.updatePosition()
        self._isSpinning = True
        self.show()

        if self.parentWidget and self._disableParentWhenSpinning:
            self.parentWidget().setEnabled(False)

        if not self._timer.isActive():
            self._timer.start()
            self._currentCounter = 0

    def stop(self):
        self._isSpinning = False
        self.hide()

        if self.parentWidget() and self._disableParentWhenSpinning:
            self.parentWidget().setEnabled(True)

        if self._timer.isActive():
            self._timer.stop()
            self._currentCounter = 0

    def setNumberOfLines(self, lines):
        self._numberOfLines = lines
        self._currentCounter = 0
        self.updateTimer()

    def setLineLength(self, length):
        self._lineLength = length
        self.updateSize()

    def setLineWidth(self, width):
        self._lineWidth = width
        self.updateSize()

    def setInnerRadius(self, radius):
        self._innerRadius = radius
        self.updateSize()

    def color(self):
        return self._color

    def roundness(self):
        return self._roundness

    def minimumTrailOpacity(self):
        return self._minimumTrailOpacity

    def trailFadePercentage(self):
        return self._trailFadePercentage

    def revolutionsPersSecond(self):
        return self._revolutionsPerSecond

    def numberOfLines(self):
        return self._numberOfLines

    def lineLength(self):
        return self._lineLength

    def lineWidth(self):
        return self._lineWidth

    def innerRadius(self):
        return self._innerRadius

    def isSpinning(self):
        return self._isSpinning

    def setRoundness(self, roundness):
        self._roundness = max(0.0, min(100.0, roundness))

    def setColor(self, color=Qt.GlobalColor.black):
        self._color = QColor(color)

    def setRevolutionsPerSecond(self, revolutionsPerSecond):
        self._revolutionsPerSecond = revolutionsPerSecond
        self.updateTimer()

    def setTrailFadePercentage(self, trail):
        self._trailFadePercentage = trail

    def setMinimumTrailOpacity(self, minimumTrailOpacity):
        self._minimumTrailOpacity = minimumTrailOpacity

    def rotate(self):
        self._currentCounter += 1
        if self._currentCounter >= self._numberOfLines:
            self._currentCounter = 0
        self.update()

    def updateSize(self):
        size = int((self._innerRadius + self._lineLength) * 2)
        self.setFixedSize(size, size)

    def updateTimer(self):
        self._timer.setInterval(
            int(1000 / (self._numberOfLines * self._revolutionsPerSecond))
        )

    def updatePosition(self):
        if self.parentWidget() and self._centerOnParent:
            self.move(
                int(self.parentWidget().width() / 2 - self.width() / 2),
                int(self.parentWidget().height() / 2 - self.height() / 2),
            )

    def lineCountDistanceFromPrimary(self, current, primary, totalNrOfLines):
        distance = primary - current
        if distance < 0:
            distance += totalNrOfLines
        return distance

    def currentLineColor(
        self,
        countDistance,
        totalNrOfLines,
        trailFadePerc,
        minOpacity,
        colorinput,
    ):
        color = QColor(colorinput)
        if countDistance == 0:
            return color
        minAlphaF = minOpacity / 100.0
        distanceThreshold = int(
            math.ceil((totalNrOfLines - 1) * trailFadePerc / 100.0)
        )
        if countDistance > distanceThreshold:
            color.setAlphaF(minAlphaF)
        else:
            alphaDiff = color.alphaF() - minAlphaF
            gradient = alphaDiff / float(distanceThreshold + 1)
            resultAlpha = color.alphaF() - gradient * countDistance
            # If alpha is out of bounds, clip it.
            resultAlpha = min(1.0, max(0.0, resultAlpha))
            color.setAlphaF(resultAlpha)
        return color

starhopper/gui/widgets/spinner.py
TkTech/starhopper
from struct import unpack, pack
from typing import BinaryIO, Callable


class BinaryReader:
    def __init__(self, file: BinaryIO, *, offset: int = 0):
        self.file = file
        self.offset = offset

    def read(self, size: int) -> bytes:
        self.offset += size
        result = self.file.read(size)
        if not result:
            raise EOFError("End of file")
        return result

    @property
    def pos(self) -> int:
        return self.offset

    def seek(self, offset: int):
        self.offset = offset
        self.file.seek(offset, 0)

    def skip(self, count: int):
        self.offset += count
        self.file.seek(count, 1)

    def uint8(self) -> int:
        return int.from_bytes(self.read(1), byteorder="little", signed=False)

    def uint16(self) -> int:
        return int.from_bytes(self.read(2), byteorder="little", signed=False)

    def uint32(self) -> int:
        return int.from_bytes(self.read(4), byteorder="little", signed=False)

    def uint64(self) -> int:
        return int.from_bytes(self.read(8), byteorder="little", signed=False)

    def int8(self) -> int:
        return int.from_bytes(self.read(1), byteorder="little", signed=True)

    def int16(self) -> int:
        return int.from_bytes(self.read(2), byteorder="little", signed=True)

    def int32(self) -> int:
        return int.from_bytes(self.read(4), byteorder="little", signed=True)

    def int64(self) -> int:
        return int.from_bytes(self.read(8), byteorder="little", signed=True)

    def float_(self) -> float:
        return unpack("<f", self.read(4))[0]

    def double(self) -> float:
        return unpack("<d", self.read(8))[0]

    def half(self) -> float:
        return unpack("<e", self.read(2))[0]

    def cstring(self, encoding: str | None = "utf-8") -> str | bytes:
        """
        Read bytes until a null byte is encountered, and decode them as the
        given encoding.

        :param encoding: The encoding to use.
        """
        b = []
        while True:
            c = self.uint8()
            if c == 0:
                break
            b.append(c)
        r = bytes(b)
        if encoding:
            return r.decode(encoding)
        return r

    def __enter__(self) -> "Capture":
        return Capture(self)

    def __exit__(self, exc_type, exc_value, traceback):
        pass


class BinaryWriter:
    def __init__(self, file: BinaryIO, *, offset: int = 0):
        self.file = file
        self.offset = offset

    def write(self, data: bytes):
        self.offset += len(data)
        self.file.write(data)

    @property
    def pos(self) -> int:
        return self.offset

    def seek(self, offset: int):
        self.offset = offset
        self.file.seek(offset, 0)

    def skip(self, count: int):
        self.offset += count
        self.file.seek(count, 1)

    def uint8(self, value: int):
        self.write(value.to_bytes(1, "little", signed=False))

    def uint16(self, value: int):
        self.write(value.to_bytes(2, "little", signed=False))

    def uint32(self, value: int):
        self.write(value.to_bytes(4, "little", signed=False))

    def uint64(self, value: int):
        self.write(value.to_bytes(8, "little", signed=False))

    def int8(self, value: int):
        self.write(value.to_bytes(1, "little", signed=True))

    def int16(self, value: int):
        self.write(value.to_bytes(2, "little", signed=True))

    def int32(self, value: int):
        self.write(value.to_bytes(4, "little", signed=True))

    def int64(self, value: int):
        self.write(value.to_bytes(8, "little", signed=True))

    def float_(self, value: float):
        self.write(pack("<f", value))

    def double(self, value: float):
        self.write(pack("<d", value))

    def half(self, value: float):
        self.write(pack("<e", value))

    def cstring(self, value: str, encoding: str = "utf-8"):
        """
        Write the given string as bytes, followed by a null byte.

        :param value: The string to write.
        :param encoding: The encoding to use.
        """
        self.write(value.encode(encoding))
        self.write(b"\x00")


class Capture:
    def __init__(self, file: BinaryReader):
        self.file = file
        self.start_pos: int = file.pos
        self.data = {}
        self._last_field_set: str | None = None

    def __getitem__(self, item: str):
        return self.data[item]

    def __setitem__(self, item: str, value):
        self._last_field_set = item
        self.data[item] = value

    def __repr__(self):
        return repr(self.data)

    @property
    def pos(self):
        """
        The current position as an offset from the start of the IO stream.
        """
        return self.file.pos

    def uint8(self, name: str) -> "Capture":
        self[name] = self.file.uint8()
        return self

    def uint16(self, name: str) -> "Capture":
        self[name] = self.file.uint16()
        return self

    def uint32(self, name: str) -> "Capture":
        self[name] = self.file.uint32()
        return self

    def uint64(self, name: str) -> "Capture":
        self[name] = self.file.uint64()
        return self

    def int8(self, name: str) -> "Capture":
        self[name] = self.file.int8()
        return self

    def int16(self, name: str) -> "Capture":
        self[name] = self.file.int16()
        return self

    def int32(self, name: str) -> "Capture":
        self[name] = self.file.int32()
        return self

    def int64(self, name: str) -> "Capture":
        self[name] = self.file.int64()
        return self

    def float_(self, name: str) -> "Capture":
        self[name] = self.file.float_()
        return self

    def double(self, name: str) -> "Capture":
        self[name] = self.file.double()
        return self

    def string(
        self, name: str, size: int, encoding: str = "utf-8"
    ) -> "Capture":
        """
        Read the given number of bytes into the given field, and decode it as
        the given encoding.

        :param name: The destination field.
        :param size: The number of bytes to read.
        :param encoding: The encoding to use.
        """
        self[name] = self.file.read(size).decode(encoding)
        return self

    def cstring(self, name: str, encoding: str = "utf-8") -> "Capture":
        """
        Read bytes until a null byte is encountered, and decode them as the
        given encoding.

        :param name: The destination field.
        :param encoding: The encoding to use.
        """
        b = []
        while True:
            c = self.file.uint8()
            if c == 0:
                break
            b.append(c)
        self[name] = bytes(b).decode(encoding)
        return self

    def bytes(self, name: str, size: int) -> "Capture":
        """
        Read the given number of bytes into the given field.

        :param name: The destination field.
        :param size: The number of bytes to read.
        """
        self[name] = self.file.read(size)
        return self

    def skip(self, count: int) -> "Capture":
        """
        Skip the given number of bytes.

        :param count: The number of bytes to skip.
        """
        self.file.seek(self.file.pos + count)
        return self

    def seek(self, offset: int) -> "Capture":
        """
        Seek to the given offset relative to the start of the record.

        .. note::

            This is offset from the _start of the capture_, not the entire
            IO stream.

        :param offset: The offset to seek to.
        """
        self.file.seek(self.start_pos + offset)
        return self

    def ensure(
        self, name: str, value_or_callable: object | Callable[[object], bool]
    ) -> "Capture":
        """
        Ensure that the value of the given field is equal to the given value.

        :param name: The field to validate.
        :param value_or_callable: A value to compare against, or a callable
                                    that takes the current value and returns
                                    True if it's valid.
        :return:
        """
        if callable(value_or_callable):
            valid = value_or_callable(self.data[name])
        else:
            valid = self.data[name] == value_or_callable

        if not valid:
            raise ValueError(f"Invalid value for {name}: {self.data[name]}")

        return self

    def change(self, callback: Callable) -> "Capture":
        """
        Change the value of the last field set.

        :param callback: Callable that takes the current value and returns
                         the new value.
        """
        self.data[self._last_field_set] = callback(
            self.data[self._last_field_set]
        )
        return self

    def stride(self, name: str) -> "Capture":
        self[name] = {
            "start": self.start_pos,
            "end": self.file.pos,
            "size": self.file.pos - self.start_pos,
        }
        return self

    def set(self, name: str, value: object) -> "Capture":
        self[name] = value
        return self

starhopper/io.py
TkTech/starhopper