docs: archive recipes page and README badges

Every archive has a recipe in recipes/archives.json, and no page a user
reads said so. A short reference page says what a recipe is, how to
rebuild one archive and what exact and canonical mean. Its counts and the
README line are counted from the recipe file at each build.

The README gains a row of four badges: PyPI version, Python versions,
the Validation workflow on main and the license. Tests hold the badges,
the counts and the page's command.
This commit is contained in:
Abdessamad Derraz committed 2026-11-09 14:23:29 +01:00
1 parent d8b55fc2de
commit 92546794e2
9 files changed
+188 -1

No files matched your search

+5
View File
@@ -11,6 +11,7 @@ on:
- "exports/**"
# Every script the build runs or imports, and no other
# (tests/test_workflow_paths.py).
- "scripts/archive_build.py"
- "scripts/artifacts.py"
- "scripts/auto_fetch.py"
- "scripts/common.py"
@@ -60,6 +61,7 @@ on:
- "scripts/siteexports.py"
- "scripts/siterender.py"
- "scripts/retired_files.py"
- "scripts/romset_recipes.py"
- "scripts/sitesources.py"
- "scripts/sitesteps.py"
- "scripts/sitestarlight.py"
@@ -68,6 +70,7 @@ on:
- "scripts/slots.py"
- "scripts/sourceref.py"
- "scripts/split_pack.py"
- "scripts/torrentzip.py"
- "scripts/truth.py"
- "scripts/upstream.py"
- "scripts/validate_pr.py"
@@ -88,6 +91,8 @@ on:
- "release.json"
- "derivable.json"
- "retired.json"
# The README and the archive recipes page count the recipes.
- "recipes/archives.json"
- "measurements/**"
# The Proof lines of the emulator pages read what an issue's runs showed.
- "tests/campaign/observations.yml"
+2
View File
@@ -32,6 +32,7 @@ on:
- "release.json"
- "derivable.json"
- "retired.json"
- "recipes/**"
- "measurements/**"
- "website/**"
# The type check's configuration and baseline.
@@ -62,6 +63,7 @@ on:
- "release.json"
- "derivable.json"
- "retired.json"
- "recipes/**"
- "measurements/**"
- "website/**"
- "pyproject.toml"
+4 -1
View File
@@ -9,6 +9,8 @@
<p align="center"><a href="https://abdess.github.io/retrobios/">Website</a> · <a href="https://abdess.github.io/retrobios/which-pack/">Get the files</a> · <a href="https://abdess.github.io/retrobios/wiki/troubleshooting/">A game does not start</a> · <a href="https://abdess.github.io/retrobios/wiki/faq/">Questions</a></p>
<p align="center"><a href="https://pypi.org/project/retrobios/"><img src="https://img.shields.io/pypi/v/retrobios" alt="PyPI version"></a> <a href="https://pypi.org/project/retrobios/"><img src="https://img.shields.io/pypi/pyversions/retrobios" alt="Python versions"></a> <a href="https://github.com/Abdess/retrobios/actions/workflows/validate.yml"><img src="https://github.com/Abdess/retrobios/actions/workflows/validate.yml/badge.svg?branch=main" alt="Validation"></a> <a href="LICENSE"><img src="https://img.shields.io/github/license/Abdess/retrobios" alt="License"></a></p>
Some consoles need a small program, the **BIOS**, to start their games: PlayStation 1 and 2, Saturn, Neo Geo, Dreamcast and more. RetroBIOS installs the BIOS and firmware files for AmberELEC, Batocera, BizHawk, EmuDeck, EmuELEC, KNULLI, Lakka, MiSTer FPGA, MinUI, MyMinUI, NextUI, ROCKNIX, Recalbox, RetroArch, RetroBat, RetroDECK, RetroPie, and RomM, checked the way each one checks them.
## Install in one line
@@ -64,6 +66,7 @@ Unzip into the BIOS folder, files straight in it. A big pack comes in parts (`Pa
- **623 emulators profiled**, 589 from their source code and 34 closed-source builds from their binaries: 93% of the files they load cite the file and line that reads them
- **18 setups**, each checked its own way: MD5 for Batocera and Recalbox, the file name for RetroArch, as their own code does
- **532 systems**, 19,947 files in the collection, [what is still missing](https://abdess.github.io/retrobios/gaps/) named file by file
- **2,222 archives**, each with a recipe that rebuilds it from the ROMs the collection holds: 1,790 byte for byte, 432 with the same files inside ([rebuilding an archive](https://abdess.github.io/retrobios/wiki/archive-recipes/))
- Public data: [JSON, CSV and SQLite](https://abdess.github.io/retrobios/data/), the metadata under MIT
<details>
@@ -107,4 +110,4 @@ The scripts and tooling are released under the [MIT License](LICENSE).
The BIOS and firmware files are not covered by that license: they are third-party system software, preserved and provided for personal backup, archival, and interoperability with emulation software. [NOTICE](NOTICE) sets out their status and how to ask for a file to be removed.
The reasoning, and where it is weakest, is in [the project's legal position](https://abdess.github.io/retrobios/wiki/legal/).
*Auto-generated on 2026-11-09T07:40:17Z*
*Auto-generated on 2026-11-09T13:12:14Z*
+11
View File
@@ -83,6 +83,17 @@ def load_ledger(path: Path = ARCHIVES_FILE) -> dict[str, dict]:
return {record["path"]: record for record in data.get("archives", [])}
def rebuild_counts(path: Path = ARCHIVES_FILE) -> dict[str, int]:
"""How many recipes rebuild ``exact`` and ``canonical``, and ``total``:
the figures the README and the site print."""
records = load_ledger(path).values()
return {
"total": len(records),
**{kind: sum(1 for r in records if r.get("rebuild") == kind)
for kind in ("exact", "canonical")},
}
def ledger_text(records: dict[str, dict]) -> str:
"""One record per line, so a diff names the archive that changed."""
lines = [
+30
View File
@@ -38,6 +38,7 @@ from common import (
write_if_changed,
)
from nativemode import check_label
import archive_build
import release_record
from siteguides import PACKAGE_COMMAND, install_command
from validate_pr import LARGE_FILE_ISSUE_URL
@@ -563,6 +564,32 @@ def _catalog_matched_line(db: dict) -> list[str]:
]
# The badge row under the links: (alt text, image, link).
BADGES = (
("PyPI version", "https://img.shields.io/pypi/v/retrobios", "https://pypi.org/project/retrobios/"),
("Python versions", "https://img.shields.io/pypi/pyversions/retrobios",
"https://pypi.org/project/retrobios/"),
("Validation",
"https://github.com/Abdess/retrobios/actions/workflows/validate.yml/badge.svg?branch=main",
"https://github.com/Abdess/retrobios/actions/workflows/validate.yml"),
("License", "https://img.shields.io/github/license/Abdess/retrobios", "LICENSE"),
)
def badge_row() -> str:
badges = " ".join(f'<a href="{link}"><img src="{image}" alt="{alt}"></a>'
for alt, image, link in BADGES)
return f'<p align="center">{badges}</p>'
def archive_line(counts: dict[str, int]) -> str:
"""The README's line on archive recipes, counted from recipes/archives.json."""
return (f"- **{counts['total']:,} archives**, each with a recipe that rebuilds it from the"
f" ROMs the collection holds: {counts['exact']:,} byte for byte,"
f" {counts['canonical']:,} with the same files inside"
f" ([rebuilding an archive]({SITE_URL}wiki/archive-recipes/))")
def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json") -> str:
total_files = db.get("total_files", 0)
total_size = db.get("total_size", 0)
@@ -629,6 +656,8 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
f'<a href="{SITE_URL}wiki/faq/">Questions</a>'
"</p>",
"",
badge_row(),
"",
"Some consoles need a small program, the **BIOS**, to start their games: PlayStation 1"
" and 2, Saturn, Neo Geo, Dreamcast and more. RetroBIOS installs the BIOS and firmware"
f" files for {_names_list(coverages)}, checked the way each one checks them.",
@@ -715,6 +744,7 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
" the file name for RetroArch, as their own code does",
f"- **{len(system_ids)} systems**, {total_files:,} files in the collection,"
f" [what is still missing]({SITE_URL}gaps/) named file by file",
archive_line(archive_build.rebuild_counts()),
f"- Public data: [JSON, CSV and SQLite]({SITE_URL}data/), the metadata under MIT",
"",
"<details>",
+29
View File
@@ -124,6 +124,7 @@ from siteguides import (
terminal_tabs,
trust_box,
)
import archive_build
import release_record
import sitebootstrap
import sitemeasurements
@@ -8784,6 +8785,30 @@ def with_romset_table(page: str, profiles: dict) -> str:
return _ROMSET_BLOCK.sub(lambda m: f"{m.group(1)}{table}{m.group(3)}", page, count=1)
ARCHIVE_RECIPES_PAGE = Path(WIKI_SRC_DIR) / "archive-recipes.md"
_ARCHIVE_BLOCK = re.compile(
r"(<!-- generated: archive-recipes -->\n)(.*?)(<!-- /generated: archive-recipes -->)", re.S)
def archive_recipe_lines(counts: dict[str, int]) -> list[str]:
"""What an exact and a canonical rebuild are, each with its count."""
total = f"{counts['total']:,}"
return [
f"- **Exact**: the same bytes as the archive in the collection."
f" {counts['exact']:,} of {total} archives.",
f"- **Canonical**: the same files inside, packed another way."
f" {counts['canonical']:,} of {total} archives.",
]
def with_archive_counts(page: str, counts: dict[str, int]) -> str:
"""The archive recipes page with its counts read from recipes/archives.json."""
block = "\n".join(archive_recipe_lines(counts)) + "\n"
if not _ARCHIVE_BLOCK.search(page):
raise ValueError(f"{ARCHIVE_RECIPES_PAGE}: no archive-recipes block to write the counts in")
return _ARCHIVE_BLOCK.sub(lambda m: f"{m.group(1)}{block}{m.group(3)}", page, count=1)
def _render_metadata_rows(profile: dict) -> list[str]:
"""Scalar profile fields, one table row each.
@@ -12673,6 +12698,10 @@ def _generate(args: argparse.Namespace) -> None:
faq = _REPO_ROOT / ROMSET_FAQ_PAGE
if faq.is_file():
write_if_changed(str(faq), with_romset_table(faq.read_text(encoding="utf-8"), profiles))
recipes_page = _REPO_ROOT / ARCHIVE_RECIPES_PAGE
if recipes_page.is_file():
write_if_changed(str(recipes_page), with_archive_counts(
recipes_page.read_text(encoding="utf-8"), archive_build.rebuild_counts()))
if wiki_src.is_dir():
for src_file in wiki_src.glob("*.md"):
shutil.copy2(src_file, wiki_dest / src_file.name)
+69
View File
@@ -636,5 +636,74 @@ class ContributorChecksFetchThePlatformsFiles(unittest.TestCase):
self.assertIn("export_native.py --all --fetch", _read("wiki/testing-guide.md"))
class ArchiveRecipesAreCountedFromTheRecipes(unittest.TestCase):
"""Every archive has a recipe in recipes/archives.json: the README and
the site's page say how many rebuild exact and canonical, counted from
that file, never typed."""
def setUp(self) -> None:
import archive_build # noqa: PLC0415
records = archive_build.load_ledger().values()
self.counts = {
"total": len(records),
"exact": sum(r["rebuild"] == "exact" for r in records),
"canonical": sum(r["rebuild"] == "canonical" for r in records),
}
self.assertEqual(archive_build.rebuild_counts(), self.counts)
self.assertEqual(self.counts["exact"] + self.counts["canonical"], self.counts["total"])
def test_the_readme_line(self) -> None:
from generate_readme import archive_line # noqa: PLC0415
line = archive_line(self.counts)
self.assertIn(line, _read("README.md").splitlines())
self.assertIn(f"**{self.counts['total']:,} archives**", line)
self.assertIn(f"{self.counts['exact']:,} byte for byte", line)
self.assertIn(f"{self.counts['canonical']:,} with the same files inside", line)
self.assertIn(f"({SITE_URL}wiki/archive-recipes/)", line)
def test_the_page_counts(self) -> None:
import generate_site # noqa: PLC0415
page = _read("wiki/archive-recipes.md")
self.assertEqual(generate_site.with_archive_counts(page, self.counts), page,
"wiki/archive-recipes.md counts are stale: run scripts/generate_site.py")
for line in generate_site.archive_recipe_lines(self.counts):
self.assertIn(line, page)
def test_the_page_command_is_the_tools(self) -> None:
command = re.search(r"python scripts/romset_recipes.py (.+)", _read("wiki/archive-recipes.md"))
source = _read("scripts/romset_recipes.py")
for flag in re.findall(r"--[a-z-]+", command.group(1)):
with self.subTest(flag=flag):
self.assertIn(f'"{flag}"', source)
class TheReadmeBadges(unittest.TestCase):
"""One row of four badges under the links, each linking its source."""
EXPECTED = (
'<p align="center">'
'<a href="https://pypi.org/project/retrobios/"><img src="https://img.shields.io/pypi/v/retrobios" alt="PyPI version"></a> '
'<a href="https://pypi.org/project/retrobios/"><img src="https://img.shields.io/pypi/pyversions/retrobios" alt="Python versions"></a> '
f'<a href="{REPO}/actions/workflows/validate.yml"><img src="{REPO}/actions/workflows/validate.yml/badge.svg?branch=main" alt="Validation"></a> '
'<a href="LICENSE"><img src="https://img.shields.io/github/license/Abdess/retrobios" alt="License"></a>'
"</p>"
)
def test_exactly_these_four(self) -> None:
readme = _read("README.md")
self.assertEqual(len(re.findall(r"<img src=\"https://(?:img\.shields\.io|github\.com)/", readme)), 4)
lines = readme.splitlines()
self.assertIn(self.EXPECTED, lines)
links = next(i for i, line in enumerate(lines) if ">Get the files</a>" in line)
self.assertEqual(lines[links + 2], self.EXPECTED)
def test_the_workflow_and_the_license_exist(self) -> None:
self.assertTrue((REPO_ROOT / ".github" / "workflows" / "validate.yml").is_file())
self.assertTrue((REPO_ROOT / "LICENSE").is_file())
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -41,6 +41,7 @@ START_PAGES = {
# Reference pages written for anyone, held to the Start here budgets.
START_LEVEL_PAGES = {
"wiki/measurements.md": 300,
"wiki/archive-recipes.md": 160,
}
# Level 1: the guide part of each setup page, above its Reference heading.
SETUP_CEILING = 220
+37
View File
@@ -0,0 +1,37 @@
---
level: reference
order: 45
group: Checking files
terms: recipe, rebuild, archive, zip, romset, exact, canonical
---
# Rebuilding an archive
Arcade emulators read their BIOS files from a ZIP archive.
The collection keeps a **recipe** for each archive. It lists the files
inside, in order, with their fingerprints. The recipe and those files make
the archive again. No copy from elsewhere is needed.
## Rebuild one archive
In a copy of the project:
```bash
python scripts/romset_recipes.py --build neogeo.zip --output-dir rebuilt
```
Each archive of that name is made under `rebuilt/`, in the folders it has in
the collection. It is checked against its recipe before it is kept.
## Exact or canonical
<!-- generated: archive-recipes -->
- **Exact**: the same bytes as the archive in the collection. 1,790 of 2,222 archives.
- **Canonical**: the same files inside, packed another way. 432 of 2,222 archives.
<!-- /generated: archive-recipes -->
An emulator reads the files inside, so both work the same. A setup that
checks the whole archive by its fingerprint needs the exact one.
Every option of the command: [tools](tools.md#archive-recipes).