diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index b20dfaf7a..debe35bc8 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -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" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 3b9c6698a..4a88e1326 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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" diff --git a/README.md b/README.md index 229784b98..bd9959fc5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@

Website · Get the files · A game does not start · Questions

+

PyPI version Python versions Validation License

+ 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 @@ -61,9 +63,10 @@ Unzip into the BIOS folder, files straight in it. A big pack comes in parts (`Pa ## How it is built -- **635 emulators profiled**, 601 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 +- **636 emulators profiled**, 602 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 - **535 systems**, 19,989 files in the collection, [what is still missing](https://abdess.github.io/retrobios/gaps/) named file by file +- **2,227 archives**, each with a recipe that rebuilds it from the ROMs the collection holds: 1,794 byte for byte, 433 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
@@ -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-09T11:53:49Z* +*Auto-generated on 2026-11-09T16:43:09Z* diff --git a/scripts/archive_build.py b/scripts/archive_build.py index 0b4fdbec2..ac22efd07 100644 --- a/scripts/archive_build.py +++ b/scripts/archive_build.py @@ -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 = [ diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py index ef377d211..20f16688d 100644 --- a/scripts/generate_readme.py +++ b/scripts/generate_readme.py @@ -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'{alt}' + for alt, image, link in BADGES) + return f'

{badges}

' + + +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'Questions' "

", "", + 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", "", "
", diff --git a/scripts/generate_site.py b/scripts/generate_site.py index fc14074cb..eb4da31ef 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -124,6 +124,7 @@ from siteguides import ( terminal_tabs, trust_box, ) +import archive_build import release_record import sitebootstrap import sitemeasurements @@ -8789,6 +8790,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"(\n)(.*?)()", 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. @@ -12678,6 +12703,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) diff --git a/scripts/siteagents.py b/scripts/siteagents.py index 60b366943..867df60d5 100644 --- a/scripts/siteagents.py +++ b/scripts/siteagents.py @@ -19,6 +19,8 @@ import json from collections.abc import Callable from pathlib import Path +from siteguides import PACKAGE_COMMAND + SITE = "https://abdess.github.io/retrobios/" PUBLISHER = "abdess.github.io" SH = "curl -fsSL https://abdess.github.io/retrobios/i | sh -s --" @@ -31,6 +33,22 @@ QUOTING = (f"A folder goes in single quotes, attached to its option. One that ho "command. On a page of the site, the `device_setup_command` WebMCP tool writes the " "command and refuses such a folder.") +UVX = PACKAGE_COMMAND +# EVENT_SCHEMA of install.py, and the schema generate_event_schema.py writes +# from EVENT_KEYS, as the site publishes it. tests/test_site_agents.py holds +# both to install.py. +EVENT_SCHEMA = 1 +EVENTS_SCHEMA_PATH = "api/v1/schemas/install-events.schema.json" +# Every code of the Exit status table of wiki/installer.md, in short. +EXIT_CODES = ( + (0, "everything asked for is installed or already in place"), + (1, "a failure: a download, a file list or a folder that cannot be used"), + (2, "nothing was done: an option refused, every file failed, or the user quit"), + (3, "`--check` found files missing or wrong, nothing having failed"), + (130, "stopped with Ctrl+C"), + (141, "the output went to a reader that closed early"), +) + START_PAGES = ( ("Which pack is needed", "which-pack/", "pick a device or setup, get the folder its BIOS files go in, " "one command that installs and checks them, or a ZIP pack"), @@ -108,6 +126,24 @@ def llms_txt(platforms: list[tuple[str, str]]) -> str: "- A game the installer cannot identify gets every BIOS of its system, and the run names it.", f"- The same steps as an Agent Skill: [SKILL.md]({SITE}{SKILL_PATH}).", "", + "## Driving the installer for a user", + "", + f"With Python 3.8 or newer, the installer is also a package: `{UVX} --for-games " + f"--required-only --dry-run`, or `pipx run retrobios` with the same options.", + "", + "1. Plan: run it with `--dry-run --events`. Nothing is written, and standard output carries " + f"one JSON object per line, event schema {EVENT_SCHEMA} " + f"([JSON Schema]({SITE}{EVENTS_SCHEMA_PATH})): a `file` event per file with its " + "`state`, `check` with the totals and `bytes_needed`, then `summary` with `pending`.", + "2. Consent: show the user what will be installed, where, and the size, and ask.", + "3. Act, only on a yes: the same command with `--yes` in place of `--dry-run`.", + "", + "- `--yes` asks nothing: use it in a run without a terminal. A run that needs an " + "answer (two setups found, none found) stops and names the option to give.", + "- The exit code, also the `code` of the last `end` event. An option refused at once " + "writes no event at all:", + *(f" - `{code}`: {meaning}" for code, meaning in EXIT_CODES), + "", "## Start", "", *(f"- [{title}]({SITE}{path}): {what}" for title, path, what in START_PAGES), diff --git a/tests/test_doc_surfaces.py b/tests/test_doc_surfaces.py index 9db4962e6..a66abc4d3 100644 --- a/tests/test_doc_surfaces.py +++ b/tests/test_doc_surfaces.py @@ -636,5 +636,74 @@ class ContributorChecksFetchThePlatformsFiles(unittest.TestCase): self.assertIn("export_native.py --all --refresh-cache", _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 = ( + '

' + 'PyPI version ' + 'Python versions ' + f'Validation ' + 'License' + "

" + ) + + def test_exactly_these_four(self) -> None: + readme = _read("README.md") + self.assertEqual(len(re.findall(r"Get the files" 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() diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 1753efc5b..e14c35e8f 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -366,6 +366,13 @@ HOME = Path("/home/user") SYSTEM_DIR = HOME / ".config" / "retroarch" / "system" +def screen_text(lines) -> str: + """The screen as one line of words, a path broken after a separator + joined again: a folder under the checkout is as long as the checkout's + own path, and a deep clone wrapped it across two lines.""" + return re.sub(r"(?<=[/\\]) ", "", " ".join(" ".join(lines).split())) + + def fresh_state() -> install.TuiState: state = install.TuiState(os_type="linux", home=HOME) state.setups = [("retroarch", SYSTEM_DIR, "RetroArch"), @@ -1856,7 +1863,7 @@ class TicksBarAndPaths(unittest.TestCase): state.stack = ["folder"] self.assertEqual(install._choose(state, "minui", card / "Bios"), []) self.assertEqual(state.screen, "cardroot") - text = " ".join(line.strip() for line in install.tui_view(state, 120, 40)) + text = screen_text(install.tui_view(state, 120, 40)) self.assertIn(f"under the card's root: {card}", text) self.assertEqual(press(state, "enter"), [("fetch", "minui")]) self.assertEqual(state.dest, card) @@ -2343,7 +2350,7 @@ class CardsSpaceAndConfirm(unittest.TestCase): state, effects = install.tui_start(_args(), "linux") label = f"muOS SD card ({root.name})" self.assertEqual(state.setups, [("retroarch", root / "MUOS" / "bios", label)]) - text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + text = screen_text(install.tui_view(state, 120, 40)) self.assertIn(f"> {label}", text) self.assertIn(f"BIOS folder: {root / 'MUOS' / 'bios'}", text) (root / "MUOS" / "bios").mkdir() @@ -4135,7 +4142,7 @@ class AFolderThisUserCannotOpen(unittest.TestCase): self.dest = self.lock / "system" def said(self, state: install.TuiState) -> str: - return " ".join(" ".join(install.tui_view(state, 200, 40)).split()) + return screen_text(install.tui_view(state, 200, 40)) def test_typed_it_stays_on_the_path_screen(self): state = fresh_state() @@ -6507,7 +6514,7 @@ class OneCardReading(unittest.TestCase): self.assertEqual(state.screen, "checking" if one else "create") if not one: self.assertIn(note.split(":")[0], - " ".join(" ".join(install.tui_view(state, 120, 40)).split())) + screen_text(install.tui_view(state, 120, 40))) install.tui_update(state, Key("enter")) self.assertEqual(state.card_note, note) @@ -11117,7 +11124,7 @@ class TapsGameDataAndExitCodes(_CardFixtures, unittest.TestCase): _title, lines = install.tui_outcome(state) self.assertIn(f"BIOS folder: {card / 'Bios'}", lines) state.stack = ["checking"] - self.assertIn(str(card / "Bios"), " ".join(install.tui_view(state, 120, 40))) + self.assertIn(str(card / "Bios"), screen_text(install.tui_view(state, 120, 40))) # Root cause 7: colour roles read the words. @@ -11263,7 +11270,7 @@ class TapsGameDataAndExitCodes(_CardFixtures, unittest.TestCase): folder = next(i for i, line in enumerate(lines) if line.startswith("BIOS folder:")) named = next(i for i, line in enumerate(lines) if line.startswith("Named by")) # Nothing between the folder, wrapped or not, and what named it. - self.assertTrue(all(line.startswith("/") for line in lines[folder + 1:named]), lines) + self.assertEqual("".join(lines[folder:named]), f"BIOS folder: {state.dest}", lines) self.assertLess(named, next(i for i, line in enumerate(lines) if line.startswith(("For ", "Copy them")))) diff --git a/tests/test_site_agents.py b/tests/test_site_agents.py index 93af6f9d1..dbbbf4eb2 100644 --- a/tests/test_site_agents.py +++ b/tests/test_site_agents.py @@ -50,6 +50,55 @@ class TheGuideLeadsToTheFewestFiles(unittest.TestCase): self.assertTrue(target.is_file() or (target / "index.html").is_file(), url) +class TheGuideDrivesTheInstaller(unittest.TestCase): + """What an agent needs to run the installer for a user: the package + command, the plan as JSON, the exit codes, and plan, consent, act. Each + fact is held to install.py, so the guide cannot drift from it.""" + + def setUp(self) -> None: + import generate_installer_doc # noqa: PLC0415 + + self.doc = generate_installer_doc + self.text = siteagents.llms_txt([("retroarch", "RetroArch")]) + self.section = self.text.split("\n## Driving the installer for a user\n", 1)[1].split("\n## ", 1)[0] + + def test_the_exit_codes_are_the_installer_page_table(self) -> None: + page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + table = page.split("", 1)[1].split("")[0] + in_table = [int(code) for code in re.findall(r"^\| `(\d+)` \|", table, re.M)] + self.assertEqual(in_table, self.doc.exit_codes()) + self.assertEqual([code for code, _ in siteagents.EXIT_CODES], in_table) + listed = [int(code) for code in re.findall(r"^ - `(\d+)`: ", self.section, re.M)] + self.assertEqual(listed, in_table) + + def test_the_event_schema_is_the_installers(self) -> None: + import json # noqa: PLC0415 + + self.assertEqual(siteagents.EVENT_SCHEMA, self.doc._install.EVENT_SCHEMA) + schema = json.loads((REPO_ROOT / "schemas" / "install-events.schema.json").read_text(encoding="utf-8")) + self.assertEqual(schema["$id"], siteagents.SITE + siteagents.EVENTS_SCHEMA_PATH) + self.assertIn(f"event schema {siteagents.EVENT_SCHEMA} ", self.section) + + def test_every_option_and_event_named_exists(self) -> None: + options = {flag for action in self.doc._install.build_parser()._actions + for flag in action.option_strings} + for flag in set(re.findall(r"(? None: + self.assertIn("`uvx retrobios --for-games --required-only --dry-run`", self.section) + self.assertIn("`pipx run retrobios`", self.section) + self.assertIn("`--dry-run --events`", self.section) + steps = re.findall(r"^\d\. (\w+)", self.section, re.M) + self.assertEqual(steps, ["Plan", "Consent", "Act"]) + + class TheSkillIsAnAgentSkill(unittest.TestCase): """ARD reserves text/markdown for agent skills (Lighthouse warns on a plain Markdown entry): the setup steps are published as one.""" diff --git a/tests/test_site_copy.py b/tests/test_site_copy.py index 5d2bb9662..999a4503b 100644 --- a/tests/test_site_copy.py +++ b/tests/test_site_copy.py @@ -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 diff --git a/tests/test_site_wiki.py b/tests/test_site_wiki.py index bd40fc7b0..e584ef7b0 100644 --- a/tests/test_site_wiki.py +++ b/tests/test_site_wiki.py @@ -956,7 +956,8 @@ class EventsTranscript(unittest.TestCase): self.assertEqual(list(event), ["event", *keys[event["event"]]]) self.assertEqual(events[0]["event"], "start") self.assertEqual(events[0]["mode"], "check") - self.assertEqual(events[-1], {"event": "end", "code": 1}) + # --check that finds files missing ends on 3 (the Exit status table). + self.assertEqual(events[-1], {"event": "end", "code": 3}) class OneOptionsTable(unittest.TestCase): diff --git a/wiki/archive-recipes.md b/wiki/archive-recipes.md new file mode 100644 index 000000000..a5091b9bf --- /dev/null +++ b/wiki/archive-recipes.md @@ -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 + + +- **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. + + +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). diff --git a/wiki/installer.md b/wiki/installer.md index fc77eb2e3..0d642dd53 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1872,7 +1872,7 @@ RetroArch folder that holds only `scph5501.bin`, in the order written: {"event": "file", "platform": "retroarch", "dest": "scph5501.bin", "systems": ["sony-playstation"], "size": 524288, "state": "present"} {"event": "check", "platform": "retroarch", "name": "RetroArch", "bios_dir": "system", "files": 118, "present": 1, "missing": 117, "wrong_hash": 0, "bytes_needed": 344457216, "data_missing": 0, "not_installable": 0} {"event": "summary", "mode": "check", "installed": 0, "up_to_date": 1, "pending": 117, "failed": [], "skipped": [], "left_out": [], "kept": [], "interrupted": false} -{"event": "end", "code": 1} +{"event": "end", "code": 3} ``` ## Environment overrides