Files
BFM-decomp/tools/bfm_extract/iso9660.py
T
Drew T de7f015436 feat(extract): ISO9660/MODE2 disc reader + SLUS_007.26 extractor
Reusable iso9660.py (Phase 2 foundation) + extract_exe.py with --list and --verify-disc. Adversarially verified: extracted EXE SHA1 143dbb89f34491258bbc27810d0a12ec8b43a8dd (413696 B), and --verify-disc confirms Track-1 SHA1 b44f0f0a19936f23b26188b658e13201a6a9c211 == redump (dump is canonical).
2026-06-10 23:43:31 -06:00

340 lines
14 KiB
Python

"""Reusable ISO9660 reader for the Brave Fencer Musashi MODE2/2352 data track.
This is the FOUNDATION layer for the Phase 2 asset pipeline (.CD / PAC / LZSS),
so it is deliberately general: nothing here knows about SLUS_007.26 or any other
specific file. It only understands the on-disc geometry and the ISO9660 file
system on top of it.
Disc geometry (verified against the BFM USA dump)
-------------------------------------------------
The data track is stored as raw MODE2/2352 sectors. Each 2352-byte sector is::
[ 12 sync ][ 4 header ][ 8 subheader ][ 2048 user data ][ 280 EDC/ECC ]
0 12 16 24 2072
So the 2048 bytes of user data for logical sector (LBA) N live at byte offset
``N * 2352 + 24`` in the BIN file. All ISO9660 structures are addressed in
logical sectors and read through that 2048-byte user-data window.
ISO9660 essentials
------------------
* The Primary Volume Descriptor (PVD) sits at LBA 16. Its user data starts with
``0x01`` (descriptor type "primary") followed by the ASCII magic ``CD001``.
* The PVD embeds the root directory record at offset 156 within its user data.
* A directory is a run of directory records packed into one or more sectors. A
record whose length byte is 0 means "no more records in this sector" -> skip
to the start of the next sector. Records never straddle a sector boundary.
"""
from __future__ import annotations
import struct
from dataclasses import dataclass
from pathlib import Path
from typing import BinaryIO, Iterator
# --- Geometry constants -----------------------------------------------------
RAW_SECTOR_SIZE = 2352 # bytes per raw MODE2/2352 sector
USER_DATA_SIZE = 2048 # bytes of ISO9660 user data per sector
USER_DATA_OFFSET = 24 # 12 sync + 4 header + 8 subheader
# --- ISO9660 constants ------------------------------------------------------
PVD_LBA = 16 # Primary Volume Descriptor is always at LBA 16
ISO_MAGIC = b"CD001" # standard identifier, follows the descriptor type
VD_TYPE_PRIMARY = 0x01 # volume-descriptor type byte for the PVD
ROOT_RECORD_OFFSET = 156 # offset of the root dir record inside the PVD
VOLUME_ID_OFFSET = 40 # offset of the 32-byte volume identifier in the PVD
VOLUME_ID_LEN = 32
# Directory-record field offsets (relative to the start of a record).
DR_LENGTH = 0 # u8 : length of this record (0 => pad to next sector)
DR_EXTENT_LE = 2 # u32 : extent (data) LBA, little-endian
DR_DATA_LEN_LE = 10 # u32 : data length in bytes, little-endian
DR_FLAGS = 25 # u8 : file flags (bit 1 set => directory)
DR_NAME_LEN = 32 # u8 : length of the name field
DR_NAME = 33 # name bytes begin here
DIR_FLAG_DIRECTORY = 0x02 # file-flags bit 1
class Iso9660Error(Exception):
"""Raised when the BIN is missing, mis-shaped, or not a valid ISO9660."""
@dataclass(frozen=True)
class FileRecord:
"""One entry parsed from an ISO9660 directory.
Attributes
----------
name:
File name with the ISO version suffix stripped (e.g. ``SLUS_007.26``).
raw_name:
File name exactly as stored on disc, version suffix included
(e.g. ``SLUS_007.26;1``).
lba:
Logical sector of the file's data extent.
size:
Data length in bytes.
is_dir:
True if the record is a directory.
"""
name: str
raw_name: str
lba: int
size: int
is_dir: bool
def _strip_version(raw_name: str) -> str:
"""Drop the trailing ``;N`` ISO version suffix if present."""
semi = raw_name.find(";")
return raw_name[:semi] if semi != -1 else raw_name
class Iso9660Image:
"""Read-only view of an ISO9660 file system on a MODE2/2352 data track.
The image is opened lazily and read one logical sector at a time (a seek
per sector), so the multi-hundred-megabyte track is never loaded whole.
Typical use::
with Iso9660Image(bin_path) as img:
for rec in img.list_files():
print(rec.name, rec.lba, rec.size)
data = img.extract_file("SLUS_007.26")
"""
def __init__(self, bin_path: str | Path):
self.bin_path = Path(bin_path)
if not self.bin_path.is_file():
raise Iso9660Error(f"BIN data track not found: {self.bin_path}")
size = self.bin_path.stat().st_size
if size == 0 or size % RAW_SECTOR_SIZE != 0:
raise Iso9660Error(
f"{self.bin_path} is {size} bytes, which is not a whole number "
f"of {RAW_SECTOR_SIZE}-byte MODE2/2352 sectors. This reader "
f"expects a raw MODE2/2352 data track."
)
self.sector_count = size // RAW_SECTOR_SIZE
self._fh: BinaryIO | None = open(self.bin_path, "rb")
try:
self._validate_pvd()
except Exception:
self.close()
raise
# -- context-manager plumbing -------------------------------------------
def __enter__(self) -> "Iso9660Image":
return self
def __exit__(self, *exc) -> None:
self.close()
def close(self) -> None:
if self._fh is not None:
self._fh.close()
self._fh = None
# -- low-level sector access --------------------------------------------
def read_sector(self, lba: int) -> bytes:
"""Return the 2048 user-data bytes of logical sector ``lba``."""
if self._fh is None:
raise Iso9660Error("image is closed")
if lba < 0 or lba >= self.sector_count:
raise Iso9660Error(
f"LBA {lba} out of range (0..{self.sector_count - 1})"
)
self._fh.seek(lba * RAW_SECTOR_SIZE + USER_DATA_OFFSET)
data = self._fh.read(USER_DATA_SIZE)
if len(data) != USER_DATA_SIZE:
raise Iso9660Error(
f"short read at LBA {lba}: got {len(data)} of "
f"{USER_DATA_SIZE} bytes"
)
return data
def read_extent(self, lba: int, size: int) -> bytes:
"""Return ``size`` bytes starting at logical sector ``lba``.
Reads whole sectors and trims to ``size``; the file system stores files
as runs of full sectors with the real length given separately.
"""
if size < 0:
raise Iso9660Error(f"negative extent size: {size}")
out = bytearray()
sectors_needed = (size + USER_DATA_SIZE - 1) // USER_DATA_SIZE
for i in range(sectors_needed):
out.extend(self.read_sector(lba + i))
return bytes(out[:size])
# -- ISO9660 parsing -----------------------------------------------------
def _validate_pvd(self) -> None:
"""Read the PVD and stash the root record + volume id."""
pvd = self.read_sector(PVD_LBA)
if pvd[0] != VD_TYPE_PRIMARY or pvd[1:6] != ISO_MAGIC:
raise Iso9660Error(
"no ISO9660 Primary Volume Descriptor at LBA 16 "
"(expected 0x01 'CD001'); is this a MODE2/2352 data track?"
)
self.volume_id = (
pvd[VOLUME_ID_OFFSET : VOLUME_ID_OFFSET + VOLUME_ID_LEN]
.rstrip(b" \x00")
.decode("ascii", "replace")
)
# The root directory record is embedded directly in the PVD.
root = self._parse_record(pvd, ROOT_RECORD_OFFSET)
if root is None or not root.is_dir:
raise Iso9660Error("PVD does not contain a valid root directory record")
self.root = root
# Smallest directory record that still has a (1-byte) name field: the 33
# fixed header bytes plus at least one name byte.
_MIN_RECORD_LEN = DR_NAME + 1
@staticmethod
def _parse_record(buf: bytes, offset: int) -> FileRecord | None:
"""Parse a single directory record at ``buf[offset:]``.
Returns ``None`` when the length byte is 0 (sector padding) so callers
can advance to the next sector.
Validates that the record fits inside ``buf`` before touching any field,
and converts any low-level ``struct``/index error into ``Iso9660Error``
so malformed input never escapes the module's error contract.
"""
try:
if offset < 0 or offset >= len(buf):
raise Iso9660Error(
f"directory record offset {offset} outside buffer "
f"(len {len(buf)})"
)
rec_len = buf[offset]
if rec_len == 0:
return None
if rec_len < Iso9660Image._MIN_RECORD_LEN:
raise Iso9660Error(
f"directory record at offset {offset} has length "
f"{rec_len}, below the {Iso9660Image._MIN_RECORD_LEN}-byte "
f"minimum"
)
if offset + rec_len > len(buf):
raise Iso9660Error(
f"directory record at offset {offset} (length {rec_len}) "
f"runs past the end of the {len(buf)}-byte buffer"
)
rec = buf[offset : offset + rec_len]
extent_lba = struct.unpack_from("<I", rec, DR_EXTENT_LE)[0]
data_len = struct.unpack_from("<I", rec, DR_DATA_LEN_LE)[0]
flags = rec[DR_FLAGS]
name_len = rec[DR_NAME_LEN]
if DR_NAME + name_len > rec_len:
raise Iso9660Error(
f"directory record at offset {offset} declares a "
f"{name_len}-byte name that overflows its {rec_len}-byte "
f"length"
)
name_bytes = rec[DR_NAME : DR_NAME + name_len]
except (struct.error, IndexError) as exc:
raise Iso9660Error(
f"malformed directory record at offset {offset}: {exc}"
) from exc
# Entries 0x00 ("." / self) and 0x01 (".." / parent) use single-byte
# names; decode everything else as plain ASCII.
if name_bytes == b"\x00":
raw_name = "."
elif name_bytes == b"\x01":
raw_name = ".."
else:
raw_name = name_bytes.decode("ascii", "replace")
return FileRecord(
name=_strip_version(raw_name),
raw_name=raw_name,
lba=extent_lba,
size=data_len,
is_dir=bool(flags & DIR_FLAG_DIRECTORY),
)
def iter_directory(self, dir_record: FileRecord) -> Iterator[FileRecord]:
"""Yield every child record of ``dir_record``, skipping '.' and '..'.
Handles directories that span multiple sectors generally: the directory
data length tells us how many sectors to walk, and a zero length byte
within a sector means "advance to the next sector".
"""
if not dir_record.is_dir:
raise Iso9660Error(f"{dir_record.name!r} is not a directory")
sectors = (dir_record.size + USER_DATA_SIZE - 1) // USER_DATA_SIZE
for s in range(sectors):
sector = self.read_sector(dir_record.lba + s)
offset = 0
while offset < USER_DATA_SIZE:
rec_len = sector[offset]
if rec_len == 0:
# Padding to the end of this sector; jump to the next one.
break
# Records never straddle a sector boundary; a length that would
# run past the end of this sector means the directory data is
# corrupt, not that another sector continues the record.
if offset + rec_len > USER_DATA_SIZE:
raise Iso9660Error(
f"directory record at sector offset {offset} (length "
f"{rec_len}) straddles the {USER_DATA_SIZE}-byte sector "
f"boundary in {dir_record.name!r}"
)
record = self._parse_record(sector, offset)
if record is not None and record.raw_name not in (".", ".."):
yield record
offset += rec_len
# -- public convenience API ---------------------------------------------
def list_files(self, directory: FileRecord | None = None) -> list[FileRecord]:
"""Return a directory's entries (excluding '.' and '..').
``directory`` defaults to the volume root. Pass a directory
``FileRecord`` (e.g. one returned by :meth:`find`) to list a
subdirectory instead. This does not recurse into subdirectories.
"""
return list(self.iter_directory(directory or self.root))
def find(self, name: str, directory: FileRecord | None = None) -> FileRecord | None:
"""Look up an entry by name within a single directory.
Searches ``directory`` (default: the volume root); it does not recurse
into subdirectories. Matches against both the version-stripped name and
the raw on-disc name, so ``"SLUS_007.26"`` and ``"SLUS_007.26;1"`` both
resolve.
"""
for rec in self.iter_directory(directory or self.root):
if name in (rec.name, rec.raw_name):
return rec
return None
def extract_file(self, name: str, directory: FileRecord | None = None) -> bytes:
"""Return the raw bytes of a file looked up by name in one directory.
Searches ``directory`` (default: the volume root); it does not recurse
into subdirectories.
"""
rec = self.find(name, directory)
if rec is None:
where = "root directory" if directory is None else f"{directory.name!r}"
raise Iso9660Error(f"file not found in {where}: {name!r}")
if rec.is_dir:
raise Iso9660Error(f"{name!r} is a directory, not a file")
return self.read_extent(rec.lba, rec.size)