Files
libretro/scripts/derivations.py
T

246 lines
9.4 KiB
Python

"""Catalog entries whose bytes a held dump already contains.
A catalog lists chips as they come off the board; a collection often holds
the image an emulator reads. The Amiga 3.1 Kickstart pair 391773/391774 is
the high and low 16-bit words of amiga-os-310-a1200.rom, byte-swapped; the
TOSEC "Basic V7" is basiclo and basichi end to end. Listed as missing, they
send a maintainer after files that are already here. Each transform below is
one the source boards use; a match is by sha1, never by size or name.
"""
from __future__ import annotations
import array
import functools
import hashlib
import os
import zipfile
from collections import defaultdict
from collections.abc import Callable, Iterable
# A held file larger than this is not read: no chip image is near it.
MAX_SOURCE_SIZE = 64 * 1024 * 1024
def _swap16(data: bytes) -> bytes:
swapped = bytearray(data)
swapped[0::2], swapped[1::2] = data[1::2], data[0::2]
return bytes(swapped)
def _words(data: bytes, start: int) -> bytes:
"""Every other 16-bit word, from word `start`, bytes kept in order.
Read as an array of 2-byte units and strided: joining slices one word
at a time made this pass take most of an hour over the romset chips.
"""
words = array.array("H")
words.frombytes(data[: len(data) - len(data) % 2])
return words[start::2].tobytes()
# (name, size of the output for an input of size n or None, transform)
TRANSFORMS: tuple[tuple[str, Callable[[int], int | None], Callable[[bytes], bytes]], ...] = (
("first half", lambda n: n // 2 if n % 2 == 0 else None, lambda d: d[:len(d) // 2]),
("second half", lambda n: n // 2 if n % 2 == 0 else None, lambda d: d[len(d) // 2:]),
*(
(f"quarter {q + 1}", lambda n: n // 4 if n % 4 == 0 else None,
lambda d, q=q: d[q * len(d) // 4:(q + 1) * len(d) // 4])
for q in range(4)
),
("even bytes", lambda n: n // 2 if n % 2 == 0 else None, lambda d: d[0::2]),
("odd bytes", lambda n: n // 2 if n % 2 == 0 else None, lambda d: d[1::2]),
("even words", lambda n: n // 2 if n % 4 == 0 else None, lambda d: _words(d, 0)),
("odd words", lambda n: n // 2 if n % 4 == 0 else None, lambda d: _words(d, 1)),
("even words, byte-swapped", lambda n: n // 2 if n % 4 == 0 else None,
lambda d: _swap16(_words(d, 0))),
("odd words, byte-swapped", lambda n: n // 2 if n % 4 == 0 else None,
lambda d: _swap16(_words(d, 1))),
("byte-swapped", lambda n: n if n % 2 == 0 else None, _swap16),
("mirrored twice", lambda n: n * 2, lambda d: d + d),
)
def _gf2_times(matrix: list[int], vector: int) -> int:
total, row = 0, 0
while vector:
if vector & 1:
total ^= matrix[row]
vector >>= 1
row += 1
return total
def _gf2_square(matrix: list[int]) -> list[int]:
return [_gf2_times(matrix, matrix[n]) for n in range(32)]
def crc32_combine(crc1: int, crc2: int, len2: int) -> int:
"""crc32 of A + B from crc32(A), crc32(B) and len(B), as zlib computes it."""
if len2 <= 0:
return crc1
return _gf2_times(_shift_operator(len2), crc1) ^ crc2
@functools.lru_cache(maxsize=None)
def _shift_operator(len2: int) -> tuple[int, ...]:
"""The linear map zlib applies to crc(A) for len2 bytes appended.
Built once per length, column by column: chip sizes repeat, and
rebuilding the squares for every pair spent minutes in GF(2) products.
"""
return tuple(_shift(1 << bit, len2) for bit in range(32))
def _shift(crc1: int, len2: int) -> int:
odd = [0xEDB88320] + [1 << (n - 1) for n in range(1, 32)]
even = _gf2_square(odd)
odd = _gf2_square(even)
while True:
even = _gf2_square(odd)
if len2 & 1:
crc1 = _gf2_times(even, crc1)
len2 >>= 1
if not len2:
break
odd = _gf2_square(even)
if len2 & 1:
crc1 = _gf2_times(odd, crc1)
len2 >>= 1
if not len2:
break
return crc1
def _read(held: dict, archives: dict[str, zipfile.ZipFile] | None = None) -> bytes | None:
"""The bytes of a held file, or of a member read out of its archive.
`archives` keeps each archive open across its members: reopened for
every one, a MAME set of a hundred chips parsed its directory a
hundred times.
"""
try:
if held.get("member"):
if archives is None:
with zipfile.ZipFile(held["archive"]) as zf:
return zf.read(held["member"])
zf = archives.get(held["archive"])
if zf is None:
zf = archives[held["archive"]] = zipfile.ZipFile(held["archive"])
return zf.read(held["member"])
with open(held["path"], "rb") as handle:
return handle.read()
except (OSError, KeyError, zipfile.BadZipFile):
return None
def _held_sources(db: dict) -> list[dict]:
"""Every file the collection holds, the members of its archives included.
A romset holds chips a catalog lists on their own: cdi910.rom inside
cdi910.zip is the CD-i 910 BIOS byte-swapped, and the two halves of the
Salora Manager sit in manager.zip. Read from the top level alone, they
stayed MISSING. A member's group is its archive, as a file's is its
directory: the pairs a catalog splits come from one machine.
"""
sources: list[dict] = []
for record in db.get("files", {}).values():
path = record.get("path", "")
if not os.path.exists(path):
continue
if 0 < record.get("size", 0) <= MAX_SOURCE_SIZE:
sources.append({**record, "group": os.path.dirname(path)})
if not path.lower().endswith(".zip"):
continue
try:
with zipfile.ZipFile(path) as zf:
infos = [i for i in zf.infolist() if not i.is_dir()]
except (OSError, zipfile.BadZipFile):
continue
for info in infos:
if 0 < info.file_size <= MAX_SOURCE_SIZE:
sources.append({
"path": f"{path}/{info.filename}", "archive": path,
"member": info.filename, "size": info.file_size,
"crc32": f"{info.CRC:08x}", "group": path,
})
return sources
def derivable(entries: Iterable[dict], db: dict) -> dict[str, dict]:
"""Missing sha1 to the held file or member and the transform that
produce its bytes; archives stay open for the whole pass."""
archives: dict[str, zipfile.ZipFile] = {}
try:
return _derivable(entries, db, archives)
finally:
for zf in archives.values():
zf.close()
def _derivable(
entries: Iterable[dict], db: dict, archives: dict[str, zipfile.ZipFile]
) -> dict[str, dict]:
"""Only held files whose size can produce a wanted size are read, and a
concatenation is only tried within one directory or one archive: the
chip pairs a catalog splits come from one machine.
"""
wanted: dict[tuple[int, str], str] = {}
by_size_crc: dict[tuple[int, int], list[str]] = defaultdict(list)
for entry in entries:
sha1, size = (entry.get("sha1") or "").lower(), entry.get("size")
if sha1 and isinstance(size, int) and size > 0:
wanted[(size, sha1)] = sha1
crc = entry.get("crc32")
if crc:
by_size_crc[(size, int(str(crc), 16))].append(sha1)
sizes = {size for size, _sha1 in wanted}
found: dict[str, dict] = {}
files = _held_sources(db)
for held in files:
n = held["size"]
useful = [t for t in TRANSFORMS if t[1](n) in sizes]
if not useful:
continue
data = _read(held, archives)
if data is None:
continue
for name, out_size, transform in useful:
out = transform(data)
sha1 = wanted.get((out_size(n), hashlib.sha1(out).hexdigest()))
if sha1 and sha1 not in found:
found[sha1] = {"from": [held["path"]], "transform": name}
by_dir: dict[str, list[dict]] = defaultdict(list)
for held in files:
if held.get("crc32"):
by_dir[held["group"]].append(held)
for group in by_dir.values():
# A pair is only worth a crc when its sizes add up to a wanted one:
# indexed by size, a group is walked once per wanted size instead
# of once per member, and a romset of a few hundred chips stays
# linear.
by_size: dict[int, list[dict]] = defaultdict(list)
for held in group:
by_size[held["size"]].append(held)
for first in group:
for total in sizes:
for second in by_size.get(total - first["size"], ()):
if first is second:
continue
crc = crc32_combine(int(first["crc32"], 16),
int(second["crc32"], 16), second["size"])
candidates = [s for s in by_size_crc.get((total, crc), [])
if s not in found]
if not candidates:
continue
a, b = _read(first, archives), _read(second, archives)
if a is None or b is None:
continue
sha1 = hashlib.sha1(a + b).hexdigest()
if sha1 in candidates:
found[sha1] = {"from": [first["path"], second["path"]],
"transform": "concatenated"}
return found