mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-06 01:53:56 -06:00
chore: merge origin/main
This commit is contained in:
commit
49ff682eda
40 files changed
+1304
-222
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 --
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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"):
|
||||
|
||||
@@ -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": {}}
|
||||
|
||||
@@ -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]}"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user