chore: merge origin/main

This commit is contained in:
Abdessamad Derraz committed 2026-11-05 23:25:48 +01:00
commit 49ff682eda
40 files changed
+1304 -222

No files matched your search

+2
View File
@@ -17,6 +17,7 @@ on:
- "scripts/cross_reference.py"
- "scripts/crypto_verify.py"
- "scripts/derivations.py"
- "scripts/dedup.py"
- "scripts/deterministic_zip.py"
- "scripts/dumpcatalog.py"
- "scripts/exporter/baseline.py"
@@ -61,6 +62,7 @@ on:
- "scripts/split_pack.py"
- "scripts/truth.py"
- "scripts/upstream.py"
- "scripts/validate_pr.py"
- "scripts/validate_schemas.py"
- "scripts/validate_site.py"
- "scripts/validation.py"
+13 -5
View File
@@ -24,8 +24,12 @@ the most useful contribution.
From a clone, the file goes to `bios/Manufacturer/Console/filename`, and
`python scripts/validate_pr.py path/to/file` names that folder for a file still
outside `bios/`. Given a folder, such as an old card's BIOS folder, it checks
every file under it. A file over the browser's 25 MB limit goes in that way.
outside `bios/`, and `--place` copies it there under its declared name. Given a
folder, such as an old card's BIOS folder, it checks every file under it. A
file over the browser's 25 MB limit goes in that way, up to the 100 MB GitHub
accepts in a push. A larger file stays out of the pull request: [open an
issue](https://github.com/Abdess/retrobios/issues/new?template=missing-bios.yml) with the hashes `validate_pr.py` prints, and a
maintainer adds it to the `large-files` release.
## Add a platform
@@ -64,18 +68,22 @@ validation above is its check. For code, profiles or platform files, these are
the checks CI runs on a pull request (Python 3.12 or newer):
```bash
pip install pyyaml "jsonschema[format-nongpl]" markdown
pip install pyyaml "jsonschema[format-nongpl]" markdown pyte hypothesis vermin
python scripts/validate_pr.py --changed # the BIOS files the branch adds
python scripts/validate_schemas.py --source-only # profiles and platform files
python scripts/profile_sync.py --added --base upstream/main --strict # each profile the branch adds
python scripts/refresh_data_dirs.py # data directories the pack-building tests read
python scripts/generate_site.py # the pages the site tests read
npm ci --prefix website && npm run build --prefix website # the built site, Node 24
python -m unittest discover -s tests -t .
python scripts/validate_site.py --site-dir website/dist # links and page budgets
vermin --no-tips -t=3.8- --violations install.py # install.py stays on Python 3.8
```
`upstream` is this repository as a remote of your fork
(`git remote add upstream https://github.com/Abdess/retrobios`). A profile you
only edit is not held to `--strict`: its older refs are reviewed by hand.
(`git remote add upstream https://github.com/Abdess/retrobios`, then
`git fetch upstream`). A profile you only edit is not held to `--strict`: its
older refs are reviewed by hand.
The full pipeline is not needed for a contribution: it rebuilds every pack
in `dist/`, which is why maintainers run it before a release.
+3 -2
View File
@@ -19,8 +19,9 @@ systems:
notes: |
Gearboy is a Game Boy / Game Boy Color emulator by drhelius (Ignacio Sanchez).
The buildbot builds from drhelius/Gearboy (upstream), not from libretro/Gearboy
(abandoned fork frozen at v2.6.0 from 2018).
The buildbot builds from drhelius/Gearboy (upstream), not from libretro/Gearboy,
an archived fork (its description reads "NOTE: PUBLIC ARCHIVE") whose
src/definitions.h states version 2.6.0.
Bootrom loading is gated behind core options (disabled by default).
Without bootroms, the core HLE-skips the boot animation.
+1 -1
View File
@@ -16,7 +16,7 @@ cores: [np2kai]
systems: [pc-98]
# NP2kai is a PC-9801/9821 emulator by AZO234, enhanced fork of Neko Project II
# by Yui (original upstream dead: np2.yui.ne.jp). All files load from
# by Yui (original site: np2.yui.ne.jp). All files load from
# <system>/np2kai/ subdirectory, set in retro_load_game()
# (sdl/libretro/libretro.c:1800-1815). All getbiospath() calls resolve
# relative to that directory.
+1 -1
View File
@@ -15,7 +15,7 @@ display_name: "Sony - PlayStation (PCSX1)"
cores: [pcsx1]
systems: [sony-playstation]
notes: >
Never-completed fork of PCSX-ReARMed targeting general PC hardware. Experimental, abandoned since 2018.
Fork of PCSX-ReARMed targeting general PC hardware. The last commit on libretro/pcsx1-libretro is from 2018-02-11.
HLE BIOS built-in: if no BIOS file found, core warns and falls back to HLE automatically.
BIOS search order: scph1001.bin, scph5501.bin, scph7001.bin by exact filename, then scans system
directory for any file matching "scph*" (case-insensitive) with size == 512 KB.
+1 -1
View File
@@ -30,7 +30,7 @@ notes: >
Database contains 24 entries (22 good, 2 known-bad rejected)
covering BIOS versions 1.0 through 4.5.
.info declares firmware_count=0, while the core reads the BIOS images this profile lists.
Project abandoned, superseded by rustation-ng.
Its README says it is no longer developed and points to rustation-ng.
files:
# -- Region: Japan --
+3 -3
View File
@@ -17,9 +17,9 @@ systems:
- sega-saturn
notes: |
Yabause is a software-rendered Sega Saturn emulator. Upstream project is
dead. The libretro port is maintained by the community on the master branch of
libretro/yabause.
Yabause is a software-rendered Sega Saturn emulator. The last commit on the
master branch of Yabause/yabause is from 2018-07-07. The libretro port is
maintained by the community on the master branch of libretro/yabause.
Saturn BIOS search order:
1. {system}/saturn_bios.bin
+16 -6
View File
@@ -27,6 +27,7 @@ from common import (
platform_arg,
ProfileSelectionError,
_norm_system_id,
archive_entry,
directory_members,
expand_directory_entries,
get_mame_clone_map,
@@ -573,18 +574,27 @@ class FileConsumers:
for dest, sha1 in members.items()
]
else:
# The archive members the profile reads decide between
# same-named copies, as in the emulator pack and verify.
found = [self.sha1_of(
with_archive_members(entry, files), entry.get("path") or "",
owner=name,
)]
found = [self.sha1_of(*entry_target(entry, files), owner=name)]
for sha1 in found:
if sha1:
self.emulators.setdefault(sha1, set()).add(name)
self.emulator_entries.setdefault(sha1, {}).setdefault(name, index)
def entry_target(entry: dict, files: list[dict]) -> tuple[dict, str]:
"""The entry a profile entry resolves through, and its destination.
A ROM read inside an archive is the archive: FBNeo opens sm1.sm1 in
neogeo.zip, and resolved by its crc32 it credited a loose copy FBNeo
never opens, while no neogeo.zip card named FBNeo. The archive
members the profile reads decide between same-named copies, as in
the emulator pack and verify.
"""
if entry.get("archive"):
return archive_entry(entry["archive"], files), ""
return with_archive_members(entry, files), entry.get("path") or ""
def entry_source(f: dict, index: dict) -> str | None:
"""Where the collection holds a profile entry, or None.
+14 -5
View File
@@ -37,6 +37,7 @@ from common import (
from nativemode import check_label
import release_record
from siteguides import install_command
from validate_pr import LARGE_FILE_ISSUE_URL
from verify import verify_platform
@@ -929,8 +930,12 @@ the most useful contribution.
From a clone, the file goes to `bios/Manufacturer/Console/filename`, and
`python scripts/validate_pr.py path/to/file` names that folder for a file still
outside `bios/`. Given a folder, such as an old card's BIOS folder, it checks
every file under it. A file over the browser's 25 MB limit goes in that way.
outside `bios/`, and `--place` copies it there under its declared name. Given a
folder, such as an old card's BIOS folder, it checks every file under it. A
file over the browser's 25 MB limit goes in that way, up to the 100 MB GitHub
accepts in a push. A larger file stays out of the pull request: [open an
issue]({LARGE_FILE_ISSUE_URL}) with the hashes `validate_pr.py` prints, and a
maintainer adds it to the `large-files` release.
## Add a platform
@@ -969,18 +974,22 @@ validation above is its check. For code, profiles or platform files, these are
the checks CI runs on a pull request (Python 3.12 or newer):
```bash
pip install pyyaml "jsonschema[format-nongpl]" markdown
pip install pyyaml "jsonschema[format-nongpl]" markdown pyte hypothesis vermin
python scripts/validate_pr.py --changed # the BIOS files the branch adds
python scripts/validate_schemas.py --source-only # profiles and platform files
python scripts/profile_sync.py --added --base upstream/main --strict # each profile the branch adds
python scripts/refresh_data_dirs.py # data directories the pack-building tests read
python scripts/generate_site.py # the pages the site tests read
npm ci --prefix website && npm run build --prefix website # the built site, Node 24
python -m unittest discover -s tests -t .
python scripts/validate_site.py --site-dir website/dist # links and page budgets
vermin --no-tips -t=3.8- --violations install.py # install.py stays on Python 3.8
```
`upstream` is this repository as a remote of your fork
(`git remote add upstream https://github.com/Abdess/retrobios`). A profile you
only edit is not held to `--strict`: its older refs are reviewed by hand.
(`git remote add upstream https://github.com/Abdess/retrobios`, then
`git fetch upstream`). A profile you only edit is not held to `--strict`: its
older refs are reviewed by hand.
The full pipeline is not needed for a contribution: it rebuilds every pack
in `dist/`, which is why maintainers run it before a release.
+94 -57
View File
@@ -63,7 +63,7 @@ from common import (
)
from artifacts import ArtifactLockBusy, artifact_lock
from artifacts import json_text as _json_text
from cross_reference import FileConsumers, held_copy
from cross_reference import FileConsumers, entry_target, held_copy
from nativemode import check_label, digest_algorithm, reads_file_contents
@@ -679,9 +679,13 @@ def release_line(platform_name: str, record: dict) -> list[str]:
return lines
# What a reader on another version of the setup does with the pack. Our data
# says which list the pack was made from, not what an older list lacks.
OTHER_VERSION = "Other version? Run `--check` after copying."
def other_version(platform_name: str, prefix: str = "../") -> str:
"""What a reader on another version of the setup does with the pack.
Our data says which list the pack was made from, not what an older list
lacks. A bare `--check` was no command to run for someone holding a ZIP:
the line links the setup page's commands that check."""
target = f"{prefix}platforms/{platform_name}.md#how-to-check"
return f"Other version? [Check the files]({target}) after copying."
def setup_version(platform_name: str, record: dict | None = None) -> str:
@@ -700,13 +704,15 @@ def setup_version(platform_name: str, record: dict | None = None) -> str:
return f"{config.get('platform', platform_name)} {version.removeprefix('v')}"
def version_line(platform_name: str, record: dict, named: bool = False) -> str:
def version_line(platform_name: str, record: dict, named: bool = False,
prefix: str = "../") -> str:
"""The version the pack fits, and what to do on another. *named* when
the release line already named the pack's version."""
made_from = setup_version(platform_name, record)
other = other_version(platform_name, prefix)
if named or not made_from:
return OTHER_VERSION
return f"Made for {made_from}. {OTHER_VERSION}"
return other
return f"Made for {made_from}. {other}"
# Why a setup has no ZIP, answered once on the FAQ.
@@ -793,7 +799,8 @@ def _pack_buttons(platform_name: str, label: str = "Pack", prefix: str = "../",
)
released = release_line(platform_name, record)
return "\n\n".join([buttons, *released,
version_line(platform_name, record, named=len(released) > 1)])
version_line(platform_name, record, named=len(released) > 1,
prefix=prefix)])
def generate_get_page(platform_name: str) -> str:
@@ -2191,9 +2198,11 @@ def generate_data_page(exports: list[dict], measurements_sqlite: Path | None = N
"when one is known, the `system_name` the site gives its system. "
"[Find or check a file](files/index.md) reads it.",
"",
"In a browser, the SHA1 of a file the reader picks finds its records. Web "
"Crypto has no MD5, and none is needed: a setup that lists a file by MD5 "
"or CRC32 is filed under the SHA1 of the collection file it names too.",
"In a browser, the SHA1 of a file the reader picks finds its records when "
"the collection holds that file. A list naming a file the collection lacks "
"is filed under its MD5 or CRC32 alone, and Web Crypto has no MD5: "
"that page computes MD5 and CRC32 in script and looks "
"up all four.",
"",
"```js",
"// The records of the file a reader picked, by its SHA1.",
@@ -2443,7 +2452,7 @@ def _browser_title(relative: Path, title: str) -> str:
# The words a web search for a console or an emulator types: "dreamcast
# bios", "duckstation bios". The h1 and the sidebar keep the name.
if key.startswith("emulators/"):
has_files = PAGE_DATA.get(key, {}).get("hasPart")
has_files = PAGE_DATA.get(key, {}).get("hasPart") and key not in NOTHING_TO_FETCH
# Lexaloffle, QEMU and ScummVM name a system page too.
own = " emulator" if f"systems/{relative.name}" in PAGE_DATA else ""
return f"{title}{own} BIOS files" if has_files else f"{title} emulator"
@@ -2514,6 +2523,12 @@ def _page_description(markdown: str, relative: Path, title: str) -> str:
return _system_description(markdown, browser)
if key.startswith("emulators/") and relative.stem != "index":
return _emulator_description(markdown, key, title, _first_paragraph(lead))
if key.startswith("platforms/") and relative.stem != "index":
# The summary ends on a link to the platform's site, whose label
# alone was left: "A system for handhelds. ROCKNIX website."
summary = _first_paragraph(lead).removesuffix(f" {title} website.")
return _shorten(f"The BIOS files for {title}, the folder they go in and the "
f"command that installs them. {summary}", 155)
first = _first_paragraph(lead, minimum=35)
questions = [_plain_markdown(re.sub(r"\s*\{[^}]*\}\s*$", "", heading))
for heading in re.findall(r"^## (.+\?)(?:[ \t]*\{[^}]*\})?[ \t]*$", markdown, re.M)]
@@ -2539,7 +2554,9 @@ def _emulator_description(markdown: str, key: str, title: str, intro: str) -> st
its profile table when the profile names no system."""
notes = re.search(r"<summary>Technical notes</summary>\s*\n\n(.+?)(?:\n\n|$)", markdown, re.S)
if not intro and notes:
sentence = re.split(r"(?<=\.)\s", _plain_markdown(notes[1]), maxsplit=1)[0]
# An initial is no sentence end: hcl's read "open-source port of E."
sentence = re.split(r"(?<![\s(][A-Z]\.)(?<=\.)\s", _plain_markdown(notes[1]),
maxsplit=1)[0]
intro = f"{title}: {sentence[:1].lower()}{sentence[1:]}"
if not intro:
row = {name: _plain_markdown(value) for name, value in re.findall(
@@ -2553,6 +2570,8 @@ def _emulator_description(markdown: str, key: str, title: str, intro: str) -> st
elif title not in intro:
# "It reads 1 file": the profile names no system to open on.
intro = f"{title}: {intro[:1].lower()}{intro[1:]}"
if key in NOTHING_TO_FETCH:
return _shorten(f"{intro} Every file it reads comes with the app.", 155)
# An alias page sends the reader to the core whose files it shares.
if PAGE_DATA.get(key, {}).get("hasPart") or "uses the same firmware as" in intro:
return _shorten(intro, 155)
@@ -2623,6 +2642,9 @@ def _system_description(markdown: str, browser: str) -> str:
# Structured data a page generator knows beyond its text, by page path
# relative to docs/: read by decorate_markdown_pages.
PAGE_DATA: dict[str, dict] = {}
# Emulator pages whose every file comes with the app: no "BIOS files" in
# their title, and their description says so (DaedalusX64, ZEsarUX).
NOTHING_TO_FETCH: set[str] = set()
# Pages that hold part of a page too long for one (a console of a split
# manufacturer, a run of an emulator's cards), by path relative to docs/:
# their H1 already says what they are.
@@ -2950,6 +2972,17 @@ def release_versions(profile: dict) -> dict[str, str]:
if re.search(r"[0-9]", value) and not names_a_commit(value)}
def record_emulator_page(name: str, profile: dict) -> None:
"""What the title and the description of an emulator page read."""
key = f"emulators/{name}.md"
PAGE_DATA[key] = emulator_structured_data(profile)
listed = [f for f in profile.get("files") or [] if isinstance(f, dict)]
if listed and all(comes_with_app(f) for f in listed):
NOTHING_TO_FETCH.add(key)
else:
NOTHING_TO_FETCH.discard(key)
def emulator_structured_data(profile: dict) -> dict:
"""What an emulator page states, for a reader of its JSON-LD.
@@ -4176,7 +4209,7 @@ def _setup_routes(name: str, display: str, page_link: bool = False) -> list[str]
# lines are on the get/ page, the one the command is shared from.
tabs = ("windows",) if page_link else ("windows", "macos", "linux")
computer += [
"Needs the device on your network. Run the command on the computer.",
"Needs the device on your network.",
"",
*terminal_tabs(tabs, args, how=how),
*([f"[Mac or Linux](../{get_page_link(name)}#from-your-computer)", ""]
@@ -6443,13 +6476,6 @@ def _region_order() -> dict[str, int]:
return {region: index for index, region in enumerate(ordered)}
_REGION_WORDS = {"uk": "UK", "asia-ntsc": "Asia (NTSC)", "asia-pal": "Asia (PAL)"}
def _region_word(region: str) -> str:
return _REGION_WORDS.get(region) or region.replace("-", " ").title()
def _reading_pairs(sha1: str, consumers: FileConsumers) -> list[tuple[str, dict]]:
"""The profile entries that read a file, each with its emulator."""
files_of = getattr(consumers, "emulator_files", {})
@@ -6662,7 +6688,7 @@ def _start_groups(
for f in primaries:
sha1 = str(f.get("sha1", ""))
for emulator, entry in _reading_pairs(sha1, consumers):
if entry.get("bundled"):
if comes_with_app(entry):
continue
system = None
systems = _entry_systems(emulator, entry)
@@ -6677,7 +6703,7 @@ def _start_groups(
for sha1, emulator, entry, system in _entries_by_section(consumers).get(here, []):
record = known.get(sha1)
parts = str((record or {}).get("path", "")).split("/")
if (sha1 in held or not record or entry.get("bundled") or ".variants" in parts
if (sha1 in held or not record or comes_with_app(entry) or ".variants" in parts
or len(parts) < 3 or parts[1] != "Arcade"):
continue
if sha1 not in kept:
@@ -7031,7 +7057,7 @@ def _group_start(group: _StartGroup, first_below: bool) -> list[tuple[str, str]]
else:
regional[(*same, region)] = regional.pop(same)
out = [(", ".join(_region_word(r) for r in regions), regional[regions])
out = [(", ".join(_region_words(r) for r in regions), regional[regions])
for regions in sorted(regional, key=lambda rs: (order.get(rs[0], len(order)), rs))]
# A name a region line gives already is no "also", and one only
# emulators no region line answers reads is an "instead": armsx1 reads
@@ -7819,12 +7845,15 @@ def generate_emulators_index(profiles: dict) -> str:
def comes_with_app(f: dict) -> bool:
"""The emulator installs the file in every build the profile documents.
"""The emulator installs the file in the build the entry describes.
``bundled`` narrowed by ``mode`` is installed by one build only, and the
other build still reads it from the user's folder.
The rule verify and the pack read (``installed_with_emulator``). An entry
narrowed by ``mode`` is that build's alone: the other build reads its
own entry, or nothing.
"""
return f.get("bundled") is True and not f.get("mode")
from validation import installed_with_emulator # noqa: PLC0415
return installed_with_emulator(f)
def _file_badges(f: dict, in_repo: bool, for_system: str = "") -> list[str]:
@@ -7841,7 +7870,6 @@ def _file_badges(f: dict, in_repo: bool, for_system: str = "") -> list[str]:
category = f.get("category", "")
region = f.get("region", "")
storage = f.get("storage", "")
bundled = f.get("bundled", False)
embedded = f.get("embedded", False)
has_builtin = f.get("has_builtin", False)
archive = f.get("archive", "")
@@ -7851,9 +7879,10 @@ def _file_badges(f: dict, in_repo: bool, for_system: str = "") -> list[str]:
if shipped:
# NetherSX2's three interface fonts sat beside the BIOS as
# "required" and "missing": the app ships them, nobody fetches them.
badges.append(
'<span class="rb-badge rb-badge-info">comes with the app</span>'
)
# Narrowed to one build, the build is named.
build = f.get("mode") if f.get("mode") in ("standalone", "libretro") else ""
label = f"installed with the {build} emulator" if build else "comes with the app"
badges.append(f'<span class="rb-badge rb-badge-info">{label}</span>')
elif required:
needed = f"required for {html.escape(for_system)}" if for_system else "required"
badges.append(
@@ -7879,7 +7908,7 @@ def _file_badges(f: dict, in_repo: bool, for_system: str = "") -> list[str]:
badges.append(
'<span class="rb-badge rb-badge-info">HLE fallback</span>'
)
if mode:
if mode and not (shipped and mode in ("standalone", "libretro")):
badges.append(
f'<span class="rb-badge rb-badge-muted">{mode}</span>'
)
@@ -7896,13 +7925,6 @@ def _file_badges(f: dict, in_repo: bool, for_system: str = "") -> list[str]:
badges.append(
f'<span class="rb-badge rb-badge-muted">{storage}</span>'
)
if bundled and not shipped:
# Said in words: the pack leaves the file out because the emulator,
# in the build the entry names, already installs it.
build = f" {f['mode']}" if f.get("mode") in ("standalone", "libretro") else ""
badges.append(
f'<span class="rb-badge rb-badge-muted">installed with the{build} emulator</span>'
)
if embedded:
badges.append(
'<span class="rb-badge rb-badge-muted">embedded</span>'
@@ -7995,10 +8017,14 @@ def _build_rows(profile: dict) -> list[str]:
found = _found_by_text(profile.get("found_by"))
if found:
rows.append(f"| Files found by | {found} |")
frozen = profile.get("pin_frozen")
if frozen:
held = _escape_tags(' '.join(str(frozen).split())).replace('|', r'\|')
rows.append(f"| Revision held | {held} |")
# Each pin says why it is held: a bare upstream revision read as a
# profile left behind HEAD (mupen64plus, aps3e).
for field, label in (("pin_frozen", "Revision held"),
("upstream_frozen", "Upstream revision held")):
frozen = profile.get(field)
if frozen:
held = _escape_tags(' '.join(str(frozen).split())).replace('|', r'\|')
rows.append(f"| {label} | {held} |")
announced = profile.get("version_source")
if isinstance(announced, dict) and announced.get("url"):
url = str(announced["url"])
@@ -8193,10 +8219,13 @@ def _render_emulator_file(
if consumers and f.get("type") == "directory":
details.extend(_directory_copies(f, consumers))
if consumers:
sha1 = consumers.sha1_of(f, f.get("path") or "", owner=owner)
sha1 = consumers.sha1_of(
*entry_target(f, profile.get("files") or []), owner=owner)
held = consumers.path_of(sha1) if sha1 and in_repo else ""
parts = held.split("/")
if len(parts) >= 3 and parts[0] == "bios":
if len(parts) >= 3 and parts[0] == "bios" and f.get("archive"):
details.append(_held_copy_line((held, f["name"]), consumers))
elif len(parts) >= 3 and parts[0] == "bios":
# Which copy answers this entry, linked to its collection card
# (provenance, every hash, download): an entry declaring no hash
# said "in repo" without naming the file.
@@ -8473,8 +8502,9 @@ def emulator_romset(profile: dict) -> str:
return ""
def _version_said(value: object, basis: dict) -> str:
"""One build's version and where the profile reads it."""
def _version_said(value: object, basis: dict, profile: dict, mode: str = "") -> str:
"""One build's version and where the profile reads it, linked to the
file or the tag where the source allows."""
kind = basis.get("kind")
if kind == "none":
return "none stated in the source"
@@ -8483,13 +8513,18 @@ def _version_said(value: object, basis: dict) -> str:
return f"unknown: {_UNVERIFIED.get(str(basis.get('why')), 'unread')}"
return "none published"
value = str(value)
url = version_basis_url(profile, basis, mode) if kind in ("file", "tag") else ""
def shown(text: object) -> str:
return f"[{_code(str(text))}]({url})" if url else _code(str(text))
if kind == "file":
return f"{value}, stated in `{basis.get('path')}`"
return f"{value}, stated in {shown(basis.get('path'))}"
if kind == "tag":
distance = int(basis.get("distance") or 0)
if not distance:
return f"{value}, the tag at this revision"
return f"{value}, {distance} commit{'s' if distance != 1 else ''} after tag `{basis.get('tag')}`"
return f"{value}, {distance} commit{'s' if distance != 1 else ''} after tag {shown(basis.get('tag'))}"
if kind == "package":
return f"{value}, read from {basis.get('artifact')}"
if kind == "unverified":
@@ -8524,10 +8559,10 @@ def version_markdown(profile: dict) -> str:
values = value if isinstance(value, dict) else {}
modes = list(dict.fromkeys([*values, *basis]))
return ", ".join(
f"{_version_said(values.get(mode), basis.get(mode) or {})} ({mode})"
f"{_version_said(values.get(mode), basis.get(mode) or {}, profile, mode)} ({mode})"
for mode in modes
)
return _version_said(value, basis)
return _version_said(value, basis, profile)
ROMSET_FAQ_PAGE = Path(WIKI_SRC_DIR) / "technical-faq.md"
@@ -8860,7 +8895,7 @@ def standalone_place(name: str, profile: dict, entry: dict, key: str = "") -> st
if not isinstance(entry, dict) or not profile.get("data_root"):
return ""
mode = entry.get("mode")
if mode == "libretro" or (entry.get("bundled") is True and mode in (None, "standalone")):
if mode == "libretro" or comes_with_app(entry):
return ""
excluded = _folder_only(name, entry)
if "*" in excluded or (key in excluded if key else excluded):
@@ -8898,7 +8933,7 @@ def _data_root_folder(name: str, profile: dict, key: str, entries: list[dict]) -
folders = set()
for f in entries:
mode = f.get("mode")
if mode == "libretro" or (f.get("bundled") is True and mode in (None, "standalone")):
if mode == "libretro" or comes_with_app(f):
continue
place = standalone_place(name, profile, f, key)
if not place:
@@ -12142,15 +12177,16 @@ def _generate(args: argparse.Namespace) -> None:
# The test each emulator page builds for itself: the parts a page is
# cut into weigh the status its rows show.
file_held = _availability_check(db, suppl_names)
# The plan names systems: the sections must be this run's data first.
manufacturers = _group_by_manufacturer(db)
set_console_sections(manufacturers, program_names(profiles))
plan_emulator_pages(profiles, file_held)
# Build system_id -> manufacturer page map (needed by all generators)
print("Building system cross-reference map...")
manufacturers = _group_by_manufacturer(db)
set_system_page_map(
_system_pages_from_data(manufacturers, coverages, db, profiles)
)
print(f" {len(_system_page_map)} system IDs mapped to pages")
set_console_sections(manufacturers, program_names(profiles))
from siterender import link_coverage, system_ids # noqa: PLC0415
print("Generating system pages...")
@@ -12300,7 +12336,7 @@ def _generate(args: argparse.Namespace) -> None:
name, profile, db, consumers, suppl_names, data_registry,
data_dirs=page_data_directories(name, profiles),
)
PAGE_DATA[f"emulators/{name}.md"] = emulator_structured_data(profile)
record_emulator_page(name, profile)
# A frozen copy answers after the emulator: a frozen_snapshot, and a
# profile pinned to the revision one platform builds (pin_frozen).
# Reicast and Flycast 2021 LE answered "dc bios missing" before Flycast.
@@ -12466,6 +12502,7 @@ from sitesources import ( # noqa: E402,F401
_forge_sources,
_source_permalink,
_source_ref_markdown,
version_basis_url,
)
from siterender import ( # noqa: E402,F401
_by_mode,
+7 -7
View File
@@ -35,8 +35,8 @@ come from .cache/large/ only.
Usage:
python scripts/pipeline.py # active platforms
python scripts/pipeline.py --include-archived # all platforms
python scripts/pipeline.py --skip-packs # steps 0 to 3b, 4d, 7 and 8
python scripts/pipeline.py --skip-docs # skip steps 7 and 8
python scripts/pipeline.py --skip-packs # skip steps 4, 4b, 4c, 5 and 6
python scripts/pipeline.py --skip-docs # skip steps 7, 8 and 8b
python scripts/pipeline.py --offline # no network, see above
"""
@@ -747,13 +747,13 @@ def main() -> None:
parser.add_argument(
"--skip-packs",
action="store_true",
help="Skip steps 4 to 6 except 4d: packs, manifests and their checks. "
"Steps 7 and 8 still rewrite README.md and the site pages in website/, "
"add --skip-docs to "
"stop after verify",
help="Skip steps 4, 4b, 4c, 5 and 6: packs, manifests and their checks. "
"Step 4d still gates the run, step 4e still rewrites install/games/, and "
"steps 7 to 8b rewrite README.md and the site unless --skip-docs is given",
)
parser.add_argument(
"--skip-docs", action="store_true", help="Skip README and site generation"
"--skip-docs", action="store_true",
help="Skip steps 7, 8 and 8b: README, installer page and site"
)
parser.add_argument(
"--offline", action="store_true",
+4
View File
@@ -4378,6 +4378,10 @@ def branch_profiles(emulators_dir: str, base: str, added_only: bool) -> list[str
branch point are not counted, and ends at the working tree, so a
profile not yet committed is checked too. A rename reads as an addition.
"""
if subprocess.run(["git", "rev-parse", "--verify", "--quiet", base],
capture_output=True, check=False).returncode:
remote = base.split("/", 1)[0] if "/" in base else "upstream"
raise SystemExit(f"ERROR: {base} is not in this clone: run git fetch {remote} first")
merge_base = _git_lines(["merge-base", base, "HEAD"])[0]
paths = _git_lines([
"diff", "--name-only", "--no-renames",
+6
View File
@@ -454,6 +454,10 @@ def fence(shell: str, command: str, indent: str = "") -> list[str]:
return [f'{indent}```{shell} title="{FENCE_TITLES[shell]}"', f"{indent}{command}", f"{indent}```"]
# Terminals named by where the command runs, not by the system it runs on.
PLACES = ("device", "server")
def terminal_tabs(
tabs: tuple[str, ...], args: dict[str, str], indent: str = "", steps: bool = True,
how: dict[str, str] | None = None,
@@ -465,6 +469,8 @@ def terminal_tabs(
items = []
for key in tabs:
label, shared, shell = TERMINALS[key]
if len(tabs) == 1 and key in PLACES:
label = "" # one place, which the text around already names
text = (how or {}).get(key) or shared
body = [text, ""] if steps else []
items.append((label, body + fence(shell, install_command(shell, args.get(key, "")))))
+10 -1
View File
@@ -139,8 +139,17 @@ def details(kind: str, title: str, body: list[str], indent: str = "", opened: bo
def tabs(items: list[tuple[str, list[str]]], indent: str = "", sync: str = "os") -> list[str]:
"""Content tabs. Tabs that share a label share the reader's choice
across pages."""
across pages.
One item is no choice: 142 bars offered a single tab, most of them
"On the device" under a heading that says so. Its body stands alone,
led by its label when it has one: the RetroPie card is read from Linux
only, and the label is where the page said so."""
lines: list[str] = []
if len(items) == 1:
label, body = items[0]
lead = [f"{indent}**{label}**", ""] if label else []
return [*lead, *_indented(_trim(body), indent), ""]
if _engine == "mkdocs":
for label, body in items:
lines += [f'{indent}=== "{label}"', "", *_indented(_trim(body), indent + " "), ""]
+33 -2
View File
@@ -240,8 +240,15 @@ def _walk(value: object) -> Iterator[tuple[str, object]]:
def cited_paths(profile: dict) -> set[str]:
"""Every path a page of the profile could link: the parts of each
source_ref and the file:line citations of its prose."""
paths: set[str] = set()
source_ref, the file:line citations of its prose and the file its
version is read from."""
basis = profile.get("version_basis")
bases = ([basis] if isinstance(basis, dict) and "kind" in basis
else list(basis.values()) if isinstance(basis, dict) else [])
paths: set[str] = {
str(b["path"]) for b in bases
if isinstance(b, dict) and b.get("kind") == "file" and b.get("path")
}
for key, value in _walk(profile):
if key == "source_ref" and isinstance(value, (str, dict)):
for _label, refs in source_ref_values(value):
@@ -355,6 +362,30 @@ def _commit_url(repo: upstream.Repo, pin: str) -> str:
return f"{base}/commit/{pin}"
def version_basis_url(profile: dict, basis: dict, mode: str = "") -> str:
"""Where a reader checks a version: the file stating it at the pin, or
the tag it counts from. "" when neither can be linked.
The Version cell printed `libretro/handy.h` as bare text beside a
pinned Revision link, so the #define it reads was one search away.
"""
field = "upstream" if basis.get("repo") == "upstream" else "source"
declared = next(iter(_repo_pins(profile, field, mode)), None)
repo = _browse_repo(declared[0]) if declared else None
if repo is None:
return ""
kind, path, tag = basis.get("kind"), str(basis.get("path") or ""), str(basis.get("tag") or "")
if kind == "file" and path:
pin = next((p for r, p in _forge_sources(profile, mode)
if (r.host, r.slug) == (repo.host, repo.slug)), "")
return _source_permalink(repo, pin, path, None, None) if pin and tree_holds(
repo, pin, path) else ""
if kind == "tag" and tag and repo.family in ("github", "gitlab"):
tree = "/-/tree/" if repo.family == "gitlab" else "/tree/"
return f"https://{repo.host}/{repo.owner}/{repo.name}{tree}{urllib.parse.quote(tag)}"
return ""
def revision_markdown(profile: dict) -> str:
"""Each repository the profile reads, linked at the commit it was read at.
+12 -1
View File
@@ -244,10 +244,21 @@ def _tooltip_line(line: str, pattern: re.Pattern, pending: dict[str, str]) -> st
return "".join(out)
_NAME_BEFORE = re.compile(r"(?:/|\b[A-Z][\w'-]*[ \t]+)$")
def _in_proper_name(term: str, before: str) -> bool:
"""A capitalised term after a capitalised word or a slash is part of a
name: Free Unix Spectrum Emulator, Windows Terminal, Documents/Dolphin
Emulator. An acronym (BIOS, ZIP) is the term wherever it stands."""
first = term.split()[0]
return first[:1].isupper() and not first.isupper() and bool(_NAME_BEFORE.search(before))
def _tooltip_text(text: str, pattern: re.Pattern, pending: dict[str, str]) -> str:
def swap(match: re.Match) -> str:
term = match.group(1)
if term not in pending:
if term not in pending or _in_proper_name(term, text[:match.start()]):
return term
definition = pending.pop(term)
return f'<abbr class="rb-term" title="{html.escape(definition)}">{term}</abbr>'
+107 -17
View File
@@ -21,6 +21,7 @@ import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
@@ -36,6 +37,7 @@ from common import (
)
from hashing import parse_hash_list
from nativemode import digest_algorithm
from dedup import _in_nodedup_dir
from validation import frontend_digest_matches, inner_rom_check
try:
@@ -79,6 +81,13 @@ class ValidationResult:
self.md5 = ""
self.crc32 = ""
self.size = 0
# "held" (a copy of a file the collection has) or "unknown" (no list
# or profile declares it): no check fails, and neither is a file
# the pull request adds to the collection.
self.outcome = ""
# Where --place copies the file: one folder and one declared name, or
# the .variants/ slot of a name the collection already holds.
self.destination = ""
def add_check(self, passed: bool, message: str) -> None:
self.checks.append(("PASS" if passed else "FAIL", message))
@@ -93,8 +102,12 @@ class ValidationResult:
def passed(self) -> bool:
return all(s != "FAIL" for s, _ in self.checks)
@property
def status(self) -> str:
return "FAIL" if not self.passed else "REVIEW" if self.outcome else "PASS"
def to_markdown(self) -> str:
status = "PASS" if self.passed else "FAIL"
status = self.status
lines = [f"### {status} `{self.filename}`"]
lines.append("")
lines.append(f"- **Path**: `{self.filepath}`")
@@ -147,7 +160,7 @@ def load_platform_hashes(platforms_dir: str, db: dict | None = None) -> dict:
under, read from the other files of that system it holds.
"""
known = {"sha1": set(), "md5": set(), "sha256": set(), "crc32": set(),
"names": set(), "folders": {}, "sizes": {}, "entries": {}}
"names": set(), "folders": {}, "sizes": {}, "entries": {}, "named": {}}
systems_of: dict[str, set[str]] = {}
folders_of: dict[str, dict[str, int]] = {}
files = (db or {}).get("files", {})
@@ -194,6 +207,9 @@ def load_platform_hashes(platforms_dir: str, db: dict | None = None) -> dict:
)
for value in (*sha1s, *md5s):
systems_of.setdefault(value, set()).add(system_id)
for value in (*sha1s, *md5s, *sha256s):
if "name" in file_entry:
known["named"].setdefault(value, set()).add(file_entry["name"])
_note_folder(folders_of, system_id, files, by_md5, sha1s, md5s)
known["folders"] = _system_homes(systems_of, folders_of)
@@ -251,6 +267,7 @@ def load_profile_hashes(
"""
known: dict[str, dict] = {
"sha1": {}, "sha256": {}, "md5": {}, "crc32": {}, "sizes": {}, "folders": {},
"named": {},
}
systems_of: dict[str, set[str]] = {}
folders_of: dict[str, dict[str, int]] = {}
@@ -274,6 +291,8 @@ def load_profile_hashes(
for algo, values in hashes.items():
for value in values:
known[algo].setdefault(value, set()).add(emulator)
if isinstance(entry.get("name"), str):
known["named"].setdefault(value, set()).add(entry["name"])
# The entry's own system, or the profile's when it emulates one.
system = entry.get("system") or (
declared_systems[0] if len(declared_systems) == 1 else None
@@ -374,9 +393,15 @@ def validate_file(
# PR that regenerated database.json was told each new file
# duplicated itself.
result.add_check(True, "Already in the database at this path")
elif _in_nodedup_dir(relative):
result.add_info(
f"Identical to `{existing['path']}`, kept: this tree is read by path"
)
else:
result.outcome = "held"
result.add_warning(
f"Duplicate: identical file already exists at `{existing['path']}`"
f"Already held at `{existing['path']}`: nothing to add, "
"leave this copy out of the pull request"
)
else:
result.add_check(True, "Not a duplicate in database")
@@ -421,8 +446,11 @@ def validate_file(
"Filename matches a known requirement but hash differs - may be a variant"
)
else:
result.outcome = "unknown"
result.add_warning(
"File not referenced in any platform config - needs manual review"
"Not recognised: no platform list or emulator profile declares this "
"file. Say in the pull request which emulator reads it, for which "
"system, and where the dump comes from"
)
normalized = os.path.normpath(relative)
@@ -452,11 +480,27 @@ def validate_file(
result.add_check(True, f"Correct placement: bios/{parts[1]}/{parts[2]}/")
else:
result.add_warning("File should be in bios/Manufacturer/Console/ structure")
elif result.outcome == "held":
pass
elif expected_folders:
# The contributor asks before forking: say where it goes.
names = {
name
for table in (platform_hashes, profile_hashes)
for value in (result.sha1, result.md5, hashes.get("sha256", ""))
for name in table.get("named", {}).get(value, ())
}
name = next(iter(names)) if len(names) == 1 else ""
if len(expected_folders) == 1 and name and "/" not in name:
folder = expected_folders[0]
result.destination = (
f"{folder}.variants/{name}.{result.sha1[:8]}"
if os.path.exists(folder + name) else folder + name
)
result.add_warning(
"File is not under bios/ yet. Its system's files are in "
+ " or ".join(f"`{f}`" for f in expected_folders)
+ (f". --place copies it to `{result.destination}`" if result.destination else "")
)
elif _variant_home(db, result.filename, result.sha1):
result.add_warning(
@@ -485,9 +529,11 @@ def validate_file(
if (name_known and not (sha1_known or md5_known or other_known or accepted_by)
and not by_profile and size_fits):
home = _variant_home(db, result.filename)
slot = f"{home}.variants/{result.filename}.{result.sha1[:8]}"
if home and not normalized.startswith("bios" + os.sep):
result.destination = slot
result.add_info(
"This may be a valid variant. If accepted, it will be placed in "
f"`{home}.variants/{result.filename}.{result.sha1[:8]}`"
f"This may be a valid variant. If accepted, it will be placed in `{slot}`"
)
return result
@@ -558,30 +604,35 @@ def _repo_key(url: str) -> str:
def _project_main_line() -> str | None:
"""The project's main line, fetched when no remote of this clone names it.
"""The project's main line, fetched when this clone does not hold it.
A fork's clone has origin alone. Once the commit is pushed to the fork's
main, origin/main is HEAD and the diff against it was empty, so --changed
found nothing and exited 0. None leaves the upstream/ and origin/ lines
in charge: a remote of that name names the project, or the clone has no
remote at all and only its uncommitted files can be read.
found nothing and exited 0. A remote added as CONTRIBUTING says and never
fetched did the same: upstream/main did not exist and origin/main won.
None leaves the upstream/ and origin/ lines in charge when the clone has
no remote at all and only its uncommitted files can be read.
"""
remotes = _git_lines("remote") or []
if not remotes:
return None
project = {_repo_key(url) for url in PROJECT_URLS}
source = PROJECT_URLS[0]
for remote in remotes:
urls = _git_lines("remote", "get-url", "--all", remote) or []
if any(_repo_key(url) in project for url in urls):
return None if remote in ("upstream", "origin") else f"{remote}/main"
if _git_lines("rev-parse", "--verify", "--quiet", f"{remote}/main"):
return f"{remote}/main"
source = remote
break
fetched = subprocess.run(
["git", "fetch", "--quiet", "--no-tags", PROJECT_URLS[0], "main"],
["git", "fetch", "--quiet", "--no-tags", source, "main"],
capture_output=True, text=True, check=False,
)
if fetched.returncode != 0:
raise RuntimeError(
f"no remote of this clone is {PROJECT_URLS[0]}, and fetching its "
f"main failed: {fetched.stderr.strip()}"
f"this clone does not hold the main of {PROJECT_URLS[0]}, and "
f"fetching its main from {source} failed: {fetched.stderr.strip()}"
)
return "FETCH_HEAD"
@@ -675,6 +726,28 @@ def _files_to_validate(
return files
def place(results: list[ValidationResult]) -> list[str]:
"""Copy each placeable file to its destination, never over a file.
The report named the folder of each file and left 40 copies from a card
to be made by hand, each .variants/ name retyped. The source is copied,
not moved: it is often a card or another tree the contributor keeps.
"""
placed = []
for r in results:
if not (r.passed and r.destination and not r.outcome):
continue
target = Path(r.destination)
if target.exists():
print(f"not placed: {target} exists", file=sys.stderr)
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(r.filepath, target)
placed.append(r.destination)
print(f"placed {r.filepath} at {r.destination}", file=sys.stderr)
return placed
def _load_database_if_any(path: str) -> dict | None:
"""The database, optional only where the default is meant.
@@ -709,6 +782,11 @@ def main() -> None:
"--markdown", action="store_true", help="Output as markdown (for PR comments)"
)
output.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument(
"--place", action="store_true",
help="copy each recognised file from outside bios/ to the folder and name "
"the report gives, or to .variants/ beside the name it varies",
)
args = parser.parse_args()
# The database, platforms and profiles are read from the repository
@@ -745,6 +823,8 @@ def main() -> None:
{
"file": r.filepath,
"passed": r.passed,
"outcome": r.outcome or "new",
"destination": r.destination,
"sha1": r.sha1,
"md5": r.md5,
"size": r.size,
@@ -754,7 +834,15 @@ def main() -> None:
print(json.dumps(output, indent=2))
elif args.markdown:
lines = ["## BIOS Validation Report", ""]
status = "all checks passed" if all_passed else "some checks failed"
held = sum(r.outcome == "held" for r in results)
unknown = sum(r.outcome == "unknown" for r in results)
review = ", ".join(part for part in (
f"{held} already held" if held else "",
f"{unknown} not recognised" if unknown else "",
) if part)
status = ("some checks failed" if not all_passed
else f"no check failed, to review: {review}" if review
else "all checks passed")
lines.append(f"**Status**: {status}")
lines.append("")
@@ -765,8 +853,7 @@ def main() -> None:
print("\n".join(lines))
else:
for r in results:
status = "PASS" if r.passed else "FAIL"
print(f"\n[{status}] {r.filepath}")
print(f"\n[{r.status}] {r.filepath}")
if r.sha1:
print(f" SHA1: {r.sha1}")
print(f" MD5: {r.md5}")
@@ -783,6 +870,9 @@ def main() -> None:
)
print(f" [{marker}] {m}")
if args.place:
place(results)
if not all_passed:
sys.exit(1)
+2 -1
View File
@@ -6781,7 +6781,8 @@ class SkipPacksHelpSaysWhatRuns(unittest.TestCase):
)
text = " ".join(proc.stdout.split())
self.assertNotIn("Only regenerate DB and verify", text)
self.assertIn("Steps 7 and 8 still rewrite README.md and the site pages in website/", text)
self.assertIn("step 4e still rewrites install/games/, and steps 7 to 8b rewrite README.md "
"and the site unless --skip-docs is given", text)
self.assertNotIn("docs/", text)
def test_every_step_it_names_is_one_it_skips(self):
+30
View File
@@ -78,6 +78,36 @@ class BeforeOpeningAPullRequest(unittest.TestCase):
self.assertIn("profile_sync.py --added --base upstream/main --strict", block)
self.assertNotIn("--changed --strict", block)
def test_it_names_the_site_build_and_the_packages_ci_runs(self):
"""A wiki-only change passed every listed check and failed CI on
validate_site.py, which the list did not name."""
text = generate_readme.generate_contributing()
section = text.split("## Before opening a PR", 1)[1].split("\n## ", 1)[0]
workflow = (REPO_ROOT / ".github" / "workflows" / "validate.yml").read_text(encoding="utf-8")
for command in ("npm ci --prefix website", "npm run build --prefix website",
"validate_site.py --site-dir website/dist",
"vermin --no-tips -t=3.8- --violations install.py"):
with self.subTest(command=command):
self.assertIn(command, workflow)
self.assertIn(command, section)
installed = re.search(r'^\s*run: pip install (pyyaml "jsonschema.*markdown.*)$',
workflow, re.M).group(1)
pip_line = next(line for line in section.splitlines() if line.startswith("pip install"))
for package in (re.split(r"[\[=]", token.strip('"'))[0] for token in installed.split()):
with self.subTest(package=package):
self.assertRegex(pip_line, rf"\b{package}\b")
def test_a_file_too_large_for_git_has_a_route(self):
"""The guide sent any file over 25 MB through a clone, which GitHub
refuses past 100 MB, and only a maintainer can write to the release."""
import validate_pr
text = generate_readme.generate_contributing()
section = text.split("## What happens next", 1)[1].split("\n## ", 1)[0]
self.assertEqual(validate_pr.MAX_FILE_SIZE, 100 * 1024 * 1024)
self.assertIn("100 MB", section)
self.assertIn(f"]({validate_pr.LARGE_FILE_ISSUE_URL})", section)
def test_it_installs_what_the_checks_need_and_no_site_package(self):
"""The site is built by Node from website/package-lock.json: a pip
line naming the old theme installed packages nothing reads."""
+15
View File
@@ -252,6 +252,21 @@ class TestPackDeterminism(unittest.TestCase):
names = {i.filename.rsplit("/", 1)[-1] for i in zf.infolist()}
self.assertIn("romset.zip", names)
def test_a_romset_is_the_collection_bytes_and_the_wiki_says_so(self):
"""The pack copies each ZIP byte for byte since fed03c3db, and the
wiki still told a third party to rebuild romsets to compare."""
pack = self._build("out1")
with zipfile.ZipFile(pack) as zf:
member = next(n for n in zf.namelist() if n.endswith("romset.zip"))
self.assertEqual(zf.read(member), (self.bios / "romset.zip").read_bytes())
root = Path(__file__).resolve().parent.parent / "wiki"
for page in ("architecture.md", "tools.md"):
with self.subTest(page=page):
text = " ".join((root / page).read_text(encoding="utf-8").split())
self.assertNotIn("rebuilt deterministically", text)
self.assertNotIn("rebuilds MAME", text)
self.assertNotIn("Rebuild MAME BIOS ZIPs", text)
def test_every_member_carries_the_fixed_epoch(self):
"""A member's date must come from the build's inputs, not its clock.
+86
View File
@@ -1799,5 +1799,91 @@ class ReportExcerptIsTheRun(unittest.TestCase):
self.assertIn(line, printed)
class ClaimRuleNamesTheSizeProof(unittest.TestCase):
"""architecture.md said a file found by its name claims nothing, while
slots.py has counted a name-found copy at the declared size against a
platform file of another size since 2b535525e."""
def test_the_page_states_what_slots_counts(self):
import slots
self.assertIn(slots.SIZE_EXACT, slots.PROVEN_STATUSES)
page = " ".join((ROOT / "wiki" / "architecture.md").read_text(encoding="utf-8").split())
section = page.split("### When both layers claim one destination", 1)[1].split("###", 1)[0]
self.assertIn(f"(`{slots.SIZE_EXACT}`)", section)
self.assertIn("Two files of equal size are revisions", section)
class CrossReferenceSettlesBySlotAndSize(unittest.TestCase):
"""reading-reports.md said the cross-reference checks whether a profile's
files "appear in the platform config", a by-name rule, while verify
settles a file only at its slot or at a size the core accepts."""
def test_the_page_states_the_rule_verify_reads(self):
from validation import declaration_settles
rom1 = {"name": "ROM1.BIN", "size": 32768, "validation": ["size"]}
self.assertFalse(declaration_settles(rom1, "ROM1.BIN", {"ROM1.BIN": [(4096, "rom1.bin")]},
dest="dosbox/ROM1.BIN"))
self.assertTrue(declaration_settles(rom1, "ROM1.BIN", {"ROM1.BIN": [(32768, "rom1.bin")]},
dest="dosbox/ROM1.BIN"))
page = " ".join((ROOT / "wiki" / "reading-reports.md").read_text(encoding="utf-8").split())
section = page.split("### Cross-reference section", 1)[1].split("###", 1)[0]
self.assertIn("declaration_settles()", section)
self.assertIn("A name alone is not enough", section)
self.assertNotIn("appear in the platform config", section)
class CommittedLargeFilesAreTheOnesNamed(unittest.TestCase):
"""release-process.md named tbblue.mmc among the committed files over
50 MB two weeks after it left bios/, and not uqm-remix-disc1.uqm."""
def test_the_list_is_the_tree(self):
import subprocess
listed = subprocess.run(["git", "ls-files", "-z", "bios"], cwd=ROOT, capture_output=True,
check=True).stdout.decode("utf-8").split("\0")
large = sorted(Path(path).name for path in listed
if path and (ROOT / path).is_file()
and (ROOT / path).stat().st_size > 50_000_000)
page = " ".join((ROOT / "wiki" / "release-process.md").read_text(encoding="utf-8").split())
said = re.search(r"Size does not decide it: (\w+) committed files are over 50 MB \(([^)]*)\)",
page)
self.assertIsNotNone(said)
self.assertEqual(sorted(re.findall(r"`([^`]+)`", said.group(2))), large)
self.assertEqual(said.group(1), {3: "three", 4: "four", 5: "five"}.get(len(large)))
class SkipFlagsNameTheSteps(unittest.TestCase):
"""--skip-packs said "add --skip-docs to stop after verify" while 4d and
4e still ran, and the flag table left 8b out of --skip-docs."""
STEP = r"\b\d[a-z]?\d?\b"
def test_the_flag_row_the_help_and_the_usage_agree_with_the_step_table(self):
page = (ROOT / "wiki" / "tools.md").read_text(encoding="utf-8")
table = page.split("Pipeline steps:", 1)[1].split("Pipeline flags:", 1)[0]
source = (ROOT / "scripts" / "pipeline.py").read_text(encoding="utf-8")
helps = {}
for node in ast.walk(ast.parse(source)):
if (isinstance(node, ast.Call) and getattr(node.func, "attr", "") == "add_argument"
and node.args and isinstance(node.args[0], ast.Constant)):
helps.update({node.args[0].value: ast.literal_eval(kw.value)
for kw in node.keywords if kw.arg == "help"})
for flag in ("--skip-packs", "--skip-docs"):
steps = set()
for row in table.splitlines():
cells = [c.strip() for c in row.split("|")]
if len(cells) > 3 and cells[3].startswith(f"`{flag}`"):
steps.add(cells[1])
self.assertTrue(steps)
row = re.search(rf"^\| `{flag}` \| skip steps ([^.|]*)", page, re.M).group(1)
usage = re.search(rf"pipeline\.py {flag} +# skip steps (.*)$", source, re.M).group(1)
said = helps[flag].split(":", 1)[0]
for text in (row, usage, said):
with self.subTest(flag=flag, text=text):
self.assertEqual(set(re.findall(self.STEP, text)), steps)
if __name__ == "__main__":
unittest.main()
+6 -4
View File
@@ -17,7 +17,7 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from cross_reference import FileConsumers # noqa: E402
from cross_reference import FileConsumers, entry_target # noqa: E402
from generate_site import generate_system_page # noqa: E402
_TMP = tempfile.TemporaryDirectory()
@@ -246,7 +246,6 @@ class EveryReaderIsTheEmulatorPackFile(unittest.TestCase):
build_zip_contents_index, load_data_dir_registry,
load_emulator_profiles, resolve_local_file,
)
from validation import with_archive_members
db_path = REPO_ROOT / "database.json"
if not db_path.exists():
@@ -275,9 +274,12 @@ class EveryReaderIsTheEmulatorPackFile(unittest.TestCase):
if members:
from_trees |= {(sha1, name) for sha1 in members.values()}
continue
# A ROM read inside an archive is the archive, as the
# emulator pack resolves it (archive_entry).
target, dest = entry_target(entry, files)
path, status = resolve_local_file(
{**with_archive_members(entry, files), "source_profile": name},
db, zips, dest_hint=entry.get("path") or "",
{**target, "source_profile": name},
db, zips, dest_hint=dest,
data_dir_registry=registry,
)
if path and status not in ("not_found", "hash_mismatch"):
+7
View File
@@ -5974,6 +5974,13 @@ class TestBranchProfiles(unittest.TestCase):
["draft", "new"],
)
def test_an_unfetched_base_says_to_fetch(self):
"""`--base upstream/main` with upstream added and never fetched
stopped on git's "Not a valid object name" and no next step."""
with self.assertRaises(SystemExit) as raised:
profile_sync.branch_profiles("emulators", "upstream/main", added_only=True)
self.assertIn("git fetch upstream", str(raised.exception))
def test_the_flag_selects_those_profiles(self):
args = profile_sync.build_parser().parse_args(["--changed", "--base", "main"])
profiles = {"kept": {}, "edited": {}, "new": {}, "draft": {}}
+16
View File
@@ -308,6 +308,22 @@ class ProfilesStateFactsAboutOtherProjects(unittest.TestCase):
]
self.assertEqual(offenders, [], "\n".join(offenders[:40]))
# A project's state is a dated fact or its own words, never a verdict:
# "Upstream project is dead" (yabause), "abandoned fork" (gearboy).
PROJECT_VERDICTS = re.compile(
r"\b(?:(?:project|upstream)(?:\s+project)?\s+(?:is\s+)?dead|"
r"abandoned\s+(?:fork|since)|project\s+abandoned|never-completed)\b",
re.IGNORECASE,
)
def test_no_verdict_on_a_project(self):
offenders = [
f"{path.relative_to(ROOT)}: {found.group(0)}"
for path in sorted((ROOT / "emulators").glob("*.yml"))
for found in self.PROJECT_VERDICTS.finditer(path.read_text(encoding="utf-8"))
]
self.assertEqual(offenders, [], "\n".join(offenders[:40]))
def test_no_grade_on_another_projects_file(self):
offenders = [
f"{path.relative_to(ROOT)}: {line.strip()[:100]}"
+1 -1
View File
@@ -130,7 +130,7 @@ class AForkPushedToItsOwnMain(unittest.TestCase):
# A project that cannot be reached is an error, never "nothing".
missing = self._changed(clone, str(Path(tmp, "absent")))
self.assertNotEqual(missing.returncode, 0)
self.assertIn("fetching its main failed", missing.stderr)
self.assertIn("fetching its main from", missing.stderr)
class RefreshFailuresAreFailures(unittest.TestCase):
+29 -4
View File
@@ -334,6 +334,22 @@ class NoLevelBadgeOnATabRoot(unittest.TestCase):
self.assertNotIn('class="rb-level ', _title_through_heading(_built(page)))
class OneTabIsNoTabBar(unittest.TestCase):
"""142 tab bars held one tab, most of them "On the device" right under
the heading "On the device"."""
def test_a_single_item_stands_alone(self):
import sitemarkup
import siteguides
self.assertEqual(sitemarkup.tabs([("Linux", ["run it"])]), ["**Linux**", "", "run it", ""])
self.assertEqual(sitemarkup.tabs([("", ["run it"])]), ["run it", ""])
self.assertIn('<Tabs syncKey="os">', sitemarkup.tabs([("A", ["a"]), ("B", ["b"])]))
device = "\n".join(siteguides.terminal_tabs(("device",), {}))
self.assertNotIn("<Tab", device)
self.assertNotIn("On the device", device)
class FooterLicenceMatchesTheLicenceFile(unittest.TestCase):
"""The footer said MIT covers the tooling. LICENSE names the metadata too."""
@@ -353,15 +369,24 @@ class WideTablesStackAndFiltersAreBuilt(unittest.TestCase):
def test_every_wide_table_stacks(self):
for page in ("emulators/duckstation", "gaps", "gaps-files-to-source", "emulators", "data",
"wiki/verification-modes"):
html = (DIST / page / "index.html")
if not html.is_file():
continue
for table in _tables(_read(html)):
for table in _tables(_built(page)):
if len(_headers(table)) < 4:
continue
with self.subTest(page=page, header=_headers(table)):
self.assertIn('data-rb-stack="true"', table.split(">", 1)[0])
def test_no_build_is_a_skip_not_a_pass(self):
"""Every missing page was a `continue`: with no build the test ran no
assertion and still reported ok."""
from unittest import mock
module = sys.modules[__name__]
case = WideTablesStackAndFiltersAreBuilt("test_every_wide_table_stacks")
result = unittest.TestResult()
with mock.patch.object(module, "DIST", REPO_ROOT / "tmp" / "no-such-dist"):
case.run(result)
self.assertEqual(len(result.skipped), 1)
def test_long_tables_get_their_labels_at_runtime(self):
"""Labels on every cell of a long stacked table were 122 KB of
emulators/mame and took it over the 600,000-byte page budget."""
+32
View File
@@ -68,6 +68,38 @@ class WhoShipsTheBuildAndWhy(unittest.TestCase):
"(https://example.org/news) (version 13.0 announced there, "
"found only as rebuilt copies) |", page)
def test_the_version_links_its_proof(self):
"""`0.97, stated in libretro/handy.h` was bare text beside a pinned
Revision link: the #define it reads was one search away."""
import generate_site as gs
import sitesources
import yaml
handy = yaml.safe_load((REPO_ROOT / "emulators" / "handy.yml").read_text(encoding="utf-8"))
path = handy["version_basis"]["path"]
self.assertIn(path, sitesources.cited_paths(handy))
self.assertIn(f"stated in [`{path}`](https://github.com/libretro/libretro-handy/blob/"
f"{handy['source_commit']}/{path})", gs.version_markdown(handy))
tagged = {"source": "https://github.com/owner/emu", "source_commit": "a" * 40,
"core_version": "1.2-3-gaaaaaaa",
"version_basis": {"kind": "tag", "tag": "1.2", "distance": 3}}
self.assertEqual(gs.version_markdown(tagged), "1.2-3-gaaaaaaa, 3 commits after tag "
"[`1.2`](https://github.com/owner/emu/tree/1.2)")
# A file no recorded tree confirms stays plain: no link beats a 404.
unread = {**tagged, "version_basis": {"kind": "file", "path": "src/none.h"}}
self.assertEqual(gs.version_markdown(unread), "1.2-3-gaaaaaaa, stated in `src/none.h`")
def test_the_upstream_pin_says_why_it_is_held(self):
"""mupen64plus showed its core revision and not that AmberELEC
builds that one: read as a profile behind HEAD."""
page = _page("mupen64plus", {
"type": "libretro",
"upstream_frozen": "AmberELEC builds the core at this revision.",
"files": [],
})
self.assertIn("| Upstream revision held | AmberELEC builds the core at this "
"revision. |", page)
class ACardKeepsItsAddress(unittest.TestCase):
"""emulators/raze/#file-duke3d-grp stopped pointing at its card once a
+10 -3
View File
@@ -254,11 +254,16 @@ class WhatTheAppShipsIsNoFileToGet(unittest.TestCase):
self.assertIn(">comes with the app</span>", badges)
self.assertNotIn(">required</span>", badges)
self.assertNotIn(">missing</span>", badges)
# Installed by one build only: the other still reads it from the folder.
# Narrowed to one build, the entry is that build's alone, as verify
# and the pack read it: EightyOne installs its ROMs, and the libretro
# core, which embeds them, reads no entry. Nothing to fetch.
dual = " ".join(gs._file_badges({"name": "a", "bundled": True, "mode": "standalone",
"required": True}, False))
self.assertIn(">required</span>", dual)
self.assertNotIn(">required</span>", dual)
self.assertNotIn(">missing</span>", dual)
self.assertIn("installed with the standalone emulator", dual)
rom = next(f for f in _profile("81")["files"] if f["name"] == "jupiterace.rom")
self.assertTrue(gs.comes_with_app(rom))
def test_counts_leave_them_out(self):
shipped = sum(1 for f in self.profile["files"] if gs.comes_with_app(f))
@@ -399,7 +404,9 @@ class AnAppThatReadsAnyBios(unittest.TestCase):
install = self.page[self.page.index("Install this emulator's files"):]
install = install[:install.index("</details>")]
self.assertRegex(install, r"With [^:]+: the installer finds")
self.assertEqual(set(re.findall(r'<TabItem label="([^"]+)">', install)), {"On the device"})
# One place to run it, so no tab bar (one tab offers no choice).
self.assertNotIn("<Tabs", install)
self.assertIn("curl -fsSL", install)
nether = _pages("nethersx2")["nethersx2"]
if "--emulator nethersx2" in nether:
self.assertIn("With ", nether)
+13 -3
View File
@@ -443,14 +443,22 @@ class PackNamesItsVersion(unittest.TestCase):
block = generate_site._pack_buttons("batocera")
version = generate_site._pack_version(release_record.pack_name_for("batocera", record))
self.assertIn(f"Made for Batocera {version}. "
+ generate_site.OTHER_VERSION, block)
+ generate_site.other_version("batocera"), block)
def test_a_version_gap_is_said_once(self):
record = {"tag": "v2026.10.10", "packs": {"RetroBat_1.0_BIOS_Pack.zip": {
"assets": ["RetroBat_1.0_BIOS_Pack.zip"], "platforms": ["retrobat"]}}}
released = generate_site.release_line("retrobat", record)
line = generate_site.version_line("retrobat", record, named=len(released) > 1)
self.assertEqual(line, generate_site.OTHER_VERSION)
self.assertEqual(line, generate_site.other_version("retrobat"))
def test_another_version_is_sent_to_the_check_commands(self):
"""A ZIP reader read "Run `--check` after copying." with no command."""
line = generate_site.other_version("emudeck")
self.assertNotIn("Run `--check`", line)
self.assertIn("](../platforms/emudeck.md#how-to-check)", line)
page = _page("platforms/emudeck.md")
self.assertIn("{ #how-to-check }", page)
def test_the_v_prefix_is_dropped(self):
self.assertEqual(generate_site.setup_version("retroarch", {}),
@@ -925,7 +933,9 @@ class ARouteWithoutANetwork(unittest.TestCase):
self.assertEqual(siteguides.GUIDES["retropie"].card_run, ("linux",))
text = _plain_page("platforms/retropie.md")
card = text.split("No network?", 1)[1].split("## On the device", 1)[0]
self.assertEqual(_tab_labels(card), {"Linux"})
# One system, so no tab bar: its label leads the command.
self.assertEqual(_tab_labels(card), set())
self.assertIn("**Linux**", card)
def test_no_zip_is_promised_for_an_unmarked_setup(self):
for name in SETUPS:
+22 -1
View File
@@ -422,7 +422,11 @@ class BuiltBatoceraPage(unittest.TestCase):
# PlayStation's list is long enough to sit on a page of its own.
pages = [main] + [p.read_text(encoding="utf-8") for p in
(DIST / "platforms").glob("batocera-sony-playstation*/index.html")]
html = "".join(pages)
# Read as a browser shows it: a page with no tab bar is plain
# Markdown, which keeps the entity a name's underscore is written as.
import html as _html
html = _html.unescape("".join(pages))
for text in ("Served by:", "scph1001_v20.bin", "https://github.com/stenzek/duckstation/blob/"):
with self.subTest(text=text):
self.assertTrue(text in html)
@@ -696,6 +700,23 @@ class ManifestsArePublished(unittest.TestCase):
self.assertLess(page.index("## Stable JSON endpoints"), page.index("The DAT is Logiqx XML"))
class TheLookupRecipeSaysWhatASha1Misses(unittest.TestCase):
"""The page said a SHA1 lookup was enough, "a setup that lists a file
by MD5 or CRC32 is filed under the SHA1 of the collection file it names
too". A record whose value no collection file answers has no SHA1: 702
platform MD5s were out of the recipe's reach."""
def test_the_page_and_the_records_agree(self):
record = {"layer": "platform", "algorithm": "md5", "value": "0" * 32,
"name": "dmg_boot.bin", "owner": "recalbox"}
self.assertEqual(generate_site._held_under_every_hash([record], {}, {}), [])
self.assertNotIn("sha1", record)
page = " ".join(generate_site.generate_data_page([]).split())
self.assertNotIn("none is needed", page)
self.assertIn("A list naming a file the collection lacks is filed under its MD5 "
"or CRC32 alone", page)
class AnIdLeadsToItsData(unittest.TestCase):
"""8.6: a system by its id, a core by the name the buildbot gives it."""
+76
View File
@@ -427,5 +427,81 @@ class TheSearchSkipsLinkLists(unittest.TestCase):
self.assertIn(short, text)
class OneDefinitionPerName(unittest.TestCase):
"""generate_site defined _REGION_WORDS twice: the second table replaced
the first at import, and _region_word, written beside the first, never
read it."""
def test_no_module_name_is_bound_twice(self):
import ast
for path in sorted((REPO_ROOT / "scripts").glob("*site*.py")):
seen: dict[str, int] = {}
for node in ast.parse(path.read_text(encoding="utf-8")).body:
if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
names = [node.name]
elif isinstance(node, ast.Assign):
names = [t.id for t in node.targets if isinstance(t, ast.Name)]
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
names = [node.target.id]
else:
continue
for name in names:
self.assertNotIn(name, seen, f"{path.name}:{node.lineno} binds {name} "
f"again, first at line {seen.get(name)}")
seen[name] = node.lineno
@unittest.skipUnless((REPO_ROOT / "database.json").is_file(), "database.json is not built")
class AMemberIsReadInItsArchive(unittest.TestCase):
"""FBNeo reads sm1.sm1 inside neogeo.zip. Resolved by its crc32, the
entry credited a loose sm1.sm1 FBNeo never opens, and no neogeo.zip card
named FBNeo, while the pack ships it one."""
def test_the_archive_is_credited_and_named_on_the_card(self):
import json
import generate_site as gs
from common import archive_entry, resolve_local_file
from cross_reference import FileConsumers
db = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8"))
profile = yaml.safe_load((REPO_ROOT / "emulators" / "fbneo.yml").read_text(encoding="utf-8"))
files = profile["files"]
consumers = FileConsumers(db)
consumers.add_emulators({"fbneo": profile})
credited = {consumers.path_of(sha1) for sha1 in consumers.emulators}
shipped, _status = resolve_local_file(
{**archive_entry("neogeo.zip", files), "source_profile": "fbneo"}, db)
self.assertIn(shipped, credited)
self.assertFalse([p for p in credited if p.endswith("/sm1.sm1")])
sm1 = next(f for f in files if f["name"] == "sm1.sm1")
rows = "\n".join(gs._render_emulator_file(sm1, profile, consumers, files,
lambda _f: True, owner="fbneo"))
self.assertIn("Collection copy: member `sm1.sm1` of", rows)
self.assertIn(shipped, rows)
class NoTooltipInsideAName(unittest.TestCase):
"""The glossary's capitalised terms matched almost only product names:
Free Unix Spectrum Emulator, Windows Terminal, Documents/Dolphin Emulator."""
def test_names_are_left_and_terms_are_marked(self):
import sitestarlight
terms = {"Emulator": "An app that acts like a console.",
"Terminal": "A window where you type commands.",
"BIOS": "The console's built-in software."}
for line in ("Fuse (Free Unix Spectrum Emulator) reads it.",
"Open Windows Terminal (WSL).",
"Copy it to Documents/Dolphin Emulator/Sys."):
with self.subTest(line=line):
self.assertNotIn("rb-term", sitestarlight.add_tooltips(line, terms))
marked = sitestarlight.add_tooltips(
"Emulator settings come first. The PlayStation BIOS goes there.", terms)
self.assertIn('title="An app that acts like a console.">Emulator</abbr>', marked)
self.assertIn('>BIOS</abbr>', marked)
if __name__ == "__main__":
unittest.main()
+34
View File
@@ -231,6 +231,40 @@ class Descriptions(unittest.TestCase):
self.assertTrue(text.startswith("DuckStation emulates Sony PlayStation."))
self.assertNotIn("source-pinned", text)
def test_a_page_whose_files_come_with_the_app_promises_none(self) -> None:
"""DaedalusX64's three files come with the app. The result read
"DaedalusX64 BIOS files" over "DaedalusX64 emulates Nintendo 64."."""
saved, shipped = dict(gs.PAGE_DATA), set(gs.NOTHING_TO_FETCH)
self.addCleanup(lambda: (gs.PAGE_DATA.clear(), gs.PAGE_DATA.update(saved),
gs.NOTHING_TO_FETCH.clear(), gs.NOTHING_TO_FETCH.update(shipped)))
with open(REPO_ROOT / "emulators" / "daedalusx64.yml", encoding="utf-8") as handle:
gs.record_emulator_page("daedalusx64", yaml.safe_load(handle))
key = Path("emulators/daedalusx64.md")
self.assertEqual(gs._browser_title(key, "DaedalusX64"), "DaedalusX64 emulator")
body = "# DaedalusX64\n\nDaedalusX64 emulates Nintendo 64.\n"
self.assertEqual(gs._page_description(body, key, "DaedalusX64"),
"DaedalusX64 emulates Nintendo 64. Every file it reads comes with the app.")
def test_an_initial_does_not_end_the_sentence(self) -> None:
"""hcl's description was "Hydra Castle Labyrinth: open-source port of E."."""
body = ("# Hydra Castle Labyrinth\n\n<details>\n<summary>Technical notes</summary>\n\n"
"Open-source port of E. Hashimoto's 2008 freeware Metroidvania. It reads "
"its data folder.\n\n</details>\n")
text = gs._emulator_description(body, "emulators/hcl.md", "Hydra Castle Labyrinth", "")
self.assertTrue(text.startswith("Hydra Castle Labyrinth: open-source port of E. "
"Hashimoto's 2008 freeware Metroidvania."), text)
self.assertNotIn("It reads", text)
def test_a_platform_page_says_what_it_gives(self) -> None:
"""All 18 ended on the label of a link a snippet cannot follow,
"ROCKNIX website.", and never named the BIOS files or their folder."""
body = ("# ROCKNIX\n\nA system for handhelds. [ROCKNIX website](https://rocknix.org).\n\n"
"## On the device { #install }\n")
self.assertEqual(
gs._page_description(body, Path("platforms/rocknix.md"), "ROCKNIX"),
"The BIOS files for ROCKNIX, the folder they go in and the command that installs "
"them. A system for handhelds.")
def test_a_console_page_is_described_by_its_start_line(self) -> None:
body = "# Sega: Dreamcast\n\nPart of Sega.\n\n**Start with**: `dc_boot.bin`, `dc_flash.bin`.\n"
text = gs._page_description(body, Path("systems/sega-dreamcast.md"), "Sega: Dreamcast")
+19
View File
@@ -116,5 +116,24 @@ class SystemNames(unittest.TestCase):
self.assertFalse(names & siterender.program_names(self.profiles))
class SectionsComeBeforeThePlan(unittest.TestCase):
"""plan_emulator_pages names systems. Run before set_console_sections,
it made siterender read ./database.json and ./emulators whatever --db
and --emulators-dir gave, so the parts were cut on another collection."""
def test_generate_installs_the_sections_before_planning(self):
import ast
import inspect
import generate_site
tree = ast.parse(inspect.getsource(generate_site._generate))
lines = {}
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
lines.setdefault(node.func.id, node.lineno)
self.assertLess(lines["set_console_sections"], lines["plan_emulator_pages"])
if __name__ == "__main__":
unittest.main()
+66 -10
View File
@@ -139,9 +139,25 @@ class AFileOnlyACoreReadsIsKnown(_Fixture):
)
self.assertIn(
("WARN", "File is not under bios/ yet. Its system's files are in "
"`bios/Maker/Console/`"),
"`bios/Maker/Console/`. --place copies it to `bios/Maker/Console/b.bin`"),
result.checks,
)
self.assertEqual(result.destination, "bios/Maker/Console/b.bin")
# A file of that name already there: the copy is a variant.
self._bios_file("bios/Maker/Console/b.bin", b"another")
self.assertEqual(
validate_pr.validate_file("Downloads/b.bin", db, EMPTY_HASHES, known).destination,
f"bios/Maker/Console/.variants/b.bin.{result.sha1[:8]}",
)
# Copied under the declared name, never over a file, and only once.
card = self._bios_file("card/B.BIN", missing)
named = validate_pr.validate_file(card, db, EMPTY_HASHES, known)
self.assertEqual(validate_pr.place([named]), [named.destination])
self.assertEqual((self.tmp / named.destination).read_bytes(), missing)
self.assertTrue((self.tmp / card).is_file())
self.assertEqual(validate_pr.place([named]), [])
unknown = validate_pr.validate_file(self._bios_file("card/x.bin"), db, EMPTY_HASHES, known)
self.assertEqual((unknown.outcome, unknown.destination), ("unknown", ""))
placed = validate_pr.validate_file(
self._bios_file("bios/Other/Place/b.bin", missing), db, EMPTY_HASHES, known,
)
@@ -243,20 +259,38 @@ class HashRecognition(_Fixture):
self.assertIn(".variants/known.bin.", markdown)
def test_an_unreferenced_file_asks_for_review(self):
"""Random bytes read "PASS" and "all checks passed", and the warning
named the platform configs alone though the profiles were searched."""
rel = self._bios_file("bios/Sony/PS/mystery.bin")
markdown = validate_pr.validate_file(rel, None, EMPTY_HASHES).to_markdown()
self.assertIn("needs manual review", markdown)
result = validate_pr.validate_file(rel, None, EMPTY_HASHES)
markdown = result.to_markdown()
self.assertEqual(result.outcome, "unknown")
self.assertIn("### REVIEW", markdown)
self.assertIn("no platform list or emulator profile declares this file", markdown)
self.assertIn("which emulator reads it", markdown)
def test_a_duplicate_of_a_known_file_is_reported(self):
rel = self._bios_file("bios/Sony/PS/dupe.bin")
"""A copy of a held file passed, and was sent to the folder that
already holds it."""
rel = self._bios_file("elsewhere/dupe.bin")
probe = validate_pr.validate_file(rel, None, EMPTY_HASHES)
db = {"files": {probe.sha1: {"path": "bios/Sony/PS/original.bin"}}}
markdown = validate_pr.validate_file(rel, db, EMPTY_HASHES).to_markdown()
self.assertIn("Duplicate", markdown)
self.assertIn("original.bin", markdown)
known = {**EMPTY_HASHES, "sha1": {probe.sha1},
"folders": {probe.sha1: {"bios/Sony/PS/"}}}
result = validate_pr.validate_file(rel, db, known)
markdown = result.to_markdown()
self.assertEqual(result.outcome, "held")
self.assertIn("### REVIEW", markdown)
self.assertIn("Already held at `bios/Sony/PS/original.bin`: nothing to add", markdown)
self.assertNotIn("not under bios/", markdown)
# A duplicate is worth saying out loud but must not block the PR.
self.assertTrue(validate_pr.validate_file(rel, db, EMPTY_HASHES).passed)
self.assertTrue(result.passed)
def test_a_copy_in_a_tree_read_by_path_is_kept(self):
rel = self._bios_file("bios/ScummVM/a/dupe.bin")
probe = validate_pr.validate_file(rel, None, EMPTY_HASHES)
db = {"files": {probe.sha1: {"path": "bios/ScummVM/b/dupe.bin"}}}
self.assertNotEqual(validate_pr.validate_file(rel, db, EMPTY_HASHES).outcome, "held")
def test_the_file_the_database_holds_is_not_its_own_duplicate(self):
"""The documented example on a tracked file warned "Duplicate:
@@ -334,7 +368,8 @@ class Placement(_Fixture):
class Reporting(_Fixture):
def test_markdown_carries_every_digest_and_the_verdict(self):
rel = self._bios_file("bios/Sega/Saturn/sega.bin")
result = validate_pr.validate_file(rel, None, EMPTY_HASHES)
probe = validate_pr.validate_file(rel, None, EMPTY_HASHES)
result = validate_pr.validate_file(rel, None, {**EMPTY_HASHES, "sha1": {probe.sha1}})
markdown = result.to_markdown()
for value in (result.sha1, result.md5, result.crc32):
self.assertIn(value, markdown)
@@ -344,12 +379,33 @@ class Reporting(_Fixture):
rel = self._bios_file("bios/Sega/Saturn/bad.exe")
self.assertIn("### FAIL", validate_pr.validate_file(rel, None, EMPTY_HASHES).to_markdown())
@unittest.skipUnless((REPO_ROOT / "database.json").is_file(), "database.json is not built")
def test_the_status_line_names_what_is_left_to_review(self):
import shutil
import subprocess
held = REPO_ROOT / "bios" / "Sony" / "PlayStation Portable" / "ppge_atlas.zim"
if not held.is_file():
self.skipTest(f"{held} is not here")
shutil.copy(held, self.tmp / "ppge_atlas.zim")
(self.tmp / "mystery.bin").write_bytes(os.urandom(4096))
proc = subprocess.run(
[sys.executable, str(REPO_ROOT / "scripts" / "validate_pr.py"), "--markdown",
str(self.tmp / "mystery.bin"), str(self.tmp / "ppge_atlas.zim")],
capture_output=True, text=True, timeout=300, check=False,
)
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertIn("**Status**: no check failed, to review: 1 already held, "
"1 not recognised", proc.stdout)
self.assertNotIn("all checks passed", proc.stdout)
class PlatformHashLoading(_Fixture):
def test_missing_directory_yields_empty_sets(self):
known = validate_pr.load_platform_hashes(str(self.tmp / "absent"))
self.assertEqual(known, {"sha1": set(), "md5": set(), "sha256": set(), "crc32": set(),
"names": set(), "folders": {}, "sizes": {}, "entries": {}})
"names": set(), "folders": {}, "sizes": {}, "entries": {},
"named": {}})
def test_declared_hashes_and_names_are_collected(self):
platforms = self.tmp / "platforms"
+41
View File
@@ -13,6 +13,7 @@ import subprocess
import sys
import tempfile
import unittest
from unittest import mock
from pathlib import Path
import yaml
@@ -146,5 +147,45 @@ class RunFromAnyDirectory(unittest.TestCase):
self.assertIn("Already in the database at this path", messages)
class AnUnfetchedUpstreamIsFetched(unittest.TestCase):
"""CONTRIBUTING has the project added as `upstream` with no fetch. The
remote named the project, so nothing was fetched, upstream/main did not
exist, origin/main was the pushed branch, and --changed found nothing."""
def _git(self, cwd: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True)
def test_the_branch_file_is_found(self):
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
root = Path(tmp)
project = root / "Abdess" / "retrobios.git"
fork = root / "fork.git"
clone = root / "clone"
for bare in (project, fork):
bare.mkdir(parents=True)
self._git(bare, "init", "-q", "--bare", "-b", "main")
clone.mkdir()
self._git(clone, "init", "-q", "-b", "main")
self._git(clone, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q",
"--allow-empty", "-m", "base")
self._git(clone, "push", "-q", str(project), "main")
(clone / "bios").mkdir()
(clone / "bios" / "new.bin").write_bytes(b"x")
self._git(clone, "add", "bios/new.bin")
self._git(clone, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q",
"-m", "add")
self._git(clone, "remote", "add", "origin", str(fork))
self._git(clone, "push", "-q", "origin", "main")
self._git(clone, "fetch", "-q", "origin")
self._git(clone, "remote", "add", "upstream", str(project))
cwd = os.getcwd()
os.chdir(clone)
try:
with mock.patch.object(validate_pr, "PROJECT_URLS", (str(project),)):
self.assertEqual(validate_pr.get_changed_files(), ["bios/new.bin"])
finally:
os.chdir(cwd)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large. Load diff
+14 -10
View File
@@ -180,7 +180,11 @@ A platform and an emulator profile can each name a different file for the same
place in the BIOS folder: RetroBat puts its own `ATARIOSB.ROM` at
`bios/ATARIOSB.ROM`, where atari800 reads another dump. A claim counts only when
it rests on content or on the path. A file found by its name alone claims
nothing. The pack then answers to whoever asked for it:
nothing, unless its copy has the exact size the profile declares and the
platform's file has another size (`size_exact`): RetroDECK's `sgb2.boot.rom` is
the Super Game Boy 2 SNES program ROM where supersnes9x reads a 256-byte boot ROM.
Two files of equal size are revisions, not a conflict. The pack then answers to
whoever asked for it:
- A **platform pack** must leave the frontend's own check green, since a user
reading red concludes the pack is broken. Where the frontend only looks for
@@ -280,17 +284,16 @@ being intact: see [verifying a release](release-process.md#verifying-a-release).
Three things make that hold. Generated members (`README.txt`, `manifest.json`)
carry a fixed date rather than the wall clock, and the manifest's `generated`
field is read from `database.json` instead of the build time. MAME and FBNeo
romsets are rebuilt deterministically from their ROMs, so a pack does not
inherit whatever metadata the source archive happened to carry. And every
member is written with the same fixed date: `ZipFile.write` copies the source
file's mtime, which is the checkout time for a file from the collection and
the wall clock for one the build just staged in `tmp/`.
field is read from `database.json` instead of the build time. Each ZIP of the
collection, MAME and FBNeo romsets included, is copied byte for byte, so it
hashes to the SHA1 the install list and the site publish, and git fixes those
bytes. And every member is written with the same fixed date: `ZipFile.write`
copies the source file's mtime, which is the checkout time for a file from the
collection and the wall clock for one the build just staged in `tmp/`.
`tests/test_deterministic_zip.py` builds the same fixture twice and compares
the bytes, for both the platform and the emulator pack paths. Its fixture
holds a romset, because without one the comparison never reaches the rebuild
path and passes while the real packs still move.
holds a ZIP, so the comparison covers the archive copy as well as loose files.
## Collection rules
@@ -383,7 +386,8 @@ resolution.
deploy identity. No workflow builds or publishes a release.
- `safe_extract_zip()` prevents zip-slip path traversal attacks
- `deterministic_zip` rebuilds MAME ZIPs so same ROMs always produce the same hash
- a pack copies each ZIP of the collection byte for byte, so every member
hashes to the SHA1 the install list publishes
- `crypto_verify.py` and `sect233r1.py` verify 3DS RSA-2048 signatures and AES-128-CBC integrity
- ZIP inner ROM verification via `checkInsideZip()` replicates Batocera's behavior
- `md5_composite()` replicates Recalbox's composite ZIP hash
+7 -2
View File
@@ -140,8 +140,13 @@ are still included in packs automatically. A required one the collection lacks
is listed under the counts, and `--verbose` lists the optional ones too.
The cross-reference uses `resolve_platform_cores()` to determine which emulator
profiles are relevant for each platform, then checks whether each profile's files
appear in the platform config.
profiles are relevant for each platform, then checks whether the platform
already declares each profile's files (`declaration_settles()`). A name alone
is not enough. The declaration must fill the core's own slot or have a size the
core accepts: Galaksija's 4 KiB `ROM1.BIN` does not stand for DOSBox's 32 KiB
SC-55 one. An alias counts only at the path where the core reads it, so
RetroDECK's root `MSX.ROM` is not CLK's `MSX/MSX.ROM`. An archive counts by its
name.
### Filtering output
+3 -3
View File
@@ -166,9 +166,9 @@ PR based on changed paths:
## Large files management
Files too large for git are stored as assets on a permanent GitHub release
named `large-files`. Size does not decide it: four committed files are over
50 MB (`history.xml`, `default-GM.sf3`, `5.avi`, `tbblue.mmc`) and are served
from the repository like any other.
named `large-files`. Size does not decide it: four committed files are
over 50 MB (`history.xml`, `default-GM.sf3`, `5.avi`, `uqm-remix-disc1.uqm`)
and are served from the repository like any other.
Examples: PS3UPDAT.PUP, PSVUPDAT.PUP, PSP2UPDAT.PUP, the DSi NAND images,
maclc3.zip, Firmware.19.0.0.zip (Switch), the QEMU EDK2 firmware, the ScummVM
+5 -5
View File
@@ -15,7 +15,7 @@ Run everything in sequence:
```bash
python scripts/pipeline.py --offline # DB + verify + packs + manifests + integrity + readme + site
python scripts/pipeline.py --offline --skip-packs # DB + verify + readme + site
python scripts/pipeline.py --offline --skip-packs # no packs or manifests
python scripts/pipeline.py --offline --skip-docs # skip readme + site generation
python scripts/pipeline.py --offline --target switch # filter by hardware target
python scripts/pipeline.py --offline --with-truth # include truth generation + diff
@@ -63,8 +63,8 @@ Pipeline flags:
| Flag | Effect |
|------|--------|
| `--offline` | skip every step that needs the network (2, 2a, 2a2, 2b, 2b2), and pack only the release assets already in `.cache/large/` |
| `--skip-packs` | skip steps 4, 4b, 4c, 6 and the consistency check. README and site are still rewritten unless `--skip-docs` is given too |
| `--skip-docs` | skip steps 7 and 8 |
| `--skip-packs` | skip steps 4, 4b, 4c, 5 and 6. Steps 4d and 4e still run, and README and site are still rewritten unless `--skip-docs` is given too |
| `--skip-docs` | skip steps 7, 8 and 8b |
| `--include-archived` | include archived platforms in verify, packs, truth and export |
| `--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` |
@@ -1334,7 +1334,7 @@ before a release (wiki/release-process.md). Its lines stay in
| `validate_site.py` | Validate rendered metadata, heading order, image alternatives, JSON-LD, local resources, links and fragments, and the page size and search index budgets |
| `romset_recipes.py` | Identify which emulator version an arcade archive matches, and rebuild a pinned archive from ROMs already held |
| `scraper/romset_dat_importer.py` | Fetch and import per-set recipes from MAME `-listxml` or FBNeo DATs into `recipes/`. `--fetch` alone takes the newest MAME release, and FBNeo DATs are refreshed by git blob sha. `--fetch <revision>` reads FBNeo at that tag, branch or commit |
| `deterministic_zip.py` | Rebuild MAME BIOS ZIPs deterministically (same ROMs = same hash) |
| `deterministic_zip.py` | The fixed date pack members carry. Its archive rebuild is no longer called by the pack, which copies each ZIP byte for byte |
| `torrentzip.py` | Build TorrentZip archives for MAME/FBNeo ROM sets (archive bytes depend only on contents) |
| `crypto_verify.py` | 3DS RSA signature and AES crypto verification |
| `sect233r1.py` | Pure Python ECDSA verification on sect233r1 curve (3DS OTP cert) |
@@ -1373,7 +1373,7 @@ Further options, by script:
| `romset_recipes.py` | `--dat-recipes PATH`, a snapshot or a directory of them (default `recipes/`) |
| `split_pack.py` | `--max-size 1900M`, the ceiling per part (K, M, G, default 2G) |
| `torrentzip.py` | `--check ZIP` reports conformance, `--rebuild ZIP -o OUT` normalizes an archive |
| `validate_pr.py` | `--changed` takes the BIOS files the branch changes, a folder named checks every file under it, `--markdown`, `--json` |
| `validate_pr.py` | `--changed` takes the BIOS files the branch changes, a folder named checks every file under it, `--place` copies each recognised file from outside `bios/` to its folder and declared name (or its `.variants/` slot), `--markdown`, `--json` |
| `validate_site.py` | `--site-dir` (rendered site, default `website/dist`), `--base` (path the site is served under), `--max-errors N` |
| `scraper/coreinfo_scraper.py` | `--compare-db database.json` lists the `.info` firmware the database lacks |
| `scraper/mame_hash_scraper.py` | `--release mameNNNN --profile FILE --source-commit SHA --core NAME --built-by PLATFORM` writes the profile of a MAME build a platform compiles. `--devices --listxml FILE` documents in `mame.yml` every device set with dumped ROMs that the release's `-listxml` names (`isdevice="yes"`), with `--dry-run` to preview |