mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-06 10:03:49 -06:00
chore: merge site fixes, fourth pass
This commit is contained in:
commit
25b11fa497
36 files changed
+878
-125
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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*
|
||||
@@ -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",
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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 += (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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"})
|
||||
@@ -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`
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user