diff --git a/scripts/download.py b/scripts/download.py
index 5176de48..2ecd7ee3 100644
--- a/scripts/download.py
+++ b/scripts/download.py
@@ -3,8 +3,11 @@
Cross-platform tool (Linux/macOS/Windows) using only Python stdlib.
-A pack over 2 GB is published as numbered volumes (`.zip.001`, `.zip.002`),
-so a platform is a group of assets rather than a single one.
+A pack over the release asset limit is published in several files, so a
+platform is a group of assets rather than a single one. The parts are each
+a ZIP (`.part1of2.zip`), checked and extracted one after the other. Releases
+up to v2026.09.04 carry byte ranges of one archive instead (`.zip.001`,
+`.zip.002`), which are joined first.
Usage:
python scripts/download.py --list # List platforms
@@ -39,6 +42,9 @@ MAX_CHECKSUMS_BYTES = 1 << 20
CHUNK = 1 << 20
_VOLUME_RE = re.compile(rf"^(?P.+{re.escape(PACK_SUFFIX)})\.(?P\d+)$")
+_PART_RE = re.compile(
+ r"^(?P.+_BIOS_Pack)\.part(?P\d+)of(?P\d+)\.zip$"
+)
_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
@@ -64,12 +70,19 @@ API = _checked_url(os.environ.get("RETROBIOS_API", DEFAULT_API), "RETROBIOS_API"
@dataclass(frozen=True)
class Pack:
- """A platform pack: one asset, or the volumes it was split into."""
+ """A platform pack: one asset, or the files it was published in.
+
+ `joined` says the files are byte ranges of one archive. `expected_parts`
+ is the count a part's own name declares, so a release that lists fewer
+ is recognised before anything is downloaded.
+ """
name: str
platform: str
parts: tuple[dict, ...]
size: int
+ joined: bool = False
+ expected_parts: int = 1
def get_latest_release() -> dict:
@@ -94,13 +107,21 @@ def get_latest_release() -> dict:
def group_packs(release: dict) -> list[Pack]:
- """Group release assets into packs, volumes folded into their archive."""
+ """Group release assets into packs, the files of one pack folded together."""
groups: dict[str, list[tuple[int, dict]]] = {}
+ joined: set[str] = set()
+ expected: dict[str, int] = {}
for asset in release.get("assets", []):
name = asset["name"]
volume = _VOLUME_RE.match(name)
+ part = _PART_RE.match(name)
if volume:
groups.setdefault(volume["base"], []).append((int(volume["index"]), asset))
+ joined.add(volume["base"])
+ elif part:
+ base = f"{part['stem']}.zip"
+ groups.setdefault(base, []).append((int(part["index"]), asset))
+ expected[base] = int(part["count"])
elif name.endswith(PACK_SUFFIX):
groups.setdefault(name, []).append((0, asset))
@@ -113,6 +134,8 @@ def group_packs(release: dict) -> list[Pack]:
platform=base[: -len(PACK_SUFFIX)].replace("_", " "),
parts=parts,
size=sum(part.get("size", 0) for part in parts),
+ joined=base in joined,
+ expected_parts=expected.get(base, len(parts)),
)
)
return packs
@@ -213,8 +236,34 @@ def make_staging(dest: Path) -> Path:
return Path(tempfile.mkdtemp(prefix=STAGING_PREFIX, dir=dest))
-def fetch_pack(pack: Pack, staging: Path, checksums: dict[str, str]) -> Path:
- """Download every volume of a pack and return the assembled archive."""
+def _check(archive: Path, checksums: dict[str, str]) -> None:
+ """Compare a downloaded file with its published SHA-256, when there is one."""
+ expected = checksums.get(archive.name)
+ if not expected:
+ print(f"No checksum published for {archive.name}, skipping.")
+ return
+ print(f"Checking {archive.name}...")
+ actual = compute_hashes(str(archive))["sha256"].lower()
+ if actual != expected:
+ archive.unlink()
+ print(
+ f"Error: checksum mismatch for {archive.name}\n"
+ f" expected {expected}\n got {actual}\n"
+ "Download the parts again; a truncated part gives this.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+
+def fetch_pack(pack: Pack, staging: Path, checksums: dict[str, str]) -> list[Path]:
+ """Download a pack and return the archives to extract, each one checked."""
+ if len(pack.parts) != pack.expected_parts:
+ print(
+ f"Error: the release lists {len(pack.parts)} of "
+ f"{pack.expected_parts} parts of {pack.name}.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
staging.mkdir(parents=True, exist_ok=True)
volumes = []
for index, part in enumerate(pack.parts, start=1):
@@ -224,32 +273,20 @@ def fetch_pack(pack: Pack, staging: Path, checksums: dict[str, str]) -> Path:
download_file(part["browser_download_url"], target, part.get("size", 0), label)
volumes.append(target)
- archive = staging / pack.name
- if volumes != [archive]:
- if len(volumes) > 1:
- print(f"Joining {len(volumes)} parts into {pack.name}...")
- join_volumes(volumes, archive)
- for volume in volumes:
- volume.unlink()
- else:
- volumes[0].replace(archive)
+ if pack.joined:
+ archive = staging / pack.name
+ print(f"Joining {len(volumes)} parts into {pack.name}...")
+ join_volumes(volumes, archive)
+ for volume in volumes:
+ volume.unlink()
+ volumes = [archive]
- expected = checksums.get(pack.name)
- if expected:
- print("Checking the archive...")
- actual = compute_hashes(str(archive))["sha256"].lower()
- if actual != expected:
- archive.unlink()
- print(
- f"Error: checksum mismatch for {pack.name}\n"
- f" expected {expected}\n got {actual}\n"
- "Download the parts again; a truncated part gives this.",
- file=sys.stderr,
- )
- sys.exit(1)
- else:
+ if not checksums:
print(f"No {CHECKSUMS_ASSET} in the release, skipping the checksum.")
- return archive
+ return volumes
+ for archive in volumes:
+ _check(archive, checksums)
+ return volumes
def show_info(platform: str, release: dict):
@@ -333,9 +370,10 @@ To check files already in place, use: python install.py --check
staging = make_staging(dest)
try:
- archive = fetch_pack(pack, staging, fetch_checksums(release))
+ archives = fetch_pack(pack, staging, fetch_checksums(release))
print(f"Extracting to {dest}/...")
- safe_extract_zip(str(archive), str(dest))
+ for archive in archives:
+ safe_extract_zip(str(archive), str(dest))
finally:
shutil.rmtree(staging, ignore_errors=True)
print("Done!")
diff --git a/scripts/download.sh b/scripts/download.sh
index 70996665..3a6b15ee 100755
--- a/scripts/download.sh
+++ b/scripts/download.sh
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# Download BIOS pack from GitHub Releases (Linux/macOS one-liner compatible)
#
-# A pack over 2 GB is published as numbered volumes (.zip.001, .zip.002),
-# which are downloaded, joined and checked here.
+# A pack over 2 GB is published in several parts. Each is a ZIP
+# (.part1of2.zip), checked and extracted in turn. Releases up to v2026.09.04
+# carry byte ranges of one archive instead (.zip.001), which are joined first.
#
# Usage:
# bash scripts/download.sh retroarch ~/RetroArch/system/
@@ -47,9 +48,11 @@ asset_urls() {
sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p'
}
-# The archive name behind each asset, volumes folded into their archive.
+# The archive name behind each asset, the files of one pack folded together:
+# Pack.part1of2.zip and Pack.zip.001 both belong to Pack.zip.
pack_names() {
asset_urls "$1" | sed 's#.*/##' |
+ sed 's/\(_BIOS_Pack\)\.part[0-9][0-9]*of[0-9][0-9]*\.zip$/\1.zip/' |
sed -n 's/\(.*_BIOS_Pack\.zip\)\(\.[0-9][0-9]*\)\{0,1\}$/\1/p' |
LC_ALL=C sort -u
}
@@ -103,14 +106,28 @@ EOF
exit 1
fi
+ local stem="${archive%.zip}"
local volumes
volumes=$(asset_urls "$release_json" |
- grep -E "/${archive//./\\.}(\.[0-9]+)?$" | LC_ALL=C sort)
+ grep -E "/${stem//./\\.}(\.zip(\.[0-9]+)?|\.part[0-9]+of[0-9]+\.zip)$" |
+ LC_ALL=C sort)
if [ -z "$volumes" ]; then
echo "Error: no asset found for ${archive}." >&2
exit 1
fi
+ local count
+ count=$(printf '%s\n' "$volumes" | wc -l | tr -d ' ')
+
+ # Parts that are each a ZIP say in their name how many there are.
+ local declared
+ declared=$(printf '%s\n' "$volumes" |
+ sed -n 's/.*\.part[0-9][0-9]*of\([0-9][0-9]*\)\.zip$/\1/p' | head -1)
+ if [ -n "$declared" ] && [ "$declared" != "$count" ]; then
+ echo "Error: the release lists ${count} of ${declared} parts of ${archive}." >&2
+ exit 1
+ fi
+
# Staged inside the destination: a pack is gigabytes, and /tmp is a RAM
# disk on the appliances these packs target.
mkdir -p "$dest"
@@ -119,9 +136,6 @@ EOF
mkdir -p "$staging"
trap 'rm -rf "$staging"' EXIT
- local count
- count=$(printf '%s\n' "$volumes" | wc -l | tr -d ' ')
-
local index=0
local url name
while read -r url; do
@@ -138,17 +152,23 @@ EOF
$volumes
EOF
- if [ "$count" -gt 1 ]; then
+ if [ -z "$declared" ] && [ "$count" -gt 1 ]; then
echo "Joining ${count} parts into ${archive}..."
- # split(1) writes plain byte ranges, so concatenation rebuilds the ZIP.
+ # Releases up to v2026.09.04: split(1) wrote plain byte ranges, so
+ # concatenation rebuilds the ZIP.
cat "${staging}/${archive}".[0-9][0-9][0-9] > "${staging}/${archive}"
rm -f "${staging}/${archive}".[0-9][0-9][0-9]
fi
- verify_checksum "$release_json" "$staging" "$archive"
+ local file
+ for file in "${staging}"/*.zip; do
+ verify_checksum "$release_json" "$staging" "$(basename "$file")"
+ done
echo "Extracting to ${dest}/..."
- unzip -o -q "${staging}/${archive}" -d "$dest"
+ for file in "${staging}"/*.zip; do
+ unzip -o -q "$file" -d "$dest"
+ done
rm -rf "$staging"
trap - EXIT
@@ -182,7 +202,7 @@ verify_checksum() {
return 0
fi
- echo "Checking the archive..."
+ echo "Checking ${archive}..."
local actual
actual=$($hasher "${staging}/${archive}" | cut -d' ' -f1)
if [ "$actual" != "$expected" ]; then
diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py
index 04f1bb07..39393d82 100644
--- a/scripts/generate_pack.py
+++ b/scripts/generate_pack.py
@@ -63,6 +63,7 @@ import packresolve
import region as region_mod
import slot as slot_mod
import slots
+import split_pack
from deterministic_zip import _FIXED_DATE_TIME, rebuild_zip_deterministic
from nativemode import (
digest_algorithm,
@@ -3274,6 +3275,20 @@ def _narrows_contents(pack_name: str) -> bool:
return any(f"{tag}_" in pack_name for tag in _CONTENT_NARROWING_TAGS)
+def _pack_archives(output_dir: str) -> list[str]:
+ """The ZIPs of a directory that are packs.
+
+ A part of a split pack holds a share of a platform by design and was
+ checked against the whole when it was cut: judged as a pack it would
+ fail conformance, and injecting a manifest would change published bytes.
+ """
+ return sorted(
+ name
+ for name in os.listdir(output_dir)
+ if name.endswith(".zip") and not split_pack.is_part(name)
+ )
+
+
def verify_and_finalize_packs(
output_dir: str,
db: dict,
@@ -3295,18 +3310,14 @@ def verify_and_finalize_packs(
# Map ZIP names to platform names
pack_to_platform: dict[str, list[str]] = {}
- for name in sorted(os.listdir(output_dir)):
- if not name.endswith(".zip"):
- continue
+ for name in _pack_archives(output_dir):
for pname in list_registered_platforms(platforms_dir):
cfg = load_platform_config(pname, platforms_dir)
display = cfg.get("platform", pname).replace(" ", "_")
if display in name or display.replace("_", "") in name.replace("_", ""):
pack_to_platform.setdefault(name, []).append(pname)
- for name in sorted(os.listdir(output_dir)):
- if not name.endswith(".zip"):
- continue
+ for name in _pack_archives(output_dir):
zip_path = os.path.join(output_dir, name)
# Stage 1: database integrity
diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py
index bf68640e..2c1399c0 100644
--- a/scripts/generate_readme.py
+++ b/scripts/generate_readme.py
@@ -340,11 +340,18 @@ def generate_readme(db: dict, platforms_dir: str) -> str:
" next because a pack carries only what that platform's emulators"
" load."
" The size is what the files occupy once extracted; the ZIP itself"
- " downloads smaller, and anything over 2 GB arrives split into"
- " `.zip.001`, `.zip.002` volumes. Open the `.001` with 7-Zip or"
- " PeaZip, or join them first"
- " (`cat Pack.zip.0* > Pack.zip`, or"
- " `copy /b Pack.zip.001+Pack.zip.002 Pack.zip` on Windows).",
+ " downloads smaller.",
+ "",
+ "A pack over 2 GB comes in several parts, and every part is needed."
+ " How to open them depends on their name:",
+ "",
+ "- `Pack.part1of2.zip`, `Pack.part2of2.zip`: each part is an ordinary"
+ " ZIP. Extract them all into the same folder.",
+ "- `Pack.zip.001`, `Pack.zip.002` (releases up to v2026.09.04): slices"
+ " of one ZIP, none of which opens on its own. Put them in one folder"
+ " and open the `.001` with 7-Zip or PeaZip, or join them first with"
+ " `cat Pack.zip.0* > Pack.zip` on Linux and macOS,"
+ " `cmd /c copy /b Pack.zip.001+Pack.zip.002 Pack.zip` on Windows.",
"",
"Every release ships `SHA256SUMS.txt` and a detached signature of it,"
" checkable against `allowed_signers` in this repository:"
diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index 05f9dae2..fd410690 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -3406,12 +3406,16 @@ download it, and extract the files into the BIOS folder listed below.
The metadata and website can be newer than that release: pack publication is
manual and only happens after the release gates pass.
-Packs over 2 GB are split into numbered volumes (`.zip.001`, `.zip.002`).
-Download every part, then open the `.001` file with 7-Zip or PeaZip, which
-extract the whole archive directly. To join the parts manually instead:
+A pack over 2 GB comes in several parts, and every part is needed. How to
+open them depends on their name:
-- Linux/macOS: `cat PackName.zip.0* > PackName.zip`
-- Windows (cmd): `copy /b PackName.zip.001+PackName.zip.002 PackName.zip`
+- `PackName.part1of2.zip`, `PackName.part2of2.zip`: each part is an ordinary
+ ZIP. Extract them all into the same folder.
+- `PackName.zip.001`, `PackName.zip.002` (releases up to v2026.09.04): slices
+ of one ZIP, none of which opens on its own. Put them in one folder and open
+ the `.001` with 7-Zip or PeaZip, or join them first:
+ - Linux/macOS: `cat PackName.zip.0* > PackName.zip`
+ - Windows: `cmd /c copy /b PackName.zip.001+PackName.zip.002 PackName.zip`
### Steam Deck
diff --git a/scripts/split_pack.py b/scripts/split_pack.py
new file mode 100644
index 00000000..dbc0f67b
--- /dev/null
+++ b/scripts/split_pack.py
@@ -0,0 +1,233 @@
+#!/usr/bin/env python3
+"""Publish a pack too large for one release asset as several ZIPs.
+
+GitHub refuses a release file of 2 GiB or more. A pack over that is written
+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.
+
+Usage:
+ python scripts/split_pack.py dist/
+ python scripts/split_pack.py dist/RetroArch_Lakka_v1.22.2_BIOS_Pack.zip
+ python scripts/split_pack.py dist/ --max-size 1900M
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import struct
+import sys
+import zipfile
+from pathlib import Path
+
+# "Each file included in a release must be under 2 GiB."
+ASSET_LIMIT = 2 * 1024**3
+
+PACK_SUFFIX = "_BIOS_Pack.zip"
+_PART_NAME = re.compile(r"^(?P.+_BIOS_Pack)\.part(?P\d+)of(?P\d+)\.zip$")
+
+_LOCAL = struct.Struct("<4s2B4HL2L2H")
+_CENTRAL = struct.Struct("<4s4B4HL2L5H2L")
+_END = struct.Struct("<4s4H2LH")
+_LOCAL_SIG = b"PK\x03\x04"
+_CENTRAL_SIG = b"PK\x01\x02"
+_END_SIG = b"PK\x05\x06"
+_DATA_DESCRIPTOR = 0x08
+_UTF8_NAME = 0x800
+_CHUNK = 1024 * 1024
+_MAX_ENTRIES = 0xFFFF
+_MAX_FIELD = 0xFFFFFFFF
+
+
+def is_part(name: str) -> bool:
+ """Whether a file name is one part of a split pack."""
+ return _PART_NAME.match(name) is not None
+
+
+def part_name(pack_name: str, index: int, count: int) -> str:
+ return f"{pack_name[: -len('.zip')]}.part{index}of{count}.zip"
+
+
+def _name_bytes(info: zipfile.ZipInfo) -> bytes:
+ encoding = "utf-8" if info.flag_bits & _UTF8_NAME else "cp437"
+ return info.orig_filename.encode(encoding)
+
+
+def _weight(info: zipfile.ZipInfo) -> int:
+ """Bytes a member adds to a part: both headers and its stored data."""
+ name = len(_name_bytes(info))
+ return _LOCAL.size + name + info.compress_size + _CENTRAL.size + name
+
+
+def plan_parts(
+ members: list[zipfile.ZipInfo], limit: int
+) -> list[list[zipfile.ZipInfo]]:
+ """Fill each part in archive order, up to the limit."""
+ parts: list[list[zipfile.ZipInfo]] = [[]]
+ size = _END.size
+ for info in members:
+ weight = _weight(info)
+ if _END.size + 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:
+ parts.append([])
+ size = _END.size
+ parts[-1].append(info)
+ size += weight
+ return parts
+
+
+def _dos_stamp(info: zipfile.ZipInfo) -> tuple[int, int]:
+ year, month, day, hour, minute, second = info.date_time
+ return (
+ (hour << 11) | (minute << 5) | (second // 2),
+ ((year - 1980) << 9) | (month << 5) | day,
+ )
+
+
+def _data_offset(source, info: zipfile.ZipInfo) -> int:
+ """Where a member's stored bytes begin, read from its own local header."""
+ source.seek(info.header_offset)
+ header = _LOCAL.unpack(source.read(_LOCAL.size))
+ if header[0] != _LOCAL_SIG:
+ raise ValueError(f"{info.filename}: no local header at its offset")
+ return info.header_offset + _LOCAL.size + header[10] + header[11]
+
+
+def _write_part(source, members: list[zipfile.ZipInfo], dest: Path) -> None:
+ directory = []
+ with open(dest, "wb") as out:
+ for info in members:
+ name = _name_bytes(info)
+ flags = info.flag_bits & ~_DATA_DESCRIPTOR
+ dos_time, dos_date = _dos_stamp(info)
+ offset = out.tell()
+ out.write(
+ _LOCAL.pack(
+ _LOCAL_SIG, info.extract_version, info.reserved, flags,
+ info.compress_type, dos_time, dos_date, info.CRC,
+ info.compress_size, info.file_size, len(name), 0,
+ )
+ )
+ out.write(name)
+ source.seek(_data_offset(source, info))
+ remaining = info.compress_size
+ while remaining:
+ chunk = source.read(min(_CHUNK, remaining))
+ if not chunk:
+ raise ValueError(f"{info.filename}: the pack ends inside it")
+ out.write(chunk)
+ remaining -= len(chunk)
+ directory.append(
+ _CENTRAL.pack(
+ _CENTRAL_SIG, info.create_version, info.create_system,
+ info.extract_version, info.reserved, flags,
+ info.compress_type, dos_time, dos_date, info.CRC,
+ info.compress_size, info.file_size, len(name), 0, 0, 0,
+ info.internal_attr, info.external_attr, offset,
+ )
+ + name
+ )
+ start = out.tell()
+ for record in directory:
+ out.write(record)
+ out.write(
+ _END.pack(
+ _END_SIG, 0, 0, len(directory), len(directory),
+ out.tell() - start, start, 0,
+ )
+ )
+
+
+def _identity(archive: zipfile.ZipFile) -> list[tuple[str, int, int]]:
+ return [(info.filename, info.CRC, info.file_size) for info in archive.infolist()]
+
+
+def split_pack(zip_path: Path, limit: int = ASSET_LIMIT) -> list[Path]:
+ """Replace a pack at or over the limit by parts that each fit under it.
+
+ Returns the files to publish. The pack is removed only once every part
+ has been read back in full and the parts together list exactly its
+ members, so a failure leaves the pack as it was and no part behind.
+ """
+ zip_path = Path(zip_path)
+ if zip_path.stat().st_size < limit:
+ return [zip_path]
+
+ with zipfile.ZipFile(zip_path) as archive:
+ members = archive.infolist()
+ expected = _identity(archive)
+ plan = plan_parts(members, limit)
+ parts = [
+ zip_path.with_name(part_name(zip_path.name, index, len(plan)))
+ for index in range(1, len(plan) + 1)
+ ]
+ try:
+ with open(zip_path, "rb") as source:
+ for dest, chosen in zip(parts, plan):
+ _write_part(source, chosen, dest)
+ found: list[tuple[str, int, int]] = []
+ for dest in parts:
+ with zipfile.ZipFile(dest) as archive:
+ damaged = archive.testzip()
+ if damaged:
+ raise ValueError(f"{dest.name}: {damaged} does not read back")
+ found.extend(_identity(archive))
+ if dest.stat().st_size >= limit:
+ raise ValueError(f"{dest.name} reached the limit of {limit:,} bytes")
+ if found != expected:
+ raise ValueError(f"the parts of {zip_path.name} do not add up to it")
+ except (OSError, ValueError, zipfile.BadZipFile):
+ for dest in parts:
+ dest.unlink(missing_ok=True)
+ raise
+ zip_path.unlink()
+ return parts
+
+
+def split_directory(directory: Path, limit: int = ASSET_LIMIT) -> list[Path]:
+ """Split every pack of a directory that needs it. Returns what to publish."""
+ published: list[Path] = []
+ for path in sorted(Path(directory).glob(f"*{PACK_SUFFIX}")):
+ published.extend(split_pack(path, limit))
+ return published
+
+
+def _size(text: str) -> int:
+ match = re.fullmatch(r"(\d+)([KMG]?)", text.strip().upper())
+ if not match:
+ raise argparse.ArgumentTypeError(f"not a size: {text}")
+ return int(match[1]) * 1024 ** " KMG".index(match[2] or " ")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Split packs over the release asset limit into ZIP parts"
+ )
+ parser.add_argument("target", type=Path, help="a pack, or a directory of packs")
+ parser.add_argument(
+ "--max-size", type=_size, default=ASSET_LIMIT, metavar="SIZE",
+ help="a part stays under this many bytes (K, M, G suffixes; default 2G)",
+ )
+ args = parser.parse_args()
+ try:
+ if args.target.is_dir():
+ published = split_directory(args.target, args.max_size)
+ else:
+ published = split_pack(args.target, args.max_size)
+ except (OSError, ValueError, zipfile.BadZipFile) as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ return 1
+ for path in published:
+ print(f"{path.stat().st_size:>13,} {path.name}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/test_download.py b/tests/test_download.py
index 4a3a39e6..cab7d4ee 100644
--- a/tests/test_download.py
+++ b/tests/test_download.py
@@ -1,8 +1,11 @@
"""Tests for the release pack downloaders.
-Packs over 2 GB are published as numbered volumes (`.zip.001`, `.zip.002`),
-so a downloader that expects one asset per platform finds nothing for most
-of them. These tests pin the grouping, the join and the staging location.
+A pack over the release asset limit is published in several files, so a
+downloader that expects one asset per platform finds nothing for most of
+them. Two layouts exist: parts that are each a ZIP (`.part1of2.zip`), and
+the byte ranges of one archive that releases up to v2026.09.04 carry
+(`.zip.001`, `.zip.002`). These tests pin the grouping, the join, the
+checksum of each layout and the staging location.
"""
from __future__ import annotations
@@ -34,6 +37,9 @@ _spec.loader.exec_module(download)
SHELL = REPO_ROOT / "scripts" / "download.sh"
+sys.path.insert(0, str(REPO_ROOT / "scripts"))
+import split_pack # noqa: E402
+
# A slice of a real release: two whole packs, three split, and the checksums.
RELEASE_ASSETS = [
"Batocera_43.1_BIOS_Pack.zip.001",
@@ -125,6 +131,46 @@ class TestPackGrouping(unittest.TestCase):
["Big_BIOS_Pack.zip.001", "Big_BIOS_Pack.zip.009", "Big_BIOS_Pack.zip.010"],
)
+ def test_zip_parts_group_into_one_pack(self):
+ packs = download.group_packs(
+ _release(
+ [
+ "Batocera_43.1_BIOS_Pack.part2of2.zip",
+ "Batocera_43.1_BIOS_Pack.part1of2.zip",
+ "BizHawk_2.11.1_BIOS_Pack.zip",
+ ]
+ )
+ )
+ self.assertEqual(
+ [(pack.name, [part["name"] for part in pack.parts]) for pack in packs],
+ [
+ (
+ "Batocera_43.1_BIOS_Pack.zip",
+ [
+ "Batocera_43.1_BIOS_Pack.part1of2.zip",
+ "Batocera_43.1_BIOS_Pack.part2of2.zip",
+ ],
+ ),
+ ("BizHawk_2.11.1_BIOS_Pack.zip", ["BizHawk_2.11.1_BIOS_Pack.zip"]),
+ ],
+ )
+ self.assertFalse(packs[0].joined)
+
+ def test_zip_parts_order_numerically_not_lexically(self):
+ names = [f"Big_BIOS_Pack.part{n}of12.zip" for n in (10, 9, 1)]
+ pack = download.group_packs(_release(names))[0]
+ self.assertEqual(
+ [part["name"] for part in pack.parts],
+ [f"Big_BIOS_Pack.part{n}of12.zip" for n in (1, 9, 10)],
+ )
+ self.assertEqual(pack.expected_parts, 12)
+
+ def test_byte_range_volumes_are_joined(self):
+ pack = download.group_packs(
+ _release(["Old_BIOS_Pack.zip.001", "Old_BIOS_Pack.zip.002"])
+ )[0]
+ self.assertTrue(pack.joined)
+
def test_other_assets_are_not_packs(self):
packs = download.group_packs(_release(["SHA256SUMS.txt", "database.json"]))
self.assertEqual(packs, [])
@@ -214,19 +260,39 @@ class TestStagingIsolation(unittest.TestCase):
class ReleaseServer:
"""Serves a release index and its assets over loopback."""
- def __init__(self, pack_name: str, payload: dict[str, bytes], volumes: int):
+ def __init__(
+ self,
+ pack_name: str,
+ payload: dict[str, bytes],
+ volumes: int,
+ zip_parts: bool = False,
+ ):
self.root = Path(tempfile.mkdtemp())
(self.root / "assets").mkdir()
archive = self.root / "assets" / pack_name
- with zipfile.ZipFile(archive, "w") as zf:
+ with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
for name, data in payload.items():
zf.writestr(name, data)
raw = archive.read_bytes()
self.digest = hashlib.sha256(raw).hexdigest()
- archive.unlink()
+ sums = f"{self.digest} {pack_name}\n"
self.names: list[str] = []
- if volumes == 1:
+ if zip_parts:
+ # One byte under the whole archive: the last member no longer fits.
+ parts = split_pack.split_pack(archive, limit=len(raw) - 1)
+ self.names = [part.name for part in parts]
+ sums = "".join(
+ f"{hashlib.sha256(part.read_bytes()).hexdigest()} {part.name}\n"
+ for part in parts
+ )
+ raw = b""
+ else:
+ archive.unlink()
+
+ if zip_parts:
+ pass
+ elif volumes == 1:
(self.root / "assets" / pack_name).write_bytes(raw)
self.names.append(pack_name)
else:
@@ -236,9 +302,7 @@ class ReleaseServer:
(self.root / "assets" / name).write_bytes(raw[start : start + cut])
self.names.append(name)
- (self.root / "assets" / "SHA256SUMS.txt").write_text(
- f"{self.digest} {pack_name}\n"
- )
+ (self.root / "assets" / "SHA256SUMS.txt").write_text(sums)
handler = functools.partial(_QuietHandler, directory=str(self.root))
self.httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
@@ -254,9 +318,18 @@ class ReleaseServer:
}
for name in self.names + ["SHA256SUMS.txt"]
]
- (index / "latest").write_text(json.dumps({"assets": assets}))
+ self.index = index / "latest"
+ self.index.write_text(json.dumps({"assets": assets}))
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
+ def unlist_last_part(self) -> None:
+ """An upload that stopped early: the release lists one part fewer."""
+ release = json.loads(self.index.read_text())
+ release["assets"] = [
+ asset for asset in release["assets"] if asset["name"] != self.names[-1]
+ ]
+ self.index.write_text(json.dumps(release))
+
def corrupt_last_volume(self) -> None:
target = self.root / "assets" / self.names[-1]
target.write_bytes(target.read_bytes()[:-16] + b"0" * 16)
@@ -270,6 +343,7 @@ class DownloaderCase(unittest.TestCase):
"""Common fixture: a two-volume pack served over loopback."""
volumes = 2
+ zip_parts = False
payload = {
"bios/scph5501.bin": b"\x10\x20" * 4096,
"bios/dc/dc_boot.bin": b"\x30\x40" * 4096,
@@ -280,7 +354,9 @@ class DownloaderCase(unittest.TestCase):
platform_label = "Batocera 43.1"
def setUp(self):
- self.server = ReleaseServer(self.pack_name, self.payload, self.volumes)
+ self.server = ReleaseServer(
+ self.pack_name, self.payload, self.volumes, self.zip_parts
+ )
self.addCleanup(self.server.close)
self.dest = Path(tempfile.mkdtemp()) / "bios"
# A pack is gigabytes: staging it in the system temp directory fills
@@ -314,6 +390,7 @@ class DownloaderCase(unittest.TestCase):
]
self.assertIn(self.platform_label, names)
self.assertNotIn(".001", out)
+ self.assertNotIn("part1of", out)
def assert_refused(self, proc):
self.assertNotEqual(proc.returncode, 0)
@@ -450,5 +527,34 @@ class TestWholePackShell(WholePackCase, TestDownloadShell):
pass
+class ZipPartsCase(DownloaderCase):
+ """A pack published as parts that are each an archive."""
+
+ zip_parts = True
+
+
+class ZipPartsTests:
+ def test_each_part_is_an_archive_on_its_own(self):
+ self.assertEqual(len(self.server.names), 2)
+ for name in self.server.names:
+ with zipfile.ZipFile(self.server.root / "assets" / name) as archive:
+ self.assertIsNone(archive.testzip())
+
+ def test_a_release_missing_a_part_is_refused(self):
+ self.server.unlist_last_part()
+ proc = self.run_cli(self.platform, str(self.dest), expect_success=False)
+ self.assertNotEqual(proc.returncode, 0)
+ self.assertIn("1 of 2", proc.stdout + proc.stderr)
+ self.assertFalse((self.dest / "bios/scph5501.bin").exists())
+
+
+class TestZipPartsPython(ZipPartsTests, ZipPartsCase, TestDownloadPython):
+ pass
+
+
+class TestZipPartsShell(ZipPartsTests, ZipPartsCase, TestDownloadShell):
+ pass
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_split_pack.py b/tests/test_split_pack.py
new file mode 100644
index 00000000..8ac11bbe
--- /dev/null
+++ b/tests/test_split_pack.py
@@ -0,0 +1,210 @@
+"""A pack too large for one release asset is published as ZIPs, not slices.
+
+GitHub refuses a release file of 2 GiB or more, and seven packs are larger.
+They used to be cut with split(1) into `.zip.001`, `.zip.002`: byte ranges
+of one archive, each under a name that still said zip and each behind its
+own download link. A range opened alone is not an archive, and no tool says
+that a part is missing: 7-Zip answers "Unavailable start of archive",
+PowerShell "not a supported archive file format", Windows has no handler
+for `.002`. Five reports in six months took a part for a broken download.
+
+Each part is now an archive of whole files. Any tool opens it, and the
+parts extracted into one folder are the pack.
+"""
+
+from __future__ import annotations
+
+import os
+import stat
+import sys
+import tempfile
+import unittest
+import zipfile
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO_ROOT / "scripts"))
+
+import generate_pack as builder # noqa: E402
+import split_pack # noqa: E402
+
+LIMIT = 4096
+
+
+def _payload(seed: int, size: int) -> bytes:
+ """Bytes deflate cannot shrink, so a member weighs what it says."""
+ return bytes((seed * 131 + n * 7919 + (n * n) % 251) % 256 for n in range(size))
+
+
+class SplitFixture(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.dist = Path(self._tmp.name)
+ self.pack = self.dist / "Demo_1.0_BIOS_Pack.zip"
+ self.members = {
+ f"system/dir{n % 3}/file{n:02d}.bin": _payload(n, 900) for n in range(12)
+ }
+ self.members["README.txt"] = b"read me\n"
+ self._build(self.pack, self.members)
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ @staticmethod
+ def _build(path: Path, members: dict[str, bytes]) -> None:
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
+ for name, data in members.items():
+ info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
+ info.compress_type = zipfile.ZIP_DEFLATED
+ info.external_attr = (0o100000 | 0o644) << 16
+ archive.writestr(info, data)
+
+ def _read(self, parts: list[Path]) -> dict[str, bytes]:
+ found: dict[str, bytes] = {}
+ for part in parts:
+ with zipfile.ZipFile(part) as archive:
+ self.assertIsNone(archive.testzip())
+ for name in archive.namelist():
+ self.assertNotIn(name, found, "a file sits in two parts")
+ found[name] = archive.read(name)
+ return found
+
+
+class PartsAreArchives(SplitFixture):
+ def test_a_pack_under_the_limit_stays_one_file(self):
+ parts = split_pack.split_pack(self.pack, limit=1 << 20)
+ self.assertEqual(parts, [self.pack])
+ self.assertTrue(self.pack.is_file())
+
+ def test_the_parts_extracted_together_are_the_pack(self):
+ parts = split_pack.split_pack(self.pack, limit=LIMIT)
+ self.assertGreater(len(parts), 1)
+ self.assertEqual(self._read(parts), self.members)
+
+ def test_every_part_fits_the_asset_limit(self):
+ for part in split_pack.split_pack(self.pack, limit=LIMIT):
+ self.assertLess(part.stat().st_size, LIMIT)
+
+ def test_a_part_name_says_how_many_there_are(self):
+ parts = split_pack.split_pack(self.pack, limit=LIMIT)
+ count = len(parts)
+ self.assertEqual(
+ [part.name for part in parts],
+ [
+ f"Demo_1.0_BIOS_Pack.part{index}of{count}.zip"
+ for index in range(1, count + 1)
+ ],
+ )
+
+ def test_the_whole_archive_leaves_once_its_parts_are_checked(self):
+ split_pack.split_pack(self.pack, limit=LIMIT)
+ self.assertFalse(self.pack.exists())
+
+ def test_the_same_pack_gives_the_same_parts(self):
+ first = [p.read_bytes() for p in split_pack.split_pack(self.pack, limit=LIMIT)]
+ other = self.dist / "again"
+ other.mkdir()
+ again = other / self.pack.name
+ self._build(again, self.members)
+ second = [p.read_bytes() for p in split_pack.split_pack(again, limit=LIMIT)]
+ self.assertEqual(first, second)
+
+ def test_a_file_no_part_can_hold_is_refused(self):
+ big = self.dist / "Big_BIOS_Pack.zip"
+ self._build(big, {"huge.bin": _payload(1, LIMIT * 2), "small.bin": b"x"})
+ with self.assertRaises(ValueError):
+ split_pack.split_pack(big, limit=LIMIT)
+ self.assertTrue(big.is_file(), "the pack is kept when it cannot be split")
+ self.assertEqual(sorted(p.name for p in self.dist.glob("Big*")), [big.name])
+
+ def test_the_executable_bit_travels(self):
+ tool = self.dist / "Tools_BIOS_Pack.zip"
+ with zipfile.ZipFile(tool, "w", zipfile.ZIP_DEFLATED) as archive:
+ info = zipfile.ZipInfo("bin/engine", date_time=(1980, 1, 1, 0, 0, 0))
+ info.compress_type = zipfile.ZIP_DEFLATED
+ info.external_attr = (0o100000 | 0o755) << 16
+ archive.writestr(info, _payload(3, 3000))
+ for n in range(4):
+ archive.writestr(f"data{n}.bin", _payload(n, 900))
+ parts = split_pack.split_pack(tool, limit=LIMIT)
+ modes = {}
+ for part in parts:
+ with zipfile.ZipFile(part) as archive:
+ for info in archive.infolist():
+ modes[info.filename] = info.external_attr >> 16
+ self.assertTrue(modes["bin/engine"] & stat.S_IXUSR)
+
+
+class PartsAreNotTakenForPacks(SplitFixture):
+ def test_a_part_is_recognised_by_its_name(self):
+ self.assertTrue(split_pack.is_part("RetroArch_BIOS_Pack.part1of2.zip"))
+ self.assertFalse(split_pack.is_part("RetroArch_BIOS_Pack.zip"))
+ self.assertFalse(split_pack.is_part("RetroArch_BIOS_Pack.zip.001"))
+
+ def test_pack_verification_skips_the_parts(self):
+ """A part holds half a platform by design: judged as a pack it fails."""
+ parts = split_pack.split_pack(self.pack, limit=LIMIT)
+ self.assertEqual(
+ builder._pack_archives(str(self.dist)), [],
+ [p.name for p in parts],
+ )
+
+ def test_the_directory_form_splits_only_what_is_too_large(self):
+ small = self.dist / "Small_BIOS_Pack.zip"
+ self._build(small, {"one.bin": b"1"})
+ written = split_pack.split_directory(self.dist, limit=LIMIT)
+ names = sorted(path.name for path in written)
+ self.assertIn("Small_BIOS_Pack.zip", names)
+ self.assertNotIn("Demo_1.0_BIOS_Pack.zip", names)
+ self.assertTrue(all(os.path.exists(path) for path in written))
+
+
+class ReleaseProcessPublishesArchives(unittest.TestCase):
+ def setUp(self):
+ self.text = (REPO_ROOT / "wiki" / "release-process.md").read_text(
+ encoding="utf-8"
+ )
+
+ def test_it_no_longer_cuts_byte_ranges(self):
+ # assertIn would print the whole page on failure.
+ self.assertFalse("split --bytes" in self.text)
+ self.assertTrue("scripts/split_pack.py" in self.text)
+
+ def test_the_checksums_are_those_of_the_published_files(self):
+ self.assertLess(
+ self.text.index("scripts/split_pack.py"),
+ self.text.index("sha256sum *.zip"),
+ )
+
+
+class PartsAreExplainedWhereTheyAreDownloaded(unittest.TestCase):
+ PAGES = (
+ "scripts/generate_readme.py",
+ "scripts/generate_site.py",
+ "wiki/getting-started.md",
+ "wiki/troubleshooting.md",
+ )
+
+ def _text(self, relative: str) -> str:
+ return (REPO_ROOT / relative).read_text(encoding="utf-8")
+
+ def test_every_page_names_both_layouts(self):
+ """The older release stays downloadable until the next one replaces it."""
+ for page in self.PAGES:
+ with self.subTest(page=page):
+ text = self._text(page)
+ self.assertTrue(".part1of2.zip" in text)
+ self.assertTrue(".zip.001" in text)
+
+ def test_the_windows_join_command_runs_from_powershell(self):
+ """`copy /b A+B C` is a cmd builtin. Typed into PowerShell, the shell
+ the install instructions open, it is Copy-Item and fails."""
+ for page in self.PAGES:
+ with self.subTest(page=page):
+ for line in self._text(page).splitlines():
+ if "copy /b" in line:
+ self.assertTrue("cmd /c copy /b" in line, line.strip())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/wiki/getting-started.md b/wiki/getting-started.md
index 362dc068..13b75449 100644
--- a/wiki/getting-started.md
+++ b/wiki/getting-started.md
@@ -77,9 +77,9 @@ bash scripts/download.sh retroarch ~/RetroArch/system/
bash scripts/download.sh --list # show available packs
```
-A pack published in several volumes is downloaded part by part, joined, and
-checked against the SHA-256 the release publishes before anything is
-extracted. `python scripts/download.py` does the same on Windows.
+A pack published in several parts is downloaded part by part and checked
+against the SHA-256 the release publishes before anything is extracted.
+`python scripts/download.py` does the same on Windows.
### Option 3: manual download
@@ -87,15 +87,19 @@ extracted. `python scripts/download.py` does the same on Windows.
2. Download the ZIP pack for your platform
3. Extract to the BIOS directory listed below
-Packs over 2 GB are split into numbered volumes (`.zip.001`, `.zip.002`).
-Download every part into the same folder, then open the `.001` with 7-Zip or
-PeaZip, which read the whole set. To join them into one ZIP first:
+A pack over 2 GB comes in several parts, and every part is needed. How to
+open them depends on their name:
-- Linux/macOS: `cat Pack.zip.0* > Pack.zip`
-- Windows (cmd): `copy /b Pack.zip.001+Pack.zip.002 Pack.zip`
+- `Pack.part1of2.zip`, `Pack.part2of2.zip`: each part is an ordinary ZIP.
+ Extract them all into the same folder.
+- `Pack.zip.001`, `Pack.zip.002` (releases up to v2026.09.04): slices of one
+ ZIP, none of which opens on its own. Put them in one folder and open the
+ `.001` with 7-Zip or PeaZip, or join them first:
+ - Linux/macOS: `cat Pack.zip.0* > Pack.zip`
+ - Windows: `cmd /c copy /b Pack.zip.001+Pack.zip.002 Pack.zip`
-A frontend's own extractor may refuse a volume: Batocera answers `Archive
-type: '001' is not yet supported`. Join the parts from a shell there. See
+A frontend's own extractor may refuse a slice: Batocera answers `Archive
+type: '001' is not yet supported`. Join the slices from a shell there. See
[Download](../which-pack.md) for the per-setup instructions.
## BIOS directory by platform
diff --git a/wiki/release-process.md b/wiki/release-process.md
index 972a8ac1..ced503bb 100644
--- a/wiki/release-process.md
+++ b/wiki/release-process.md
@@ -161,19 +161,19 @@ python scripts/pipeline.py
python scripts/generate_pack.py --platform retropie --output-dir dist/
python scripts/generate_pack.py --platform retropie --verify-packs --output-dir dist/
-# 3. Checksums of the full ZIPs, before splitting, then sign the list. The
-# checksums answer corruption; the signature answers a rewritten release,
-# which is the one thing a checksum published beside its own artifacts
-# cannot answer.
+# 3. A release file must be under 2 GiB. A larger pack becomes parts that are
+# each a ZIP of whole files (Pack.part1of2.zip): any tool opens one, and
+# the parts extracted into one folder are the pack. Each part is read back
+# and the set compared with the pack before the pack is removed.
+python scripts/split_pack.py dist/
+
+# 4. Checksums of the files as published, then sign the list. The checksums
+# answer corruption; the signature answers a rewritten release, which is
+# the one thing a checksum published beside its own artifacts cannot
+# answer.
(cd dist && sha256sum *.zip > SHA256SUMS.txt)
ssh-keygen -Y sign -f ~/.ssh/retrobios_signing -n file dist/SHA256SUMS.txt
-# 4. Split anything over 2 GB (GitHub asset cap); 7-Zip and PeaZip open .001 directly
-for f in dist/*.zip; do
- [ "$(stat -c%s "$f")" -gt 2000000000 ] || continue
- split --bytes=1900M --numeric-suffixes=1 --suffix-length=3 "$f" "$f." && rm "$f"
-done
-
# 5. The two sizes a pack has, and its file count. Extracted runs well above
# downloaded, so the notes table names the one it carries. The count is
# what a file manager shows once the pack is extracted.
@@ -183,8 +183,8 @@ sys.path.insert(0, "scripts")
from download import _match_key
parts = {}
-for path in sorted(pathlib.Path("dist").glob("*_BIOS_Pack.zip*")):
- parts.setdefault(path.name.split(".zip")[0] + ".zip", []).append(path)
+for path in sorted(pathlib.Path("dist").glob("*_BIOS_Pack*.zip")):
+ parts.setdefault(path.name.split("_BIOS_Pack")[0], []).append(path)
def size(n):
return f"{n / 1024 ** 3:.1f} GB" if n >= 1024 ** 3 else f"{n / 1024 ** 2:.0f} MB"
@@ -204,7 +204,7 @@ PY
# during the whole upload.
DATE=$(date +%Y.%m.%d)
gh release create "v${DATE}" --draft --title "BIOS Pack v${DATE}" --notes-file notes.md
-for f in dist/SHA256SUMS.txt dist/SHA256SUMS.txt.sig dist/*.zip dist/*.zip.0*; do
+for f in dist/SHA256SUMS.txt dist/SHA256SUMS.txt.sig dist/*.zip; do
[ -f "$f" ] && gh release upload "v${DATE}" "$f#$(basename "$f")" --clobber
done
gh release view "v${DATE}" --json assets --jq '.assets | length' # expect every file
@@ -233,14 +233,17 @@ sha256sum --check --ignore-missing SHA256SUMS.txt
```
The first command must print `Good "file" signature for releases@retrobios`.
-Order matters: verify the list before trusting the sums in it, and join split
-volumes before checking, since the sums are of the full ZIPs.
+Order matters: verify the list before trusting the sums in it. The list names
+every file as published, so a single part checks on its own. Releases up to
+v2026.09.04 listed the whole ZIPs instead, and their `.zip.001` volumes have
+to be joined before checking.
The signature and the reproducible build answer different questions. The
signature says the list came from the holder of the release key. The build
-says the bytes are derivable: packs are deterministic, so rebuilding one from
-the same collection yields the same archive, and its checksum can be compared
-against the signed list without trusting either.
+says the bytes are derivable: packs are deterministic and a part copies its
+members as stored, so rebuilding and splitting from the same collection
+yields the same files, and their checksums can be compared against the
+signed list without trusting either.
The private half lives on the maintainer's machine and is generated with
`ssh-keygen -t ed25519 -f ~/.ssh/retrobios_signing -C releases@retrobios`. Its
@@ -261,8 +264,18 @@ the closed issues. A pack has two sizes and they are far apart: Batocera
downloads as 2.4 GB and extracts to 4.0 GB. Step 5 prints both, and whichever
one the table carries, the header names it. Someone sizing a USB drive is
reading that column. The README table is the extracted size, from the install
-manifests. `SHA256SUMS.txt` lists the checksums of the full ZIPs before
-splitting.
+manifests. `SHA256SUMS.txt` lists the checksums of the files as published,
+parts included.
+
+A pack in parts is said so above the table, where the links are: every part
+is needed, each is an ordinary ZIP, and they extract into the same folder.
+Until v2026.09.04 the parts were byte ranges cut by `split`, named
+`.zip.001`, and the sentence explaining them sat under the table. A range
+opened alone is not an archive and no tool says a part is missing, so five
+reports in six months took one for a broken download (#45, #51, #66, #77,
+#79). The README, the download page and the troubleshooting page describe
+both layouts for as long as a release cut that way is still published; once
+step 7 has deleted it, the `.zip.001` paragraph leaves those three pages.
The table carries a Files column, the `pack_files` step 5 prints, and the
notes never open on the size of the collection. That total covers every
diff --git a/wiki/tools.md b/wiki/tools.md
index dd385d0a..083f47a5 100644
--- a/wiki/tools.md
+++ b/wiki/tools.md
@@ -538,8 +538,9 @@ python scripts/refresh_stale.py --only platforms,targets --jobs 6
| `validate_schemas.py` | Validate the data contracts: schemas and semantic invariants. `--source-only` checks `emulators/` and `platforms/` alone, which is what PR validation runs |
| `auto_fetch.py` | Fetch missing BIOS files from known sources (4-step pipeline) |
| `list_platforms.py` | List active platforms (`--all` includes archived, used by CI) |
-| `download.py` | Download a pack from GitHub releases, split volumes joined and checked (Python, stdlib only) |
+| `download.py` | Download a pack from GitHub releases, every part checked (Python, stdlib only) |
| `download.sh` | Same, as a shell one-liner (`curl` + `unzip`) |
+| `split_pack.py` | Cut a pack over the release asset limit into parts that are each a ZIP of whole files, read back before the pack is removed |
| `provenance_report.py` | Dump-catalog coverage and acquisition targets (see above) |
| `generate_readme.py` | Generate README.md and CONTRIBUTING.md from database |
| `generate_site.py` | Generate all MkDocs site pages (this documentation) |
@@ -581,9 +582,11 @@ same-named file.
`scripts/download.sh` remains available for downloading a prebuilt platform
ZIP when a manually reviewed pack release contains it; it is separate from the
-per-file automatic installer above. A pack over 2 GB is published as numbered
-volumes: both downloaders group them under one platform name, download each
-one, join them and check the result against the release's `SHA256SUMS.txt`.
+per-file automatic installer above. A pack over 2 GB is published in several
+parts: both downloaders group them under one platform name, download each
+one and check it against the release's `SHA256SUMS.txt`. Parts that are each
+a ZIP are extracted one after the other; the `.zip.001` slices of releases up
+to v2026.09.04 are joined first.
That list carries a detached signature, `SHA256SUMS.txt.sig`, verifiable
against `allowed_signers` at the repository root; the
[release process](release-process.md#verifying-a-release) gives the commands.
diff --git a/wiki/troubleshooting.md b/wiki/troubleshooting.md
index f2b1f350..38f9a54e 100644
--- a/wiki/troubleshooting.md
+++ b/wiki/troubleshooting.md
@@ -271,18 +271,25 @@ Some platforms share packs (Lakka uses the RetroArch pack). The installer handle
this mapping automatically, but if you're downloading manually, check which pack
name corresponds to your platform.
-**A split pack will not extract:**
+**A pack in several parts will not extract:**
-A pack over 2 GB is published as `.zip.001`, `.zip.002`. Put every part in one
-folder; 7-Zip and PeaZip open the `.001` directly. A frontend's own extractor
-may refuse it, Batocera among them:
+Parts named `Pack.part1of2.zip` are ordinary ZIPs: extract each one into the
+same folder, and check that all of them were downloaded, the name says how
+many there are.
+
+Parts named `Pack.zip.001`, `Pack.zip.002` (releases up to v2026.09.04) are
+slices of one ZIP. None opens on its own, and the error never says a part is
+missing: 7-Zip reports `Unavailable start of archive` on a `.002`, Windows
+has no program for the extension. Put every slice in one folder; 7-Zip and
+PeaZip open the `.001` directly. A frontend's own extractor may refuse it,
+Batocera among them:
```
Archive type: '001' is not yet supported
```
-The volumes are plain byte ranges, so joining them from a shell rebuilds the
-ZIP:
+The slices are plain byte ranges, so joining them from a shell rebuilds the
+ZIP (`cmd /c copy /b Pack.zip.001+Pack.zip.002 Pack.zip` on Windows):
```bash
cat Pack.zip.0* > Pack.zip