#!/usr/bin/env python3 """TorrentZip archive builder for MAME/FBNeo ROM sets. TorrentZip is the archive format MAME romset distributions use. It fixes every non-content variable (member order, timestamps, compression level, extra fields, attributes), so the archive bytes are a pure function of the member names and their contents. That property makes it possible to rebuild a romset ZIP that matches an upstream-published hash exactly, and to identify which romset revision a published hash refers to. Usage: from torrentzip import build_torrentzip, identify_romset data = build_torrentzip([("sp-s2.sp1", rom_bytes), ...]) md5 = hashlib.md5(data).hexdigest() CLI: python scripts/torrentzip.py --check bios/Arcade/Arcade/naomi2.zip python scripts/torrentzip.py --rebuild in.zip --output out.zip """ from __future__ import annotations import argparse import functools import hashlib import io import struct import zipfile import zlib from collections.abc import Callable, Iterable from pathlib import Path from typing import Any, NamedTuple, Protocol class Source(Protocol): """An open member: read in chunks, closed by its ``with`` block.""" def read(self, size: int = -1, /) -> bytes: ... def __enter__(self) -> Source: ... def __exit__(self, *exc: Any) -> Any: ... class Sink(Protocol): def write(self, data: bytes, /) -> int: ... # TorrentZip constants: 1996-12-24 23:32:00, deflate level 9 _DOS_DATE = ((1996 - 1980) << 9) | (12 << 5) | 24 _DOS_TIME = (23 << 11) | (32 << 5) | 0 _COMMENT_PREFIX = "TORRENTZIPPED-" # Members are read and deflated this much at a time. Deflate output does not # depend on how its input is cut, so a 2 GiB disk image streams through in # the same bytes a one-shot compression gives. CHUNK = 16 * 1024 * 1024 # A size or offset that does not fit 32 bits takes a ZIP64 field. No # TorrentZip-conformant archive needs one, so their layout is untouched. ZIP32_LIMIT = 0xFFFFFFFF _ZIP64_MARK = 0xFFFFFFFF class Written(NamedTuple): """A member as the writer read it from its source.""" name: str size: int crc32: str sha1: str def zlib_ng_version(module: object = zlib) -> str | None: """The zlib-ng version this interpreter deflates with, None for zlib. TorrentZip bytes are the output of zlib's level-9 deflate. Stock zlib 1.2.13 to 1.3.2 reproduces every TorrentZip archive in the collection. zlib-ng 2.2.4 (python.org 3.14 on Windows) reproduces 479 of 1,561 and 2.3.3 545, and the two disagree with each other. """ version = getattr(module, "ZLIBNG_VERSION", None) if version: return str(version) runtime = str(getattr(module, "ZLIB_RUNTIME_VERSION", "") or "") return runtime if "zlib-ng" in runtime.lower() else None def _deflate(stream: Source) -> tuple[bytes, Written]: compressor = zlib.compressobj(9, zlib.DEFLATED, -15) blob = bytearray() crc, size, sha1 = 0, 0, hashlib.sha1() while chunk := stream.read(CHUNK): crc = zlib.crc32(chunk, crc) size += len(chunk) sha1.update(chunk) blob += compressor.compress(chunk) blob += compressor.flush() return bytes(blob), Written("", size, f"{crc & 0xFFFFFFFF:08x}", sha1.hexdigest()) def write_torrentzip( sink: Sink, members: Iterable[tuple[str, Callable[[], Source]]] ) -> list[Written]: """Write a TorrentZip archive to *sink*, one member in memory at a time. *members* pairs each name with a callable opening its bytes. Members are sorted case-insensitively by name, as the format requires. Returns what was read, in archive order, so a caller can check every member. """ ordered = sorted(members, key=lambda m: m[0].lower()) central = bytearray() offset = 0 written: list[Written] = [] for name, opener in ordered: raw = name.encode("ascii") with opener() as stream: blob, read = _deflate(stream) crc = int(read.crc32, 16) written.append(read._replace(name=name)) wide = read.size >= ZIP32_LIMIT or len(blob) >= ZIP32_LIMIT far = offset >= ZIP32_LIMIT local_extra = struct.pack("= ZIP32_LIMIT or len(central) >= ZIP32_LIMIT or count >= 0xFFFF: end64 = cd_offset + len(central) sink.write(struct.pack( " bytes: """Build a TorrentZip archive from (name, data) members. Members are sorted case-insensitively by name, as the format requires. Returns the complete archive bytes. """ sink = io.BytesIO() write_torrentzip( sink, [(name, functools.partial(io.BytesIO, data)) for name, data in members] ) return sink.getvalue() def rebuild_torrentzip(source: str | Path) -> bytes: """Read an archive and return its TorrentZip normalization.""" with zipfile.ZipFile(source) as zf: members = [ (info.filename, zf.read(info.filename)) for info in zf.infolist() if not info.is_dir() ] return build_torrentzip(members) def is_torrentzip(source: str | Path) -> bool: """Report whether an archive already carries the TorrentZip signature.""" with zipfile.ZipFile(source) as zf: return zf.comment.decode("ascii", "replace").startswith(_COMMENT_PREFIX) def identify_romset( recipes: dict[str, list[tuple[str, str]]], atoms: dict[str, bytes], target_md5: str, ) -> str | None: """Find which recipe rebuilds to a target archive MD5. recipes maps a label (e.g. a MAME version) to a list of (member name, CRC32 hex) pairs, and atoms maps CRC32 hex to ROM bytes. Returns the matching label, or None when no recipe reproduces the hash. """ target = target_md5.lower() for label, recipe in recipes.items(): if any(crc.lower() not in atoms for _, crc in recipe): continue members = [(name, atoms[crc.lower()]) for name, crc in recipe] if hashlib.md5(build_torrentzip(members)).hexdigest() == target: return label return None def main() -> int: """Entry point: 1 when a checked archive is not TorrentZip.""" parser = argparse.ArgumentParser(allow_abbrev=False, description="TorrentZip builder and checker") parser.add_argument("--check", metavar="ZIP", help="report TorrentZip conformance") parser.add_argument("--rebuild", metavar="ZIP", help="normalize an archive") parser.add_argument("--output", "-o", help="output path for --rebuild") args = parser.parse_args() if args.check: data = Path(args.check).read_bytes() rebuilt = rebuild_torrentzip(args.check) print(f"{args.check}") print(f" signature: {is_torrentzip(args.check)}") print(f" md5: {hashlib.md5(data).hexdigest()}") print(f" rebuilt: {hashlib.md5(rebuilt).hexdigest()}") print(f" conform: {data == rebuilt}") return 0 if data == rebuilt else 1 if args.rebuild: data = rebuild_torrentzip(args.rebuild) dest = args.output or args.rebuild Path(dest).write_bytes(data) print(f"{dest}: {len(data)} bytes, md5 {hashlib.md5(data).hexdigest()}") return 0 parser.error("nothing to do: pass --check or --rebuild") if __name__ == "__main__": from common import run_cli run_cli(main)