feat: mark each pack part with its siblings

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 07:09:43 +02:00
1 parent e86defdb1e
commit c2de7c3a8e
8 files changed
+218 -25

No files matched your search

+123 -16
View File
@@ -6,6 +6,11 @@ as parts, each a complete archive of whole files: any tool opens one, and
the parts extracted into the same folder are the pack. Members are copied
as stored, never recompressed, so the parts depend on the pack alone.
Each part also carries a short text file, its marker, at the archive root:
the pack it belongs to, its number, and the name and size of every part.
Extracted with the files, the markers let `install.py --check` name a part
that is missing from the folder.
Usage:
python scripts/split_pack.py dist/
python scripts/split_pack.py dist/RetroArch_v1.22.2_BIOS_Pack.zip
@@ -20,6 +25,7 @@ import re
import struct
import sys
import zipfile
import zlib
from pathlib import Path
from artifacts import ArtifactLockBusy, artifact_lock
@@ -29,6 +35,10 @@ ASSET_LIMIT = 2 * 1024**3
PACK_SUFFIX = "_BIOS_Pack.zip"
_PART_NAME = re.compile(r"^(?P<stem>.+_BIOS_Pack)\.part(?P<index>\d+)of(?P<count>\d+)\.zip$")
_MARKER_NAME = re.compile(r"^(?P<stem>.+_BIOS_Pack)\.part(?P<index>\d+)of(?P<count>\d+)\.txt$")
# Sizes are written at a fixed width, so a marker's length, and with it the
# size of its own part, does not depend on the sizes it records.
_SIZE_WIDTH = 13
_LOCAL = struct.Struct("<4s2B4HL2L2H")
_CENTRAL = struct.Struct("<4s4B4HL2L5H2L")
@@ -58,6 +68,44 @@ def part_name(pack_name: str, index: int, count: int) -> str:
return f"{pack_name[: -len('.zip')]}.part{index}of{count}.zip"
def marker_name(pack_name: str, index: int, count: int) -> str:
return f"{pack_name[: -len('.zip')]}.part{index}of{count}.txt"
def is_marker(name: str) -> bool:
"""Whether an archive member is the marker a split writes in each part."""
return _MARKER_NAME.match(name) is not None
def marker_text(pack_name: str, index: int, sizes: list[int]) -> bytes:
"""The marker of part *index* of a pack published in ``len(sizes)`` parts.
Plain text a person can read, in a form install.py parses back: the
lines that matter are `pack:`, `part:` and one `file:` per part.
"""
count = len(sizes)
lines = [
"RetroBIOS split pack",
f"This folder holds part {index} of {count} of {pack_name}.",
"Every part is needed: extract them all into this same folder.",
"The installer's --check names a part missing from the folder.",
"",
f"pack: {pack_name}",
f"part: {index} of {count}",
]
lines += [
f"file: {size:>{_SIZE_WIDTH}} {part_name(pack_name, number, count)}"
for number, size in enumerate(sizes, 1)
]
return ("\n".join(lines) + "\n").encode("utf-8")
def _marker_weight(pack_name: str, index: int, count: int) -> int:
name = len(marker_name(pack_name, index, count).encode("utf-8"))
body = len(marker_text(pack_name, index, [0] * count))
return _LOCAL.size + name + body + _CENTRAL.size + name
def _name_bytes(info: zipfile.ZipInfo) -> bytes:
encoding = "utf-8" if info.flag_bits & _UTF8_NAME else "cp437"
return info.orig_filename.encode(encoding)
@@ -70,23 +118,24 @@ def _weight(info: zipfile.ZipInfo) -> int:
def plan_parts(
members: list[zipfile.ZipInfo], limit: int
members: list[zipfile.ZipInfo], limit: int, reserve: int = 0
) -> list[list[zipfile.ZipInfo]]:
"""Fill each part in archive order, up to the limit."""
"""Fill each part in archive order, up to the limit less *reserve*."""
parts: list[list[zipfile.ZipInfo]] = [[]]
size = _END.size
floor = _END.size + reserve
size = floor
for info in members:
weight = _weight(info)
if _END.size + weight >= limit:
if floor + weight >= limit:
raise ValueError(
f"{info.filename} needs {weight:,} bytes, more than a part of "
f"{limit:,} can hold"
)
if info.compress_size > _MAX_FIELD or info.file_size > _MAX_FIELD:
raise ValueError(f"{info.filename} needs ZIP64, which a part does not use")
if size + weight >= limit or len(parts[-1]) >= _MAX_ENTRIES:
if size + weight >= limit or len(parts[-1]) >= _MAX_ENTRIES - 1:
parts.append([])
size = _END.size
size = floor
parts[-1].append(info)
size += weight
return parts
@@ -109,12 +158,24 @@ def _data_offset(source: io.BufferedReader, info: zipfile.ZipInfo) -> int:
return info.header_offset + _LOCAL.size + header[10] + header[11]
def _marker_info(name: str, body: bytes) -> zipfile.ZipInfo:
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_STORED
info.CRC = zlib.crc32(body)
info.compress_size = info.file_size = len(body)
info.create_system = 3
info.external_attr = (0o100000 | 0o644) << 16
return info
def _write_part(
source: io.BufferedReader, members: list[zipfile.ZipInfo], dest: Path
source: io.BufferedReader, members: list[zipfile.ZipInfo], dest: Path,
marker: tuple[str, bytes],
) -> None:
directory = []
marker_info = _marker_info(*marker)
with open(dest, "wb") as out:
for info in members:
for info in [marker_info, *members]:
name = _name_bytes(info)
flags = info.flag_bits & ~_DATA_DESCRIPTOR
dos_time, dos_date = _dos_stamp(info)
@@ -127,8 +188,12 @@ def _write_part(
)
)
out.write(name)
source.seek(_data_offset(source, info))
remaining = info.compress_size
if info is marker_info:
out.write(marker[1])
remaining = 0
else:
source.seek(_data_offset(source, info))
remaining = info.compress_size
while remaining:
chunk = source.read(min(_CHUNK, remaining))
if not chunk:
@@ -157,7 +222,33 @@ def _write_part(
def _identity(archive: zipfile.ZipFile) -> list[tuple[str, int, int]]:
return [(info.filename, info.CRC, info.file_size) for info in archive.infolist()]
return [
(info.filename, info.CRC, info.file_size)
for info in archive.infolist()
if not is_marker(info.filename)
]
def marker_weight(pack_name: str, count: int) -> int:
"""Bytes the largest marker of a pack in *count* parts adds to its part."""
return max(_marker_weight(pack_name, index, count) for index in (1, count))
def _plan_with_markers(
pack_name: str, members: list[zipfile.ZipInfo], limit: int
) -> list[list[zipfile.ZipInfo]]:
"""Plan the parts with room for the marker each one carries.
The marker lists every part, so its length grows with their count: the
plan is made again until the count it assumed is the count it gives.
"""
count = 1
while True:
reserve = marker_weight(pack_name, count)
plan = plan_parts(members, limit, reserve)
if len(plan) <= count:
return plan
count = len(plan)
def split_pack(zip_path: Path, limit: int = ASSET_LIMIT) -> list[Path]:
@@ -174,15 +265,31 @@ def split_pack(zip_path: Path, limit: int = ASSET_LIMIT) -> list[Path]:
with zipfile.ZipFile(zip_path) as archive:
members = archive.infolist()
expected = _identity(archive)
plan = plan_parts(members, limit)
taken = [info.filename for info in members if is_marker(info.filename)]
if taken:
raise ValueError(f"{zip_path.name} already holds {taken[0]}, a part marker name")
plan = _plan_with_markers(zip_path.name, members, limit)
count = len(plan)
parts = [
zip_path.with_name(part_name(zip_path.name, index, len(plan)))
for index in range(1, len(plan) + 1)
zip_path.with_name(part_name(zip_path.name, index, count))
for index in range(1, count + 1)
]
sizes = [
_END.size + _marker_weight(zip_path.name, index, count)
+ sum(_weight(info) for info in chosen)
for index, chosen in enumerate(plan, 1)
]
try:
with open(zip_path, "rb") as source:
for dest, chosen in zip(parts, plan):
_write_part(source, chosen, dest)
for index, (dest, chosen) in enumerate(zip(parts, plan), 1):
marker = (marker_name(zip_path.name, index, count),
marker_text(zip_path.name, index, sizes))
_write_part(source, chosen, dest, marker)
if dest.stat().st_size != sizes[index - 1]:
raise ValueError(
f"{dest.name} is {dest.stat().st_size:,} bytes, its marker "
f"says {sizes[index - 1]:,}"
)
found: list[tuple[str, int, int]] = []
for dest in parts:
with zipfile.ZipFile(dest) as archive: