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/cross_reference.py"
|
||||||
- "scripts/crypto_verify.py"
|
- "scripts/crypto_verify.py"
|
||||||
- "scripts/derivations.py"
|
- "scripts/derivations.py"
|
||||||
|
- "scripts/dedup.py"
|
||||||
- "scripts/deterministic_zip.py"
|
- "scripts/deterministic_zip.py"
|
||||||
- "scripts/dumpcatalog.py"
|
- "scripts/dumpcatalog.py"
|
||||||
- "scripts/exporter/baseline.py"
|
- "scripts/exporter/baseline.py"
|
||||||
@@ -61,6 +62,7 @@ on:
|
|||||||
- "scripts/split_pack.py"
|
- "scripts/split_pack.py"
|
||||||
- "scripts/truth.py"
|
- "scripts/truth.py"
|
||||||
- "scripts/upstream.py"
|
- "scripts/upstream.py"
|
||||||
|
- "scripts/validate_pr.py"
|
||||||
- "scripts/validate_schemas.py"
|
- "scripts/validate_schemas.py"
|
||||||
- "scripts/validate_site.py"
|
- "scripts/validate_site.py"
|
||||||
- "scripts/validation.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
|
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
|
`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
|
outside `bios/`, and `--place` copies it there under its declared name. Given a
|
||||||
every file under it. A file over the browser's 25 MB limit goes in that way.
|
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
|
## 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):
|
the checks CI runs on a pull request (Python 3.12 or newer):
|
||||||
|
|
||||||
```bash
|
```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_pr.py --changed # the BIOS files the branch adds
|
||||||
python scripts/validate_schemas.py --source-only # profiles and platform files
|
python scripts/validate_schemas.py --source-only # profiles and platform files
|
||||||
python scripts/profile_sync.py --added --base upstream/main --strict # each profile the branch adds
|
python scripts/profile_sync.py --added --base upstream/main --strict # each profile the branch adds
|
||||||
python scripts/refresh_data_dirs.py # data directories the pack-building tests read
|
python scripts/refresh_data_dirs.py # data directories the pack-building tests read
|
||||||
python scripts/generate_site.py # the pages the site 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 -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
|
`upstream` is this repository as a remote of your fork
|
||||||
(`git remote add upstream https://github.com/Abdess/retrobios`). A profile you
|
(`git remote add upstream https://github.com/Abdess/retrobios`, then
|
||||||
only edit is not held to `--strict`: its older refs are reviewed by hand.
|
`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
|
The full pipeline is not needed for a contribution: it rebuilds every pack
|
||||||
in `dist/`, which is why maintainers run it before a release.
|
in `dist/`, which is why maintainers run it before a release.
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ systems:
|
|||||||
|
|
||||||
notes: |
|
notes: |
|
||||||
Gearboy is a Game Boy / Game Boy Color emulator by drhelius (Ignacio Sanchez).
|
Gearboy is a Game Boy / Game Boy Color emulator by drhelius (Ignacio Sanchez).
|
||||||
The buildbot builds from drhelius/Gearboy (upstream), not from libretro/Gearboy
|
The buildbot builds from drhelius/Gearboy (upstream), not from libretro/Gearboy,
|
||||||
(abandoned fork frozen at v2.6.0 from 2018).
|
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).
|
Bootrom loading is gated behind core options (disabled by default).
|
||||||
Without bootroms, the core HLE-skips the boot animation.
|
Without bootroms, the core HLE-skips the boot animation.
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ cores: [np2kai]
|
|||||||
systems: [pc-98]
|
systems: [pc-98]
|
||||||
|
|
||||||
# NP2kai is a PC-9801/9821 emulator by AZO234, enhanced fork of Neko Project II
|
# 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()
|
# <system>/np2kai/ subdirectory, set in retro_load_game()
|
||||||
# (sdl/libretro/libretro.c:1800-1815). All getbiospath() calls resolve
|
# (sdl/libretro/libretro.c:1800-1815). All getbiospath() calls resolve
|
||||||
# relative to that directory.
|
# relative to that directory.
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ display_name: "Sony - PlayStation (PCSX1)"
|
|||||||
cores: [pcsx1]
|
cores: [pcsx1]
|
||||||
systems: [sony-playstation]
|
systems: [sony-playstation]
|
||||||
notes: >
|
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.
|
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
|
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.
|
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)
|
Database contains 24 entries (22 good, 2 known-bad rejected)
|
||||||
covering BIOS versions 1.0 through 4.5.
|
covering BIOS versions 1.0 through 4.5.
|
||||||
.info declares firmware_count=0, while the core reads the BIOS images this profile lists.
|
.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:
|
files:
|
||||||
# -- Region: Japan --
|
# -- Region: Japan --
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ systems:
|
|||||||
- sega-saturn
|
- sega-saturn
|
||||||
|
|
||||||
notes: |
|
notes: |
|
||||||
Yabause is a software-rendered Sega Saturn emulator. Upstream project is
|
Yabause is a software-rendered Sega Saturn emulator. The last commit on the
|
||||||
dead. The libretro port is maintained by the community on the master branch of
|
master branch of Yabause/yabause is from 2018-07-07. The libretro port is
|
||||||
libretro/yabause.
|
maintained by the community on the master branch of libretro/yabause.
|
||||||
|
|
||||||
Saturn BIOS search order:
|
Saturn BIOS search order:
|
||||||
1. {system}/saturn_bios.bin
|
1. {system}/saturn_bios.bin
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from common import (
|
|||||||
platform_arg,
|
platform_arg,
|
||||||
ProfileSelectionError,
|
ProfileSelectionError,
|
||||||
_norm_system_id,
|
_norm_system_id,
|
||||||
|
archive_entry,
|
||||||
directory_members,
|
directory_members,
|
||||||
expand_directory_entries,
|
expand_directory_entries,
|
||||||
get_mame_clone_map,
|
get_mame_clone_map,
|
||||||
@@ -573,18 +574,27 @@ class FileConsumers:
|
|||||||
for dest, sha1 in members.items()
|
for dest, sha1 in members.items()
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
# The archive members the profile reads decide between
|
found = [self.sha1_of(*entry_target(entry, files), owner=name)]
|
||||||
# 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,
|
|
||||||
)]
|
|
||||||
for sha1 in found:
|
for sha1 in found:
|
||||||
if sha1:
|
if sha1:
|
||||||
self.emulators.setdefault(sha1, set()).add(name)
|
self.emulators.setdefault(sha1, set()).add(name)
|
||||||
self.emulator_entries.setdefault(sha1, {}).setdefault(name, index)
|
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:
|
def entry_source(f: dict, index: dict) -> str | None:
|
||||||
"""Where the collection holds a profile entry, or None.
|
"""Where the collection holds a profile entry, or None.
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from common import (
|
|||||||
from nativemode import check_label
|
from nativemode import check_label
|
||||||
import release_record
|
import release_record
|
||||||
from siteguides import install_command
|
from siteguides import install_command
|
||||||
|
from validate_pr import LARGE_FILE_ISSUE_URL
|
||||||
from verify import verify_platform
|
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
|
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
|
`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
|
outside `bios/`, and `--place` copies it there under its declared name. Given a
|
||||||
every file under it. A file over the browser's 25 MB limit goes in that way.
|
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
|
## 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):
|
the checks CI runs on a pull request (Python 3.12 or newer):
|
||||||
|
|
||||||
```bash
|
```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_pr.py --changed # the BIOS files the branch adds
|
||||||
python scripts/validate_schemas.py --source-only # profiles and platform files
|
python scripts/validate_schemas.py --source-only # profiles and platform files
|
||||||
python scripts/profile_sync.py --added --base upstream/main --strict # each profile the branch adds
|
python scripts/profile_sync.py --added --base upstream/main --strict # each profile the branch adds
|
||||||
python scripts/refresh_data_dirs.py # data directories the pack-building tests read
|
python scripts/refresh_data_dirs.py # data directories the pack-building tests read
|
||||||
python scripts/generate_site.py # the pages the site 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 -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
|
`upstream` is this repository as a remote of your fork
|
||||||
(`git remote add upstream https://github.com/Abdess/retrobios`). A profile you
|
(`git remote add upstream https://github.com/Abdess/retrobios`, then
|
||||||
only edit is not held to `--strict`: its older refs are reviewed by hand.
|
`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
|
The full pipeline is not needed for a contribution: it rebuilds every pack
|
||||||
in `dist/`, which is why maintainers run it before a release.
|
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 ArtifactLockBusy, artifact_lock
|
||||||
from artifacts import json_text as _json_text
|
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
|
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
|
return lines
|
||||||
|
|
||||||
|
|
||||||
# What a reader on another version of the setup does with the pack. Our data
|
def other_version(platform_name: str, prefix: str = "../") -> str:
|
||||||
# says which list the pack was made from, not what an older list lacks.
|
"""What a reader on another version of the setup does with the pack.
|
||||||
OTHER_VERSION = "Other version? Run `--check` after copying."
|
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:
|
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')}"
|
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 version the pack fits, and what to do on another. *named* when
|
||||||
the release line already named the pack's version."""
|
the release line already named the pack's version."""
|
||||||
made_from = setup_version(platform_name, record)
|
made_from = setup_version(platform_name, record)
|
||||||
|
other = other_version(platform_name, prefix)
|
||||||
if named or not made_from:
|
if named or not made_from:
|
||||||
return OTHER_VERSION
|
return other
|
||||||
return f"Made for {made_from}. {OTHER_VERSION}"
|
return f"Made for {made_from}. {other}"
|
||||||
|
|
||||||
|
|
||||||
# Why a setup has no ZIP, answered once on the FAQ.
|
# 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)
|
released = release_line(platform_name, record)
|
||||||
return "\n\n".join([buttons, *released,
|
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:
|
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. "
|
"when one is known, the `system_name` the site gives its system. "
|
||||||
"[Find or check a file](files/index.md) reads it.",
|
"[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 "
|
"In a browser, the SHA1 of a file the reader picks finds its records when "
|
||||||
"Crypto has no MD5, and none is needed: a setup that lists a file by MD5 "
|
"the collection holds that file. A list naming a file the collection lacks "
|
||||||
"or CRC32 is filed under the SHA1 of the collection file it names too.",
|
"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",
|
"```js",
|
||||||
"// The records of the file a reader picked, by its SHA1.",
|
"// 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
|
# The words a web search for a console or an emulator types: "dreamcast
|
||||||
# bios", "duckstation bios". The h1 and the sidebar keep the name.
|
# bios", "duckstation bios". The h1 and the sidebar keep the name.
|
||||||
if key.startswith("emulators/"):
|
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.
|
# Lexaloffle, QEMU and ScummVM name a system page too.
|
||||||
own = " emulator" if f"systems/{relative.name}" in PAGE_DATA else ""
|
own = " emulator" if f"systems/{relative.name}" in PAGE_DATA else ""
|
||||||
return f"{title}{own} BIOS files" if has_files else f"{title} emulator"
|
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)
|
return _system_description(markdown, browser)
|
||||||
if key.startswith("emulators/") and relative.stem != "index":
|
if key.startswith("emulators/") and relative.stem != "index":
|
||||||
return _emulator_description(markdown, key, title, _first_paragraph(lead))
|
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)
|
first = _first_paragraph(lead, minimum=35)
|
||||||
questions = [_plain_markdown(re.sub(r"\s*\{[^}]*\}\s*$", "", heading))
|
questions = [_plain_markdown(re.sub(r"\s*\{[^}]*\}\s*$", "", heading))
|
||||||
for heading in re.findall(r"^## (.+\?)(?:[ \t]*\{[^}]*\})?[ \t]*$", markdown, re.M)]
|
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."""
|
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)
|
notes = re.search(r"<summary>Technical notes</summary>\s*\n\n(.+?)(?:\n\n|$)", markdown, re.S)
|
||||||
if not intro and notes:
|
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:]}"
|
intro = f"{title}: {sentence[:1].lower()}{sentence[1:]}"
|
||||||
if not intro:
|
if not intro:
|
||||||
row = {name: _plain_markdown(value) for name, value in re.findall(
|
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:
|
elif title not in intro:
|
||||||
# "It reads 1 file": the profile names no system to open on.
|
# "It reads 1 file": the profile names no system to open on.
|
||||||
intro = f"{title}: {intro[:1].lower()}{intro[1:]}"
|
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.
|
# 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:
|
if PAGE_DATA.get(key, {}).get("hasPart") or "uses the same firmware as" in intro:
|
||||||
return _shorten(intro, 155)
|
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
|
# Structured data a page generator knows beyond its text, by page path
|
||||||
# relative to docs/: read by decorate_markdown_pages.
|
# relative to docs/: read by decorate_markdown_pages.
|
||||||
PAGE_DATA: dict[str, dict] = {}
|
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
|
# 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/:
|
# manufacturer, a run of an emulator's cards), by path relative to docs/:
|
||||||
# their H1 already says what they are.
|
# 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)}
|
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:
|
def emulator_structured_data(profile: dict) -> dict:
|
||||||
"""What an emulator page states, for a reader of its JSON-LD.
|
"""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.
|
# lines are on the get/ page, the one the command is shared from.
|
||||||
tabs = ("windows",) if page_link else ("windows", "macos", "linux")
|
tabs = ("windows",) if page_link else ("windows", "macos", "linux")
|
||||||
computer += [
|
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),
|
*terminal_tabs(tabs, args, how=how),
|
||||||
*([f"[Mac or Linux](../{get_page_link(name)}#from-your-computer)", ""]
|
*([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)}
|
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]]:
|
def _reading_pairs(sha1: str, consumers: FileConsumers) -> list[tuple[str, dict]]:
|
||||||
"""The profile entries that read a file, each with its emulator."""
|
"""The profile entries that read a file, each with its emulator."""
|
||||||
files_of = getattr(consumers, "emulator_files", {})
|
files_of = getattr(consumers, "emulator_files", {})
|
||||||
@@ -6662,7 +6688,7 @@ def _start_groups(
|
|||||||
for f in primaries:
|
for f in primaries:
|
||||||
sha1 = str(f.get("sha1", ""))
|
sha1 = str(f.get("sha1", ""))
|
||||||
for emulator, entry in _reading_pairs(sha1, consumers):
|
for emulator, entry in _reading_pairs(sha1, consumers):
|
||||||
if entry.get("bundled"):
|
if comes_with_app(entry):
|
||||||
continue
|
continue
|
||||||
system = None
|
system = None
|
||||||
systems = _entry_systems(emulator, entry)
|
systems = _entry_systems(emulator, entry)
|
||||||
@@ -6677,7 +6703,7 @@ def _start_groups(
|
|||||||
for sha1, emulator, entry, system in _entries_by_section(consumers).get(here, []):
|
for sha1, emulator, entry, system in _entries_by_section(consumers).get(here, []):
|
||||||
record = known.get(sha1)
|
record = known.get(sha1)
|
||||||
parts = str((record or {}).get("path", "")).split("/")
|
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"):
|
or len(parts) < 3 or parts[1] != "Arcade"):
|
||||||
continue
|
continue
|
||||||
if sha1 not in kept:
|
if sha1 not in kept:
|
||||||
@@ -7031,7 +7057,7 @@ def _group_start(group: _StartGroup, first_below: bool) -> list[tuple[str, str]]
|
|||||||
else:
|
else:
|
||||||
regional[(*same, region)] = regional.pop(same)
|
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))]
|
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
|
# A name a region line gives already is no "also", and one only
|
||||||
# emulators no region line answers reads is an "instead": armsx1 reads
|
# 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:
|
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
|
The rule verify and the pack read (``installed_with_emulator``). An entry
|
||||||
other build still reads it from the user's folder.
|
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]:
|
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", "")
|
category = f.get("category", "")
|
||||||
region = f.get("region", "")
|
region = f.get("region", "")
|
||||||
storage = f.get("storage", "")
|
storage = f.get("storage", "")
|
||||||
bundled = f.get("bundled", False)
|
|
||||||
embedded = f.get("embedded", False)
|
embedded = f.get("embedded", False)
|
||||||
has_builtin = f.get("has_builtin", False)
|
has_builtin = f.get("has_builtin", False)
|
||||||
archive = f.get("archive", "")
|
archive = f.get("archive", "")
|
||||||
@@ -7851,9 +7879,10 @@ def _file_badges(f: dict, in_repo: bool, for_system: str = "") -> list[str]:
|
|||||||
if shipped:
|
if shipped:
|
||||||
# NetherSX2's three interface fonts sat beside the BIOS as
|
# NetherSX2's three interface fonts sat beside the BIOS as
|
||||||
# "required" and "missing": the app ships them, nobody fetches them.
|
# "required" and "missing": the app ships them, nobody fetches them.
|
||||||
badges.append(
|
# Narrowed to one build, the build is named.
|
||||||
'<span class="rb-badge rb-badge-info">comes with the app</span>'
|
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:
|
elif required:
|
||||||
needed = f"required for {html.escape(for_system)}" if for_system else "required"
|
needed = f"required for {html.escape(for_system)}" if for_system else "required"
|
||||||
badges.append(
|
badges.append(
|
||||||
@@ -7879,7 +7908,7 @@ def _file_badges(f: dict, in_repo: bool, for_system: str = "") -> list[str]:
|
|||||||
badges.append(
|
badges.append(
|
||||||
'<span class="rb-badge rb-badge-info">HLE fallback</span>'
|
'<span class="rb-badge rb-badge-info">HLE fallback</span>'
|
||||||
)
|
)
|
||||||
if mode:
|
if mode and not (shipped and mode in ("standalone", "libretro")):
|
||||||
badges.append(
|
badges.append(
|
||||||
f'<span class="rb-badge rb-badge-muted">{mode}</span>'
|
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(
|
badges.append(
|
||||||
f'<span class="rb-badge rb-badge-muted">{storage}</span>'
|
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:
|
if embedded:
|
||||||
badges.append(
|
badges.append(
|
||||||
'<span class="rb-badge rb-badge-muted">embedded</span>'
|
'<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"))
|
found = _found_by_text(profile.get("found_by"))
|
||||||
if found:
|
if found:
|
||||||
rows.append(f"| Files found by | {found} |")
|
rows.append(f"| Files found by | {found} |")
|
||||||
frozen = profile.get("pin_frozen")
|
# Each pin says why it is held: a bare upstream revision read as a
|
||||||
if frozen:
|
# profile left behind HEAD (mupen64plus, aps3e).
|
||||||
held = _escape_tags(' '.join(str(frozen).split())).replace('|', r'\|')
|
for field, label in (("pin_frozen", "Revision held"),
|
||||||
rows.append(f"| Revision held | {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")
|
announced = profile.get("version_source")
|
||||||
if isinstance(announced, dict) and announced.get("url"):
|
if isinstance(announced, dict) and announced.get("url"):
|
||||||
url = str(announced["url"])
|
url = str(announced["url"])
|
||||||
@@ -8193,10 +8219,13 @@ def _render_emulator_file(
|
|||||||
if consumers and f.get("type") == "directory":
|
if consumers and f.get("type") == "directory":
|
||||||
details.extend(_directory_copies(f, consumers))
|
details.extend(_directory_copies(f, consumers))
|
||||||
if 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 ""
|
held = consumers.path_of(sha1) if sha1 and in_repo else ""
|
||||||
parts = held.split("/")
|
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
|
# Which copy answers this entry, linked to its collection card
|
||||||
# (provenance, every hash, download): an entry declaring no hash
|
# (provenance, every hash, download): an entry declaring no hash
|
||||||
# said "in repo" without naming the file.
|
# said "in repo" without naming the file.
|
||||||
@@ -8473,8 +8502,9 @@ def emulator_romset(profile: dict) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _version_said(value: object, basis: dict) -> str:
|
def _version_said(value: object, basis: dict, profile: dict, mode: str = "") -> str:
|
||||||
"""One build's version and where the profile reads it."""
|
"""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")
|
kind = basis.get("kind")
|
||||||
if kind == "none":
|
if kind == "none":
|
||||||
return "none stated in the source"
|
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 f"unknown: {_UNVERIFIED.get(str(basis.get('why')), 'unread')}"
|
||||||
return "none published"
|
return "none published"
|
||||||
value = str(value)
|
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":
|
if kind == "file":
|
||||||
return f"{value}, stated in `{basis.get('path')}`"
|
return f"{value}, stated in {shown(basis.get('path'))}"
|
||||||
if kind == "tag":
|
if kind == "tag":
|
||||||
distance = int(basis.get("distance") or 0)
|
distance = int(basis.get("distance") or 0)
|
||||||
if not distance:
|
if not distance:
|
||||||
return f"{value}, the tag at this revision"
|
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":
|
if kind == "package":
|
||||||
return f"{value}, read from {basis.get('artifact')}"
|
return f"{value}, read from {basis.get('artifact')}"
|
||||||
if kind == "unverified":
|
if kind == "unverified":
|
||||||
@@ -8524,10 +8559,10 @@ def version_markdown(profile: dict) -> str:
|
|||||||
values = value if isinstance(value, dict) else {}
|
values = value if isinstance(value, dict) else {}
|
||||||
modes = list(dict.fromkeys([*values, *basis]))
|
modes = list(dict.fromkeys([*values, *basis]))
|
||||||
return ", ".join(
|
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
|
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"
|
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"):
|
if not isinstance(entry, dict) or not profile.get("data_root"):
|
||||||
return ""
|
return ""
|
||||||
mode = entry.get("mode")
|
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 ""
|
return ""
|
||||||
excluded = _folder_only(name, entry)
|
excluded = _folder_only(name, entry)
|
||||||
if "*" in excluded or (key in excluded if key else excluded):
|
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()
|
folders = set()
|
||||||
for f in entries:
|
for f in entries:
|
||||||
mode = f.get("mode")
|
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
|
continue
|
||||||
place = standalone_place(name, profile, f, key)
|
place = standalone_place(name, profile, f, key)
|
||||||
if not place:
|
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
|
# The test each emulator page builds for itself: the parts a page is
|
||||||
# cut into weigh the status its rows show.
|
# cut into weigh the status its rows show.
|
||||||
file_held = _availability_check(db, suppl_names)
|
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)
|
plan_emulator_pages(profiles, file_held)
|
||||||
# Build system_id -> manufacturer page map (needed by all generators)
|
# Build system_id -> manufacturer page map (needed by all generators)
|
||||||
print("Building system cross-reference map...")
|
print("Building system cross-reference map...")
|
||||||
manufacturers = _group_by_manufacturer(db)
|
|
||||||
set_system_page_map(
|
set_system_page_map(
|
||||||
_system_pages_from_data(manufacturers, coverages, db, profiles)
|
_system_pages_from_data(manufacturers, coverages, db, profiles)
|
||||||
)
|
)
|
||||||
print(f" {len(_system_page_map)} system IDs mapped to pages")
|
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
|
from siterender import link_coverage, system_ids # noqa: PLC0415
|
||||||
|
|
||||||
print("Generating system pages...")
|
print("Generating system pages...")
|
||||||
@@ -12300,7 +12336,7 @@ def _generate(args: argparse.Namespace) -> None:
|
|||||||
name, profile, db, consumers, suppl_names, data_registry,
|
name, profile, db, consumers, suppl_names, data_registry,
|
||||||
data_dirs=page_data_directories(name, profiles),
|
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
|
# A frozen copy answers after the emulator: a frozen_snapshot, and a
|
||||||
# profile pinned to the revision one platform builds (pin_frozen).
|
# profile pinned to the revision one platform builds (pin_frozen).
|
||||||
# Reicast and Flycast 2021 LE answered "dc bios missing" before Flycast.
|
# Reicast and Flycast 2021 LE answered "dc bios missing" before Flycast.
|
||||||
@@ -12466,6 +12502,7 @@ from sitesources import ( # noqa: E402,F401
|
|||||||
_forge_sources,
|
_forge_sources,
|
||||||
_source_permalink,
|
_source_permalink,
|
||||||
_source_ref_markdown,
|
_source_ref_markdown,
|
||||||
|
version_basis_url,
|
||||||
)
|
)
|
||||||
from siterender import ( # noqa: E402,F401
|
from siterender import ( # noqa: E402,F401
|
||||||
_by_mode,
|
_by_mode,
|
||||||
|
|||||||
+7
-7
@@ -35,8 +35,8 @@ come from .cache/large/ only.
|
|||||||
Usage:
|
Usage:
|
||||||
python scripts/pipeline.py # active platforms
|
python scripts/pipeline.py # active platforms
|
||||||
python scripts/pipeline.py --include-archived # all 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-packs # skip steps 4, 4b, 4c, 5 and 6
|
||||||
python scripts/pipeline.py --skip-docs # skip steps 7 and 8
|
python scripts/pipeline.py --skip-docs # skip steps 7, 8 and 8b
|
||||||
python scripts/pipeline.py --offline # no network, see above
|
python scripts/pipeline.py --offline # no network, see above
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -747,13 +747,13 @@ def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--skip-packs",
|
"--skip-packs",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Skip steps 4 to 6 except 4d: packs, manifests and their checks. "
|
help="Skip steps 4, 4b, 4c, 5 and 6: packs, manifests and their checks. "
|
||||||
"Steps 7 and 8 still rewrite README.md and the site pages in website/, "
|
"Step 4d still gates the run, step 4e still rewrites install/games/, and "
|
||||||
"add --skip-docs to "
|
"steps 7 to 8b rewrite README.md and the site unless --skip-docs is given",
|
||||||
"stop after verify",
|
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
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(
|
parser.add_argument(
|
||||||
"--offline", action="store_true",
|
"--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
|
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.
|
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]
|
merge_base = _git_lines(["merge-base", base, "HEAD"])[0]
|
||||||
paths = _git_lines([
|
paths = _git_lines([
|
||||||
"diff", "--name-only", "--no-renames",
|
"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}```"]
|
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(
|
def terminal_tabs(
|
||||||
tabs: tuple[str, ...], args: dict[str, str], indent: str = "", steps: bool = True,
|
tabs: tuple[str, ...], args: dict[str, str], indent: str = "", steps: bool = True,
|
||||||
how: dict[str, str] | None = None,
|
how: dict[str, str] | None = None,
|
||||||
@@ -465,6 +469,8 @@ def terminal_tabs(
|
|||||||
items = []
|
items = []
|
||||||
for key in tabs:
|
for key in tabs:
|
||||||
label, shared, shell = TERMINALS[key]
|
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
|
text = (how or {}).get(key) or shared
|
||||||
body = [text, ""] if steps else []
|
body = [text, ""] if steps else []
|
||||||
items.append((label, body + fence(shell, install_command(shell, args.get(key, "")))))
|
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]:
|
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
|
"""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] = []
|
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":
|
if _engine == "mkdocs":
|
||||||
for label, body in items:
|
for label, body in items:
|
||||||
lines += [f'{indent}=== "{label}"', "", *_indented(_trim(body), indent + " "), ""]
|
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]:
|
def cited_paths(profile: dict) -> set[str]:
|
||||||
"""Every path a page of the profile could link: the parts of each
|
"""Every path a page of the profile could link: the parts of each
|
||||||
source_ref and the file:line citations of its prose."""
|
source_ref, the file:line citations of its prose and the file its
|
||||||
paths: set[str] = set()
|
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):
|
for key, value in _walk(profile):
|
||||||
if key == "source_ref" and isinstance(value, (str, dict)):
|
if key == "source_ref" and isinstance(value, (str, dict)):
|
||||||
for _label, refs in source_ref_values(value):
|
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}"
|
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:
|
def revision_markdown(profile: dict) -> str:
|
||||||
"""Each repository the profile reads, linked at the commit it was read at.
|
"""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)
|
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 _tooltip_text(text: str, pattern: re.Pattern, pending: dict[str, str]) -> str:
|
||||||
def swap(match: re.Match) -> str:
|
def swap(match: re.Match) -> str:
|
||||||
term = match.group(1)
|
term = match.group(1)
|
||||||
if term not in pending:
|
if term not in pending or _in_proper_name(term, text[:match.start()]):
|
||||||
return term
|
return term
|
||||||
definition = pending.pop(term)
|
definition = pending.pop(term)
|
||||||
return f'<abbr class="rb-term" title="{html.escape(definition)}">{term}</abbr>'
|
return f'<abbr class="rb-term" title="{html.escape(definition)}">{term}</abbr>'
|
||||||
|
|||||||
+107
-17
@@ -21,6 +21,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -36,6 +37,7 @@ from common import (
|
|||||||
)
|
)
|
||||||
from hashing import parse_hash_list
|
from hashing import parse_hash_list
|
||||||
from nativemode import digest_algorithm
|
from nativemode import digest_algorithm
|
||||||
|
from dedup import _in_nodedup_dir
|
||||||
from validation import frontend_digest_matches, inner_rom_check
|
from validation import frontend_digest_matches, inner_rom_check
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -79,6 +81,13 @@ class ValidationResult:
|
|||||||
self.md5 = ""
|
self.md5 = ""
|
||||||
self.crc32 = ""
|
self.crc32 = ""
|
||||||
self.size = 0
|
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:
|
def add_check(self, passed: bool, message: str) -> None:
|
||||||
self.checks.append(("PASS" if passed else "FAIL", message))
|
self.checks.append(("PASS" if passed else "FAIL", message))
|
||||||
@@ -93,8 +102,12 @@ class ValidationResult:
|
|||||||
def passed(self) -> bool:
|
def passed(self) -> bool:
|
||||||
return all(s != "FAIL" for s, _ in self.checks)
|
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:
|
def to_markdown(self) -> str:
|
||||||
status = "PASS" if self.passed else "FAIL"
|
status = self.status
|
||||||
lines = [f"### {status} `{self.filename}`"]
|
lines = [f"### {status} `{self.filename}`"]
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"- **Path**: `{self.filepath}`")
|
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.
|
under, read from the other files of that system it holds.
|
||||||
"""
|
"""
|
||||||
known = {"sha1": set(), "md5": set(), "sha256": set(), "crc32": set(),
|
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]] = {}
|
systems_of: dict[str, set[str]] = {}
|
||||||
folders_of: dict[str, dict[str, int]] = {}
|
folders_of: dict[str, dict[str, int]] = {}
|
||||||
files = (db or {}).get("files", {})
|
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):
|
for value in (*sha1s, *md5s):
|
||||||
systems_of.setdefault(value, set()).add(system_id)
|
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)
|
_note_folder(folders_of, system_id, files, by_md5, sha1s, md5s)
|
||||||
|
|
||||||
known["folders"] = _system_homes(systems_of, folders_of)
|
known["folders"] = _system_homes(systems_of, folders_of)
|
||||||
@@ -251,6 +267,7 @@ def load_profile_hashes(
|
|||||||
"""
|
"""
|
||||||
known: dict[str, dict] = {
|
known: dict[str, dict] = {
|
||||||
"sha1": {}, "sha256": {}, "md5": {}, "crc32": {}, "sizes": {}, "folders": {},
|
"sha1": {}, "sha256": {}, "md5": {}, "crc32": {}, "sizes": {}, "folders": {},
|
||||||
|
"named": {},
|
||||||
}
|
}
|
||||||
systems_of: dict[str, set[str]] = {}
|
systems_of: dict[str, set[str]] = {}
|
||||||
folders_of: dict[str, dict[str, int]] = {}
|
folders_of: dict[str, dict[str, int]] = {}
|
||||||
@@ -274,6 +291,8 @@ def load_profile_hashes(
|
|||||||
for algo, values in hashes.items():
|
for algo, values in hashes.items():
|
||||||
for value in values:
|
for value in values:
|
||||||
known[algo].setdefault(value, set()).add(emulator)
|
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.
|
# The entry's own system, or the profile's when it emulates one.
|
||||||
system = entry.get("system") or (
|
system = entry.get("system") or (
|
||||||
declared_systems[0] if len(declared_systems) == 1 else None
|
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
|
# PR that regenerated database.json was told each new file
|
||||||
# duplicated itself.
|
# duplicated itself.
|
||||||
result.add_check(True, "Already in the database at this path")
|
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:
|
else:
|
||||||
|
result.outcome = "held"
|
||||||
result.add_warning(
|
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:
|
else:
|
||||||
result.add_check(True, "Not a duplicate in database")
|
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"
|
"Filename matches a known requirement but hash differs - may be a variant"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
result.outcome = "unknown"
|
||||||
result.add_warning(
|
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)
|
normalized = os.path.normpath(relative)
|
||||||
@@ -452,11 +480,27 @@ def validate_file(
|
|||||||
result.add_check(True, f"Correct placement: bios/{parts[1]}/{parts[2]}/")
|
result.add_check(True, f"Correct placement: bios/{parts[1]}/{parts[2]}/")
|
||||||
else:
|
else:
|
||||||
result.add_warning("File should be in bios/Manufacturer/Console/ structure")
|
result.add_warning("File should be in bios/Manufacturer/Console/ structure")
|
||||||
|
elif result.outcome == "held":
|
||||||
|
pass
|
||||||
elif expected_folders:
|
elif expected_folders:
|
||||||
# The contributor asks before forking: say where it goes.
|
# 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(
|
result.add_warning(
|
||||||
"File is not under bios/ yet. Its system's files are in "
|
"File is not under bios/ yet. Its system's files are in "
|
||||||
+ " or ".join(f"`{f}`" for f in expected_folders)
|
+ " 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):
|
elif _variant_home(db, result.filename, result.sha1):
|
||||||
result.add_warning(
|
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)
|
if (name_known and not (sha1_known or md5_known or other_known or accepted_by)
|
||||||
and not by_profile and size_fits):
|
and not by_profile and size_fits):
|
||||||
home = _variant_home(db, result.filename)
|
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(
|
result.add_info(
|
||||||
"This may be a valid variant. If accepted, it will be placed in "
|
f"This may be a valid variant. If accepted, it will be placed in `{slot}`"
|
||||||
f"`{home}.variants/{result.filename}.{result.sha1[:8]}`"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -558,30 +604,35 @@ def _repo_key(url: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _project_main_line() -> str | None:
|
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
|
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
|
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
|
found nothing and exited 0. A remote added as CONTRIBUTING says and never
|
||||||
in charge: a remote of that name names the project, or the clone has no
|
fetched did the same: upstream/main did not exist and origin/main won.
|
||||||
remote at all and only its uncommitted files can be read.
|
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 []
|
remotes = _git_lines("remote") or []
|
||||||
if not remotes:
|
if not remotes:
|
||||||
return None
|
return None
|
||||||
project = {_repo_key(url) for url in PROJECT_URLS}
|
project = {_repo_key(url) for url in PROJECT_URLS}
|
||||||
|
source = PROJECT_URLS[0]
|
||||||
for remote in remotes:
|
for remote in remotes:
|
||||||
urls = _git_lines("remote", "get-url", "--all", remote) or []
|
urls = _git_lines("remote", "get-url", "--all", remote) or []
|
||||||
if any(_repo_key(url) in project for url in urls):
|
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(
|
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,
|
capture_output=True, text=True, check=False,
|
||||||
)
|
)
|
||||||
if fetched.returncode != 0:
|
if fetched.returncode != 0:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"no remote of this clone is {PROJECT_URLS[0]}, and fetching its "
|
f"this clone does not hold the main of {PROJECT_URLS[0]}, and "
|
||||||
f"main failed: {fetched.stderr.strip()}"
|
f"fetching its main from {source} failed: {fetched.stderr.strip()}"
|
||||||
)
|
)
|
||||||
return "FETCH_HEAD"
|
return "FETCH_HEAD"
|
||||||
|
|
||||||
@@ -675,6 +726,28 @@ def _files_to_validate(
|
|||||||
return files
|
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:
|
def _load_database_if_any(path: str) -> dict | None:
|
||||||
"""The database, optional only where the default is meant.
|
"""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)"
|
"--markdown", action="store_true", help="Output as markdown (for PR comments)"
|
||||||
)
|
)
|
||||||
output.add_argument("--json", action="store_true", help="Output as JSON")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# The database, platforms and profiles are read from the repository
|
# The database, platforms and profiles are read from the repository
|
||||||
@@ -745,6 +823,8 @@ def main() -> None:
|
|||||||
{
|
{
|
||||||
"file": r.filepath,
|
"file": r.filepath,
|
||||||
"passed": r.passed,
|
"passed": r.passed,
|
||||||
|
"outcome": r.outcome or "new",
|
||||||
|
"destination": r.destination,
|
||||||
"sha1": r.sha1,
|
"sha1": r.sha1,
|
||||||
"md5": r.md5,
|
"md5": r.md5,
|
||||||
"size": r.size,
|
"size": r.size,
|
||||||
@@ -754,7 +834,15 @@ def main() -> None:
|
|||||||
print(json.dumps(output, indent=2))
|
print(json.dumps(output, indent=2))
|
||||||
elif args.markdown:
|
elif args.markdown:
|
||||||
lines = ["## BIOS Validation Report", ""]
|
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(f"**Status**: {status}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -765,8 +853,7 @@ def main() -> None:
|
|||||||
print("\n".join(lines))
|
print("\n".join(lines))
|
||||||
else:
|
else:
|
||||||
for r in results:
|
for r in results:
|
||||||
status = "PASS" if r.passed else "FAIL"
|
print(f"\n[{r.status}] {r.filepath}")
|
||||||
print(f"\n[{status}] {r.filepath}")
|
|
||||||
if r.sha1:
|
if r.sha1:
|
||||||
print(f" SHA1: {r.sha1}")
|
print(f" SHA1: {r.sha1}")
|
||||||
print(f" MD5: {r.md5}")
|
print(f" MD5: {r.md5}")
|
||||||
@@ -783,6 +870,9 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
print(f" [{marker}] {m}")
|
print(f" [{marker}] {m}")
|
||||||
|
|
||||||
|
if args.place:
|
||||||
|
place(results)
|
||||||
|
|
||||||
if not all_passed:
|
if not all_passed:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|||||||
@@ -6781,7 +6781,8 @@ class SkipPacksHelpSaysWhatRuns(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
text = " ".join(proc.stdout.split())
|
text = " ".join(proc.stdout.split())
|
||||||
self.assertNotIn("Only regenerate DB and verify", text)
|
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)
|
self.assertNotIn("docs/", text)
|
||||||
|
|
||||||
def test_every_step_it_names_is_one_it_skips(self):
|
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.assertIn("profile_sync.py --added --base upstream/main --strict", block)
|
||||||
self.assertNotIn("--changed --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):
|
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
|
"""The site is built by Node from website/package-lock.json: a pip
|
||||||
line naming the old theme installed packages nothing reads."""
|
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()}
|
names = {i.filename.rsplit("/", 1)[-1] for i in zf.infolist()}
|
||||||
self.assertIn("romset.zip", names)
|
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):
|
def test_every_member_carries_the_fixed_epoch(self):
|
||||||
"""A member's date must come from the build's inputs, not its clock.
|
"""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)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
|||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
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
|
from generate_site import generate_system_page # noqa: E402
|
||||||
|
|
||||||
_TMP = tempfile.TemporaryDirectory()
|
_TMP = tempfile.TemporaryDirectory()
|
||||||
@@ -246,7 +246,6 @@ class EveryReaderIsTheEmulatorPackFile(unittest.TestCase):
|
|||||||
build_zip_contents_index, load_data_dir_registry,
|
build_zip_contents_index, load_data_dir_registry,
|
||||||
load_emulator_profiles, resolve_local_file,
|
load_emulator_profiles, resolve_local_file,
|
||||||
)
|
)
|
||||||
from validation import with_archive_members
|
|
||||||
|
|
||||||
db_path = REPO_ROOT / "database.json"
|
db_path = REPO_ROOT / "database.json"
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
@@ -275,9 +274,12 @@ class EveryReaderIsTheEmulatorPackFile(unittest.TestCase):
|
|||||||
if members:
|
if members:
|
||||||
from_trees |= {(sha1, name) for sha1 in members.values()}
|
from_trees |= {(sha1, name) for sha1 in members.values()}
|
||||||
continue
|
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(
|
path, status = resolve_local_file(
|
||||||
{**with_archive_members(entry, files), "source_profile": name},
|
{**target, "source_profile": name},
|
||||||
db, zips, dest_hint=entry.get("path") or "",
|
db, zips, dest_hint=dest,
|
||||||
data_dir_registry=registry,
|
data_dir_registry=registry,
|
||||||
)
|
)
|
||||||
if path and status not in ("not_found", "hash_mismatch"):
|
if path and status not in ("not_found", "hash_mismatch"):
|
||||||
|
|||||||
@@ -5974,6 +5974,13 @@ class TestBranchProfiles(unittest.TestCase):
|
|||||||
["draft", "new"],
|
["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):
|
def test_the_flag_selects_those_profiles(self):
|
||||||
args = profile_sync.build_parser().parse_args(["--changed", "--base", "main"])
|
args = profile_sync.build_parser().parse_args(["--changed", "--base", "main"])
|
||||||
profiles = {"kept": {}, "edited": {}, "new": {}, "draft": {}}
|
profiles = {"kept": {}, "edited": {}, "new": {}, "draft": {}}
|
||||||
|
|||||||
@@ -308,6 +308,22 @@ class ProfilesStateFactsAboutOtherProjects(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
self.assertEqual(offenders, [], "\n".join(offenders[:40]))
|
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):
|
def test_no_grade_on_another_projects_file(self):
|
||||||
offenders = [
|
offenders = [
|
||||||
f"{path.relative_to(ROOT)}: {line.strip()[:100]}"
|
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".
|
# A project that cannot be reached is an error, never "nothing".
|
||||||
missing = self._changed(clone, str(Path(tmp, "absent")))
|
missing = self._changed(clone, str(Path(tmp, "absent")))
|
||||||
self.assertNotEqual(missing.returncode, 0)
|
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):
|
class RefreshFailuresAreFailures(unittest.TestCase):
|
||||||
|
|||||||
@@ -334,6 +334,22 @@ class NoLevelBadgeOnATabRoot(unittest.TestCase):
|
|||||||
self.assertNotIn('class="rb-level ', _title_through_heading(_built(page)))
|
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):
|
class FooterLicenceMatchesTheLicenceFile(unittest.TestCase):
|
||||||
"""The footer said MIT covers the tooling. LICENSE names the metadata too."""
|
"""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):
|
def test_every_wide_table_stacks(self):
|
||||||
for page in ("emulators/duckstation", "gaps", "gaps-files-to-source", "emulators", "data",
|
for page in ("emulators/duckstation", "gaps", "gaps-files-to-source", "emulators", "data",
|
||||||
"wiki/verification-modes"):
|
"wiki/verification-modes"):
|
||||||
html = (DIST / page / "index.html")
|
for table in _tables(_built(page)):
|
||||||
if not html.is_file():
|
|
||||||
continue
|
|
||||||
for table in _tables(_read(html)):
|
|
||||||
if len(_headers(table)) < 4:
|
if len(_headers(table)) < 4:
|
||||||
continue
|
continue
|
||||||
with self.subTest(page=page, header=_headers(table)):
|
with self.subTest(page=page, header=_headers(table)):
|
||||||
self.assertIn('data-rb-stack="true"', table.split(">", 1)[0])
|
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):
|
def test_long_tables_get_their_labels_at_runtime(self):
|
||||||
"""Labels on every cell of a long stacked table were 122 KB of
|
"""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."""
|
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, "
|
"(https://example.org/news) (version 13.0 announced there, "
|
||||||
"found only as rebuilt copies) |", page)
|
"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):
|
class ACardKeepsItsAddress(unittest.TestCase):
|
||||||
"""emulators/raze/#file-duke3d-grp stopped pointing at its card once a
|
"""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.assertIn(">comes with the app</span>", badges)
|
||||||
self.assertNotIn(">required</span>", badges)
|
self.assertNotIn(">required</span>", badges)
|
||||||
self.assertNotIn(">missing</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",
|
dual = " ".join(gs._file_badges({"name": "a", "bundled": True, "mode": "standalone",
|
||||||
"required": True}, False))
|
"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)
|
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):
|
def test_counts_leave_them_out(self):
|
||||||
shipped = sum(1 for f in self.profile["files"] if gs.comes_with_app(f))
|
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 = self.page[self.page.index("Install this emulator's files"):]
|
||||||
install = install[:install.index("</details>")]
|
install = install[:install.index("</details>")]
|
||||||
self.assertRegex(install, r"With [^:]+: the installer finds")
|
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"]
|
nether = _pages("nethersx2")["nethersx2"]
|
||||||
if "--emulator nethersx2" in nether:
|
if "--emulator nethersx2" in nether:
|
||||||
self.assertIn("With ", nether)
|
self.assertIn("With ", nether)
|
||||||
|
|||||||
@@ -443,14 +443,22 @@ class PackNamesItsVersion(unittest.TestCase):
|
|||||||
block = generate_site._pack_buttons("batocera")
|
block = generate_site._pack_buttons("batocera")
|
||||||
version = generate_site._pack_version(release_record.pack_name_for("batocera", record))
|
version = generate_site._pack_version(release_record.pack_name_for("batocera", record))
|
||||||
self.assertIn(f"Made for Batocera {version}. "
|
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):
|
def test_a_version_gap_is_said_once(self):
|
||||||
record = {"tag": "v2026.10.10", "packs": {"RetroBat_1.0_BIOS_Pack.zip": {
|
record = {"tag": "v2026.10.10", "packs": {"RetroBat_1.0_BIOS_Pack.zip": {
|
||||||
"assets": ["RetroBat_1.0_BIOS_Pack.zip"], "platforms": ["retrobat"]}}}
|
"assets": ["RetroBat_1.0_BIOS_Pack.zip"], "platforms": ["retrobat"]}}}
|
||||||
released = generate_site.release_line("retrobat", record)
|
released = generate_site.release_line("retrobat", record)
|
||||||
line = generate_site.version_line("retrobat", record, named=len(released) > 1)
|
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):
|
def test_the_v_prefix_is_dropped(self):
|
||||||
self.assertEqual(generate_site.setup_version("retroarch", {}),
|
self.assertEqual(generate_site.setup_version("retroarch", {}),
|
||||||
@@ -925,7 +933,9 @@ class ARouteWithoutANetwork(unittest.TestCase):
|
|||||||
self.assertEqual(siteguides.GUIDES["retropie"].card_run, ("linux",))
|
self.assertEqual(siteguides.GUIDES["retropie"].card_run, ("linux",))
|
||||||
text = _plain_page("platforms/retropie.md")
|
text = _plain_page("platforms/retropie.md")
|
||||||
card = text.split("No network?", 1)[1].split("## On the device", 1)[0]
|
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):
|
def test_no_zip_is_promised_for_an_unmarked_setup(self):
|
||||||
for name in SETUPS:
|
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.
|
# 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
|
pages = [main] + [p.read_text(encoding="utf-8") for p in
|
||||||
(DIST / "platforms").glob("batocera-sony-playstation*/index.html")]
|
(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/"):
|
for text in ("Served by:", "scph1001_v20.bin", "https://github.com/stenzek/duckstation/blob/"):
|
||||||
with self.subTest(text=text):
|
with self.subTest(text=text):
|
||||||
self.assertTrue(text in html)
|
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"))
|
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):
|
class AnIdLeadsToItsData(unittest.TestCase):
|
||||||
"""8.6: a system by its id, a core by the name the buildbot gives it."""
|
"""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)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -231,6 +231,40 @@ class Descriptions(unittest.TestCase):
|
|||||||
self.assertTrue(text.startswith("DuckStation emulates Sony PlayStation."))
|
self.assertTrue(text.startswith("DuckStation emulates Sony PlayStation."))
|
||||||
self.assertNotIn("source-pinned", text)
|
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:
|
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"
|
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")
|
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))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
+66
-10
@@ -139,9 +139,25 @@ class AFileOnlyACoreReadsIsKnown(_Fixture):
|
|||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
("WARN", "File is not under bios/ yet. Its system's files are in "
|
("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,
|
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(
|
placed = validate_pr.validate_file(
|
||||||
self._bios_file("bios/Other/Place/b.bin", missing), db, EMPTY_HASHES, known,
|
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)
|
self.assertIn(".variants/known.bin.", markdown)
|
||||||
|
|
||||||
def test_an_unreferenced_file_asks_for_review(self):
|
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")
|
rel = self._bios_file("bios/Sony/PS/mystery.bin")
|
||||||
markdown = validate_pr.validate_file(rel, None, EMPTY_HASHES).to_markdown()
|
result = validate_pr.validate_file(rel, None, EMPTY_HASHES)
|
||||||
self.assertIn("needs manual review", markdown)
|
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):
|
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)
|
probe = validate_pr.validate_file(rel, None, EMPTY_HASHES)
|
||||||
db = {"files": {probe.sha1: {"path": "bios/Sony/PS/original.bin"}}}
|
db = {"files": {probe.sha1: {"path": "bios/Sony/PS/original.bin"}}}
|
||||||
markdown = validate_pr.validate_file(rel, db, EMPTY_HASHES).to_markdown()
|
known = {**EMPTY_HASHES, "sha1": {probe.sha1},
|
||||||
self.assertIn("Duplicate", markdown)
|
"folders": {probe.sha1: {"bios/Sony/PS/"}}}
|
||||||
self.assertIn("original.bin", markdown)
|
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.
|
# 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):
|
def test_the_file_the_database_holds_is_not_its_own_duplicate(self):
|
||||||
"""The documented example on a tracked file warned "Duplicate:
|
"""The documented example on a tracked file warned "Duplicate:
|
||||||
@@ -334,7 +368,8 @@ class Placement(_Fixture):
|
|||||||
class Reporting(_Fixture):
|
class Reporting(_Fixture):
|
||||||
def test_markdown_carries_every_digest_and_the_verdict(self):
|
def test_markdown_carries_every_digest_and_the_verdict(self):
|
||||||
rel = self._bios_file("bios/Sega/Saturn/sega.bin")
|
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()
|
markdown = result.to_markdown()
|
||||||
for value in (result.sha1, result.md5, result.crc32):
|
for value in (result.sha1, result.md5, result.crc32):
|
||||||
self.assertIn(value, markdown)
|
self.assertIn(value, markdown)
|
||||||
@@ -344,12 +379,33 @@ class Reporting(_Fixture):
|
|||||||
rel = self._bios_file("bios/Sega/Saturn/bad.exe")
|
rel = self._bios_file("bios/Sega/Saturn/bad.exe")
|
||||||
self.assertIn("### FAIL", validate_pr.validate_file(rel, None, EMPTY_HASHES).to_markdown())
|
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):
|
class PlatformHashLoading(_Fixture):
|
||||||
def test_missing_directory_yields_empty_sets(self):
|
def test_missing_directory_yields_empty_sets(self):
|
||||||
known = validate_pr.load_platform_hashes(str(self.tmp / "absent"))
|
known = validate_pr.load_platform_hashes(str(self.tmp / "absent"))
|
||||||
self.assertEqual(known, {"sha1": set(), "md5": set(), "sha256": set(), "crc32": set(),
|
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):
|
def test_declared_hashes_and_names_are_collected(self):
|
||||||
platforms = self.tmp / "platforms"
|
platforms = self.tmp / "platforms"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
@@ -146,5 +147,45 @@ class RunFromAnyDirectory(unittest.TestCase):
|
|||||||
self.assertIn("Already in the database at this path", messages)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.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
|
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
|
`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
|
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
|
- 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
|
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`)
|
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`
|
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
|
field is read from `database.json` instead of the build time. Each ZIP of the
|
||||||
romsets are rebuilt deterministically from their ROMs, so a pack does not
|
collection, MAME and FBNeo romsets included, is copied byte for byte, so it
|
||||||
inherit whatever metadata the source archive happened to carry. And every
|
hashes to the SHA1 the install list and the site publish, and git fixes those
|
||||||
member is written with the same fixed date: `ZipFile.write` copies the source
|
bytes. And every member is written with the same fixed date: `ZipFile.write`
|
||||||
file's mtime, which is the checkout time for a file from the collection and
|
copies the source file's mtime, which is the checkout time for a file from the
|
||||||
the wall clock for one the build just staged in `tmp/`.
|
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
|
`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
|
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
|
holds a ZIP, so the comparison covers the archive copy as well as loose files.
|
||||||
path and passes while the real packs still move.
|
|
||||||
|
|
||||||
## Collection rules
|
## Collection rules
|
||||||
|
|
||||||
@@ -383,7 +386,8 @@ resolution.
|
|||||||
deploy identity. No workflow builds or publishes a release.
|
deploy identity. No workflow builds or publishes a release.
|
||||||
|
|
||||||
- `safe_extract_zip()` prevents zip-slip path traversal attacks
|
- `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
|
- `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
|
- ZIP inner ROM verification via `checkInsideZip()` replicates Batocera's behavior
|
||||||
- `md5_composite()` replicates Recalbox's composite ZIP hash
|
- `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.
|
is listed under the counts, and `--verbose` lists the optional ones too.
|
||||||
|
|
||||||
The cross-reference uses `resolve_platform_cores()` to determine which emulator
|
The cross-reference uses `resolve_platform_cores()` to determine which emulator
|
||||||
profiles are relevant for each platform, then checks whether each profile's files
|
profiles are relevant for each platform, then checks whether the platform
|
||||||
appear in the platform config.
|
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
|
### Filtering output
|
||||||
|
|
||||||
|
|||||||
@@ -166,9 +166,9 @@ PR based on changed paths:
|
|||||||
## Large files management
|
## Large files management
|
||||||
|
|
||||||
Files too large for git are stored as assets on a permanent GitHub release
|
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
|
named `large-files`. Size does not decide it: four committed files are
|
||||||
50 MB (`history.xml`, `default-GM.sf3`, `5.avi`, `tbblue.mmc`) and are served
|
over 50 MB (`history.xml`, `default-GM.sf3`, `5.avi`, `uqm-remix-disc1.uqm`)
|
||||||
from the repository like any other.
|
and are served from the repository like any other.
|
||||||
|
|
||||||
Examples: PS3UPDAT.PUP, PSVUPDAT.PUP, PSP2UPDAT.PUP, the DSi NAND images,
|
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
|
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
|
```bash
|
||||||
python scripts/pipeline.py --offline # DB + verify + packs + manifests + integrity + readme + site
|
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 --skip-docs # skip readme + site generation
|
||||||
python scripts/pipeline.py --offline --target switch # filter by hardware target
|
python scripts/pipeline.py --offline --target switch # filter by hardware target
|
||||||
python scripts/pipeline.py --offline --with-truth # include truth generation + diff
|
python scripts/pipeline.py --offline --with-truth # include truth generation + diff
|
||||||
@@ -63,8 +63,8 @@ Pipeline flags:
|
|||||||
| Flag | Effect |
|
| 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/` |
|
| `--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-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 and 8 |
|
| `--skip-docs` | skip steps 7, 8 and 8b |
|
||||||
| `--include-archived` | include archived platforms in verify, packs, truth and export |
|
| `--include-archived` | include archived platforms in verify, packs, truth and export |
|
||||||
| `--target TARGET` | filter verify, packs and truth by hardware target |
|
| `--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` |
|
| `--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 |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `crypto_verify.py` | 3DS RSA signature and AES crypto verification |
|
||||||
| `sect233r1.py` | Pure Python ECDSA verification on sect233r1 curve (3DS OTP cert) |
|
| `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/`) |
|
| `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) |
|
| `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 |
|
| `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` |
|
| `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/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 |
|
| `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