chore: merge site fixes, fourth pass

This commit is contained in:
Abdessamad Derraz committed 2026-11-06 10:33:56 +01:00
commit 25b11fa497
36 files changed
+878 -125

No files matched your search

+1
View File
@@ -49,6 +49,7 @@ on:
- "scripts/sitediagrams.py"
- "scripts/siteentries.py"
- "scripts/siteguides.py"
- "scripts/siteinputs.py"
- "scripts/sitemarkup.py"
- "scripts/sitemeasurements.py"
- "scripts/siteexports.py"
+2 -2
View File
@@ -43,7 +43,7 @@ First time? Follow the [first install, step by step](https://abdess.github.io/re
## 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.
A pack holds its platform's own BIOS list plus the files its emulator cores load. What no pack carries yet is in the [gap analysis](https://abdess.github.io/retrobios/gaps/). 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.
A pack over 2 GB comes in several parts (`Pack.part1of2.zip`, `Pack.part2of2.zip`), and every part is needed. Each part is an ordinary ZIP: extract them all into the same folder.
@@ -208,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-06T08:56:19Z*
*Auto-generated on 2026-11-06T09:30:57Z*
+4 -2
View File
@@ -393,7 +393,8 @@
"retired",
"data-directory",
"platform",
"emulator"
"emulator",
"wanted"
]
},
"left": {
@@ -433,7 +434,8 @@
"description": "On a platform or emulator record: the algorithm of the value its owner wrote, filed under that value and under the file's other hashes. name: the emulator reads the file by its name and declares no hash that matches it."
},
"owner": {
"type": "string"
"type": "string",
"description": "On a platform or emulator record: the platform or profile key. On a wanted record: the dump catalog that lists the value (Redump, No-Intro, TOSEC), the collection holding no file with it."
},
"system_name": {
"type": "string",
+5
View File
@@ -1492,6 +1492,11 @@ def check_source_paths(profiles: dict) -> Finding:
sitesources.tree_key(repo, pin)
for profile in profiles.values() if sitesources.cited_paths(profile)
for repo, pin in sitesources.profile_trees(profile)
} | {
# The platform build files pin_build names, linked from a page too.
sitesources.tree_key(repo, pin)
for profile in profiles.values()
for repo, pin, _path in sitesources.build_files(profile)
}
try:
recorded = set(json.loads(sitesources.SOURCE_PATHS.read_text(encoding="utf-8")))
+1 -1
View File
@@ -608,7 +608,7 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
"",
"## 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.",
f"A pack holds its platform's own BIOS list plus the files its emulator cores load. What no pack carries yet is in the [gap analysis]({SITE_URL}gaps/). 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.",
"",
"A pack over 2 GB comes in several parts (`Pack.part1of2.zip`,"
" `Pack.part2of2.zip`), and every part is needed. Each part is an"
+176 -64
View File
@@ -86,9 +86,10 @@ from generate_readme import (
extract_targets,
release_totals,
)
from provenance_report import build_report, distinct_missing
from provenance_report import build_report, distinct_missing, profile_needs
import sitemarkup as markup
import siteentries
import siteinputs
import retired_files
import sitestarlight
from siteexports import generate_exports_page, load_export_reports
@@ -662,7 +663,7 @@ def release_line(platform_name: str, record: dict) -> list[str]:
f"[SHA256SUMS.txt]({base}/SHA256SUMS.txt) and its "
f"[signature]({base}/SHA256SUMS.txt.sig).",
]
config = load_platform_config(platform_name, "platforms")
config = load_platform_config(platform_name, str(siteinputs.current().platforms))
page_version = str(config.get("version") or "")
pack_version = _pack_version(pack_name)
if page_version and pack_version and page_version.lstrip("v") != pack_version.lstrip("v"):
@@ -687,7 +688,7 @@ def setup_version(platform_name: str, record: dict | None = None) -> str:
platform YAML was read at. Empty when neither says, or when the
platform has no YAML to read a version from."""
try:
config = load_platform_config(platform_name, "platforms")
config = load_platform_config(platform_name, str(siteinputs.current().platforms))
except FileNotFoundError:
return ""
pack_name = release_record.pack_name_for(platform_name, record or {})
@@ -714,8 +715,7 @@ NO_ZIP_FAQ = "wiki/faq.md#why-is-there-no-zip-for-my-setup"
def has_release_pack(platform_name: str) -> bool:
"""Whether a release publishes a ZIP for the platform (`release_pack`)."""
entry = load_platform_registry("platforms").get(platform_name) or {}
return entry.get("release_pack") is True
return platform_name in _release_pack_platforms()
def no_zip_line(display: str) -> str:
@@ -734,7 +734,7 @@ def get_page_title(platform_name: str, display: str) -> str:
the system's own download. A setup that is also an emulator (BizHawk)
names it last: "BizHawk BIOS files" is the title of its emulator page."""
kind = "BIOS pack" if has_release_pack(platform_name) else "BIOS files"
if (_REPO_ROOT / "emulators" / f"{platform_name}.yml").is_file():
if (siteinputs.current().emulators / f"{platform_name}.yml").is_file():
return f"{kind} for {display}"
return f"{display} {kind}"
@@ -779,7 +779,7 @@ def _pack_buttons(platform_name: str, label: str = "Pack", prefix: str = "../",
frontend user asks. A setup with no release pack gets the line that
says so, and why."""
if not has_release_pack(platform_name):
display = display or load_platform_config(platform_name, "platforms").get(
display = display or load_platform_config(platform_name, str(siteinputs.current().platforms)).get(
"platform", platform_name)
return f"{no_zip_line(display)} [Why no ZIP?]({prefix}{NO_ZIP_FAQ})"
record = release_record.load_advertised()
@@ -804,7 +804,7 @@ def generate_get_page(platform_name: str) -> str:
the files of the pack the current release holds, read from release.json,
or the command when no release publishes one. The release process
never touches it: committing release.json rebuilds it."""
config = load_platform_config(platform_name, "platforms")
config = load_platform_config(platform_name, str(siteinputs.current().platforms))
display = config.get("platform", platform_name)
record = release_record.load_advertised()
pack = release_record.pack_for(platform_name, record)
@@ -823,7 +823,7 @@ def generate_get_page(platform_name: str) -> str:
if released and pack and pack.get("assets"):
parts = len(pack["assets"])
release = release_line(platform_name, record)
extract_to = (load_platform_registry("platforms").get(platform_name) or {}).get("extract_to")
extract_to = (load_platform_registry(str(siteinputs.current().platforms)).get(platform_name) or {}).get("extract_to")
lines += [
"This address stays the same. It always gives the latest ZIP.",
"",
@@ -872,7 +872,7 @@ def generate_get_page(platform_name: str) -> str:
def generate_get_index() -> str:
"""get/: the permanent page of every setup, grouped as the home tiles
are. A link trimmed to /get/ answered 404."""
registry = load_platform_registry("platforms")
registry = load_platform_registry(str(siteinputs.current().platforms))
lines = [
"---", "search: exclude",
'description: "The BIOS files of every setup, one page each. Each address stays the same."',
@@ -886,7 +886,7 @@ def generate_get_index() -> str:
continue
lines += [f"## {label}", ""]
for name in names:
display = load_platform_config(name, "platforms").get("platform", name)
display = load_platform_config(name, str(siteinputs.current().platforms)).get("platform", name)
lines.append(f"- [{get_page_title(name, display)}]({name}.md)")
lines.append("")
return "\n".join(lines)
@@ -1067,10 +1067,13 @@ def _split_contract(config: dict) -> tuple[dict, dict]:
return contract, registry
@functools.lru_cache(maxsize=1)
def _release_pack_platforms() -> frozenset[str]:
return frozenset(list_release_pack_platforms(
str(_REPO_ROOT / "platforms"), include_archived=True))
return _release_packs_in(str(siteinputs.current().platforms))
@functools.lru_cache(maxsize=None)
def _release_packs_in(platforms_dir: str) -> frozenset[str]:
return frozenset(list_release_pack_platforms(platforms_dir, include_archived=True))
# Per-entry fields a folder check needs beyond the hashes: an md5 is a ZIP
@@ -1617,6 +1620,7 @@ def generate_data_exports(
members = data_directory_members()
locations = file_location_items(db, profiles, members)
wanted = _provenance_wanted_rows(db)
envelopes = {
"platforms.json": ("platforms", platform_items),
"emulators.json": ("emulators", emulator_items),
@@ -1625,7 +1629,7 @@ def generate_data_exports(
}
write_lookup_shards(
api, file_lookup_records(db, coverages, profiles, locations, members, consumers,
retired_files.load(_REPO_ROOT / "retired.json")),
retired_files.load(_REPO_ROOT / "retired.json"), wanted),
generated_at, commit,
)
for filename, (kind, items) in envelopes.items():
@@ -1744,7 +1748,7 @@ def generate_data_exports(
_record(downloads / "retrobios.sqlite")
write_if_changed(
str(downloads / "provenance-wanted.csv"),
_csv_document(list(WANTED_FIELDS), _provenance_wanted_rows(db)),
_csv_document(list(WANTED_FIELDS), wanted),
)
assets = [
@@ -2104,7 +2108,7 @@ def _short_sha256(item: dict, catalog: str) -> str:
def _platforms_without_a_list(platforms_dir: Path | None = None) -> list[str]:
"""The names of the platforms whose registry says they keep no list."""
platforms_dir = platforms_dir or _REPO_ROOT / "platforms"
platforms_dir = platforms_dir or siteinputs.current().platforms
names = []
for key, entry in sorted(load_platform_registry(str(platforms_dir)).items()):
if entry.get("native_list") != "none":
@@ -3236,7 +3240,9 @@ def _front_matter(markdown: str) -> dict:
return data if isinstance(data, dict) else {}
_SHA1_BY_PREFIX: dict[str, str] = {}
@functools.lru_cache(maxsize=None)
def _sha1_by_prefix(db_path: str) -> dict[str, str]:
return {sha1[:12]: sha1 for sha1 in load_database(db_path)["files"]}
def _cards_elsewhere(markdown: str) -> str:
@@ -3247,11 +3253,9 @@ def _cards_elsewhere(markdown: str) -> str:
away = {ident for ident in links if f'id="{ident}"' not in markdown}
if not away:
return markdown
if not _SHA1_BY_PREFIX:
db = load_database(str(_REPO_ROOT / "database.json"))
_SHA1_BY_PREFIX.update({sha1[:12]: sha1 for sha1 in db["files"]})
by_prefix = _sha1_by_prefix(str(siteinputs.current().db))
for ident in away:
full = _SHA1_BY_PREFIX[ident.removeprefix("sha1-")]
full = by_prefix[ident.removeprefix("sha1-")]
markdown = markdown.replace(f"](#{ident})", f"]({markup.site_href(f'files/?sha1={full}')})")
return markdown
@@ -3562,10 +3566,14 @@ def _list_pages(
f"[part {i}]({other}.md)" for i, other in enumerate(stems, 1))
@functools.lru_cache(maxsize=1)
def _collection_sha1_by_path() -> dict[str, str]:
"""The sha1 of each file the collection holds, by its path."""
with open(_REPO_ROOT / "database.json", encoding="utf-8") as handle:
return _sha1_by_path_in(str(siteinputs.current().db))
@functools.lru_cache(maxsize=None)
def _sha1_by_path_in(db_path: str) -> dict[str, str]:
with open(db_path, encoding="utf-8") as handle:
files = json.load(handle).get("files", {})
return {str(entry.get("path", "")): sha1 for sha1, entry in files.items()}
@@ -3595,7 +3603,7 @@ def _system_named_link(sys_id: str, prefix: str = "") -> str:
def _emulator_named_link(key: str, prefix: str = "") -> str:
"""An emulator by the name its page carries, linked to that page. A
profile with no page (an alias, a test) is named only."""
profile = load_emulator_profiles(str(_REPO_ROOT / "emulators"), skip_aliases=False).get(key, {})
profile = _profiles_on_disk().get(key, {})
label = _escape_tags(str(profile.get("emulator") or key))
if profile.get("type") in ("alias", "test"):
return label
@@ -3985,10 +3993,9 @@ def _platform_data_links(name: str) -> str:
return ", ".join(links)
@functools.lru_cache(maxsize=1)
def _site_profiles() -> dict[str, dict]:
"""Every emulator profile, read once for the citations of a page."""
return load_emulator_profiles(str(_REPO_ROOT / "emulators"))
return _profiles_in(str(siteinputs.current().emulators), skip_aliases=True)
# Whose code a source reference cites, in one word: a platform page names
@@ -4072,13 +4079,17 @@ def _category_evidence_md(evidence: dict, launched: list[str] | None = None) ->
return f"No reader found ({cores})"
@functools.lru_cache(maxsize=1)
def _profiles_by_core_name() -> dict[str, list[str]]:
"""Core name as a platform writes it -> the profiles with a page for it.
MinUI writes `mednafen_vb`, the profile and its page are `beetle_vb`.
"""
profiles = load_emulator_profiles(str(_REPO_ROOT / "emulators"))
return _core_names_in(str(siteinputs.current().emulators))
@functools.lru_cache(maxsize=None)
def _core_names_in(emulators_dir: str) -> dict[str, list[str]]:
profiles = _profiles_in(emulators_dir, skip_aliases=True)
with_page = {k for k, v in profiles.items() if v.get("type") not in ("alias", "test")}
return {
core: sorted(k for k in keys if k in with_page)
@@ -4248,7 +4259,7 @@ def _card_route(name: str, display: str, guide: Guide) -> list[str]:
sees it is the reader's to name: no guide cites it."""
if guide.on_card or _offline_line(name):
return []
extract_to = (load_platform_registry("platforms").get(name) or {}).get("extract_to")
extract_to = (load_platform_registry(str(siteinputs.current().platforms)).get(name) or {}).get("extract_to")
leaf = (extract_to or "bios").rstrip("/").rsplit("/", 1)[-1].lstrip("~") or "bios"
tabs = guide.card_run or ("windows", "macos", "linux")
args = {key: f"--platform {name} --dest "
@@ -4449,7 +4460,7 @@ def generate_handheld_guide(guide: HandheldGuide) -> str:
A handheld owner starts from the device name. The systems were named
only in a closed note, with a path seen from inside the device."""
uses = GUIDES[guide.uses]
display = load_platform_config(guide.uses, "platforms").get("platform", guide.uses)
display = load_platform_config(guide.uses, str(siteinputs.current().platforms)).get("platform", guide.uses)
args = {"windows": f"--platform {guide.uses} --dest E:\\BIOS",
"macos": f"--platform {guide.uses} --dest /path/to/card/BIOS",
"linux": f"--platform {guide.uses} --dest /path/to/card/BIOS"}
@@ -4504,7 +4515,7 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list:
lines.append("")
for note in guide.notes:
lines += [note, ""]
lines += [line for note in extract_notes("platforms", released_only=True)
lines += [line for note in extract_notes(str(siteinputs.current().platforms), released_only=True)
if f"The {display} pack " in note
for line in (note, "")]
if guide.more_folders:
@@ -4979,7 +4990,7 @@ def _data_member_digests(
from hashing import compute_hashes # noqa: PLC0415
if data_registry is None:
data_registry = load_data_dir_registry(str(root / "platforms"))
data_registry = load_data_dir_registry(str(siteinputs.current().platforms))
found: dict[str, dict[str, str]] = {}
for ref, tree in sorted(members.items()):
cache = (data_registry.get(ref) or {}).get("local_cache")
@@ -5062,12 +5073,14 @@ def file_lookup_records(
members: dict | None = None,
consumers: FileConsumers | None = None,
retired: dict | None = None,
wanted: list[dict] | None = None,
) -> list[dict]:
"""Every hash the site knows, one record per value, for files/?hash=.
The collection's own files lead to their card, a platform's declared
value to the system section of its page, a profile's to the entry's
card. The site search indexed these values as text, 4 MB of the 21 MB
card, a wanted dump (``wanted``, the provenance-wanted.csv rows) to
its catalog on the provenance page. The site search indexed these values as text, 4 MB of the 21 MB
it made every visitor download: the lookup reads one shard instead.
"""
files = db.get("files", {})
@@ -5140,6 +5153,7 @@ def file_lookup_records(
**({"system_name": entry_label} if entry_label else {}),
})
records += retired_records(retired or {}, files, locations)
records += wanted_records(wanted or [])
records += _held_under_every_hash(records, files, db.get("indexes") or {})
for record in records:
if record["layer"] in ("platform", "emulator"):
@@ -5151,6 +5165,23 @@ def file_lookup_records(
r["value"], r["layer"], r.get("owner", ""), r["name"], r["url"]))
def wanted_records(rows: list[dict]) -> list[dict]:
"""A record under each hash of a dump a catalog lists and the collection
lacks, from the rows of provenance-wanted.csv. A pasted hash of a
wanted dump (Redump's ps2-dvd-212k.bin) answered that nothing the site
knows has it, and never said the dump is wanted."""
records = []
for row in rows:
label = _PROVENANCE_LABELS.get(row["catalog"], row["catalog"])
url = _site_path(f"provenance.md#{_slugify_anchor(label)}")
for algorithm in ("sha1", "md5", "crc32"):
value = str(row.get(algorithm) or "").lower()
if value:
records.append({"value": value, "algorithm": algorithm, "layer": "wanted",
"owner": label, "name": row["name"], "url": url})
return records
def _read_by_name(records: list[dict], files: dict, consumers: FileConsumers) -> list[dict]:
"""The emulators the file's card names that no declared hash reached.
@@ -5836,6 +5867,13 @@ def _wanted_list(source: str, label: str, data: dict, by_dat: dict[str, list],
_RELEASE_ASSET_NAMES: dict[str, str] | None = None
def _download_name(path: str) -> str:
"""The name a browser saves a collection file under: the last segment
of its download URL. Neither host sends a Content-Disposition, and the
download attribute is ignored on another origin."""
return urllib.parse.unquote(_fetch_url(path).rsplit("/", 1)[-1])
def _fetch_url(path: str) -> str:
"""Where a collection file downloads from: its raw URL, or the
large-files release asset when .gitignore keeps it out of git."""
@@ -5867,7 +5905,7 @@ def _platform_display(name: str) -> str:
display = _platform_names.get(name)
if display is None:
try:
config = load_platform_config(name, str(_REPO_ROOT / "platforms"))
config = load_platform_config(name, str(siteinputs.current().platforms))
except FileNotFoundError:
config = {}
display = _platform_names[name] = str(config.get("platform") or name)
@@ -6210,6 +6248,12 @@ def _system_file_card(
# Each fact: its label, its values, whether the search reads it. What a
# setup does with the file comes first, the fingerprints last.
facts: list[tuple[str, list[str], bool]] = []
saved = _download_name(f["path"]) if f.get("path") else name
if saved != name:
# A .variants/ copy is stored as advision.zip.1c086064, and the
# browser names the download after the URL: copied as it came, the
# file a platform pins was not found under its name.
facts.append(("Save as", [f"{_code(name)}, the download is named {_code(saved)}"], False))
if plats:
# Each link lands on the platform's system section, and says where
# that platform puts the file. Out of the search: the card's title
@@ -6268,7 +6312,7 @@ def _system_file_card(
# What a system card shows at a glance: who lists the file and who reads it.
# Its fingerprints and the rest open on demand, so a page of 250 cards reads
# as a list of files, not a wall of hashes.
_CARD_KEY_FACTS = ("On the setup's list", "Packed for its emulators by", "Emulators")
_CARD_KEY_FACTS = ("Save as", "On the setup's list", "Packed for its emulators by", "Emulators")
_CARD_HASHES = ("SHA1", "MD5", "CRC32", "SHA256")
# The words a card's fold names its other facts by.
_CARD_MORE_WORDS = {"Romset": "romset", "Collection path": "collection path",
@@ -6664,11 +6708,13 @@ def _reading_pairs(sha1: str, consumers: FileConsumers) -> list[tuple[str, dict]
return pairs
@functools.lru_cache(maxsize=1)
def _profiles_on_disk() -> dict[str, dict]:
from common import load_emulator_profiles # noqa: PLC0415
return _profiles_in(str(siteinputs.current().emulators), skip_aliases=False)
return load_emulator_profiles(str(_REPO_ROOT / "emulators"), skip_aliases=False)
@functools.lru_cache(maxsize=None)
def _profiles_in(emulators_dir: str, skip_aliases: bool) -> dict[str, dict]:
return load_emulator_profiles(emulators_dir, skip_aliases=skip_aliases)
def _entry_systems(emulator: str, entry: dict) -> list[str]:
@@ -8212,6 +8258,26 @@ def _exact_size(size: int) -> str:
return _fmt_size(size) if size < 1024 else f"{_fmt_size(size)} ({size:,} bytes)"
def _build_file_links(pin_build: list[dict]) -> list[str]:
"""Each platform build file that holds the revision, linked at the
commit it was read at when the source record holds it. The prose of
pin_frozen named the file as plain text."""
from sitesources import _browse_repo, _source_permalink, tree_holds # noqa: PLC0415
links = []
for build in pin_build:
repo = _browse_repo(str(build["repo"]))
path = str(build["path"])
revision = str(build["revision"])
text = f"{_escape_tags(_platform_display(str(build['platform'])))} {_code(path)}"
# Linked where the record saw the file at that commit, as every
# source link is: a tag can move, and is never recorded.
if repo is not None and tree_holds(repo, revision, path):
text = f"[{text}]({_source_permalink(repo, revision, path, None, None)})"
links.append(f"{text} at {_code(str(build['revision']))} sets {_code(str(build['variable']))}")
return links
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."""
@@ -8232,6 +8298,9 @@ def _build_rows(profile: dict) -> list[str]:
if frozen:
held = _escape_tags(' '.join(str(frozen).split())).replace('|', r'\|')
rows.append(f"| {label} | {held} |")
builds = _build_file_links(profile.get("pin_build") or [])
if builds:
rows.append(f"| Build file | {'<br>'.join(builds)} |")
announced = profile.get("version_source")
if isinstance(announced, dict) and announced.get("url"):
url = str(announced["url"])
@@ -8483,12 +8552,7 @@ def _render_emulator_file(
if fnote:
lines.append(f"- {data_root_words(_escape_tags(link_prose_citations(profile, str(fnote))), data_href)}")
if not in_repo and f.get("unsourceable"):
# The badge alone sent readers after a dump that cannot exist, and the
# reason was only on the gaps page, which the card did not link.
lines.append(
f"- Not collectable: {_escape_tags(str(f['unsourceable']))} "
"([acknowledged gaps](../gaps.md#acknowledged-gaps))"
)
lines.append(f"- {_unsourceable_line(f)}")
lines.extend(_contents_lines(contents))
lines[body_start:] = _search_skipped_apart(lines[body_start:])
lines.append("")
@@ -8600,12 +8664,7 @@ def _emulator_card_folds(
open_lines.append(data_root_words(
_escape_tags(link_prose_citations(profile, str(fnote))), data_href))
if not in_repo and f.get("unsourceable"):
# The badge alone sent readers after a dump that cannot exist, and the
# reason was only on the gaps page, which the card did not link.
open_lines.append(
f"Not collectable: {_escape_tags(str(f['unsourceable']))} "
"([acknowledged gaps](../gaps.md#acknowledged-gaps))"
)
open_lines.append(_unsourceable_line(f))
lines = [f"- {d}" for d in open_lines]
shut_fold = [f"- {d}" for d in hashes + [m for m in more if skipped(m)]]
read_fold = [f"- {m}" for m in more if not skipped(m)]
@@ -8988,10 +9047,14 @@ def emulator_install_route(name: str, profile: dict) -> tuple[str, list[str], tu
return command, setups, oses
@functools.cache
def _standalone_cores(platform: str) -> frozenset[str]:
"""The emulators a setup launches as apps, by its platform file."""
config = load_platform_config(platform, str(_REPO_ROOT / "platforms"))
return _standalone_cores_in(str(siteinputs.current().platforms), platform)
@functools.cache
def _standalone_cores_in(platforms_dir: str, platform: str) -> frozenset[str]:
config = load_platform_config(platform, platforms_dir)
return frozenset(str(c) for c in config.get("standalone_cores") or [])
@@ -10505,6 +10568,33 @@ def _hash_cell(hashes: dict[str, list[str]]) -> str:
)
def _unsourceable_line(f: dict) -> str:
"""Why the collection holds no copy of a file a profile marks
`unsourceable:`, linked to the list of them. The flag only keeps a
same-named file from being served in its place: the file is still
wanted, and "Not collectable" sent readers away from KANJI2.ROM, which
MAME's fm7740sx.zip carries."""
return (f"Not collected yet: {_escape_tags(str(f['unsourceable']))} "
"([files not collected yet](../gaps.md#not-collected-yet))")
def _catalogued_needs(report: dict | None) -> dict[tuple[str, str], list[str]]:
"""(profile, file name casefolded) -> where a dump catalog or an
imported romset lists that file, from a provenance report built with
the profiles' needs."""
out: dict[tuple[str, str], list[str]] = {}
for source, data in sorted((report or {}).items()):
label = _PROVENANCE_LABELS.get(source, source)
for entry in data.get("missing", []):
where = [f"{label} `{_escape_tags(str(entry.get('name', '')))}`"]
where += [f"{_escape_tags(str(hit['source']))} `{_escape_tags(str(hit['archive']))}`"
for hit in entry.get("romsets", [])]
for need in entry.get("needed_by", []):
held = out.setdefault((need["emulator"], str(need["name"]).casefold()), [])
held += [w for w in where if w not in held]
return out
def _reason_cell(text: str) -> str:
"""A reason in a gaps table, in the class that keeps its column readable."""
return f'<span class="rb-reason">{text}</span>' if text else "-"
@@ -10799,9 +10889,14 @@ def generate_gap_analysis(
registry: dict | None = None,
gap_report: dict | None = None,
moved: dict[str, str] | None = None,
catalogued: dict | None = None,
) -> str:
"""Generate a unified gap analysis page.
``catalogued`` is a provenance report built with the profiles' needs:
a file the collection does not hold yet names the catalog or romset
that lists it.
With ``moved``, the list of files to source goes to pages of its own,
keyed by stem: on the page it made 662 KB of HTML.
@@ -11232,7 +11327,7 @@ def generate_gap_analysis(
"",
])
# ---- Section 4: Acknowledged gaps (unsourceable files) ----
# ---- Section 4: files the collection does not hold yet (unsourceable) ----
all_unsourceable: list[dict] = []
for emu_name, data in sorted(report_all.items()):
@@ -11246,19 +11341,26 @@ def generate_gap_analysis(
})
if all_unsourceable:
listed = _catalogued_needs(catalogued)
# The old heading's address still leads here.
lines.extend([
"## Acknowledged gaps",
'<span id="acknowledged-gaps"></span>',
"",
f"{len(all_unsourceable)} files documented as unsourceable "
"(verified from source code).",
"## Not collected yet",
"",
"| File | Emulator | Reason | Source ref |",
"|------|----------|--------|-----------|",
f"{len(all_unsourceable)} files the collection does not hold. A file "
"with the same name is never served in their place. Each is still "
"wanted, and the last column names a catalog or romset that lists it.",
"",
"| File | Emulator | Reason | Source ref | Listed in |",
"|------|----------|--------|-----------|-----------|",
])
for u in sorted(all_unsourceable, key=lambda x: x["name"]):
where = listed.get((u["emu_key"], str(u["name"]).casefold()), [])
lines.append(
f"| `{u['name']}` | {u['emulator']} | {_reason_cell(u['reason'])} "
f"| {_source_ref_markdown(profiles[u['emu_key']], u['source_ref'])} |"
f"| {_source_ref_markdown(profiles[u['emu_key']], u['source_ref'])} "
f"| {', '.join(where) or '-'} |"
)
lines.append("")
@@ -11844,7 +11946,7 @@ def _own_root_folders(registry: dict) -> dict[str, str]:
destination carries its root in every path."""
folders = {}
for key, entry in registry.items():
config = load_platform_config(key, "platforms")
config = load_platform_config(key, str(siteinputs.current().platforms))
if config.get("base_destination") == "" and entry.get("extract_to"):
folders[key] = entry["extract_to"]
return folders
@@ -11944,9 +12046,10 @@ def generate_which_pack() -> str:
The tiles are the choice. Open tables under them listed every setup a
second time and made the page 11,800 px tall on a phone."""
registry = load_platform_registry("platforms")
registry = load_platform_registry(str(siteinputs.current().platforms))
record = release_record.load_advertised()
display = {key: load_platform_config(key, "platforms").get("platform", key) for key in registry}
display = {key: load_platform_config(key, str(siteinputs.current().platforms)).get("platform", key)
for key in registry}
all_tabs = ("windows", "macos", "linux", "steamdeck", "android")
lines = [
"---",
@@ -12322,6 +12425,12 @@ def main() -> None:
def _generate(args: argparse.Namespace) -> None:
# Every page reads these, never the repository's own files.
siteinputs.use(siteinputs.SiteInputs(
db=Path(args.db).resolve(),
emulators=Path(args.emulators_dir).resolve(),
platforms=Path(args.platforms_dir).resolve(),
))
db = load_database(args.db)
docs = Path(args.docs_dir)
# Files the site serves as they are (data, downloads, images), in the
@@ -12602,12 +12711,16 @@ def _generate(args: argparse.Namespace) -> None:
sitestarlight.MOVED_PAGES.setdefault(
f"cross-reference-{pname}.md", f"platforms/{pname}.md")
provenance_report = build_report(
db, load_provenance_snapshots(), needs=profile_needs(profiles, db))
print("Generating gap analysis page...")
gap_pages: dict[str, str] = {}
write_if_changed(
str(docs / "gaps.md"),
tables_out_of_search(generate_gap_analysis(
profiles, coverages, db, suppl_names, registry, gap_report, gap_pages,
catalogued=provenance_report,
)) + reference_next(),
)
for stem, page in gap_pages.items():
@@ -12623,7 +12736,6 @@ def _generate(args: argparse.Namespace) -> None:
)
print("Generating dump provenance page...")
provenance_report = build_report(db, load_provenance_snapshots())
provenance_pages: dict[str, str] = {}
write_if_changed(
str(docs / "provenance.md"),
+3 -3
View File
@@ -319,8 +319,8 @@ def _build_readme(
if fallback_systems:
listed = "\n".join(f" {_group_label(s)}" for s in fallback_systems)
region_info += (
"\n These systems have no BIOS in those regions, so all\n"
" of theirs were kept:\n"
"\n These systems have no BIOS in those regions, so every\n"
" BIOS file for them was kept:\n"
f"{listed}\n"
)
region_info += (
@@ -335,7 +335,7 @@ def _build_readme(
slot_info = (
" Where a core declares which BIOS it prefers, only that one was\n"
" kept for each system and region. Systems whose cores declare no\n"
" order keep all of theirs.\n"
" order keep every BIOS file.\n"
)
if undecidable_slots:
slot_info += (
+27
View File
@@ -978,6 +978,32 @@ def _refuse_offline_without_caches(
)
def _refuse_offline_export_without_originals(
parser: argparse.ArgumentParser, args: argparse.Namespace,
cache: str = ".cache/upstream-native",
) -> None:
"""An offline run with --with-export needs each patched file cached.
Seven formats carry code, so step 2e patches the platform's own file,
which --offline does not fetch. On a fresh clone the database, verify
and the truth model were built before step 2e failed on every one.
"""
if not (args.offline and args.with_export):
return
from exporter import discover_exporters # noqa: PLC0415
lacking = sorted(
name for name, exporter in discover_exporters().items()
if exporter.needs_original() and not any(Path(cache, name).glob("*"))
)
if lacking:
parser.error(
f"--with-export patches each platform's own file, and {cache}/ holds none "
f"for {', '.join(lacking)}: run python scripts/export_native.py --all "
"--refresh-cache once, or drop --offline"
)
def _truth_steps(args: argparse.Namespace, results: dict) -> bool:
"""Steps 2c to 2e: truth model, its diff, the native export."""
all_ok = True
@@ -1105,6 +1131,7 @@ def main() -> None:
args = parser.parse_args()
_refuse_unapplied_flags(parser, args)
_refuse_offline_without_caches(parser, args)
_refuse_offline_export_without_originals(parser, args)
if not args.offline:
# Read once and put in the environment every step inherits: the
# scrapers and refresh_data_dirs went anonymous unless the user had
+24 -2
View File
@@ -7,7 +7,8 @@ issue 81 happened. Install commands, the parts sentence and the table come
from the record and the registry. Each row links the platform's page under
get/ on the site, which outlives the release, and the setups the registry
does not mark release_pack are named with the command. The maintainer writes
what changed, and that text is appended as given.
what changed, and that text is appended as given once it holds none of the
REFUSED phrases.
Usage:
python scripts/release_notes.py --whats-new tmp/whats-new.md --output notes.md
@@ -102,6 +103,20 @@ def _link(name: str, pack: dict) -> str:
return f"[{label}]({release_record.get_page(_owner(name, pack), SITE)})"
# Words the published notes of v2026.10.10 carried by hand: "never from a
# forum list" judged lists other people keep, and "a game never stops on a
# missing file" was a claim the gap analysis contradicts. Text about other
# projects says what changed, not who erred (wiki/release-process.md).
REFUSED = ("their file", "theirs", "of ours", "corrected", "wrong", "drift",
"left out", "never seen", "forum", "never stops", "never fails")
def refused_words(text: str) -> list[str]:
"""The refused phrases the text holds, case ignored."""
lowered = text.lower()
return [phrase for phrase in REFUSED if phrase in lowered]
def render(record: dict, platforms_dir: str, whats_new: str) -> str:
packs = record.get("packs", {})
folders = _extract_folders(record, platforms_dir)
@@ -124,7 +139,8 @@ def render(record: dict, platforms_dir: str, whats_new: str) -> str:
"### Download a pack",
"",
("One complete pack for each platform below: the BIOS list the platform"
" publishes plus every file its emulators open. Each link is a page"
" publishes plus the files its emulators open. Files no pack carries yet"
f" are listed in the [gap analysis]({SITE}gaps/). Each link is a page"
" that keeps its address from one release to the next. Files is what the pack"
" holds once extracted, the count a file manager shows. Extracted size"
" is the space those files take, and the download is smaller."),
@@ -173,6 +189,12 @@ def main() -> int:
if not whats_new.strip():
print(f"Error: {args.whats_new} is empty", file=sys.stderr)
return 1
refused = refused_words(whats_new)
if refused:
print(f"Error: {args.whats_new} says {', '.join(repr(w) for w in refused)}. State what"
" changed and how it was checked, not a verdict on other projects or a"
" promise the gap analysis contradicts", file=sys.stderr)
return 1
write_text_atomic(str(args.output), render(record, args.platforms_dir, whats_new))
print(f"Wrote {args.output} for {record['tag']}, {len(record['packs'])} packs")
return 0
+27 -10
View File
@@ -36,6 +36,7 @@ from xml.sax.saxutils import escape
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))
import siteinputs # noqa: E402
import sitemarkup as markup # noqa: E402
PLACEHOLDER = re.compile(r"(?m)^([ \t]*)<!--\s*image:\s*([A-Za-z0-9_-]+)\s*-->[ \t]*$")
@@ -314,21 +315,29 @@ def svg(figure: Figure, layout: str, suffix: str) -> str:
# Data
@lru_cache(maxsize=None)
def registry() -> dict:
return _registry_in(str(siteinputs.current().platforms))
@lru_cache(maxsize=None)
def _registry_in(platforms_dir: str) -> dict:
import yaml
with open(ROOT / "platforms" / "_registry.yml", encoding="utf-8") as handle:
with open(Path(platforms_dir) / "_registry.yml", encoding="utf-8") as handle:
return yaml.safe_load(handle).get("platforms", {})
@lru_cache(maxsize=None)
def setups() -> tuple[str, ...]:
"""The registered setups, archived ones included: each has its own page,
pack and file list, so every count of setups on the site is this one."""
return _setups_in(str(siteinputs.current().platforms))
@lru_cache(maxsize=None)
def _setups_in(platforms_dir: str) -> tuple[str, ...]:
from common import list_registered_platforms
return tuple(sorted(list_registered_platforms(str(ROOT / "platforms"), include_archived=True)))
return tuple(sorted(list_registered_platforms(platforms_dir, include_archived=True)))
def setups_named(named: list[str]) -> str:
@@ -339,15 +348,19 @@ def setups_named(named: list[str]) -> str:
return ", ".join(named) + (f" and {others} more" if others else "")
@lru_cache(maxsize=None)
def platform_modes() -> dict[str, tuple[str, str, str | None]]:
"""name -> (display name, verification mode, hash rule) for every platform."""
return _platform_modes_in(str(siteinputs.current().platforms))
@lru_cache(maxsize=None)
def _platform_modes_in(platforms_dir: str) -> dict[str, tuple[str, str, str | None]]:
from common import load_platform_config
out: dict[str, tuple[str, str, str | None]] = {}
for name in setups():
config = load_platform_config(name, str(ROOT / "platforms"))
rule = registry().get(name, {}).get("hash_rule") or config.get("hash_rule")
for name in _setups_in(platforms_dir):
config = load_platform_config(name, platforms_dir)
rule = _registry_in(platforms_dir).get(name, {}).get("hash_rule") or config.get("hash_rule")
out[name] = (config.get("platform", name), config.get("verification_mode", ""), rule)
return out
@@ -365,13 +378,17 @@ def _folder(name: str) -> str:
CHAIN_EXAMPLES = (("batocera", "duckstation"), ("emudeck", "pcsx2"), ("retroarch", "beetle_psx"))
@lru_cache(maxsize=None)
def emulator_names() -> dict[str, str]:
return _emulator_names_in(str(siteinputs.current().emulators))
@lru_cache(maxsize=None)
def _emulator_names_in(emulators_dir: str) -> dict[str, str]:
import yaml
names: dict[str, str] = {}
for _, emulator in CHAIN_EXAMPLES:
with open(ROOT / "emulators" / f"{emulator}.yml", encoding="utf-8") as handle:
with open(Path(emulators_dir) / f"{emulator}.yml", encoding="utf-8") as handle:
label = str(yaml.safe_load(handle).get("emulator", emulator))
names[emulator] = re.sub(r"\s*\(.*\)$", "", label)
return names
+32
View File
@@ -0,0 +1,32 @@
"""The collection, profiles and platform files the site describes.
generate_site.py takes them as --db, --emulators-dir and --platforms-dir and
installs them here before writing a page. Every page module reads its inputs
from current(), so a run on another collection describes that collection on
every page. A module used on its own reads the repository's files.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
@dataclass(frozen=True)
class SiteInputs:
db: Path = ROOT / "database.json"
emulators: Path = ROOT / "emulators"
platforms: Path = ROOT / "platforms"
_current = [SiteInputs()]
def current() -> SiteInputs:
return _current[0]
def use(inputs: SiteInputs) -> None:
_current[0] = inputs
+8 -5
View File
@@ -7,6 +7,7 @@ import urllib.parse
from collections.abc import Callable
from pathlib import Path
import siteinputs
import sitemarkup as markup
@@ -401,7 +402,8 @@ def _build_boards() -> None:
from common import load_emulator_profiles
database = _REPO_ROOT / "database.json"
inputs = siteinputs.current()
database = inputs.db
if not database.exists():
_archive_boards.clear()
_board_names.clear()
@@ -409,7 +411,7 @@ def _build_boards() -> None:
return
with open(database, encoding="utf-8") as handle:
db = json.load(handle)
profiles = load_emulator_profiles(str(_REPO_ROOT / "emulators"), skip_aliases=False)
profiles = load_emulator_profiles(str(inputs.emulators), skip_aliases=False)
_arcade_source[0] = (profiles, db)
profiles, db = _arcade_source[0]
files = db.get("files", {})
@@ -780,9 +782,10 @@ def _ensure_console_sections() -> None:
from common import load_emulator_profiles
from generate_site import _group_by_manufacturer
with open(_REPO_ROOT / "database.json", encoding="utf-8") as handle:
inputs = siteinputs.current()
with open(inputs.db, encoding="utf-8") as handle:
db = json.load(handle)
profiles = load_emulator_profiles(str(_REPO_ROOT / "emulators"), skip_aliases=False)
profiles = load_emulator_profiles(str(inputs.emulators), skip_aliases=False)
set_console_sections(_group_by_manufacturer(db), program_names(profiles))
@@ -1431,7 +1434,7 @@ def system_search_aliases(sys_id: str) -> list[str]:
platform_system_names,
)
platforms_dir = str(_REPO_ROOT / "platforms")
platforms_dir = str(siteinputs.current().platforms)
index: dict[str, set[str]] = {}
for platform in list_registered_platforms(platforms_dir, include_archived=True):
config = load_platform_config(platform, platforms_dir)
+19
View File
@@ -321,9 +321,28 @@ def build_source_paths(
# Recorded even when it holds no cited path, so a pinned tree
# missing from the record is one no run has read.
held.setdefault(tree_key(repo, pin), set()).update(paths & tree)
for repo, pin, path in build_files(profile):
tree = (_cached_tree(repo, pin, cache_dirs, fetch)
or _listed_directories(repo, pin, {path}, cache_dirs, fetch))
if tree is None:
unread.add(tree_key(repo, pin))
continue
held.setdefault(tree_key(repo, pin), set()).update({path} & tree)
return {key: sorted(paths) for key, paths in sorted(held.items())}, sorted(unread)
def build_files(profile: dict) -> list[tuple[upstream.Repo, str, str]]:
"""The platform build files `pin_build` names at a commit, as (repo,
revision, path). A tag can move, so a build read at a tag is not linked."""
found = []
for build in profile.get("pin_build") or []:
repo = _browse_repo(str(build.get("repo", "")))
revision = str(build.get("revision", ""))
if repo is not None and re.fullmatch(r"[0-9a-f]{40}", revision):
found.append((repo, revision, str(build.get("path", ""))))
return found
def keep_unread(
held: dict[str, list[str]], unread: list[str], previous: dict[str, list[str]]
) -> dict[str, list[str]]:
+9
View File
@@ -44,3 +44,12 @@ def pages() -> list[Path]:
def name(path: Path) -> str:
"""A page file's docs path, the name tests use for it."""
return path.relative_to(CONTENT).with_suffix(".md").as_posix()
def generator_sources() -> list[Path]:
"""Every module whose strings reach a page: generate_site.py, each
scripts/site*.py and the pack README writer. Listed by glob, so text
moved out of generate_site.py stays under the guards that read it."""
scripts = REPO_ROOT / "scripts"
return sorted({*scripts.glob("site*.py"), scripts / "generate_site.py",
scripts / "packreadme.py"})
+85 -3
View File
@@ -1145,9 +1145,12 @@ class PipelineRegressions(unittest.TestCase):
argv = ["pipeline.py", "--offline", "--skip-packs", "--skip-docs",
"--with-export", "--target", "switch"]
# The originals the offline export patches are not cached in a
# checkout: the run would be refused before any step.
with mock.patch.object(sys, "argv", argv), mock.patch.object(
pipeline, "run", side_effect=fake_run
), contextlib.redirect_stdout(io.StringIO()), contextlib.suppress(SystemExit):
), mock.patch.object(pipeline, "_refuse_offline_export_without_originals"), \
contextlib.redirect_stdout(io.StringIO()), contextlib.suppress(SystemExit):
pipeline.main()
truth_runs = [c for c in commands if "scripts/generate_truth.py" in c]
export = next(c for c in commands if "scripts/export_native.py" in c)
@@ -5218,8 +5221,32 @@ class UnsourceableCardSaysWhy(unittest.TestCase):
text = "\n".join(_render_emulator_file(
entry, {"files": [entry]}, None, [entry], lambda f: False, "file-x",
))
self.assertIn("Not collectable: written by the firmware editor", text)
self.assertIn("../gaps.md#acknowledged-gaps", text)
self.assertIn("Not collected yet: written by the firmware editor", text)
self.assertIn("../gaps.md#not-collected-yet", text)
self.assertNotIn("collectable", text)
def test_the_gaps_page_names_where_a_wanted_file_is_listed(self):
"""The section read "Acknowledged gaps ... (verified from source
code)" while No-Intro lists XM7's KANJI2.ROM and MAME's fm7740sx.zip
carries it: the flag only keeps a homonym out, the file is wanted."""
from scripts.generate_site import generate_gap_analysis
unsourced = {"name": "KANJI2.ROM", "reason": "no dump distributed", "source_ref": ""}
report = {"xm7": {"emulator": "XM7", "gaps": 0, "gap_missing": 0, "gap_bios": 0,
"unsourceable": [unsourced], "gap_details": []}}
catalogued = {"no-intro": {"missing": [{
"name": "KANJI2.rom",
"romsets": [{"source": "mame", "archive": "fm7740sx.zip"}],
"needed_by": [{"emulator": "xm7", "name": "KANJI2.ROM", "required": True}],
}]}}
page = generate_gap_analysis(
{"xm7": {"emulator": "XM7", "files": []}}, {}, {"files": {}, "indexes": {}},
gap_report=report, catalogued=catalogued)
self.assertIn("## Not collected yet", page)
self.assertIn('<span id="acknowledged-gaps"></span>', page)
self.assertIn("| No-Intro `KANJI2.rom`, mame `fm7740sx.zip` |", page)
self.assertNotIn("Acknowledged", page)
self.assertNotIn("verified from source code", page)
@@ -5258,6 +5285,61 @@ class CollectionCardsCarryFullHashes(unittest.TestCase):
self.assertIn(f'<dd class="rb-hash">{value}</dd>', text)
class AHeldRevisionLinksItsBuildFile(unittest.TestCase):
"""advanced_drastic's page printed the KNULLI build file that holds its
revision as plain text, while pin_build carries the repository, the tag
and the path a pinned link needs. 36 profiles carry one."""
def test_the_build_file_row(self):
from scripts.generate_site import _build_rows
rows = _build_rows({"pin_frozen": "KNULLI builds this revision.", "pin_build": [{
"platform": "knulli", "repo": "https://github.com/knulli-cfw/knulli-linux",
"revision": "20260511", "path": "package/emulators/advanced_drastic/advanced_drastic.mk",
"variable": "ADVANCED_DRASTIC_VERSION", "value": "eaf41ab9"}]})
# A tag can move: named, never linked.
self.assertIn(
"| Build file | KNULLI `package/emulators/advanced_drastic/advanced_drastic.mk` "
"at `20260511` sets `ADVANCED_DRASTIC_VERSION` |", rows)
def test_a_build_read_at_a_recorded_commit_links(self):
from scripts.generate_site import _build_rows
pin = "46128cd252cdecae852a4d0bafa2aaf9d03ab563"
rows = _build_rows({"pin_build": [{
"platform": "retropie", "repo": "https://github.com/RetroPie/RetroPie-Setup",
"revision": pin, "path": "scriptmodules/emulators/advmame-1.4.sh",
"variable": "rp_module_repo", "value": "x"}]})
self.assertIn(
f"[RetroPie `scriptmodules/emulators/advmame-1.4.sh`](https://github.com/RetroPie/"
f"RetroPie-Setup/blob/{pin}/scriptmodules/emulators/advmame-1.4.sh)", rows[0])
def test_the_record_holds_the_build_files(self):
import sitesources # noqa: PLC0415
files = sitesources.build_files({"pin_build": [
{"repo": "https://github.com/o/r", "revision": "a" * 40, "path": "p.mk"},
{"repo": "https://github.com/o/r", "revision": "v1", "path": "q.mk"}]})
self.assertEqual([(f[1], f[2]) for f in files], [("a" * 40, "p.mk")])
class AVariantCardSaysWhatToSaveItAs(unittest.TestCase):
"""The Download button of a .variants/ card saved advision.zip.1c086064,
the storage name, and nothing said RetroBat looks for advision.zip."""
def test_the_name_to_save_it_under(self):
from scripts.generate_site import FileConsumers, _system_file_card
entry = {"name": "advision.zip", "size": 10, "sha1": "a" * 40,
"path": "bios/Entex/Adventure Vision/.variants/advision.zip.1c086064"}
text = "\n".join(_system_file_card(entry, FileConsumers({}, {})))
self.assertIn("Save as", text)
self.assertIn("advision.zip.1c086064", text)
plain = "\n".join(_system_file_card(
{**entry, "path": "bios/Entex/Adventure Vision/advision.zip"}, FileConsumers({}, {})))
self.assertNotIn("Save as", plain)
class NullReadsAsNone(unittest.TestCase):
"""`upstream: ~` printed `| Upstream | None |` and `bios_filename: null`
+19
View File
@@ -1984,5 +1984,24 @@ class ArchivedIsWhatTheToolsDo(unittest.TestCase):
self.assertNotIn("list_platforms", workflow.read_text(encoding="utf-8"))
class TheCatalogRowsStateFacts(unittest.TestCase):
"""tools.md said "redump.org serves stale DATs" and "Dat-o-Matic blocks
automation", verdicts on two preservation projects, and gave Redump's
/static/bios links, which the scraper no longer reads."""
def test_dates_and_routes_not_verdicts(self):
text = (WIKI / "tools.md").read_text(encoding="utf-8")
rows = [line for line in text.splitlines()
if line.startswith(("| Redump |", "| No-Intro |"))]
self.assertEqual(len(rows), 2)
for word in ("stale", "blocks"):
self.assertNotIn(word, " ".join(rows))
scraper = (ROOT / "scripts" / "scraper" / "redump_dat_scraper.py").read_text(encoding="utf-8")
self.assertIn("/bios/<CODE>", scraper)
self.assertIn("`/bios/<CODE>`", rows[0])
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -11,12 +11,14 @@ installer fetches and leaves out the data directories a pack also carries.
from __future__ import annotations
import hashlib
import io
import json
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
import yaml
@@ -552,6 +554,35 @@ class TheReleaseNotesReadTheRecord(unittest.TestCase):
def test_the_notes_never_open_on_the_collection(self):
self.assertNotIn("collection", self.render().split("### What changed")[0])
def test_the_notes_claim_only_what_the_gaps_allow(self):
"""v2026.10.10 said a pack holds every file its emulators open, "so a
game never stops on a missing file", and that hashes never come
"from a forum list". The gap analysis lists what no pack carries."""
text = self.render().split("### What changed")[0]
self.assertNotIn("every file its emulators", text)
self.assertIn("https://abdess.github.io/retrobios/gaps/", text)
self.assertEqual(release_notes.refused_words(text), [])
def test_a_judging_whats_new_is_refused(self):
self.assertEqual(
release_notes.refused_words(
"- Every hash comes from the emulator's source, never from a forum list.\n"
"- So a game never stops on a missing file."),
["forum", "never stops"])
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
whats_new = Path(tmp) / "whats-new.md"
whats_new.write_text("- Hashes never from a forum list\n", encoding="utf-8")
record = Path(tmp) / "release.json"
record.write_text(json.dumps(self.RECORD), encoding="utf-8")
argv = ["release_notes.py", "--record", str(record), "--whats-new", str(whats_new),
"--platforms-dir", str(REPO_ROOT / "platforms"),
"--output", str(Path(tmp) / "notes.md")]
with mock.patch.object(sys, "argv", argv), \
mock.patch("sys.stderr", new_callable=io.StringIO) as err:
self.assertEqual(release_notes.main(), 1)
self.assertIn("'forum'", err.getvalue())
self.assertFalse((Path(tmp) / "notes.md").exists())
def test_the_release_process_runs_it(self):
page = (REPO_ROOT / "wiki" / "release-process.md").read_text(encoding="utf-8")
self.assertIn("scripts/release_notes.py", page)
+7
View File
@@ -124,6 +124,13 @@ class LauncherIndex(unittest.TestCase):
"install/launchers/index.json is stale: run "
"python scripts/generate_launcher_index.py")
def test_the_installer_page_says_when_to_rewrite_it(self):
"""The list records install.py's SHA-256, and the page that says
what a change to install.py rewrites named only the two pins."""
page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
section = page.split("## Changing the installer", 1)[1].split("\n## ", 1)[0]
self.assertIn("scripts/generate_launcher_index.py", section)
def test_every_member_of_the_archives_is_listed(self):
import generate_launcher_index # noqa: PLC0415
+12
View File
@@ -386,6 +386,18 @@ class NoClauseSemicolons(unittest.TestCase):
]
self.assertEqual(offenders, [], "\n".join(offenders[:20]))
def test_bold_labels_do_not_run_together(self):
"""community-tools.md wrote Author, Repository and License on three
lines of one paragraph: rendered, the three ran on one line."""
label = r"\*\*[^*\n]+:\*\*"
run = re.compile(rf"^{label}.*\n{label}", re.M)
offenders = [
f"{p.relative_to(ROOT)}: {m.group(0).splitlines()[0]}"
for p in _tracked() if p.suffix == ".md"
for m in run.finditer(FENCED.sub("", p.read_text(encoding="utf-8")))
]
self.assertEqual(offenders, [], "\n".join(offenders[:20]))
def test_wiki_headings_use_sentence_case(self):
"""The gaps page and two wiki pages titled in Title Case ("What Each
Pack Contains") beside sentence-case pages ("Quick install")."""
+59
View File
@@ -832,6 +832,65 @@ class AnArchiveMemberIsFoundByItsCrc(unittest.TestCase):
self.assertIn("byLayer('archive-member')", script)
class AWantedDumpIsFoundByItsHash(unittest.TestCase):
"""The SHA1 of a PS2 SCPH-30005R BIOS, which Redump lists as
ps2-dvd-212k.bin and the Wanted page asks for, answered that no file
the site knows has it."""
def test_each_hash_of_a_wanted_row_is_a_record(self):
import generate_site # noqa: PLC0415
row = {"catalog": "redump", "dat": "Sony - PlayStation 2 - BIOS Images",
"name": "ps2-dvd-212k.bin", "description": "", "size": "4194304",
"crc32": "1A2B3C4D", "md5": "", "sha1": "5b346eb9258d710609a9855befd35cab8f3a334a"}
records = generate_site.file_lookup_records(
{"files": {}, "indexes": {}}, {}, {}, [], wanted=[row, {**row, "dat": "other"}])
self.assertEqual(records, [
{"value": "1a2b3c4d", "algorithm": "crc32", "layer": "wanted", "owner": "Redump",
"name": "ps2-dvd-212k.bin", "url": "/retrobios/provenance/#redump"},
{"value": "5b346eb9258d710609a9855befd35cab8f3a334a", "algorithm": "sha1",
"layer": "wanted", "owner": "Redump", "name": "ps2-dvd-212k.bin",
"url": "/retrobios/provenance/#redump"},
])
def test_the_schema_and_the_page_carry_the_layer(self):
import json as _json # noqa: PLC0415
import generate_site # noqa: PLC0415
root = Path(__file__).resolve().parents[1]
schema = _json.loads((root / "schemas" / "site-api-envelope.schema.json").read_text())
self.assertIn('"wanted"', _json.dumps(schema))
self.assertIn("byLayer('wanted')", (root / "website" / "src" / "scripts" / "site.ts").read_text())
source = Path(generate_site.__file__).read_text(encoding="utf-8")
self.assertIn('retired_files.load(_REPO_ROOT / "retired.json"), wanted)', source)
class AMovedPartCardReachesItsPage(unittest.TestCase):
"""One entry added to mame.yml moved 938 of its 1,585 cards from one
part page to another, and emulators/mame-files-1/#file-mackbd-m0110a-zip
led to a page without the card: part pages have no card index, and
site.js redirected only sha1- ids."""
def test_a_part_page_sends_a_missing_card_to_the_emulator_page(self):
import re as _re # noqa: PLC0415
import generate_site # noqa: PLC0415
script = (Path(__file__).resolve().parents[1] / "website" / "src" / "scripts"
/ "site.ts").read_text(encoding="utf-8")
body = script.split("const followMovedCard = () => {", 1)[1].split("\n};", 1)[0]
pattern = _re.search(r"const part = /(.+?)/\.exec\(location\.pathname\)", body).group(1)
files = [{"name": f"f{i}.bin", "path": f"f{i}.bin", "required": True} for i in range(600)]
stem = generate_site.emulator_parts("mame", files)[1][0]
found = _re.search(pattern.replace("\\/", "/"), f"/retrobios/emulators/{stem}/")
self.assertEqual(found.group(1), "mame")
self.assertIn("emulators/${part[1]}/#", body)
self.assertIn("id.startsWith('file-')", body)
# The emulator page holds the index whose row leads to the card.
self.assertIn("closest('.rb-card-index tr')", body)
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
+122
View File
@@ -0,0 +1,122 @@
"""generate_site --db, --emulators-dir and --platforms-dir reach every page.
The run loaded the given database, but the page-finishing pass reloaded the
repository's database.json: a card held only by the given collection stopped
the whole run on a KeyError. The emulator names and engine sections came from
./emulators, and has_release_pack read `platforms` relative to the working
directory, so a get/ page said "No ZIP" for a setup the API marked as released.
"""
from __future__ import annotations
import ast
import json
import os
import shutil
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 generate_site # noqa: E402
import sitediagrams # noqa: E402
import siteinputs # noqa: E402
SHA1 = "0123456789ab" + "c" * 28
class _Inputs(unittest.TestCase):
def use(self, **paths: Path) -> None:
siteinputs.use(siteinputs.SiteInputs(**paths))
self.addCleanup(siteinputs.use, siteinputs.SiteInputs())
class TheGivenDatabase(_Inputs):
def setUp(self) -> None:
tmp = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp" if (REPO_ROOT / "tmp").is_dir() else None))
self.addCleanup(shutil.rmtree, tmp)
self.db = tmp / "database.json"
self.db.write_text(json.dumps({"files": {SHA1: {"path": "bios/X/only-here.bin"}}}))
def test_a_card_held_elsewhere_resolves_in_the_given_collection(self) -> None:
self.use(db=self.db)
page = generate_site._cards_elsewhere("[x](#sha1-0123456789ab) ")
self.assertIn(f"files/?sha1={SHA1}", page)
def test_paths_are_read_from_the_given_collection(self) -> None:
self.use(db=self.db)
self.assertEqual(generate_site._collection_sha1_by_path(), {"bios/X/only-here.bin": SHA1})
class TheGivenProfilesAndPlatforms(_Inputs):
def setUp(self) -> None:
tmp = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp" if (REPO_ROOT / "tmp").is_dir() else None))
self.addCleanup(shutil.rmtree, tmp)
self.emulators = tmp / "emulators"
self.emulators.mkdir()
(self.emulators / "fixturecore.yml").write_text(
"emulator: Fixture Core\ntype: libretro\nsystems: [fixture-system]\nfiles: []\n")
self.platforms = tmp / "platforms"
shutil.copytree(REPO_ROOT / "platforms", self.platforms)
def test_emulator_names_come_from_the_given_profiles(self) -> None:
self.use(emulators=self.emulators)
self.assertEqual(set(generate_site._profiles_on_disk()), {"fixturecore"})
self.assertEqual(generate_site._emulator_named_link("fixturecore"),
"[Fixture Core](emulators/fixturecore.md)")
def test_release_pack_does_not_depend_on_the_working_directory(self) -> None:
released = generate_site._release_pack_platforms()
self.assertIn("retroarch", released)
here = os.getcwd()
os.chdir(REPO_ROOT / "scripts")
self.addCleanup(os.chdir, here)
self.assertTrue(generate_site.has_release_pack("retroarch"))
self.assertEqual(generate_site.get_page_title("retroarch", "RetroArch"), "RetroArch BIOS pack")
def test_the_registry_comes_from_the_given_platforms(self) -> None:
registry = self.platforms / "_registry.yml"
registry.write_text(registry.read_text().replace("release_pack: true", "release_pack: false"))
self.use(platforms=self.platforms)
self.assertFalse(generate_site.has_release_pack("retroarch"))
self.assertEqual(sitediagrams.registry()["retroarch"].get("release_pack"), False)
class NoPageModuleReadsTheRepositoryInputs(unittest.TestCase):
"""A helper that opens the repository's database, profiles or platform
files itself bypasses the flags. siteinputs.current() is the one source."""
MODULES = ("generate_site.py", "siterender.py", "sitediagrams.py")
INPUTS = {"database.json", "emulators", "platforms"}
def test_no_literal_input_path(self) -> None:
found = []
for module in self.MODULES:
tree = ast.parse((REPO_ROOT / "scripts" / module).read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div) \
and getattr(node.left, "id", "") in ("_REPO_ROOT", "ROOT") \
and isinstance(node.right, ast.Constant) and node.right.value in self.INPUTS:
found.append(f"{module}:{node.lineno}")
if isinstance(node, ast.Call) and getattr(node.func, "id", getattr(node.func, "attr", "")) in (
"load_platform_config", "load_platform_registry", "load_emulator_profiles",
"list_registered_platforms", "load_database", "extract_notes",
"load_data_dir_registry"):
for arg in node.args:
if isinstance(arg, ast.Constant) and arg.value in self.INPUTS:
found.append(f"{module}:{node.lineno}")
self.assertEqual(found, [])
def test_generate_installs_the_inputs_first(self) -> None:
source = ast.get_source_segment(
(REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8"),
next(n for n in ast.parse((REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")).body
if isinstance(n, ast.FunctionDef) and n.name == "_generate"))
self.assertLess(source.index("siteinputs.use("), source.index("load_database("))
if __name__ == "__main__":
unittest.main()
+15
View File
@@ -117,6 +117,21 @@ class SourcePathsFreshness(unittest.TestCase):
finding = check_freshness.check_source_paths(profiles)
self.assertEqual(finding.status, check_freshness.OK, finding.detail)
def test_a_recorded_build_file_tree_is_pinned(self):
"""The record holds the platform trees pin_build reads at a commit,
and the check called them orphaned."""
import sitesources # noqa: PLC0415
pin = "a" * 40
profile = {"pin_build": [{"repo": "https://github.com/o/r", "revision": pin,
"path": "p.mk"}]}
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "source-paths.json"
path.write_text(json.dumps({f"github.com/o/r@{pin}": ["p.mk"]}), encoding="utf-8")
with mock.patch.object(sitesources, "SOURCE_PATHS", path):
finding = check_freshness.check_source_paths({"p": profile})
self.assertEqual(finding.status, check_freshness.OK, finding.detail)
def test_a_moved_pin_is_stale(self):
profile = _profile("geolith")
moved = dict(profile, source_commit="f" * 40, upstream_commit="e" * 40)
+5 -4
View File
@@ -50,15 +50,16 @@ class EveryPageAddressesPlatformsAsPeers(unittest.TestCase):
text every site generator writes, docstrings and bare identifiers aside."""
WORDS = ("their file", "theirs", "of ours", "corrected", "left out", "drift")
SOURCES = ("generate_site.py", "siterender.py", "sitesources.py", "siteexports.py")
def test_no_page_text_judges_another_project(self):
import ast
import re
from tests.sitepages import generator_sources
found = []
for name in self.SOURCES:
tree = ast.parse((REPO_ROOT / "scripts" / name).read_text(encoding="utf-8"))
for source in generator_sources():
name = source.name
tree = ast.parse(source.read_text(encoding="utf-8"))
docstrings = {
id(node.body[0].value) for node in ast.walk(tree)
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef,
+13 -5
View File
@@ -349,9 +349,13 @@ class TheSitemapIsAnnouncedWhereCrawlersLook(unittest.TestCase):
config = (root / "website" / "astro.config.mjs").read_text()
self.assertIn("site: SITE", config)
self.assertIn("sitemap.xml", config)
source = (root / "scripts" / "generate_site.py").read_text()
self.assertNotIn('"robots.txt"),\n "User-agent', source)
self.assertNotIn("Sitemap: https://", source)
from tests.sitepages import generator_sources
for path in generator_sources():
source = path.read_text(encoding="utf-8")
with self.subTest(module=path.name):
self.assertNotIn('"robots.txt"),\n "User-agent', source)
self.assertNotIn("Sitemap: https://", source)
@@ -409,8 +413,12 @@ class PlainHouseStyle(unittest.TestCase):
def test_headings_do_not_repeat_the_site_name(self):
# The header bar already names the site, and the browser title gets
# it from the theme: "# DuckStation - RetroBIOS" said it twice.
source = (ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
self.assertNotRegex(source, r'"# [^"]* - (?:\{SITE_NAME\}|RetroBIOS)')
from tests.sitepages import generator_sources
for path in generator_sources():
with self.subTest(module=path.name):
self.assertNotRegex(path.read_text(encoding="utf-8"),
r'"# [^"]* - (?:\{SITE_NAME\}|RetroBIOS)')
for page in sorted((ROOT / "wiki").glob("*.md")):
first = page.read_text(encoding="utf-8").splitlines()[0]
with self.subTest(page=page.name):
+20
View File
@@ -426,6 +426,19 @@ def _entry(profile: dict, name: str) -> dict:
raise AssertionError(f"{name} not in the profile")
class TheFaqAnswersWithACommand(unittest.TestCase):
""""Do I need every file of a console?" answered `--region us keeps only
your region's BIOS`, with no command to run, and that run kept 44 files
of the PlayStation set, Japanese PS2 images among them. `--core pcsx1`
is the lever the installer itself names."""
def test_every_file_of_a_console(self):
section = _section(_wiki("faq.md"), "Do I need every file of a console?")
self.assertIn("<!-- os-tabs -->", section)
self.assertIn("--system sony-playstation --core pcsx1", section)
self.assertNotIn("only your region", section)
class LegalOpensInPlainWords(unittest.TestCase):
"""C5: Is it legal? is a Start here page and opened on case law."""
@@ -455,6 +468,13 @@ class LegalOpensInPlainWords(unittest.TestCase):
self.assertEqual(re.findall(r"(?m)^## ", detail), [])
self.assertIn("### Emulation and BIOS redistribution", detail)
def test_said_once(self):
"""A closing Summary restated the opening lines, and "not legal
advice" came three times."""
self.assertEqual(self.text.count("legal advice"), 1)
self.assertNotIn("### Summary", self.text)
self.assertIn("The project accepts that risk deliberately", self.text)
def test_a_contact_section_names_only_a_channel_that_exists(self):
section = _section(self.text, "Contact")
self.assertIn("https://github.com/Abdess/retrobios/issues", section)
+77
View File
@@ -175,6 +175,83 @@ class AnOfflineRunWithNoCacheStopsFirst(unittest.TestCase):
parser.error.assert_not_called()
class AnOfflineExportWithNoOriginalsStopsFirst(unittest.TestCase):
"""pipeline.py --offline --with-export, which tools.md listed as a
standard command, built the database, verify and the truth model on a
fresh clone, then failed step 2e on every platform whose own file it
patches: --offline never passes --fetch."""
def _args(self, **flags):
import argparse
return argparse.Namespace(**{"offline": True, "with_export": True, **flags})
def test_refused_before_any_step(self):
from exporter import discover_exporters
patched = sorted(n for n, e in discover_exporters().items() if e.needs_original())
self.assertIn("batocera", patched)
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
parser = mock.Mock()
pipeline._refuse_offline_export_without_originals(parser, self._args(), tmp)
message = parser.error.call_args.args[0]
self.assertIn("export_native.py --all --refresh-cache", message)
self.assertIn("batocera", message)
for args in (self._args(offline=False), self._args(with_export=False)):
parser = mock.Mock()
pipeline._refuse_offline_export_without_originals(parser, args, tmp)
parser.error.assert_not_called()
for name in patched:
(Path(tmp) / name).mkdir()
(Path(tmp) / name / "original").write_text("x")
parser = mock.Mock()
pipeline._refuse_offline_export_without_originals(parser, self._args(), tmp)
parser.error.assert_not_called()
def test_the_tools_page_says_so(self):
text = (REPO_ROOT / "wiki" / "tools.md").read_text(encoding="utf-8")
self.assertIn("export_native.py --all --refresh-cache", text)
class ThePipelineFlagTableSaysEveryRefusal(unittest.TestCase):
"""tools.md said --full-narrowings is refused with --skip-packs only, and
advanced-usage.md kept its own shorter list: `--source truth
--full-narrowings` failed with nothing on either page saying so."""
PAIRS = {"--source": "platform", "--all-variants": True, "--full-narrowings": True,
"--skip-packs": True}
def _refused(self, flags: dict) -> bool:
import argparse
args = argparse.Namespace(source=None, all_variants=False, full_narrowings=False,
skip_packs=False)
for flag, value in flags.items():
setattr(args, flag.lstrip("-").replace("-", "_"), value)
parser = mock.Mock()
pipeline._refuse_unapplied_flags(parser, args)
return parser.error.called
def test_each_refused_pair_is_in_the_row_of_a_flag(self):
import itertools
text = (REPO_ROOT / "wiki" / "tools.md").read_text(encoding="utf-8")
rows = {line.split("`")[1].split()[0]: line for line in text.splitlines()
if line.startswith("| `--")}
for (a, va), (b, vb) in itertools.combinations(self.PAIRS.items(), 2):
if not self._refused({a: va, b: vb}):
continue
with self.subTest(pair=(a, b)):
self.assertTrue(f"`{b}" in rows.get(a, "") or f"`{a}" in rows.get(b, ""),
f"{a} with {b} is refused, and neither row says so")
self.assertIn("`--source truth`", rows["--full-narrowings"])
def test_one_list_of_refusals(self):
text = (REPO_ROOT / "wiki" / "advanced-usage.md").read_text(encoding="utf-8")
self.assertNotIn("are refused with", text)
self.assertIn("tools.md#pipeline", text)
class TheTruthDiffShowsWhereAndAsksNothing(unittest.TestCase):
"""The markdown wrote `**Remove** hiscore.dat (phantom)` beside six
`**Add** hiscore.dat`, none with a path, for files profiled cores read."""
+14
View File
@@ -424,6 +424,11 @@
"crates/wqxemu/src/keypad.rs",
"crates/wqxemu/src/main.rs"
],
"github.com/AmberELEC/AmberELEC@a21975bfb1a3af68f66e07a931b994b523e1e6e2": [
"packages/games/emulators/lzdoom/package.mk",
"packages/games/emulators/piemu/package.mk",
"packages/games/libretro/flycast2021le/package.mk"
],
"github.com/Amjad50/plastic@74152adb3acd890d3abee2ff5906408f26a199e2": [
"plastic_core/src/cartridge/mod.rs",
"plastic_core/src/display/color.rs",
@@ -1584,6 +1589,13 @@
"imagine/src/font/FreetypeFont.cc",
"imagine/src/fs/ArchiveFS.cc"
],
"github.com/RetroPie/RetroPie-Setup@46128cd252cdecae852a4d0bafa2aaf9d03ab563": [
"scriptmodules/emulators/advmame-1.4.sh",
"scriptmodules/emulators/ppsspp-1.5.4.sh",
"scriptmodules/ports/alephone.sh",
"scriptmodules/ports/love-0.10.2.sh",
"scriptmodules/ports/lzdoom.sh"
],
"github.com/RetroPie/mame4all-pi@c6bfd2aef331b5a5e157c15d8bf8a77393c85689": [
"readme.txt",
"src/cheat.cpp",
@@ -3475,6 +3487,7 @@
"src/ui/video.rs"
],
"github.com/habib256/pom2@de86e8e35df96fe6ffced56dfb727bbd567f8568": [
"CMakeLists.txt",
"packaging/bundle.manifest",
"packaging/fujinet/stage.py",
"src/AppleParallelCard.cpp",
@@ -8930,6 +8943,7 @@
"nes.c"
],
"github.com/sarah-walker-pcem/pcem@10739b9618e388a5bf8c21e752f12b3952fa83e1": [
"configure.ac",
"src/mem.c",
"src/nvr.c",
"src/ps1.c",
+27 -3
View File
@@ -558,9 +558,12 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, matched:
const setups = new Set(platforms.map((item) => item.owner));
const members = distinct(byLayer('archive-member'), (item) => `${item.sha1}|${item.name}`);
// A dump a catalog lists and the collection lacks: the Wanted list.
const wanted = held.length || members.length ? [] : distinct(byLayer('wanted'), (item) => `${item.owner}|${item.name}`);
const counted = [
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')
: wanted.length ? 'a wanted dump, not in the collection yet'
: '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')] : []),
@@ -617,8 +620,20 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, matched:
);
});
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.'));
if (!held.length && !members.length && !retired.length && !wanted.length) blocks.push(paragraph('No file in the collection has this hash.'));
blocks.push(...retired.map(retiredParagraph));
if (wanted.length) {
const list = document.createElement('ul');
for (const item of wanted) {
const row = document.createElement('li');
row.append(link(item.url, item.owner ?? ''), ' lists it as ', code(item.name ?? item.value));
list.append(row);
}
blocks.push(
paragraph(strong('Wanted.'), ' The collection does not hold this dump yet. If you dumped it yourself, ', link('contributing/', 'add it'), '.'),
list,
);
}
if (members.length) {
// A ROM the collection holds inside a set: the set's card, by its name.
const list = document.createElement('ul');
@@ -799,7 +814,10 @@ const resolveFileAddress = () => {
// A file card moved to another page keeps its address: an id of the form
// sha1-<12 hex> that this page does not hold is looked up by content, and
// an emulator card listed in the table of a split page opens its page.
// an emulator card listed in the table of a split page opens its page. A
// part of a split emulator page (emulators/mame-files-1/) sends a card id
// it no longer holds to that table: an entry added to the profile moves
// hundreds of cards from one part to the next.
const followMovedCard = () => {
const id = decodeURIComponent(location.hash.slice(1));
if (!id) return;
@@ -809,8 +827,14 @@ const followMovedCard = () => {
location.replace(link.href);
return;
}
if (target) return;
const match = /^sha1-([0-9a-f]{12})$/.exec(id);
if (match && !target) location.replace(new URL(`files/?sha1=${match[1]}`, siteRoot()));
if (match) {
location.replace(new URL(`files/?sha1=${match[1]}`, siteRoot()));
return;
}
const part = /\/emulators\/(.+)-files-\d+\/?$/.exec(location.pathname);
if (part && id.startsWith('file-')) location.replace(new URL(`emulators/${part[1]}/#${encodeURIComponent(id)}`, siteRoot()));
};
// A link to a card folded in a table row (emulators with many entries)
+3 -2
View File
@@ -538,7 +538,8 @@ Include truth generation and diffing in the pipeline:
python scripts/pipeline.py --offline --with-truth
```
Include truth + native format export:
Include truth + native format export. Offline, the platforms' own files
must already be cached ([tools](tools.md#pipeline)):
```bash
python scripts/pipeline.py --offline --with-export
@@ -546,7 +547,7 @@ python scripts/pipeline.py --offline --with-export
### Combining flags
These flags compose (`--source` and `--all-variants` are refused with `--skip-packs`):
These flags compose, except the pairs the [pipeline flags](tools.md#pipeline) table marks as refused:
```bash
python scripts/pipeline.py --offline --skip-packs --skip-docs --with-truth --target switch
+3 -3
View File
@@ -12,9 +12,9 @@ Projects built on RetroBIOS data.
## BIOS preservation tool
**Author:** [monster-penguin](https://github.com/monster-penguin)
**Repository:** [github.com/monster-penguin/BIOS-Preservation-Tool](https://github.com/monster-penguin/BIOS-Preservation-Tool)
**License:** MIT
- **Author:** [monster-penguin](https://github.com/monster-penguin)
- **Repository:** [github.com/monster-penguin/BIOS-Preservation-Tool](https://github.com/monster-penguin/BIOS-Preservation-Tool)
- **License:** MIT
A local tool for managing your own BIOS collection. Point it at your files, it verifies them against RetroBIOS platform YAMLs, stores them in a deduplicated database, and produces ready-to-use directories for each platform. Supports scanning directories, archives, and URLs.
+6 -1
View File
@@ -25,7 +25,12 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --dry-run
## Do I need every file of a console?
No. `--region us` keeps only your region's BIOS.
No. `--core pcsx1` keeps what PCSX reads:
<!-- os-tabs -->
```sh
curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --system sony-playstation --core pcsx1
```
## Does the BIOS set the game's language?
+5 -2
View File
@@ -1711,8 +1711,11 @@ The build scripts need 3.9 and are not bound by this.
Every change to `install.py` recomputes the SHA-256 pinned in `install.sh` and
`install.ps1` in the same commit: `python scripts/pin_installer.py` writes both
(`--check` writes nothing and exits 1 on a stale pin). The tests compare the
pins with the file and name that command when they differ.
(`--check` writes nothing and exits 1 on a stale pin). It also runs
`scripts/generate_launcher_index.py`: `install/launchers/index.json` records the
hash of `install.py` that `--add-app` checks before it writes a launcher on a
card. The tests compare the pins and the list with the file and name the command
that fixes each.
The tables of options, events, exit codes, keys and card markers, the line
menu and the run samples on this page sit between `generated` markers and are
+2 -8
View File
@@ -18,7 +18,7 @@ It depends on your country. Nothing here is legal advice.
## The reasoning in detail
Redistributing firmware is not settled law. Below is the reasoning the project acts on, with the strength of each argument, so anyone can weigh it. None of it is legal advice, and none of it has been tested in court.
Redistributing firmware is not settled law. Below is the reasoning the project acts on, with the strength of each argument, so anyone can weigh it. None of it has been tested in court.
### Emulation and BIOS redistribution
@@ -29,7 +29,7 @@ Redistributing firmware is not settled law. Below is the reasoning the project a
### Encryption keys (Switch prod.keys, 3DS AES keys, Wii U keys)
This is the most contested area, and the weakest part of the project's position.
This is the most contested area, and the weakest part of the project's position. The project accepts that risk deliberately, in the belief that these files are worth preserving while they can still be obtained.
- **Keys are unlikely to be copyrightable.** A bare numeric value has no author's creative expression, so copyright is a poor fit. This says nothing about the DMCA, which is the provision that actually applies.
- **DMCA 1201(f) is narrow.** Section 1201(f) allows a person who has lawfully obtained the right to use a copy to circumvent for the sole purpose of achieving interoperability with an independently created program, and 1201(f)(3) allows the means to be made available to others only for that same purpose. Publishing keys openly is not obviously within that allowance, and the trafficking prohibitions in 1201(a)(2) and 1201(b) remain in play. This is the clearest legal risk the project carries.
@@ -43,12 +43,6 @@ This is the most contested area, and the weakest part of the project's position.
- **Functional necessity.** Emulators cannot run the software these systems were built for without this firmware. That is why the files are collected. It is a statement of purpose, not a legal defence in itself.
- **Yuzu context.** The Yuzu settlement (2024) concerned the emulator and its alleged facilitation of piracy. Yuzu settled without admitting liability, so it set no precedent either way on firmware or key redistribution. It does show that rights holders in this space litigate.
### Summary
This project preserves BIOS files, firmware and keys for personal use, archival and interoperability. The strongest ground is the older, discontinued firmware, where fair use and the absence of a market both point the same way. The weakest is the encryption keys, where DMCA 1201 applies and the interoperability exemption is narrower than the use made of it here. The project accepts that risk deliberately, in the belief that these files are worth preserving while they can still be obtained.
This reflects the project's good-faith understanding, not legal advice.
## Asking for a file to be removed
A rights holder, or anyone acting for one, can [open an issue](https://github.com/Abdess/retrobios/issues) identifying the file by path or hash. A file whose removal is requested by its rights holder is removed from the repository and from the next release. No formal notice is required and none will be demanded. State the file and the basis of the claim. The same channel is open to anyone else with a concern about a specific file.
+4 -1
View File
@@ -281,7 +281,10 @@ python scripts/generate_site.py
# 5. Write the notes: install commands, the parts sentence and the pack table
# come from release.json, and what changed since the previous tag is the
# maintainer's own text in tmp/whats-new.md, appended as written.
# maintainer's own text in tmp/whats-new.md, appended as written. The
# script refuses a text that judges another project ("forum list") or
# promises what the gap analysis contradicts ("never stops"): it says
# what changed and how it was checked.
python scripts/release_notes.py --whats-new tmp/whats-new.md --output tmp/notes.md
# Create the release as a DRAFT, upload every asset, and only then publish it.
+1 -1
View File
@@ -67,7 +67,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_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` |
| 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_inputs`, `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
+10 -3
View File
@@ -28,6 +28,13 @@ python scripts/pipeline.py --check-buildbot # online run + buildbot fresh
`python scripts/refresh_data_dirs.py` once first. A run that builds packs with no
data directory cached is refused before step 0.
`--with-export` online passes `--fetch` to step 2e, which downloads each
platform's own file and patches it. Offline that file must already be in
`.cache/upstream-native/`: on a fresh clone run
`python scripts/export_native.py --all --refresh-cache` once first. An offline
run with `--with-export` and an empty cache for any platform it patches is
refused before step 0.
Pipeline steps:
| Step | Description | Skipped by |
@@ -69,7 +76,7 @@ Pipeline flags:
| `--target TARGET` | filter verify, packs and truth by hardware target |
| `--source {platform,truth,full}` | pack file source, passed through to `generate_pack.py`. Refused with `--skip-packs` |
| `--all-variants` | build the 6 source x required combinations. Refused with `--skip-packs` and with `--source` |
| `--full-narrowings` | step 5 compares every region, target and system of each platform instead of three of each. Refused with `--skip-packs` |
| `--full-narrowings` | step 5 compares every region, target and system of each platform instead of three of each. Refused with `--skip-packs` and with `--source platform` or `--source truth` |
| `--check-buildbot` | run step 2b. Additive, and ignored under `--offline` |
| `--with-truth` | run steps 2c and 2d |
| `--with-export` | run steps 2c, 2d and 2e |
@@ -1653,8 +1660,8 @@ names the profiles read.
| Catalog | Acquisition | Notes |
|---------|------------|-------|
| Redump | direct fetch from redump.info `/static/bios` | 4 BIOS DATs. redump.org serves stale DATs |
| No-Intro | local pack, imported with `dat_pack_importer` | Dat-o-Matic blocks automation, and the `hugo19941994/auto-datfile-generator` mirror rebuilds daily |
| Redump | direct fetch from redump.info, one `/bios/<CODE>` route per system | 4 BIOS DATs. redump.org serves BIOS DATs dated 2010 to 2022, redump.info the current ones |
| No-Intro | local pack, imported with `dat_pack_importer` | Dat-o-Matic serves its DATs through a browser session, and the `hugo19941994/auto-datfile-generator` mirror rebuilds daily |
| TOSEC | local pack, imported with `dat_pack_importer` | annual pack from tosecdev.org, 138 Firmware DATs |
The join is by SHA1, then by MD5 + size. A match sets the `provenance` field