chore: merge site and docs fixes, third pass

This commit is contained in:
Abdessamad Derraz committed 2026-11-06 06:38:47 +01:00
commit 19898cb6ea
46 files changed
+38582 -163

No files matched your search

+2
View File
@@ -53,6 +53,7 @@ on:
- "scripts/sitemeasurements.py"
- "scripts/siteexports.py"
- "scripts/siterender.py"
- "scripts/retired_files.py"
- "scripts/sitesources.py"
- "scripts/sitestarlight.py"
- "scripts/sitewrite.py"
@@ -79,6 +80,7 @@ on:
- "database.json"
- "release.json"
- "derivable.json"
- "retired.json"
- "measurements/**"
# The Proof lines of the emulator pages read what an issue's runs showed.
- "tests/campaign/observations.yml"
+2
View File
@@ -29,6 +29,7 @@ on:
- "database.json"
- "release.json"
- "derivable.json"
- "retired.json"
- "measurements/**"
- "website/**"
# Spelled out twice because the workflow parser reads no YAML anchor. A test
@@ -54,6 +55,7 @@ on:
- "database.json"
- "release.json"
- "derivable.json"
- "retired.json"
- "measurements/**"
- "website/**"
+4 -5
View File
@@ -71,7 +71,7 @@ the checks CI runs on a pull request (Python 3.12 or newer):
pip install pyyaml "jsonschema[format-nongpl]" markdown pyte hypothesis vermin setuptools
python scripts/validate_pr.py --changed # the BIOS files the branch adds
python scripts/validate_schemas.py --source-only # profiles and platform files
python scripts/profile_sync.py --added --base upstream/main --strict # each profile the branch adds
python scripts/profile_sync.py --added --strict # each profile the branch adds
python scripts/refresh_data_dirs.py # data directories the pack-building tests read
python scripts/generate_site.py # the pages the site tests read
npm ci --prefix website && npm run build --prefix website # the built site, Node 24
@@ -80,10 +80,9 @@ python scripts/validate_site.py --site-dir website/dist # links and page budget
vermin --no-tips -t=3.8- --violations install.py # install.py stays on Python 3.8
```
`upstream` is this repository as a remote of your fork
(`git remote add upstream https://github.com/Abdess/retrobios`, then
`git fetch upstream`). A profile you only edit is not held to `--strict`: its
older refs are reviewed by hand.
`profile_sync.py` compares the branch with this repository's `main`, and
fetches it in a fork's clone. A profile you only edit is not held to
`--strict`: its older refs are reviewed by hand.
The full pipeline is not needed for a contribution: it rebuilds every pack
in `dist/`, which is why maintainers run it before a release.
+5 -3
View File
@@ -27,7 +27,7 @@ irm https://abdess.github.io/retrobios/i.ps1 | iex
# Android (Termux: it installs Python and asks for storage access)
curl -fsSL https://abdess.github.io/retrobios/i | sh
# SD card mounted on a PC: a muOS, KNULLI, ROCKNIX, Onion, spruceOS or MinUI card
# SD card mounted on a PC: a muOS, KNULLI, ROCKNIX, Lakka, MiSTer, Onion, spruceOS or MinUI card
# is found by itself. For another card, --dest is its BIOS folder
# (RetroArch on Switch: retroarch/cores/system, on Android: RetroArch/system)
curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --platform retroarch --dest /path/to/sdcard/retroarch/cores/system
@@ -39,6 +39,8 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --platform retroarch
The script opens a full-screen installer, driven with the arrow keys and Enter, or with a controller on a handheld. It finds your setup and its BIOS folder, downloads only the missing files and checks each one. It can also copy the BIOS files from your old SD card.
Every option, and what the installer detects and writes, is on the [installer page](https://abdess.github.io/retrobios/wiki/installer/#passing-options).
First time? Follow the [first install, step by step](https://abdess.github.io/retrobios/wiki/first-install/). A game does not start: [what to check](https://abdess.github.io/retrobios/wiki/troubleshooting/). Other questions: the [FAQ](https://abdess.github.io/retrobios/wiki/faq/).
## Download BIOS packs
A pack holds everything its platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file: its screen installs everything, or fewer under **Choose systems**, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below.
@@ -72,7 +74,7 @@ The Files column counts what a pack holds once extracted, the figure a file mana
ZIPs are made for the setups most people download. The others read "Use the command": the installer above installs every file for them and checks each one, and `--dry-run` shows what it would install ([why no ZIP](https://abdess.github.io/retrobios/wiki/faq/#why-is-there-no-zip-for-my-setup)).
\* Archived: the configuration is kept and the installer serves it, but upstream is no longer scraped on a schedule.
\* Archived: the installer serves it, and checks that cover every setup include it only on request.
## What's included
@@ -206,4 +208,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-06T04:40:27Z*
*Auto-generated on 2026-11-06T05:38:30Z*
+3 -2
View File
@@ -87,8 +87,9 @@ jobs:
body: >-
Regenerated by the offline pipeline, database.json and the
manifests computed twice with the same result.
paths: [database.json, install/*.json, install/targets/*.json, install/games/*.json,
install/games/*/*.json, README.md, CONTRIBUTING.md, website/src/data/*.json]
paths: [database.json, derivable.json, retired.json, install/*.json, install/targets/*.json,
install/games/*.json, install/games/*/*.json, README.md, CONTRIBUTING.md,
wiki/installer.md, website/src/data/*.json]
scratch: [dist, tmp/autorun]
bios-integrity:
+4 -2
View File
@@ -452,6 +452,9 @@ guides:
Turn on **SSH**, a terminal over the network, in Lakka's **Settings > Services**. From
a computer, connect with `ssh root@lakka`, password `root`. Paste the line. Press
Enter.
# /storage is the card's ext4 partition, the other one FAT32 (Lakka-LibreELEC
# 4696b48f, scripts/mkimage:71-72 and 95): from a computer, only Linux opens it.
card_run: [linux]
reference_notes:
- The folder is reachable over SSH and over the network share.
pack_manual: |
@@ -648,8 +651,7 @@ guides:
RetroPie also installs standalone emulators (openMSX, PCSX2, XRoar, Amiberry and
others) whose files only the RetroPie file list carries.
- >-
RetroPie is archived in this project: its configuration is kept and the command
serves it, but its upstream list is no longer read again on a schedule.
RetroPie is archived here: checks of every setup include it only on request.
pack_manual: |
Option B: Via network share
--------------------------
+37427
View File
File diff suppressed because it is too large. Load diff
+16
View File
@@ -389,11 +389,27 @@
"layer": {
"enum": [
"collection",
"archive-member",
"retired",
"data-directory",
"platform",
"emulator"
]
},
"left": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$",
"description": "On a retired record: the day the file, whose SHA1 is `sha1`, left the collection (retired.json)."
},
"successor": {
"type": "string",
"pattern": "^[0-9a-f]{40}$",
"description": "On a retired record: SHA1 of the collection file now holding its place, whose card `url` leads to."
},
"archive": {
"type": "string",
"description": "On an archive-member record: the name of the collection archive holding the member, whose SHA1 is `sha1`. Its value is the member's CRC32, read from the archive's central directory."
},
"name": {
"type": "string"
},
+52 -1
View File
@@ -80,6 +80,9 @@ TOSEC_DOWNLOADS = "https://www.tosecdev.org/downloads"
MAME_REPO = "https://github.com/mamedev/mame"
FBNEO_REPO = "https://github.com/libretro/FBNeo"
PYPI_URL = "https://pypi.org/pypi/{name}/json"
NPM_URL = "https://registry.npmjs.org/{name}/latest"
# The site's npm dependencies, pinned to exact versions and built by Deploy Site.
SITE_PACKAGE = REPO_ROOT / "website" / "package.json"
INSTALLERS = ("install.sh", "install.ps1")
SHOWN_ITEMS = 4 # items named in a diff summary before ", ..."
@@ -1356,6 +1359,53 @@ def pypi_latest(name: str) -> str | None:
return str(info.get("version") or "") or None
def parse_npm_pins(text: str) -> dict[str, str]:
"""Package -> the version a package.json names, dependencies of both kinds."""
data = json.loads(text)
pins: dict[str, str] = {}
for key in ("dependencies", "devDependencies"):
pins.update({str(k): str(v) for k, v in (data.get(key) or {}).items()})
return pins
def npm_latest(name: str) -> str | None:
payload = upstream._http_json(NPM_URL.format(name=name)) # noqa: SLF001
if not isinstance(payload, dict):
return None
return str(payload.get("version") or "") or None
def _npm_findings() -> list[Finding]:
"""One row per site dependency: an exact pin is stale once npm's latest
release is newer, since nothing else asks npm."""
findings = []
for name, pinned in sorted(parse_npm_pins(SITE_PACKAGE.read_text(encoding="utf-8")).items()):
subject = f"npm:{name}"
if not re.fullmatch(r"\d+(\.\d+)*", pinned):
findings.append(Finding("ci", subject, UNKNOWN, local=pinned, detail="a range, not an exact pin"))
continue
try:
latest = npm_latest(name)
except upstream.UpstreamError as exc:
findings.append(Finding("ci", subject, UNKNOWN, local=pinned, detail=str(exc)))
continue
if latest is None:
findings.append(Finding("ci", subject, UNKNOWN, local=pinned, detail="not on npm"))
continue
newer = _version_tuple(latest) > _version_tuple(pinned)
findings.append(
Finding(
"ci",
subject,
STALE if newer else OK,
local=pinned,
remote=latest,
detail="a newer release is out" if newer else "",
)
)
return findings
def check_ci(cache_dir: str, offline: bool) -> list[Finding]:
findings = [_check_installer_pins()]
workflows = sorted((REPO_ROOT / ".github" / "workflows").glob("*.yml"))
@@ -1367,8 +1417,9 @@ def check_ci(cache_dir: str, offline: bool) -> list[Finding]:
pins.setdefault(name, spec)
actions.update(parse_action_pins(text))
if offline:
findings.append(Finding("ci", "workflow pins", SKIPPED, detail="offline"))
findings.append(Finding("ci", "workflow and site pins", SKIPPED, detail="offline"))
return findings
findings.extend(_npm_findings())
for name, spec in sorted(pins.items()):
try:
latest = pypi_latest(name)
+14
View File
@@ -35,6 +35,7 @@ from common import (
write_text_atomic,
yaml_load,
)
import retired_files
# Platform and profile aliases are read from YAML: without pyyaml the
# database lost every one of them and the run still exited 0.
@@ -489,6 +490,19 @@ def _generate(args: argparse.Namespace) -> int:
"indexes": indexes,
}
# A sha1 the rebuild drops keeps its published address: the record
# beside the database says when it left and what holds its place.
output = Path(args.output)
if output.is_file():
previous = json.loads(output.read_text(encoding="utf-8"))
left = retired_files.update(
retired_files.retired_path(output), previous, database,
database["generated_at"][:10],
)
if left:
print(f" Left the collection: {left} files, recorded in "
f"{retired_files.retired_path(output)}")
new_content = json.dumps(database, indent=2)
written = write_if_changed(args.output, new_content)
+3 -3
View File
@@ -3672,9 +3672,9 @@ def _main(held: contextlib.ExitStack) -> None:
# Platform mode
if args.all and args.manifest:
# Install manifests cover every registered platform: the installer
# serves each one, with or without a release pack. "Archived" means
# upstream is no longer scraped on a schedule, not that the platform
# lost its users.
# serves each one, with or without a release pack. "Archived" keeps a
# platform out of the runs that cover every platform, not out of the
# installer: it has not lost its users.
platforms = list_registered_platforms(args.platforms_dir, include_archived=True)
elif args.all:
# A release publishes the packs the registry marks release_pack and
+11 -8
View File
@@ -588,7 +588,7 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
"# Android (Termux: it installs Python and asks for storage access)",
install_command("sh"),
"",
"# SD card mounted on a PC: a muOS, KNULLI, ROCKNIX, Onion, spruceOS or MinUI card",
"# SD card mounted on a PC: a muOS, KNULLI, ROCKNIX, Lakka, MiSTer, Onion, spruceOS or MinUI card",
"# is found by itself. For another card, --dest is its BIOS folder",
"# (RetroArch on Switch: retroarch/cores/system, on Android: RetroArch/system)",
install_command("sh", "--platform retroarch --dest /path/to/sdcard/retroarch/cores/system"),
@@ -602,6 +602,10 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
" files and checks each one. It can also copy the BIOS files from your old SD card.",
f"Every option, and what the installer detects and writes, is on the [installer page]({SITE_URL}wiki/installer/#passing-options).",
"",
f"First time? Follow the [first install, step by step]({SITE_URL}wiki/first-install/)."
f" A game does not start: [what to check]({SITE_URL}wiki/troubleshooting/)."
f" Other questions: the [FAQ]({SITE_URL}wiki/faq/).",
"",
"## Download BIOS packs",
"",
"A pack holds everything its platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file: its screen installs everything, or fewer under **" + _installer_words().TUI_SYSTEMS_ROW + "**, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below.",
@@ -647,8 +651,8 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
lines.extend(
[
"",
"\\* Archived: the configuration is kept and the installer serves it,"
" but upstream is no longer scraped on a schedule.",
"\\* Archived: the installer serves it, and checks that cover every"
" setup include it only on request.",
]
)
@@ -986,7 +990,7 @@ the checks CI runs on a pull request (Python 3.12 or newer):
pip install pyyaml "jsonschema[format-nongpl]" markdown pyte hypothesis vermin setuptools
python scripts/validate_pr.py --changed # the BIOS files the branch adds
python scripts/validate_schemas.py --source-only # profiles and platform files
python scripts/profile_sync.py --added --base upstream/main --strict # each profile the branch adds
python scripts/profile_sync.py --added --strict # each profile the branch adds
python scripts/refresh_data_dirs.py # data directories the pack-building tests read
python scripts/generate_site.py # the pages the site tests read
npm ci --prefix website && npm run build --prefix website # the built site, Node 24
@@ -995,10 +999,9 @@ python scripts/validate_site.py --site-dir website/dist # links and page budget
vermin --no-tips -t=3.8- --violations install.py # install.py stays on Python 3.8
```
`upstream` is this repository as a remote of your fork
(`git remote add upstream https://github.com/Abdess/retrobios`, then
`git fetch upstream`). A profile you only edit is not held to `--strict`: its
older refs are reviewed by hand.
`profile_sync.py` compares the branch with this repository's `main`, and
fetches it in a fork's clone. A profile you only edit is not held to
`--strict`: its older refs are reviewed by hand.
The full pipeline is not needed for a contribution: it rebuilds every pack
in `dist/`, which is why maintainers run it before a release.
+199 -40
View File
@@ -47,6 +47,7 @@ from common import (
GAME_DATA_TOPS,
compute_composition,
count_catalog_matched,
entry_system_ids,
list_registered_platforms,
list_release_pack_platforms,
load_platform_registry,
@@ -88,6 +89,7 @@ from generate_readme import (
from provenance_report import build_report, distinct_missing
import sitemarkup as markup
import siteentries
import retired_files
import sitestarlight
from siteexports import generate_exports_page, load_export_reports
from siteguides import (
@@ -1622,7 +1624,8 @@ def generate_data_exports(
"file-locations.json": ("file-locations", locations),
}
write_lookup_shards(
api, file_lookup_records(db, coverages, profiles, locations, members, consumers),
api, file_lookup_records(db, coverages, profiles, locations, members, consumers,
retired_files.load(_REPO_ROOT / "retired.json")),
generated_at, commit,
)
for filename, (kind, items) in envelopes.items():
@@ -1808,7 +1811,9 @@ def generate_data_exports(
# entry point is a document of its own, naming where to start.
entry_points = [
{"title": "Catalog", "url": "catalog.json",
"description": "Every published file with its size, SHA256 and schema."},
"description": "Each dataset, manifest, export and download with its size, "
"SHA256 and schema. The one-item documents of emulators/, "
"platforms/, systems/ and lookup/ are not listed."},
{"title": "System ids", "url": "systems.json",
"description": "Each system id with its name and the section holding its files."},
{"title": "Core names", "url": "cores.json",
@@ -2203,11 +2208,15 @@ def generate_data_page(exports: list[dict], measurements_sqlite: Path | None = N
"One hash without the whole list: `api/v1/lookup/<xx>.json`, where `<xx>` "
"is the first two characters of the hash in lowercase, holds every SHA1, "
"MD5, CRC32 and SHA256 starting with them. Each record names its `layer` "
"(the collection, a data directory, a platform's list or an emulator "
"profile), the file `name`, the `url` of the page that shows it and, "
"(the collection, a member of a collection archive, a file that left the "
"collection, a data directory, a platform's list or an emulator profile), "
"the file `name`, the `url` of the page that shows it and, "
"when one is known, the `system_name` the site gives its system. "
"[Find or check a file](files/index.md) reads it.",
"",
"`catalog.json` lists the datasets, manifests, exports and downloads. It "
"does not list these one-item documents, which hold no SHA256 of their own.",
"",
"In a browser, the SHA1 of a file the reader picks finds its records when "
"the collection holds that file. A list naming a file the collection lacks "
"is filed under its MD5 or CRC32 alone, and Web Crypto has no MD5: "
@@ -2225,7 +2234,7 @@ def generate_data_page(exports: list[dict], measurements_sqlite: Path | None = N
" const response = await fetch(`${base}${sha1.slice(0, 2)}.json`);",
" if (!response.ok) return [];",
" const shard = await response.json();",
" // layer: collection, data-directory, platform or emulator. owner: which one.",
" // layer: collection, archive-member, retired, data-directory, platform, emulator.",
" return shard.items.filter((item) => item.value === sha1);",
"}",
"```",
@@ -3350,8 +3359,8 @@ def setup_table(coverages: dict, registry: dict | None = None) -> list[str]:
lines += ["No ZIP: the setup is served by the command alone. "
f"[Why?](../{NO_ZIP_FAQ})", ""]
if archived:
lines += ["An archived setup keeps its page and the command. Its list is no longer "
"read again on a schedule.", ""]
lines += ["An archived setup keeps its page and the command. Checks that cover "
"every setup include it only on request.", ""]
return lines
@@ -4187,7 +4196,11 @@ def _card_route(name: str, display: str, guide: Guide) -> list[str]:
lead = ("No network here? Run the command on a computer that has one, with a USB "
f"drive's folder after `--dest`. Then copy it into {_folder_words(display, extract_to)}.")
return [lead, "", *terminal_tabs(tabs, args, steps=False)]
lead = ("No network? Put the device's card in your computer. After `--dest`, "
# A folder on the card's Linux partition: Windows and macOS show the
# small boot partition alone, and no folder there fits --dest.
put = ("Put the device's card in your computer." if "windows" in tabs
else "Only a Linux computer opens the card.")
lead = (f"No network? {put} After `--dest`, "
f"name the folder the device calls `{extract_to}`.")
return [lead, "", *terminal_tabs(tabs, args, steps=False),
f"[More on SD cards](../{CARD_ROUTE_FAQ})", ""]
@@ -4729,7 +4742,9 @@ def generate_files_page() -> str:
"A file's card lives on its manufacturer's page, and a file refiled "
"between manufacturers moves with it. `files/?sha1=<sha1>` is the address "
"that does not move: it opens the card wherever it is now. The link icon "
"beside each card's name is that address.",
"beside each card's name is that address. A file that left the "
"collection keeps it: the page says when, and opens the card of the "
"file in its place.",
"",
"## The index behind it",
"",
@@ -4763,6 +4778,33 @@ def data_directory_revisions(data_dir: Path = _REPO_ROOT / "data") -> dict[str,
return json.loads(path.read_text(encoding="utf-8"))
_GIT_COMMIT = re.compile(r"[0-9a-f]{40}")
_GITHUB_REPO = re.compile(r"https://github\.com/([^/]+/[^/]+)/")
def data_directory_origin(entry: dict, fetched: dict) -> list[str]:
"""Where a data directory is fetched from, and the commit the packs hold.
The URL is written with the `{version}` refresh_data_dirs substitutes:
printed raw it led to a 404. A zip source records the server's ETag,
which names no revision anyone can fetch, so only a git commit is shown.
"""
lines = []
url = str(entry.get("source_url") or "")
if url:
url = url.replace("{version}", str(entry.get("version", "master")))
lines.append(f"Fetched from <{url}>")
sha = str(fetched.get("sha", "")).strip('"')
if _GIT_COMMIT.fullmatch(sha):
repo = _GITHUB_REPO.match(url)
commit = f"[{_code(sha[:12])}](https://github.com/{repo.group(1)}/commit/{sha})" \
if repo else _code(sha)
branch = str(fetched.get("version", ""))
lines.append(f"Revision: {commit}" + (
f", the head of `{branch}` when fetched" if branch and branch != sha else ""))
return lines
def page_data_directories(name: str, profiles: dict) -> list[dict]:
"""The data directories an emulator page lists.
@@ -4892,6 +4934,64 @@ def _data_member_digests(
return found
def retired_records(retired: dict[str, dict], files: dict, locations: list[dict]) -> list[dict]:
"""A record for each hash of a file that left the collection.
Its address (files/?sha1=, #sha1-<12>) was published and answered that
no file the site knows has the hash, inviting a contribution of a file
the collection held. The record says when it left, and leads to the
card of the file holding its place, followed to the one held today.
"""
cards = {item["sha1"]: item["url"] for item in locations}
records = []
for sha1, record in sorted(retired.items()):
if sha1 in files:
continue
successor, seen = record.get("successor"), {sha1}
while successor and successor not in files and successor not in seen:
seen.add(successor)
successor = (retired.get(successor) or {}).get("successor")
url = cards.get(successor) if successor in files else None
extra = {"left": str(record["left"])}
if url:
extra["successor"] = successor
else:
# The page its card was on: the reader lands among its neighbours.
url = _site_path(f"systems/{card_page(str(record.get('path', '')), sha1)}.md")
for algorithm in HASH_ALGORITHMS:
value = sha1 if algorithm == "sha1" else record.get(algorithm)
for one in _hash_values(value):
records.append({"value": one, "algorithm": algorithm, "layer": "retired",
"name": str(record.get("name", "")), "sha1": sha1,
"url": url, **extra})
return records
def _archive_member_records(entry: dict, sha1: str, url: str,
root: Path = _REPO_ROOT) -> list[dict]:
"""The CRC32 of each member of a collection archive, read from its
central directory.
A pasted CRC of a ROM the collection holds only inside a set (logica2.u2
in a500.zip, 8484f426) found nothing: 26,014 of 33,403 members were in
no record. The record leads to the archive's card and names it.
"""
path = root / str(entry.get("path", ""))
if path.suffix.lower() != ".zip" or not path.is_file():
return []
try:
with zipfile.ZipFile(path) as archive:
members = [(info.filename, info.CRC) for info in archive.infolist()
if not info.is_dir()]
except (OSError, zipfile.BadZipFile) as exc:
print(f" WARNING: {path}: members unreadable ({exc})", file=sys.stderr)
return []
return [{"value": f"{crc:08x}", "algorithm": "crc32", "layer": "archive-member",
"name": member.rsplit("/", 1)[-1], "archive": str(entry.get("name", "")),
"sha1": sha1, "url": url}
for member, crc in members]
def file_lookup_records(
db: dict,
coverages: dict,
@@ -4899,6 +4999,7 @@ def file_lookup_records(
locations: list[dict],
members: dict | None = None,
consumers: FileConsumers | None = None,
retired: dict | None = None,
) -> list[dict]:
"""Every hash the site knows, one record per value, for files/?hash=.
@@ -4936,6 +5037,7 @@ def file_lookup_records(
"name": str(entry.get("name", "")), "sha1": sha1, "url": item["url"],
**({"system_name": held_by} if held_by else {}),
})
records += _archive_member_records(entry, sha1, item["url"])
for key, coverage in sorted(coverages.items()):
for system_id, system in sorted((coverage["config"].get("systems") or {}).items()):
url = _site_path(f"platforms/{key}.md#{system_id.replace(' ', '-')}")
@@ -4975,6 +5077,7 @@ def file_lookup_records(
"owner": key, "name": name, "url": url,
**({"system_name": entry_label} if entry_label else {}),
})
records += retired_records(retired or {}, files, locations)
records += _held_under_every_hash(records, files, db.get("indexes") or {})
for record in records:
if record["layer"] in ("platform", "emulator"):
@@ -5999,7 +6102,9 @@ def _system_file_card(
name = f["name"]
sha1_full = f.get("sha1", "unknown")
md5_full = f.get("md5", "unknown")
size = _fmt_size(f.get("size", 0))
# The size a card names is the file's own, exact: rounded, 44,348,015
# and 44,356,548 bytes were one 42.3 MB.
size = _card_size(int(f.get("size", 0)))
# Who reads this file, not who reads a file of this name
plats = sorted(consumers.platforms.get(sha1_full, ()))
emus = sorted(consumers.emulators.get(sha1_full, ()))
@@ -6254,7 +6359,7 @@ def search_excluded(lines: list[str]) -> list[str]:
SEARCH_SKIPPED = (
"- SHA1:", "- MD5:", "- CRC32:", "- SHA256:", "- Adler32", "- Size:",
"- Validation", "- Priority:", "- Contents (",
"- Platforms:", "- Also installed by:", "- Source:", "- Asked for by:",
"- Platforms:", "- Source:", "- Asked for by:",
"- Collection path:", "- Collection copy:", "- Collection copies:",
"- Installed by the platform's own package on:",
"- Not in the platform's package", "- Never opened on these platforms",
@@ -6505,9 +6610,10 @@ def _profiles_on_disk() -> dict[str, dict]:
def _entry_systems(emulator: str, entry: dict) -> list[str]:
"""The system an entry is read for: its own, or its profile's only one."""
if entry.get("system"):
return [str(entry["system"])]
"""The systems an entry is read for: its own `system:` or `systems:`
(a MAME device set several systems load), else its profile's only one."""
if entry.get("system") or entry.get("systems"):
return entry_system_ids(entry, {})
systems = (_profiles_on_disk().get(emulator) or {}).get("systems") or []
return [str(systems[0])] if len(systems) == 1 else []
@@ -7719,20 +7825,17 @@ def _distinct_spellings(names: list) -> list[str]:
return list(seen.values())
# Region slugs whose words are not the slug title-cased.
_REGION_WORDS = {
"uk": "UK",
"asia-ntsc": "Asia (NTSC)",
"asia-pal": "Asia (PAL)",
"world": "every region",
}
# Region slugs whose words are not the slug title-cased, beyond region.LABELS.
_REGION_WORDS = {"world": "every region"}
def _region_words(value: object, most: int = 0) -> str:
"""A region: value in words. ``most`` caps the regions named, the rest
counted: one Saturn BIOS serves seven."""
raw = value if isinstance(value, list) else [value] if value else []
words = [_REGION_WORDS.get(str(r), str(r).replace("-", " ").title()) for r in raw]
from region import label # noqa: PLC0415
words = [_REGION_WORDS.get(str(r)) or label(str(r)) for r in raw]
if most and len(words) > most:
words = words[:most] + [f"{len(words) - most} more"]
return _said_as_list(words)
@@ -8015,6 +8118,38 @@ def _on_mismatch_lines(value: object) -> list[str]:
return []
def emulator_setups(coverages: dict, profiles: dict) -> dict[str, list[str]]:
"""Each emulator with the platforms that run it, by display name."""
setups: dict[str, list[str]] = {}
for platform, cov in coverages.items():
for emulator in resolve_platform_cores(cov["config"], profiles):
setups.setdefault(emulator, []).append(platform)
return {
emulator: sorted(names, key=lambda p: _platform_display(p).casefold())
for emulator, names in setups.items()
}
def _validation_names(validation: object) -> set[str]:
"""The checks a `validation:` names, a list or one list per build."""
if isinstance(validation, dict):
return {str(v) for checks in validation.values() for v in checks or []}
return {str(v) for v in validation or []}
def _card_size(size: int) -> str:
"""A file's size, exact: in whole MB or KB when it is one, else in B."""
for unit, scale in (("MB", 1024 * 1024), ("KB", 1024)):
if size >= scale and size % scale == 0:
return f"{size // scale:,} {unit}"
return f"{size:,} B"
def _exact_size(size: int) -> str:
"""A rounded size with its bytes, where the bytes are not the same figure."""
return _fmt_size(size) if size < 1024 else f"{_fmt_size(size)} ({size:,} bytes)"
def _build_rows(profile: dict) -> list[str]:
"""Meta card rows for who ships the build, how its files are found, why
its revision is held and where its releases are announced."""
@@ -8148,25 +8283,28 @@ def _render_emulator_file(
details.append(f"Found by: {found}")
if fsystem:
details.append(f"System: {_named_system_link(fsystem, '../')}")
# Where the code compares the size, the exact bytes: raze tells three
# DUKE3D.GRP apart by size, all three shown as 42.3 MB.
sized = _exact_size if "size" in _validation_names(validation) else _fmt_size
if size:
if isinstance(size, list):
size_str = " / ".join(_fmt_size(s) for s in size)
size_str = " / ".join(sized(s) for s in size)
else:
size_str = _fmt_size(size)
size_str = sized(size)
if fmin or fmax:
bounds = []
if fmin:
bounds.append(f"min {_fmt_size(fmin)}")
bounds.append(f"min {sized(fmin)}")
if fmax:
bounds.append(f"max {_fmt_size(fmax)}")
bounds.append(f"max {sized(fmax)}")
size_str += f" ({', '.join(bounds)})"
details.append(f"Size: {size_str}")
elif fmin or fmax:
bounds = []
if fmin:
bounds.append(f"min {_fmt_size(fmin)}")
bounds.append(f"min {sized(fmin)}")
if fmax:
bounds.append(f"max {_fmt_size(fmax)}")
bounds.append(f"max {sized(fmax)}")
details.append(f"Size: {', '.join(bounds)}")
# Starlight folds the hashes and gives each a copy button.
copyable = markup.starlight()
@@ -9040,6 +9178,21 @@ def _install_args(platform: str, system: str, shell: str) -> str:
return f"--platform {platform} --system {quoted}"
def _default_setup(declared: list[tuple[str, str]]) -> tuple[str, str]:
"""The setup an install box shows before the reader picks one.
The first in alphabetical order was AmberELEC on 17 system pages, a
handheld system under a Windows tab. The box shows the setup whose
command runs under the most of its tabs (the guide's `run`), then on the
most systems overall, then one the release publishes a ZIP for.
"""
def reach(pair: tuple[str, str]) -> tuple[int, int, bool]:
runs = GUIDES[pair[0]].run if pair[0] in GUIDES else ()
return (len(set(runs) & set(INSTALL_PICKER_OSES)), len(runs), has_release_pack(pair[0]))
return max(declared, key=reach)
def _install_picker(declared: list[tuple[str, str]]) -> list[str]:
"""One command, for the setup the reader picks, under the OS tabs.
@@ -9054,15 +9207,16 @@ def _install_picker(declared: list[tuple[str, str]]) -> list[str]:
return html.escape(text, quote=False).replace('"', "&quot;")
names = Counter(platform for platform, _system in declared)
lead = _default_setup(declared)
options = "".join(
f'<option value="{attribute(platform)}" '
f'data-sh="{attribute(_install_args(platform, system, "sh"))}" '
f'<option value="{attribute(platform)}"'
+ (" selected" if (platform, system) == lead else "")
+ f' data-sh="{attribute(_install_args(platform, system, "sh"))}" '
f'data-ps="{attribute(_install_args(platform, system, "powershell"))}">'
+ html.escape(_platform_display(platform) + (f" ({system})" if names[platform] > 1 else ""),
quote=False)
+ "</option>"
for platform, system in declared)
lead = declared[0]
args = {key: _install_args(*lead, TERMINALS[key][2]) for key in INSTALL_PICKER_OSES}
return [
"Each platform names the system its own way.",
@@ -9379,6 +9533,8 @@ def _said_as_list(items: list[str]) -> str:
# Data directory members and revisions, read once per build.
_DATA_MEMBERS: dict[str, dict[str, dict]] = {}
_DATA_REVISIONS: dict[str, dict] = {}
# Emulator -> the platforms that run it, as resolve_platform_cores answers.
_EMULATOR_SETUPS: dict[str, list[str]] = {}
# The measurement index items of each emulator, for the proof lines of its page.
_MEASUREMENT_ITEMS: dict[str, list[dict]] = {}
# The emulator pages that list each data directory, by ref.
@@ -9974,6 +10130,15 @@ def emulator_pages(
if revision:
lines.append(f"| Revision | {revision} |")
lines.extend(_build_rows(profile))
setups = _EMULATOR_SETUPS.get(name)
if setups:
# The file cards name the setups whose own list holds a file: the
# ones that run the emulator and pack its files were said nowhere.
# Said once here: a line on each card put MAME's parts and four
# other pages over their element budget. Out of the search: setup
# names on 530 pages ranked the data page first for "batocera folder".
lines.append(f"| Run by | <span {markup.search_exclude()}>" + ", ".join(
f"[{_platform_display(p)}](../platforms/{p}.md)" for p in setups) + "</span> |")
lines.append(f"| Profiled | {profiled} |")
# The YAML is the reusable document this site renders, and the JSON is the
# same profile for a program.
@@ -10078,15 +10243,8 @@ def emulator_pages(
if entry.get("description"):
line += f": {entry['description']}"
lines.append(line)
if entry.get("source_url"):
lines.append(f" - Fetched from <{entry['source_url']}>")
fetched = _DATA_REVISIONS.get(ref) or {}
if fetched.get("version") or fetched.get("sha"):
# The URL serves whatever is current: the revision says
# which content the packs were built from.
revision = str(fetched.get("sha", "")).strip('"')
lines.append(f" - Revision: {fetched.get('version', '')}"
+ (f" ({_code(revision)})" if revision else ""))
lines.extend(f" - {line}" for line in data_directory_origin(
entry, _DATA_REVISIONS.get(ref) or {}))
if dd.get("mode"):
lines.append(f" - Read by the {dd['mode']} build only")
if dd.get("bundled"):
@@ -12311,6 +12469,7 @@ def _generate(args: argparse.Namespace) -> None:
PAGE_DATA[f"platforms/{name}.md"] = platform_structured_data(name, cov)
print("Generating emulator pages...")
_EMULATOR_SETUPS.update(emulator_setups(coverages, profiles))
_DATA_MEMBERS.update(data_directory_members())
_DATA_REVISIONS.update(data_directory_revisions())
for key in sorted(profiles):
+4 -4
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""List available platforms for CI matrix strategy.
"""List the registered platforms.
Respects the `status` field in _registry.yml:
- active: included in CI releases and automated scraping
- archived: excluded from CI, user can generate manually
- active: listed by default
- archived: listed with --include-archived only
Usage:
python scripts/list_platforms.py # Active platforms (for CI)
python scripts/list_platforms.py # active platforms
python scripts/list_platforms.py --include-archived # archived ones too
python scripts/list_platforms.py >> "$GITHUB_OUTPUT"
"""
+2 -3
View File
@@ -5,6 +5,7 @@ goes, and which ones the user has to rename."""
from __future__ import annotations
from region import label as region_label
from siteguides import INSTALL_PS1, INSTALL_SH, pack_manual
def _detect_systems(detect: list[dict]) -> list[str]:
@@ -290,9 +291,7 @@ def _build_readme(
region_info = ""
if regions:
pretty = ", ".join(
" ".join(w.title() for w in slug.split("-")) for slug in regions
)
pretty = ", ".join(region_label(slug) for slug in regions)
region_info = (
"PACK TYPE: Region Filtered\n\n"
f" Region priority: {pretty}\n\n"
+13
View File
@@ -107,6 +107,19 @@ ALIASES: dict[str, str] = {
}
# Slugs a person does not write title-cased: "Asia Ntsc" read as machine text.
LABELS: dict[str, str] = {
"uk": "UK",
"asia-ntsc": "Asia (NTSC)",
"asia-pal": "Asia (PAL)",
}
def label(slug: str) -> str:
"""A region slug in words: north-america as North America."""
return LABELS.get(slug) or " ".join(word.title() for word in slug.split("-"))
def unknown_region_message(raw: str, aliases: dict[str, str], regions: frozenset[str]) -> str:
"""What to type instead: the closest spellings, then every short form.
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Files that left the collection, and the file that took their place.
A file's content address (files/?sha1=, #sha1-<12> on its card) is
published, and a published address never stops answering. When a rebuild
of database.json drops a sha1, the file it named is recorded here: its
name, its last path, its hashes, the day it left and the sha1 the
collection now holds at that path, or under that name in that folder.
The site's lookup reads the record and answers with it.
The record is append-only, written by generate_db.py beside the database.
A sha1 that comes back leaves it. `--backfill` rebuilds it from the
history of database.json since content addresses were published.
Usage:
python scripts/retired_files.py --backfill
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from common import run_cli, write_if_changed # noqa: E402
REPO_ROOT = Path(__file__).resolve().parents[1]
RETIRED_FILE = REPO_ROOT / "retired.json"
# The commit that published content addresses: 0d586ef6b, 2026-10-19.
FIRST_ADDRESSED = "0d586ef6b"
KEPT_FIELDS = ("name", "path", "size", "md5", "crc32", "sha256")
def retired_path(database: str | Path) -> Path:
"""The record kept beside a database file."""
return Path(database).with_name("retired.json")
def _successor(entry: dict, by_path: dict[str, str],
by_name: dict[str, list[tuple[str, str]]]) -> str | None:
"""The sha1 the new database holds at the entry's path, else under its
name in the same folder, else the one file of that name: a2diskiing.zip
was rebuilt from its recipe and filed under Apple II the same day."""
path = str(entry.get("path", ""))
if path in by_path:
return by_path[path]
named = by_name.get(str(entry.get("name", "")), [])
folder = path.rsplit("/", 1)[0]
beside = [sha1 for sha1, other in named if other.rsplit("/", 1)[0] == folder]
for found in (beside, [sha1 for sha1, _other in named]):
if len(found) == 1:
return found[0]
return None
def retired_between(old: dict, new: dict, day: str) -> dict[str, dict]:
"""sha1 -> record, for each file of *old* that *new* no longer holds."""
old_files = old.get("files") or {}
new_files = new.get("files") or {}
by_path = {str(e.get("path", "")): sha1 for sha1, e in new_files.items()}
by_name: dict[str, list[tuple[str, str]]] = {}
for sha1, e in new_files.items():
by_name.setdefault(str(e.get("name", "")), []).append((sha1, str(e.get("path", ""))))
records = {}
for sha1 in sorted(set(old_files) - set(new_files)):
entry = old_files[sha1]
record = {key: entry[key] for key in KEPT_FIELDS if entry.get(key) not in (None, "")}
record["left"] = day
successor = _successor(entry, by_path, by_name)
if successor:
record["successor"] = successor
records[sha1] = record
return records
def merge(record: dict[str, dict], left: dict[str, dict], new: dict) -> dict[str, dict]:
"""The record with *left* added, first departure kept, and every sha1
the new database holds again taken out."""
held = set(new.get("files") or {})
merged = {sha1: entry for sha1, entry in record.items() if sha1 not in held}
for sha1, entry in left.items():
merged.setdefault(sha1, entry)
return dict(sorted(merged.items()))
def load(path: Path) -> dict[str, dict]:
if not path.is_file():
return {}
return json.loads(path.read_text(encoding="utf-8")).get("files") or {}
def dump(records: dict[str, dict]) -> str:
return json.dumps({"schema_version": 1, "files": records}, indent=1, sort_keys=True) + "\n"
def update(path: Path, old: dict, new: dict, day: str) -> int:
"""Records what *new* dropped from *old*. Returns how many left now."""
left = retired_between(old, new, day)
record = load(path)
merged = merge(record, left, new)
if merged != record:
write_if_changed(str(path), dump(merged))
return len(set(left) - set(record))
def _git(*args: str) -> str:
return subprocess.run(["git", *args], cwd=REPO_ROOT, capture_output=True, text=True,
check=True).stdout
def backfill() -> dict[str, dict]:
"""Every sha1 that left database.json in the commits since content
addresses were published, with the day of the commit that dropped it."""
revisions = [line.split() for line in _git(
"log", "--reverse", "--format=%H %cs", f"{FIRST_ADDRESSED}^..HEAD", "--", "database.json",
).splitlines() if line.strip()]
records: dict[str, dict] = {}
previous: dict | None = None
for commit, day in revisions:
current = json.loads(_git("show", f"{commit}:database.json"))
if previous is not None:
records = merge(records, retired_between(previous, current, day), current)
previous = current
return records
def main() -> int:
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__.split("\n\n")[0])
parser.add_argument("--backfill", action="store_true",
help="rebuild the record from the history of database.json")
parser.add_argument("--output", default=str(RETIRED_FILE),
help="the record to write (default: retired.json)")
args = parser.parse_args()
if not args.backfill:
parser.print_help()
return 2
records = backfill()
current = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8"))
records = merge(records, {}, current)
write_if_changed(args.output, dump(records))
print(f"{len(records)} files left the collection since {FIRST_ADDRESSED}")
return 0
if __name__ == "__main__":
run_cli(main)
+5 -3
View File
@@ -372,19 +372,21 @@ def _in_arcade(sha1: str, db: dict) -> bool:
return sha1 in _arcade_suffix_sha1s(db)
_suffix_cache: list[tuple[int, frozenset[str]] | None] = [None]
_suffix_cache: list[tuple[dict, frozenset[str]] | None] = [None]
def _arcade_suffix_sha1s(db: dict) -> frozenset[str]:
# Holding the database itself, never id(db): a database freed and
# replaced at the same address answered with the first one's set.
cached = _suffix_cache[0]
if cached is not None and cached[0] == id(db):
if cached is not None and cached[0] is db:
return cached[1]
found = frozenset(
sha1s[0]
for tail, sha1s in db.get("indexes", {}).get("by_path_suffix", {}).items()
if tail.startswith("Arcade/") and len(sha1s) == 1
)
_suffix_cache[0] = (id(db), found)
_suffix_cache[0] = (db, found)
return found
+8 -25
View File
@@ -7,11 +7,14 @@ module existed nothing compared the two: the builder resolves the platform
entry first and drops the profile entry on a bare filename match, so a profile
that names the right file for a slot could never win and never even be heard.
The index is keyed by destination AND by name, the name carrying the union of
what every same-named entry claims. Dolphin declares three IPL.bin separated
only by their path, so a name-only key merges them, and a path-only key answers
nothing when a candidate is known by name alone. Ambiguity resolves to the
union and is never discarded, the same rule region.py already applies.
Claims are keyed by destination, folded to one case only where the platform's
filesystem folds it. Dolphin declares three IPL.bin separated only by their
path, and each is its own slot. A profile claim resting on a bare filename
proves nothing about content and contests no slot.
The pack builder, the install manifest and verify read slot_choices() through
pack_overrides(). arbitrate() states the same decision for one conflict and
feeds the report this module prints.
"""
from __future__ import annotations
@@ -126,26 +129,6 @@ def _normalize(destination: str, fold: bool = True) -> str:
return cleaned.casefold() if fold else cleaned
def build_claim_index(claims: list[Claim]) -> dict[str, list[Claim]]:
"""Index claims by destination and by name, the name carrying the union.
A lookup by full destination answers for one slot. A lookup by bare name
answers with every slot that shares it, which is what a candidate known
only by its filename needs in order not to be dropped.
"""
index: dict[str, list[Claim]] = {}
for claim in claims:
keys = {_normalize(claim.destination)}
if claim.name:
keys.add(_normalize(claim.name))
base = claim.destination.rsplit("/", 1)[-1]
if base:
keys.add(_normalize(base))
for key in keys - {""}:
index.setdefault(key, []).append(claim)
return index
def platform_claims(
config: dict,
db: dict,
+28 -6
View File
@@ -24,6 +24,7 @@ import re
import shutil
import subprocess
import sys
import urllib.parse
from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__))
@@ -73,6 +74,22 @@ LARGE_FILE_ISSUE_URL = (
)
def large_file_issue_url(result: ValidationResult) -> str:
"""The issue form with the values this run computed already filled in.
GitHub fills an issue form field from the query parameter named by its
id: filename, sha1, md5 and size in missing-bios.yml.
"""
fields = {
"title": f"[BIOS] Missing: {result.filename}",
"filename": result.filename,
"sha1": result.sha1,
"md5": result.md5,
"size": str(result.size),
}
return f"{LARGE_FILE_ISSUE_URL}&{urllib.parse.urlencode(fields)}"
class ValidationResult:
def __init__(self, filepath: str) -> None:
self.filepath = filepath
@@ -359,14 +376,15 @@ def validate_file(
if ext in BLOCKED_EXTENSIONS:
result.add_check(False, f"Blocked file extension: {ext}")
if result.size > MAX_FILE_SIZE:
too_large = result.size > MAX_FILE_SIZE
if too_large:
# GitHub refuses a pushed file over 100 MiB, so a pull request cannot
# carry it. Only a maintainer can add an asset to the release.
result.add_check(
False,
f"File too large for git ({result.size:,} > {MAX_FILE_SIZE:,} bytes). "
"Leave it out of the pull request and open an issue "
f"({LARGE_FILE_ISSUE_URL}) with the hashes above and where the file "
"Leave it out of the pull request and open an issue, its hashes "
f"filled in ({large_file_issue_url(result)}), saying where the file "
"can be obtained: a maintainer adds it to the `large-files` release.",
)
elif result.size == 0:
@@ -450,8 +468,8 @@ def validate_file(
result.outcome = "unknown"
result.add_warning(
"Not recognised: no platform list or emulator profile declares this "
"file. Say in the pull request which emulator reads it, for which "
"system, and where the dump comes from"
f"file. Say in the {'issue' if too_large else 'pull request'} which "
"emulator reads it, for which system, and where the dump comes from"
)
normalized = os.path.normpath(relative)
@@ -467,7 +485,11 @@ def validate_file(
set(),
)
expected_folders = sorted(expected)
if normalized.startswith("bios" + os.sep):
if too_large:
# The file stays out of the pull request, so no folder of bios/ is
# the contributor's to choose: the release asset is placed by hand.
pass
elif normalized.startswith("bios" + os.sep):
parts = normalized.split(os.sep)
here = f"bios/{parts[1]}/{parts[2]}/" if len(parts) >= 4 else ""
makers = {folder.split("/")[1] for folder in expected}
+7 -1
View File
@@ -6330,7 +6330,13 @@ class DocsDescribeTheRegistriesTheyName(unittest.TestCase):
from dedup import NODEDUP_DIRS
tools = (ROOT / "wiki" / "tools.md").read_text(encoding="utf-8")
line = next(l for l in tools.splitlines() if "NODEDUP_DIRS" in l)
self.assertEqual(sorted(d for d in NODEDUP_DIRS if d not in line), [])
# Compared as list items: `mame` was missing while the substring
# test found it in `_mame_clones.json` on the same line.
listed = line.split("`NODEDUP_DIRS`", 1)[1].split(":", 1)[1].rstrip(" |")
self.assertEqual(
sorted(item.strip() for item in listed.split(",")),
sorted(NODEDUP_DIRS),
)
readme_gen = (ROOT / "scripts" / "generate_readme.py").read_text(encoding="utf-8")
self.assertIn("NODEDUP_DIRS", readme_gen)
+38
View File
@@ -8,6 +8,7 @@ report is folded, since each of those decides whether a row says STALE.
from __future__ import annotations
import json
import os
import sys
import tempfile
@@ -353,6 +354,43 @@ class PinParsingTests(unittest.TestCase):
self.assertTrue(cf.specifier_allows(">=10.14", "12.0.1"))
class SiteNpmPins(unittest.TestCase):
"""The site's npm dependencies are pinned to exact versions in
website/package.json, and the ci area compared only the PyPI and action
pins: astro 7.4.2 stayed behind 7.4.7 with no STALE row."""
def test_both_dependency_kinds_are_read(self):
pins = cf.parse_npm_pins(
'{"dependencies": {"astro": "7.4.2"}, "devDependencies": {"x": "^1.0.0"}}'
)
self.assertEqual(pins, {"astro": "7.4.2", "x": "^1.0.0"})
def test_a_newer_release_is_stale(self):
with tempfile.TemporaryDirectory() as tmp:
package = Path(tmp) / "package.json"
package.write_text(json.dumps({"dependencies": {
"astro": "7.4.2", "@astrojs/starlight": "0.42.9", "y": "^2.0.0",
}}))
latest = {"astro": "7.4.7", "@astrojs/starlight": "0.42.9"}
def answer(url):
name = url.split("registry.npmjs.org/", 1)[1].rsplit("/latest", 1)[0]
return {"version": latest[name]}
with mock.patch.object(cf, "SITE_PACKAGE", package), \
mock.patch.object(cf.upstream, "_http_json", side_effect=answer):
rows = {f.subject: f for f in cf._npm_findings()}
self.assertEqual(rows["npm:astro"].status, cf.STALE)
self.assertEqual(rows["npm:astro"].remote, "7.4.7")
self.assertEqual(rows["npm:@astrojs/starlight"].status, cf.OK)
self.assertEqual(rows["npm:y"].status, cf.UNKNOWN)
def test_the_ci_area_reads_the_site_pins(self):
with mock.patch.object(cf, "_npm_findings", return_value=[]) as npm, \
mock.patch.object(cf, "parse_pip_pins", return_value={}), \
mock.patch.object(cf, "parse_action_pins", return_value={}):
cf.check_ci("unused", offline=False)
npm.assert_called_once()
class RomFolderPins(unittest.TestCase):
"""The rom folders of a setup were pinned by hand and nothing compared
the pins with the platform file or the project's releases."""
+2 -1
View File
@@ -75,7 +75,8 @@ class BeforeOpeningAPullRequest(unittest.TestCase):
# CI holds added profiles to their upstream, not edited ones: --changed
# failed a contributor on refs they never wrote.
self.assertIn("profile_sync.py --added --base", workflow)
self.assertIn("profile_sync.py --added --base upstream/main --strict", block)
self.assertIn("profile_sync.py --added --strict", block)
self.assertNotIn("upstream/main", section)
self.assertNotIn("--changed --strict", block)
def test_it_names_the_site_build_and_the_packages_ci_runs(self):
+90
View File
@@ -1738,6 +1738,12 @@ class ArchitectureStatesTheArbitration(unittest.TestCase):
section = page[page.index("### When both layers claim one destination"):]
section = section[:section.index("\n## ")]
self.assertIn("`slots.arbitrate()`", section)
# The pack, the manifest and verify decide through slot_choices, and
# a test in test_slots forbids them to call arbitrate: the page names
# the function they read, not only the one the report prints.
self.assertIn("`slots.slot_choices()`", section)
self.assertIn("`slots.pack_overrides()`", section)
self.assertNotIn("both decide through `slots.arbitrate()`", section)
source = inspect.getsource(slots.arbitrate)
# The three outcomes the section describes.
for outcome in ("ADDRESSEE", "SERVES_BOTH", "FRONTEND_CHECKS_CONTENT"):
@@ -1894,5 +1900,89 @@ class SkipFlagsNameTheSteps(unittest.TestCase):
self.assertEqual(set(re.findall(self.STEP, text)), steps)
class TheInstallerSamplesTravelWithTheManifests(unittest.TestCase):
"""A manifest refresh (c624a820f) went out without the run samples of
wiki/installer.md, which count what the manifests hold, and CI turned
red. The rule lived in no page, and the nightly's commit rules had no
place for the samples the gate's pipeline rewrites."""
def test_the_pages_and_the_nightly_carry_them(self):
import autorun # noqa: PLC0415
guide = (WIKI / "testing-guide.md").read_text(encoding="utf-8")
for name in ("wiki/installer.md", "derivable.json", "generate_installer_doc.py"):
self.assertIn(name, guide)
tools = (WIKI / "tools.md").read_text(encoding="utf-8")
manifests = tools.index("--all --manifest --output-dir install/")
self.assertIn("generate_installer_doc.py", tools[manifests:manifests + 300])
nightly = autorun.load_jobs()["nightly"]
patterns = [p for rule in nightly.commits for p in rule.paths]
for path in ("wiki/installer.md", "derivable.json", "install/retroarch.json"):
with self.subTest(path=path):
self.assertTrue(any(autorun.path_matches(p, path) for p in patterns))
class TheRemovalProofsAreTheExportersOwn(unittest.TestCase):
"""tools.md listed the proofs that keep an entry out of
removal_candidates without the platform's launcher records, which
candidates.py checks right after a path."""
WORDS = {"path": "`path:`", "launcher": "`launchers:`", "name": "by name or alias",
"hash": "by any hash", "directory": "`type: directory`",
"data_directory": "a data directory"}
def test_the_page_names_each_proof_in_order(self):
from exporter.candidates import _PROOF_ORDER # noqa: PLC0415
page = (WIKI / "tools.md").read_text(encoding="utf-8")
start = page.index("No entry the platform declares is removed.")
section = page[start:page.index("removal_candidates", start)]
self.assertEqual(set(self.WORDS), set(_PROOF_ORDER))
positions = [section.index(self.WORDS[kind]) for kind in _PROOF_ORDER]
self.assertEqual(positions, sorted(positions))
class APlatformListIsDescribedNotJudged(unittest.TestCase):
"""adding-a-platform opened on files a platform's own list "leaves out",
read first by the maintainer of that platform: "does not name" says the
same without imputing an omission."""
def test_no_page_says_a_list_leaves_files_out(self):
pattern = re.compile(r"\blists? (?:leaves|left|leave) out\b")
for page in sorted(WIKI.glob("*.md")):
with self.subTest(page=page.name):
self.assertIsNone(pattern.search(page.read_text(encoding="utf-8")))
class ArchivedIsWhatTheToolsDo(unittest.TestCase):
"""The README and the setup pages said an archived platform is no longer
scraped on a schedule, and adding-a-platform said `active` feeds a CI
matrix and the scrapers. No workflow calls list_platforms.py and no tool
reads `schedule:`: what `archived` changes is which full runs include it."""
def test_no_text_ties_the_status_to_automation(self):
import generate_pack # noqa: PLC0415
import generate_readme # noqa: PLC0415
import generate_site # noqa: PLC0415
import list_platforms # noqa: PLC0415
texts = {
"adding-a-platform.md": (WIKI / "adding-a-platform.md").read_text(encoding="utf-8"),
"generate_readme": inspect.getsource(generate_readme),
"generate_site": inspect.getsource(generate_site),
"generate_pack": inspect.getsource(generate_pack),
"list_platforms": inspect.getsource(list_platforms),
"_guides.yml": (ROOT / "platforms" / "_guides.yml").read_text(encoding="utf-8"),
}
for name, text in texts.items():
with self.subTest(name):
for claim in ("scraped on a schedule", "read again on a schedule",
"CI matrix", "without the automation"):
self.assertNotIn(claim, text)
self.assertIn("`--include-archived`", texts["adding-a-platform.md"])
for workflow in (ROOT / ".github" / "workflows").glob("*.yml"):
self.assertNotIn("list_platforms", workflow.read_text(encoding="utf-8"))
if __name__ == "__main__":
unittest.main()
+15
View File
@@ -248,6 +248,21 @@ class SlicesLeaveWithTheirRelease(unittest.TestCase):
self.assertNotIn(".zip.001", _which_pack())
class TheReadmeLeadsBeginnersToTheirPages(unittest.TestCase):
"""The README linked the installer reference and the FAQ's no-ZIP anchor
only: a user stopped by an install error or a BIOS-not-found message had
no link to the first install or troubleshooting page."""
def test_quick_install_links_the_start_pages(self):
# The code block holds `#` comment lines, so the section runs to
# the next level-two heading.
section = _read("README.md").split("## Quick install", 1)[1].split("\n## ", 1)[0]
for page in ("first-install", "troubleshooting", "faq"):
with self.subTest(page=page):
self.assertIn(f"wiki/{page}/)", section)
self.assertTrue((REPO_ROOT / "wiki" / f"{page}.md").is_file())
class EndUsersCheckTheirOwnFolder(unittest.TestCase):
"""verify.py reads database.json and bios/. Advice about a user's own
files points at the installer's --check, which hashes their folder."""
+5 -1
View File
@@ -6034,7 +6034,11 @@ class TestBranchCheckIsWired(unittest.TestCase):
import generate_readme # noqa: PLC0415
text = generate_readme.generate_contributing()
self.assertIn("profile_sync.py --added --base upstream/main --strict", text)
self.assertIn("profile_sync.py --added --strict", text)
# The default base is fetched by the tool: a documented upstream
# remote failed every clone that had origin alone.
self.assertNotIn("--base upstream/main", text)
self.assertNotIn("remote add upstream", text)
self.assertNotIn("--emulator NAME --strict", text)
workflow = (root / ".github" / "workflows" / "validate.yml").read_text()
self.assertIn("profile_sync.py --added --base", workflow)
+18
View File
@@ -1452,5 +1452,23 @@ class UsaRegionServesTheAmericas(unittest.TestCase):
self.assertEqual(region.rank(jpn, requested), 1)
class RegionWordsInAPackReadme(unittest.TestCase):
"""The pack README title-cased each slug: asia-ntsc read "Asia Ntsc"."""
def test_the_priority_line_reads_as_written(self):
from packreadme import _build_readme # noqa: PLC0415
readme = _build_readme(
"retroarch", "RetroArch", "system", 1, 1,
regions=region.parse_requested("us,asia-ntsc,jp"),
)
self.assertIn("Region priority: North America, Asia (NTSC), Japan", readme)
def test_every_region_has_a_label(self):
for slug in region.REGIONS:
with self.subTest(slug=slug):
self.assertNotRegex(region.label(slug), r"\bNtsc\b|\bPal\b|\bUk\b")
if __name__ == "__main__":
unittest.main()
+103
View File
@@ -0,0 +1,103 @@
"""A file that leaves the collection keeps its published address.
a2diskiing.zip (bd02f6a0) was rebuilt from its recipe on 2026-10-22 and
filed under Apple II as d9bb1897. Its card's address, files/?sha1=bd02f6a0,
answered that no file the site knows has the hash and asked for a
contribution of a file the collection had held.
"""
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import retired_files # noqa: E402
OLD = "bd" * 20
NEW = "d9" * 20
def _db(**files: dict) -> dict:
return {"files": files}
class WhatLeftIsRecorded(unittest.TestCase):
def setUp(self):
self.old = _db(**{
OLD: {"name": "a2diskiing.zip", "path": "bios/Arcade/Arcade/a2diskiing.zip",
"md5": "43" * 16, "crc32": "292ef826", "size": 589},
"11" * 20: {"name": "kept.bin", "path": "bios/A/B/kept.bin"},
})
self.new = _db(**{
NEW: {"name": "a2diskiing.zip", "path": "bios/Apple/Apple II/a2diskiing.zip"},
"11" * 20: {"name": "kept.bin", "path": "bios/A/B/kept.bin"},
})
def test_the_departure_and_the_file_in_its_place(self):
left = retired_files.retired_between(self.old, self.new, "2026-10-22")
self.assertEqual(list(left), [OLD])
self.assertEqual(left[OLD]["left"], "2026-10-22")
self.assertEqual(left[OLD]["successor"], NEW)
self.assertEqual(left[OLD]["md5"], "43" * 16)
def test_the_first_departure_stays_and_a_return_leaves_the_record(self):
first = retired_files.merge({}, retired_files.retired_between(self.old, self.new, "2026-10-22"),
self.new)
again = retired_files.merge(first, {OLD: {"left": "2026-11-01"}}, self.new)
self.assertEqual(again[OLD]["left"], "2026-10-22")
self.assertEqual(retired_files.merge(again, {}, self.old), {})
def test_generate_db_writes_it_beside_the_database(self):
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
database = Path(tmp) / "database.json"
path = retired_files.retired_path(database)
self.assertEqual(path, Path(tmp) / "retired.json")
self.assertEqual(retired_files.update(path, self.old, self.new, "2026-10-22"), 1)
self.assertEqual(retired_files.update(path, self.old, self.new, "2026-10-23"), 0)
record = json.loads(path.read_text())["files"]
self.assertEqual(record[OLD]["left"], "2026-10-22")
source = (REPO_ROOT / "scripts" / "generate_db.py").read_text(encoding="utf-8")
self.assertIn("retired_files.update(", source)
class TheLookupAnswersForIt(unittest.TestCase):
def test_records_lead_to_the_card_in_its_place(self):
import generate_site # noqa: PLC0415
retired = {OLD: {"name": "a2diskiing.zip", "path": "bios/Arcade/Arcade/a2diskiing.zip",
"crc32": "292ef826", "left": "2026-10-22", "successor": "ee" * 20},
"ee" * 20: {"name": "a2diskiing.zip", "path": "bios/Apple/x/a2diskiing.zip",
"left": "2026-10-23", "successor": NEW}}
files = {NEW: {"name": "a2diskiing.zip"}}
locations = [{"sha1": NEW, "url": "/retrobios/systems/apple/#sha1-d9d9d9d9d9d9"}]
records = generate_site.retired_records(retired, files, locations)
found = {(r["sha1"], r["algorithm"]): r for r in records}
# Followed to the file held today, through the copy that left after.
self.assertEqual(found[(OLD, "sha1")]["url"], locations[0]["url"])
self.assertEqual(found[(OLD, "sha1")]["successor"], NEW)
self.assertEqual(found[(OLD, "crc32")]["value"], "292ef826")
self.assertEqual(found[(OLD, "sha1")]["layer"], "retired")
self.assertEqual(found[(OLD, "sha1")]["left"], "2026-10-22")
def test_the_committed_record_holds_the_reported_file(self):
record = retired_files.load(REPO_ROOT / "retired.json")
if not record:
self.skipTest("retired.json is not written")
self.assertIn("bd02f6a0ce8accba7ea5415fd22c2e8f23ec4b10", record)
database = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8"))
self.assertFalse(set(record) & set(database["files"]))
def test_the_page_script_answers_with_it(self):
script = (REPO_ROOT / "website" / "src" / "scripts" / "site.ts").read_text(encoding="utf-8")
self.assertIn("item.layer === 'retired'", script)
self.assertIn("left the collection on", script)
if __name__ == "__main__":
unittest.main()
+15 -3
View File
@@ -110,7 +110,7 @@ class ASystemCardShowsTheAnswerFirst(unittest.TestCase):
self.shown = self.text.replace(self.fold, "")
def test_the_open_part(self) -> None:
for fact in ("<code>ppge_atlas.zim</code>", ">650.9 KB<", ">Download</a>",
for fact in ("<code>ppge_atlas.zim</code>", ">666,539 B<", ">Download</a>",
"<dt>On the setup's list</dt>", "<dt>Packed for its emulators by</dt>",
"<dt>Emulators</dt>", f'<dt>SHA1</dt><dd class="rb-hash">{SHA1[:12]}</dd>'):
self.assertIn(fact, self.shown)
@@ -193,11 +193,23 @@ class AnInstallBoxShowsOneCommand(unittest.TestCase):
self.assertEqual(re.findall(r'<TabItem label="([^"]+)"', tabs),
["Windows", "macOS", "Linux", "Steam Deck"])
self.assertEqual(tabs.count("--platform"), 4)
self.assertEqual(tabs.count("--platform emudeck --system sony-psp"), 4)
# RetroArch, which runs on each tab's system, not the first by name.
self.assertEqual(tabs.count("--platform retroarch --system"), 4)
def test_the_default_setup(self) -> None:
"""AmberELEC led 17 system pages, a handheld system under the
Windows tab, for being first in alphabetical order."""
self.assertEqual(gs._default_setup([("amberelec", "psx"), ("retroarch", "Sony - PlayStation")]),
("retroarch", "Sony - PlayStation"))
self.assertEqual(gs._default_setup([("amberelec", "x"), ("batocera", "x")]), ("batocera", "x"))
self.assertEqual(gs._default_setup([("amberelec", "x"), ("knulli", "x")]), ("amberelec", "x"))
select = re.search(r"<select>(.*?)</select>", self.text).group(1)
self.assertEqual(re.findall(r'<option value="([^"]+)" selected', select), ["retroarch"])
def test_the_setup_list_carries_every_command(self) -> None:
select = re.search(r"<select>(.*?)</select>", self.text).group(1)
options = re.findall(r'<option value="([^"]+)" data-sh="([^"]+)" data-ps="([^"]+)">', select)
options = re.findall(r'<option value="([^"]+)"(?: selected)? data-sh="([^"]+)" data-ps="([^"]+)">',
select)
self.assertEqual([value for value, _sh, _ps in options], ["emudeck", "retroarch", "retrobat"])
self.assertIn(("retrobat", "--platform retrobat --system 'Neo Geo'",
"--platform retrobat --system 'Neo Geo'"), options)
+71
View File
@@ -763,5 +763,76 @@ class SmallFixes(unittest.TestCase):
self.assertEqual(said, [])
class AnEmulatorPageNamesItsSetups(unittest.TestCase):
"""emulators/handy named no setup that runs Handy: its lynxboot.img card
names the setups whose own list holds it, not EmuDeck, ROCKNIX and
NextUI, which pack it for the core."""
def test_setups_come_from_the_core_resolution(self):
coverages = {
"b": {"config": {"cores": ["handy"]}},
"a": {"config": {"cores": ["handy", "other"]}},
"c": {"config": {"cores": ["other"]}},
}
profiles = {"handy": {"type": "libretro"}, "other": {"type": "libretro"}}
self.assertEqual(gs.emulator_setups(coverages, profiles)["handy"], ["a", "b"])
def test_the_page_names_them(self):
from tests import sitepages # noqa: PLC0415
sitepages.require()
text = sitepages.page("emulators/handy.md").read_text(encoding="utf-8")
row = next(line for line in text.splitlines() if line.startswith("| Run by |"))
for setup in ("emudeck", "rocknix", "nextui", "retroarch"):
self.assertIn(f"/platforms/{setup}/", row)
# Setup names on every emulator page outranked the setup's own page.
self.assertIn("data-pagefind-ignore", row)
class SizesTheCodeComparesAreExact(unittest.TestCase):
"""raze validates DUKE3D.GRP by size, and its page showed 42.3 MB for
44,356,548 and 44,348,015 bytes alike."""
def test_a_validated_size_carries_its_bytes(self):
profile = {"emulator": "x", "type": "libretro", "systems": ["sony-playstation"], "files": [
{"name": "DUKE3D.GRP", "size": 44348015, "validation": ["size", "crc32"]},
{"name": "other.bin", "size": 44356548},
{"name": "built.bin", "size": 2304, "validation": {"core": ["size"]}},
]}
page = _page("x", profile)
self.assertIn("Size: 42.3 MB (44,348,015 bytes)", page)
self.assertIn("Size: 2.2 KB (2,304 bytes)", page)
self.assertNotIn("44,356,548", page)
class ADataDirectoryNamesAFetchableOrigin(unittest.TestCase):
"""pcee2's page linked github.com/PCSX2/pcsx2/archive/{version}.tar.gz,
a 404, and buildbot zips showed `Revision: master (2047720058)`: a branch
that means nothing for a buildbot URL and the server's ETag."""
def test_the_url_is_substituted_and_a_commit_is_linked(self):
entry = {"source_url": "https://github.com/PCSX2/pcsx2/archive/{version}.tar.gz",
"version": "6db638dc0373769b4c5d5b5dd7ed6da641b9a18e"}
lines = gs.data_directory_origin(entry, {"sha": entry["version"],
"version": entry["version"]})
self.assertEqual(lines[0], "Fetched from <https://github.com/PCSX2/pcsx2/archive/"
"6db638dc0373769b4c5d5b5dd7ed6da641b9a18e.tar.gz>")
self.assertIn("(https://github.com/PCSX2/pcsx2/commit/6db638dc0373769b4c5d5b5dd7ed6da641b9a18e)",
lines[1])
self.assertNotIn("{version}", " ".join(lines))
def test_an_etag_is_no_revision(self):
entry = {"source_url": "https://buildbot.libretro.com/assets/system/Dolphin.zip"}
lines = gs.data_directory_origin(entry, {"sha": '"2047720058"', "version": "master"})
self.assertEqual(lines, ["Fetched from <https://buildbot.libretro.com/assets/system/Dolphin.zip>"])
def test_every_registered_url_resolves(self):
registry = yaml.safe_load((REPO_ROOT / "platforms" / "_data_dirs.yml").read_text())
for ref, entry in (registry.get("data_directories") or registry).items():
if isinstance(entry, dict):
with self.subTest(ref=ref):
self.assertNotIn("{", " ".join(gs.data_directory_origin(entry, {})))
if __name__ == "__main__":
unittest.main()
+13
View File
@@ -937,6 +937,19 @@ class ARouteWithoutANetwork(unittest.TestCase):
self.assertEqual(_tab_labels(card), set())
self.assertIn("**Linux**", card)
def test_a_linux_partition_card_says_why_and_offers_linux_alone(self):
"""Lakka's /storage is the card's ext4 partition (Lakka-LibreELEC
scripts/mkimage): its page offered Windows and macOS tabs for a
folder neither can open."""
for name in ("lakka", "retropie"):
with self.subTest(setup=name):
self.assertEqual(siteguides.GUIDES[name].card_run, ("linux",))
card = _plain_page(f"platforms/{name}.md").split("No network?", 1)[1]
card = card.split("## On the device", 1)[0]
self.assertEqual(_tab_labels(card), set())
self.assertNotIn("E:\\", card)
self.assertIn("Only a Linux computer opens the card.", card)
def test_no_zip_is_promised_for_an_unmarked_setup(self):
for name in SETUPS:
if REGISTRY[name].get("release_pack") is True:
+47
View File
@@ -785,6 +785,53 @@ class AHashFindsEveryPageThatNamesIt(unittest.TestCase):
self.assertNotIn("no.json", names)
class AnArchiveMemberIsFoundByItsCrc(unittest.TestCase):
"""A pasted CRC of logica2.u2 (8484f426), which the collection holds
inside a500.zip, answered that no file the site knows has it: the
lookup held the members a profile's `contents:` declares, 7,389 of
33,403."""
def test_each_member_is_a_record_leading_to_its_archive(self):
import zipfile # noqa: PLC0415
import generate_site # noqa: PLC0415
with tempfile.TemporaryDirectory(dir=Path(__file__).resolve().parents[1] / "tmp") as tmp:
root = Path(tmp)
archive = root / "bios" / "Arcade" / "Arcade" / "a500.zip"
archive.parent.mkdir(parents=True)
with zipfile.ZipFile(archive, "w") as handle:
handle.writestr("logica2.u2", b"rom")
handle.writestr("sub/kick.rom", b"kick")
entry = {"name": "a500.zip", "path": "bios/Arcade/Arcade/a500.zip"}
records = generate_site._archive_member_records(entry, "ab" * 20, "/retrobios/x/", root)
plain = generate_site._archive_member_records(
{"name": "a.bin", "path": "bios/a.bin"}, "cd" * 20, "/retrobios/y/", root)
self.assertEqual(plain, [])
import zlib # noqa: PLC0415
self.assertEqual(records, [
{"value": f"{zlib.crc32(b'rom'):08x}", "algorithm": "crc32", "layer": "archive-member",
"name": "logica2.u2", "archive": "a500.zip", "sha1": "ab" * 20, "url": "/retrobios/x/"},
{"value": f"{zlib.crc32(b'kick'):08x}", "algorithm": "crc32", "layer": "archive-member",
"name": "kick.rom", "archive": "a500.zip", "sha1": "ab" * 20, "url": "/retrobios/x/"},
])
def test_the_lookup_and_the_schema_carry_the_layer(self):
import json as _json # noqa: PLC0415
import generate_site # noqa: PLC0415
source = Path(generate_site.__file__).read_text(encoding="utf-8")
self.assertIn("records += _archive_member_records(entry, sha1, item[\"url\"])", source)
schema = _json.loads((Path(__file__).resolve().parents[1] / "schemas"
/ "site-api-envelope.schema.json").read_text())
self.assertIn('"archive-member"', _json.dumps(schema))
script = (Path(__file__).resolve().parents[1] / "website" / "src" / "scripts"
/ "site.ts").read_text(encoding="utf-8")
self.assertIn("byLayer('archive-member')", script)
class AFileHasAnAddressByContent(unittest.TestCase):
"""mt32_control.rom moved from the Commodore page to the Roland page and
every link to its card broke: the anchor was content-based, the page
+11 -3
View File
@@ -206,13 +206,21 @@ class FaqInstallsFromWhatYouHold(unittest.TestCase):
import install # noqa: PLC0415
section = " ".join(self._section("Fill an SD card from a computer?").split())
self.assertIn("The interface finds a muOS, Onion, spruceOS, MinUI, KNULLI or ROCKNIX "
"card by itself.", section)
self.assertIn("The interface finds a muOS, Onion, spruceOS, MinUI, KNULLI, ROCKNIX, Lakka "
"or MiSTer card.", section)
known = {rule.name for rule in install._card_rules()}
known |= {install.display_name(name) for name, _marker, _prefix in install._CARD_RULES}
for name in ("muOS", "Onion", "spruceOS", "MinUI", "KNULLI", "ROCKNIX"):
for name in ("muOS", "Onion", "spruceOS", "MinUI", "KNULLI", "ROCKNIX", "MiSTer FPGA"):
with self.subTest(card=name):
self.assertIn(name, known)
# A card told by a rule of its share (MiSTer since 73b82375d) is named
# here and in the README, or its user passes --dest the card's root.
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
for rule in install._SHARE_RULES:
word = rule.name.split()[0]
with self.subTest(share=rule.name):
self.assertIn(word, section)
self.assertRegex(readme, rf"# SD card mounted on a PC: .*\b{word}\b")
# The label the first screen gives a card found: "Onion SD card".
self.assertEqual(install._card_word(("retroarch", Path("/x/BIOS"), "Onion")), "SD card")
self.assertEqual(install._card_word(("batocera", Path("/x/bios"), "Batocera")),
+18
View File
@@ -649,6 +649,24 @@ class TheContractsValidate(unittest.TestCase):
[e.message for e in envelope.iter_errors(json.loads((API / name).read_text()))], [])
class TheIndexSaysWhatTheCatalogLists(unittest.TestCase):
"""index.json called catalog.json every published file, while the 990
one-item documents of emulators/, platforms/, systems/ and lookup/ are
in none of its 80 items."""
def test_the_description_matches_the_items(self):
index = json.loads(_built(API / "index.json").read_text())
catalog = json.loads(_built(API / "catalog.json").read_text())
described = next(i["description"] for i in index["items"] if i["url"] == "catalog.json")
self.assertNotIn("Every published file", described)
listed = {item["url"].split("/", 1)[0] for item in catalog["items"]}
for folder in ("emulators", "platforms", "systems", "lookup"):
with self.subTest(folder=folder):
self.assertIn(f"{folder}/", described)
self.assertNotIn(folder, listed)
self.assertTrue(any((API / folder).glob("*.json")))
class EveryEnvelopeCarriesTheCommit(unittest.TestCase):
"""8.5: emulators/ and systems/ lacked `commit` (716 documents)."""
+29
View File
@@ -503,5 +503,34 @@ class NoTooltipInsideAName(unittest.TestCase):
self.assertIn('>BIOS</abbr>', marked)
class TheArcadeCacheHoldsItsDatabase(unittest.TestCase):
"""The set of sha1s filed under Arcade/ was cached by id(db): a database
freed and replaced at the same address answered with the first one's set,
and an arcade card of the second landed in another section."""
@staticmethod
def _db(sha1: str) -> dict:
return {"files": {}, "indexes": {"by_path_suffix": {"Arcade/x.zip": [sha1]}}}
def test_a_new_database_at_a_reused_address_is_read_again(self):
saved = siterender._suffix_cache[0]
self.addCleanup(siterender._suffix_cache.__setitem__, 0, saved)
first = self._db("a" * 40)
self.assertTrue(siterender._in_arcade("a" * 40, first))
address = id(first)
del first
# CPython hands a freed dict's address to a later one of its size,
# unless the cache still holds the first database.
for _ in range(1000):
second = self._db("b" * 40)
if id(second) == address:
break
del second
else:
second = self._db("b" * 40)
self.assertTrue(siterender._in_arcade("b" * 40, second))
self.assertFalse(siterender._in_arcade("a" * 40, second))
if __name__ == "__main__":
unittest.main()
+17
View File
@@ -304,5 +304,22 @@ class BuildCount(unittest.TestCase):
self.assertIn("system ids linked", source)
class AnEntryListingSystemsIsFiledUnderEach(unittest.TestCase):
"""MAME declares a device set under `systems:`, the machines of several
systems loading it. The site read only `system:`, so ym2608.zip was filed
under neither Arcade nor NEC PC-88, and kept on pages it does not serve."""
def test_the_site_reads_the_common_rule(self):
import generate_site as gs # noqa: PLC0415
from common import entry_system_ids # noqa: PLC0415
entry = {"name": "ym2608.zip", "systems": ["arcade", "pc80"]}
self.assertEqual(gs._entry_systems("mame", entry), ["arcade", "pc80"])
self.assertEqual(
gs._entry_systems("mame", entry), entry_system_ids(entry, {"systems": []})
)
self.assertEqual(gs._entry_systems("mame", {"name": "a", "system": "pc80"}), ["pc80"])
if __name__ == "__main__":
unittest.main()
+13
View File
@@ -184,6 +184,19 @@ class ThePermalinkIsAnIcon(unittest.TestCase):
self.assertEqual(page.count('href="#rb-link-icon"'), 2)
class TheCardGivesTheSizeInBytes(unittest.TestCase):
"""The badge rounded to 0.1 KB and no line of a system card gave the
bytes an emulator compares."""
def test_the_badge_is_exact(self):
for size, shown in ((524288, "512 KB"), (44348015, "44,348,015 B"),
(2 * 1024 * 1024, "2 MB"), (2304, "2,304 B"), (10, "10 B")):
with self.subTest(size=size):
card = {**_sony_file("scph5501.bin", "4"), "size": size}
title = gs._system_file_card(card, FileConsumers({"files": {}}))[2]
self.assertIn(f'<span class="rb-badge rb-badge-muted">{shown}</span>', title)
class TheCardLinesSayWhatTheyMean(unittest.TestCase):
""""Platforms:" and "Also installed by:" listed different setups, as
lower-case ids, and only a code comment told them apart."""
+22 -25
View File
@@ -75,31 +75,6 @@ def tearDownModule():
shutil.rmtree(_ROOT, ignore_errors=True)
class TestClaimIndex(unittest.TestCase):
"""Keyed by destination and by name, the name carrying the union."""
def _claims(self):
return [
slots.Claim("profile", "GC/USA/IPL.bin", "IPL.bin", emulator="dolphin"),
slots.Claim("profile", "GC/JAP/IPL.bin", "IPL.bin", emulator="dolphin"),
]
def test_destination_key_separates_same_named_entries(self):
index = slots.build_claim_index(self._claims())
self.assertEqual(len(index["gc/usa/ipl.bin"]), 1)
self.assertEqual(len(index["gc/jap/ipl.bin"]), 1)
def test_bare_name_answers_with_the_union(self):
index = slots.build_claim_index(self._claims())
self.assertEqual(len(index["ipl.bin"]), 2)
def test_lookup_ignores_case_and_leading_separator(self):
index = slots.build_claim_index(
[slots.Claim("platform", "/BIOS/Disk.rom", "Disk.rom")]
)
self.assertIn("bios/disk.rom", index)
class TestConflicts(unittest.TestCase):
"""A proven profile claim contradicting what the baseline would ship."""
@@ -411,6 +386,28 @@ class TestArbitration(unittest.TestCase):
# The hashed declaration keeps the file the frontend checks.
self.assertEqual(overrides, {"IPL.bin": "bios/Console/GC/JAP/IPL.bin"})
def test_the_report_and_the_pack_decide_alike(self):
"""The report prints arbitrate(), the pack, the manifest and verify
read slot_choices(): both must name the same file for a slot."""
for mode in ("existence", "md5"):
config = {
"verification_mode": mode,
"cores": ["dolphin"],
"systems": {"console": {"files": [
{"name": "IPL.bin", "destination": "GC/JAP/IPL.bin", "md5": "m" * 32},
]}},
}
profiles = {"dolphin": {"type": "libretro", "systems": ["console"], "files": [
{"name": "IPL.bin", "path": "GC/JAP/IPL.bin"},
]}}
with self.subTest(mode=mode):
[conflict] = slots.find_conflicts(config, profiles, REGIONS_DB)
decision = slots.arbitrate(conflict, mode)
served = slots.pack_overrides(config, profiles, REGIONS_DB).get(
"GC/JAP/IPL.bin", conflict.platform_claim.local_path
)
self.assertEqual(served, decision.winner.local_path)
def test_the_reported_line_gives_the_ground_for_the_decision(self):
kept = slots.format_decision(slots.arbitrate(self._conflict(), "md5"))
self.assertIn("verifies content", kept)
+24
View File
@@ -115,6 +115,30 @@ class OversizedFileAdvice(unittest.TestCase):
self.assertIn("open an issue", message)
self.assertIn(validate_pr.LARGE_FILE_ISSUE_URL, message)
def test_the_issue_form_is_filled_and_nothing_points_to_the_pr(self):
"""The link opened an empty form, and the same report said to put the
file in the pull request and under bios/."""
import urllib.parse # noqa: PLC0415
path = self.tmp / "big.bin"
path.write_bytes(b"x" * 8)
validate_pr.MAX_FILE_SIZE = 4
result = validate_pr.validate_file(
"big.bin", None, {"sha1": set(), "md5": set(), "names": set()},
)
message = next(m for s, m in result.checks if s == "FAIL" and "too large" in m)
url = message.split("(", 2)[2].split(")", 1)[0]
query = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
template = (REPO_ROOT / ".github" / "ISSUE_TEMPLATE" / "missing-bios.yml").read_text()
for field, value in (("filename", "big.bin"), ("sha1", result.sha1),
("md5", result.md5), ("size", "8")):
with self.subTest(field=field):
self.assertIn(f"id: {field}\n", template)
self.assertEqual(query[field], [value])
others = " ".join(m for _s, m in result.checks)
self.assertNotIn("pull request which", others)
self.assertNotIn("not under bios/", others)
def test_the_linked_issue_template_exists(self):
template = validate_pr.LARGE_FILE_ISSUE_URL.rsplit("template=", 1)[1]
self.assertTrue(
+1
View File
@@ -291,6 +291,7 @@
"systems/other-fbneo.md": "systems/other.md",
"systems/other-future-pinball-fploader.md": "systems/other.md",
"systems/other-gopher2600.md": "systems/other.md",
"systems/other-groovymame.md": "systems/other.md",
"systems/other-idtech4a.md": "systems/other.md",
"systems/other-ines.md": "systems/other.md",
"systems/other-mame-2.md": "systems/other.md",
+39 -4
View File
@@ -21,6 +21,11 @@ type Item = {
owner?: string;
sha1?: string;
system_name?: string;
// On an archive-member record: the collection archive holding the member.
archive?: string;
// On a retired record: the day the file left, and the file in its place.
left?: string;
successor?: string;
};
const siteRoot = () => new URL(import.meta.env.BASE_URL.replace(/\/?$/, '/'), location.origin);
@@ -521,7 +526,9 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, matched:
// naomi2.zip by the MD5 of a ROM inside it, which no file of the
// collection has.
const searched = new Set(matched.map((item) => item.value));
const carried = [...new Set(matched.flatMap((item) => (item.sha1 && !searched.has(item.sha1) ? [item.sha1] : [])))];
// A member's record names its archive, which is not the file searched for.
const carried = [...new Set(matched.flatMap((item) =>
(item.sha1 && !['archive-member', 'retired'].includes(item.layer) && !searched.has(item.sha1) ? [item.sha1] : [])))];
const found = carried.length ? [...matched, ...(await lookupValues(carried).catch(() => [] as Item[]))] : matched;
const held = distinct(found.filter((item) => item.layer === 'collection'), (item) => item.sha1 ?? item.url);
const files = await Promise.all(held.map((item) => heldFile(item).catch(() => undefined)));
@@ -550,8 +557,11 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, matched:
const readEntries = emulators.map((item) => readerEntry(reader.get(item.owner ?? ''), item, valuesOf(item)));
const setups = new Set(platforms.map((item) => item.owner));
const members = distinct(byLayer('archive-member'), (item) => `${item.sha1}|${item.name}`);
const counted = [
held.length ? count(held.length, 'file in the collection', 'files in the collection') : 'not in the collection',
held.length ? count(held.length, 'file in the collection', 'files in the collection')
: members.length ? count(members.length, 'member of a collection archive', 'members of collection archives')
: 'not in the collection',
...(platforms.length ? [`${count(platforms.length, 'declaration', 'declarations')} by ${count(setups.size, 'setup', 'setups')}`] : []),
...(owners.length ? [count(owners.length, 'emulator that reads it', 'emulators that read it')] : []),
];
@@ -606,7 +616,19 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, matched:
),
);
});
if (!held.length) blocks.push(paragraph('No file in the collection has this hash.'));
const retired = held.length ? [] : distinct(byLayer('retired'), (item) => item.sha1 ?? item.value);
if (!held.length && !members.length && !retired.length) blocks.push(paragraph('No file in the collection has this hash.'));
blocks.push(...retired.map(retiredParagraph));
if (members.length) {
// A ROM the collection holds inside a set: the set's card, by its name.
const list = document.createElement('ul');
for (const item of members) {
const row = document.createElement('li');
row.append(code(item.name ?? item.value), ' in ', link(item.url, code(item.archive ?? '')));
list.append(row);
}
blocks.push(paragraph(strong('Inside an archive of the collection')), list);
}
const shipped = byLayer('data-directory');
if (shipped.length) {
const list = document.createElement('ul');
@@ -640,6 +662,15 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, matched:
result.replaceChildren(...blocks);
};
// A file the collection held: its address keeps answering, with the day
// it left and the card of the file in its place.
const retiredParagraph = (item: Item) =>
paragraph(
code(item.name || item.value), ` left the collection on ${item.left}. `,
item.successor ? link(item.url, 'Open the card of the file in its place') : link(item.url, 'Open the page it was on'),
'.',
);
const WANTED = 'provenance/#wanted-catalogued-dumps-this-project-does-not-have';
// Nothing the site knows has the value: where a dump of one's own can go.
@@ -749,8 +780,12 @@ const resolveFileAddress = () => {
const matches = items.filter((item) =>
prefix ? item.algorithm === 'sha1' && item.value.startsWith(value) : item.value === value);
const cards = matches.filter((item) => item.layer === 'collection' || item.layer === 'data-directory');
const retired = matches.filter((item) => item.layer === 'retired' && item.algorithm === 'sha1');
if (new Set(cards.map((item) => item.url)).size === 1) {
location.replace(new URL(cards[0].url, siteRoot()));
} else if (!cards.length && retired.length) {
status.textContent = 'This file is no longer in the collection.';
result.replaceChildren(...distinct(retired, (item) => item.value).map(retiredParagraph));
} else if (matches.length) {
return renderMatches(status, result, prefix ? cards : matches);
} else {
@@ -1086,7 +1121,7 @@ const copyButtons = () => {
};
// A system's install box: one command, for the setup picked in its list,
// under the site's OS tabs. The box is built with the first setup's command;
// under the site's OS tabs. The box is built with its selected setup's command;
// the choice rewrites the options of each command and its copy text, and is
// remembered for the next box and the next page. Without this script the
// picker stays hidden and the list of every command shows instead.
+8 -5
View File
@@ -7,7 +7,7 @@ order: 20
Adding a platform gives its users a pack and a one-line install with the files
their setup checks, in the place it checks them, plus every file its emulators
need that its own list leaves out. The work is to read how the platform
need that its own list does not name. The work is to read how the platform
declares and checks BIOS files, then teach this project to read that
declaration. A platform can be a frontend like Batocera, a manager like EmuDeck
or a firmware database like BizHawk.
@@ -732,10 +732,13 @@ This executes in sequence, numbered as the run prints them (`STEP_LABELS` in
`--offline` skips steps 2 to 2b2.
A new platform is archived-by-default nowhere: `status: active` puts it in
`list_platforms.py`, the CI matrix, and the scrapers. Set
`status: archived` to keep the configuration without the automation. Archived
platforms are excluded unless `--all` or `--include-archived` is passed.
A new platform is `status: active`. `status: archived` keeps the platform
out of the runs that cover every platform (`verify.py --all`,
`generate_pack.py --all`, `slots.py`, `pipeline.py`, `list_platforms.py`)
unless `--include-archived` is passed. The installer manifests, the site, the
native export and `check_freshness.py` cover archived platforms as well. No
tool scrapes a platform on its own, archived or not: the `schedule:` field is
the cadence a maintainer keeps by hand.
A new platform has no release ZIP: `release_pack: true` is set for the setups
most people download, and the installer serves every other one from its
+5 -3
View File
@@ -52,6 +52,7 @@ website/ Astro Starlight project; pages, data and navigation
_mame_clones.json MAME parent/clone set mappings
database.json file index built from bios/ (SHA1 primary key)
derivable.json files a held dump already contains, from provenance_report.py
retired.json files that left the collection, written by generate_db.py
release.json files, sizes and assets of each pack of the last release
allowed_signers public key that signs SHA256SUMS.txt
dist/ generated packs (gitignored)
@@ -196,9 +197,10 @@ whoever asked for it:
- A pack built with `--emulator` answers to that emulator, and takes its file.
`scripts/slots.py` prints each contested destination and the reason for its
winner (`python scripts/slots.py --platform retrobat`). `generate_pack.py` and
`verify.py` both decide through `slots.arbitrate()`, so the pack and the report
agree.
winner (`python scripts/slots.py --platform retrobat`), as `slots.arbitrate()`
decides it. `generate_pack.py`, the install manifest and `verify.py` all read
`slots.slot_choices()` through `slots.pack_overrides()`, which applies the same
order, so the pack, the installer and the report agree.
A platform YAML is regenerated by its scraper and never edited by hand: an
edit would be lost at the next scrape, and the file would stop describing the
+2 -2
View File
@@ -77,8 +77,8 @@ setup's files and checks each one. `--dry-run` shows the list first.
## Fill an SD card from a computer?
The interface finds a muOS, Onion, spruceOS, MinUI, KNULLI or ROCKNIX card by
itself. Name your setup and the card's BIOS folder. For RetroArch on a Switch,
The interface finds a muOS, Onion, spruceOS, MinUI, KNULLI, ROCKNIX, Lakka or
MiSTer card. Else name your setup and its BIOS folder. For RetroArch on a Switch,
with the card in drive `E:`, run:
<!-- os-tabs windows: --platform retroarch --dest E:\retroarch\cores\system -->
+6 -3
View File
@@ -65,7 +65,7 @@ Every module in `tests/`, by the area it guards.
| Pack probe | the cores a platform runs for a system, staging grown to a fixed point, runs mapped to pack and manifest entries, the line and its schema, the probe lock per run, that no page shows its lines. One class builds a real pack narrowed to PlayStation and lays it out both ways, from the archive and with `install.py --dest`, and skips without the collection | `test_pack_probe` |
| Automation runner, disk cleanup, asset sync and bit-flip repair | fake jobs, git repositories with a bare origin and session worktrees, a committed file with one bit flipped, canned CI runs in place of `gh` | `test_autorun` |
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
| Games on the card | small DATs, listxml and setup sources, a fake fetcher, fixture install lists, and the committed data against its schemas | `test_arcade_parents`, `test_game_catalogs`, `test_game_headers`, `test_game_regions`, `test_games_index`, `test_romfolders_card`, `test_romfolders_es` |
## Test architecture
@@ -463,9 +463,12 @@ refactor: extract shared utils to common.py
Files are staged by name, never with `git add -A` or `git add .`, which sweep in
deletions and work that is not yours. Generated artifacts (`database.json`,
`install/*.json`, `README.md`, `CONTRIBUTING.md`, `exports/`) are committed
`retired.json`, `derivable.json`, `install/*.json`, `README.md`, `CONTRIBUTING.md`, `exports/`,
the generated tables and run samples of `wiki/installer.md`) are committed
with the data they derive from, never alone: a manifest committed before the
files it points at gives the installer a 404.
files it points at gives the installer a 404. The run samples count what the
manifests hold, so `generate_installer_doc.py` runs after every manifest
regeneration and its output goes in the same commit.
## CI integration
+12 -7
View File
@@ -243,6 +243,7 @@ python scripts/generate_pack.py --all --offline # cache-only, never open t
# Install manifests (consumed by install.py)
python scripts/generate_pack.py --all --manifest --output-dir install/
python scripts/generate_pack.py --manifest-targets --output-dir install/targets/
python scripts/generate_installer_doc.py # run samples of wiki/installer.md, from the manifests
```
Packs include platform baseline files plus files required by the platform's cores.
@@ -364,9 +365,11 @@ test reads the original and the updated System.dat and es_bios.xml back to
check that the list is the diff.
No entry the platform declares is removed. One the truth does not pair is
looked for in the profiles of every core the platform runs: by name or
alias, by the basename or tail of a `path:`, by any hash (archive members
included), under a `type: directory` entry or a data directory. An entry
looked for, in this order: by the tail of a `path:` in the profiles of every
core the platform runs, by a name the platform's own launch code reads (its
`launchers:` records, such as the 22 names Batocera's DuckStation generator
tries), then in those profiles by name or alias, by any hash (archive
members included), under a `type: directory` entry or a data directory. An entry
none of them answers is written to `exports/<platform>.json` under
`removal_candidates`, with the cores examined for its system and a reason.
The verdict is `undetermined` when a core the platform names has no
@@ -481,7 +484,7 @@ python scripts/profile_sync.py --all --stale-before 2026-06-01 --limit 20 --tria
python scripts/profile_sync.py --emulator vice --full-diff --ref vice/src/c64/c64rom.h --context 6
python scripts/profile_sync.py --emulator vice --strict
python scripts/profile_sync.py --changed --strict
python scripts/profile_sync.py --added --base upstream/main --strict
python scripts/profile_sync.py --added --strict
```
Per part of a ref: `ANCHORED` (same content, same lines), `SHIFTED` (same
@@ -694,8 +697,9 @@ directories are checked against their upstream, dump catalogs and romset
snapshots against the catalog they were read from (Redump DAT versions, the
No-Intro daily rebuild, the newest TOSEC pack, the newest MAME release, the
git blobs of the FBNeo DATs), the game catalogs of `games/catalogs/` against
the No-Intro pack and the Redump datfiles they were read from, and the CI
toolchain against PyPI and the actions' latest releases.
the No-Intro pack and the Redump datfiles they were read from, the CI
toolchain against PyPI and the actions' latest releases, and the site's exact
npm pins in `website/package.json` against the npm registry.
The `native` area needs no network. It reads the cache `export_native.py`
patches from and reports an original that is absent, that came from another
@@ -1330,7 +1334,8 @@ before a release (wiki/release-process.md). Its lines stay in
| Script | Purpose |
|--------|---------|
| `common.py` | Shared library: hash computation, file resolution, platform config loading, emulator profiles, target filtering |
| `dedup.py` | Deduplicate `bios/` (`--dry-run`, `--bios-dir`): byte-identical copies are deleted and one kept. Identical MAME sets under other names stay one file, named in `_mame_clones.json`. Trees addressed by path are left alone (`NODEDUP_DIRS`): BBK, Bungie, C-Dogs SDL, Ikemen GO, PinMAME, RPG Maker, ScummVM, Softdisk, TheXTech, splitwolf, Theme Hospital, c64-emu, opentyrian, tzdata |
| `retired_files.py` | The record of files that left the collection, `retired.json`, which `generate_db.py` appends to and the site's lookup reads. `--backfill` rebuilds it from the history of `database.json` since content addresses were published (`--output` names another file) |
| `dedup.py` | Deduplicate `bios/` (`--dry-run`, `--bios-dir`): byte-identical copies are deleted and one kept. Identical MAME sets under other names stay one file, named in `_mame_clones.json`. Trees addressed by path are left alone (`NODEDUP_DIRS`), under a folder of that name at any depth: BBK, Bungie, C-Dogs SDL, Ikemen GO, mame, PinMAME, RPG Maker, ScummVM, Softdisk, TheXTech, splitwolf, Theme Hospital, c64-emu, opentyrian, tzdata |
| `identity.py` | One content filed under two identities: groups `bios/` by content and reports a group that crosses manufacturers outside `KNOWN_SHARED` (`--strict` exits 1, pipeline step 3a) |
| `slots.py` | Destinations the platform and a core both claim, and how the pack settles each (`--platform`, `--include-archived`, `--target`, `--json`, `--strict`, and pipeline step 3b passes the run's own `--include-archived` and `--target`) |
| `region_audit.py` | Confront every profile `region:` with the regions No-Intro names for the same dump (`--json`, `--db`, `--emulators-dir`). An opinion, never an authority: the code decides, and a disagreement is a line to read in the emulator's source |