chore: merge tools page refresh

This commit is contained in:
Abdessamad Derraz committed 2026-11-04 05:49:49 +01:00
commit 8848f3e6f6
12 files changed
+348 -95

No files matched your search

+4 -2
View File
@@ -8,7 +8,8 @@ sessions create theirs. A worktree elsewhere on disk and the automation
worktree (.cache/autorun/worktree) are never candidates. One is removed only
when no process runs in or holds a file of it, it is older than --hours, and
nothing tracked is modified in it except generated artifacts (database.json,
install/*.json, exports/*.json, README.md, mkdocs.yml). Branches are always
install/*.json, exports/*.json, README.md, CONTRIBUTING.md,
website/src/data/*.json). Branches are always
kept, so committed work stays reachable. A worktree with real uncommitted
code is listed, never removed.
@@ -39,7 +40,8 @@ from dataclasses import dataclass
from pathlib import Path
GENERATED = re.compile(
r"^(database\.json|install/.*\.json|exports/.*\.json|README\.md|mkdocs\.yml)$"
r"^(database\.json|install/.*\.json|exports/.*\.json|README\.md|CONTRIBUTING\.md"
r"|website/src/data/[^/]+\.json)$"
)
KEPT_TMP = ("acquire",)
AUTOMATION_WORKTREE = Path(".cache") / "autorun" / "worktree"
+4 -2
View File
@@ -678,7 +678,8 @@ def main() -> None:
"--skip-packs",
action="store_true",
help="Skip steps 4 to 6 except 4d: packs, manifests and their checks. "
"Steps 7 and 8 still rewrite README.md and docs/, add --skip-docs to "
"Steps 7 and 8 still rewrite README.md and the site pages in website/, "
"add --skip-docs to "
"stop after verify",
)
parser.add_argument(
@@ -734,7 +735,8 @@ def main() -> None:
# hold lasts until this process exits: taken step by step, it let another
# run purge the packs between the build and the integrity check, which
# then reported every platform SKIP and passed.
# database.json, install/, README.md and docs/ sit at fixed paths, so
# database.json, install/, README.md, CONTRIBUTING.md and website/ sit at
# fixed paths (artifacts.REPOSITORY_OUTPUTS), so
# every run holds them too: a run on a scratch --output-dir otherwise
# rewrote them beside the run on dist/.
held = contextlib.ExitStack()
+2 -1
View File
@@ -6576,7 +6576,8 @@ class SkipPacksHelpSaysWhatRuns(unittest.TestCase):
)
text = " ".join(proc.stdout.split())
self.assertNotIn("Only regenerate DB and verify", text)
self.assertIn("Steps 7 and 8 still rewrite README.md and docs/", text)
self.assertIn("Steps 7 and 8 still rewrite README.md and the site pages in website/", text)
self.assertNotIn("docs/", text)
def test_every_step_it_names_is_one_it_skips(self):
sys.path.insert(0, str(ROOT / "scripts"))
+9
View File
@@ -687,6 +687,15 @@ class Cleanup(Origin):
def _decisions(self, busy=()) -> dict[str, cd.Decision]:
return {d.path.name: d for d in cd.plan(self.repo, busy, self.HOURS, time.time())}
def test_generated_files_are_the_ones_a_run_rewrites(self):
"""mkdocs.yml stayed in the list after the site moved to Starlight,
while CONTRIBUTING.md and website/src/data/*.json were left out."""
for path in ("README.md", "CONTRIBUTING.md", "database.json", "install/retroarch.json",
"exports/batocera.json", "website/src/data/source-paths.json"):
self.assertTrue(cd.GENERATED.match(path), path)
for path in ("mkdocs.yml", "website/src/data/x/y.json", "scripts/verify.py"):
self.assertFalse(cd.GENERATED.match(path), path)
def test_worktree_and_tmp_rules(self):
old_clean = self._worktree("old-clean", 100)
old_code = self._worktree("old-code", 100)
+186
View File
@@ -1240,5 +1240,191 @@ class ExamplesComeFromTheData(unittest.TestCase):
self.assertEqual(cited, entry["source_ref"])
# A command a page gives: `python scripts/X.py ...` or `python -m scripts.x.y ...`,
# up to a comment, a pipe or the end of the line. A placeholder module
# (`myplatform_scraper`) is what a guide asks the reader to write.
_COMMAND = re.compile(
r"python3? (?:(scripts/[\w/]+\.py)|-m (scripts(?:\.\w+)+)(?![.*\w]))((?:[ \t]+[^\s#|&;`]+)*)"
)
_OPTION = re.compile(r"(?<![\w-])--[a-z0-9][a-z0-9-]*")
_PLACEHOLDER = ("myplatform",)
def _commands(text: str):
for number, line in enumerate(text.splitlines(), 1):
for match in _COMMAND.finditer(line):
target = match.group(1) or match.group(2)
if any(word in target for word in _PLACEHOLDER):
continue
yield number, target, bool(match.group(2)), match.group(3).split()
class CitedScriptsExist(unittest.TestCase):
"""The wiki names scripts by path. A script renamed or removed leaves a
command that fails, and the page says nothing until a reader runs it."""
@staticmethod
def missing(text: str) -> list[str]:
found = []
for number, target, module, _args in _commands(text):
path = ROOT / (target.replace(".", "/") + ".py" if module else target)
if not path.is_file():
found.append(f"{number}: {target}")
found += [
f"{number}: {name}"
for number, line in enumerate(text.splitlines(), 1)
for name in re.findall(r"(?<![\w./-])scripts/[\w/]+\.py", line)
if not (ROOT / name).is_file()
]
return found
def test_every_cited_script_exists(self):
for page in sorted(WIKI.glob("*.md")):
with self.subTest(page=page.name):
self.assertEqual(self.missing(page.read_text(encoding="utf-8")), [])
def test_a_removed_script_is_named(self):
self.assertEqual(self.missing("python scripts/no_such_tool.py --all\n"),
["1: scripts/no_such_tool.py", "1: scripts/no_such_tool.py"])
class CitedOptionsExist(unittest.TestCase):
"""Every option a command on a wiki page passes is one the script's own
parser lists, read from its --help (a subcommand's when the command names
one). A renamed flag otherwise survives on the page as a command that
exits 2."""
_helps: dict[tuple[str, str], str] = {}
@classmethod
def help_text(cls, target: str, module: bool, sub: str = "") -> str:
key = (target, sub)
if key not in cls._helps:
import subprocess # noqa: PLC0415
argv = [sys.executable, *(["-m", target] if module else [target])]
proc = subprocess.run(
[*argv, *([sub] if sub else []), "--help"],
cwd=ROOT, capture_output=True, text=True, timeout=120,
)
cls._helps[key] = proc.stdout if proc.returncode == 0 else ""
return cls._helps[key]
@classmethod
def subcommands(cls, target: str, module: bool) -> list[str]:
usage = cls.help_text(target, module).split("\n\n", 1)[0]
choice = re.search(r"\{([\w,-]+)\}", usage)
return choice.group(1).split(",") if choice else []
@classmethod
def unknown(cls, text: str) -> list[str]:
found = []
for number, target, module, args in _commands(text):
subs = cls.subcommands(target, module)
sub = args[0] if args and args[0] in subs else ""
known = set(_OPTION.findall(cls.help_text(target, module, sub)))
if not sub:
for other in subs:
if not other.startswith("_"):
known |= set(_OPTION.findall(cls.help_text(target, module, other)))
if not known:
found.append(f"{number}: {target} prints no --help")
continue
found += [
f"{number}: {target} {sub} {option}".replace(" ", " ")
for arg in args
for option in _OPTION.findall(arg.split("=", 1)[0])
if arg.startswith(option) and option not in known
]
return found
def test_every_cited_option_is_in_the_parser(self):
for page in sorted(WIKI.glob("*.md")):
with self.subTest(page=page.name):
self.assertEqual(self.unknown(page.read_text(encoding="utf-8")), [])
def test_an_unknown_option_is_named(self):
self.assertEqual(
self.unknown("python scripts/campaign.py status --budget 6h\n"),
["1: scripts/campaign.py status --budget"],
)
class PipelineStepsMatchTheRun(unittest.TestCase):
"""The step numbers the pages give are the ones pipeline.py prints. The
testing guide called the integrity check "step 6/8" in a run of 24 steps
numbered by label, and the tools table can lose or keep a step the run
no longer has."""
@staticmethod
def labels() -> list[str]:
import pipeline # noqa: PLC0415
return [pipeline.step_number(key) for key in pipeline.STEP_LABELS]
@staticmethod
def cited(text: str) -> list[str]:
"""Step numbers a sentence attributes to the pipeline."""
found = []
for match in re.finditer(
r"(?i)pipeline steps? ((?:\d[a-z0-9]*(?:/\d+)?(?:, | and | or )?)+)"
r"|steps? (\d[a-z0-9]*(?:/\d+)?) of the pipeline",
text,
):
found += re.findall(r"\d[a-z0-9]*(?:/\d+)?", match.group(1) or match.group(2))
return found
def test_the_table_lists_every_step_in_order(self):
section = _section(_page("tools.md"), "Pipeline")
table = re.findall(r"^\| (\d[a-z0-9]*) \|", section, re.M)
self.assertEqual(table, self.labels())
def test_steps_named_in_the_pipeline_section_exist(self):
section = _section(_page("tools.md"), "Pipeline")
labels = set(self.labels())
named = [
step
for clause in re.findall(r"(?i)\bsteps? ((?:\(?\d[a-z0-9]*\)?(?:, | and |, and )?)+)", section)
for step in re.findall(r"\d[a-z0-9]*", clause)
]
named += [step for group in re.findall(r"\(((?:\d[a-z0-9]*, )+\d[a-z0-9]*)\)", section)
for step in group.split(", ")]
self.assertTrue(named)
self.assertEqual(sorted(set(named) - labels), [])
def test_every_page_cites_a_step_the_run_has(self):
labels = set(self.labels())
for page in sorted(WIKI.glob("*.md")):
with self.subTest(page=page.name):
self.assertEqual(
[step for step in self.cited(page.read_text(encoding="utf-8"))
if step not in labels], [])
def test_a_fraction_of_steps_is_refused(self):
self.assertEqual(self.cited("Integrated as pipeline step 6/8 (runs after"), ["6/8"])
self.assertEqual(self.cited("pipeline steps 3a and 4d"), ["3a", "4d"])
class SiteOutputIsTheStarlightProject(unittest.TestCase):
"""generate_site.py writes into website/ since the move to Starlight,
while tools.md still gave `docs/` as the default of --docs-dir and as a
path the pipeline holds, and the testing guide measured pages in `docs/`."""
PAGES = ("tools.md", "testing-guide.md", "release-process.md",
"reading-reports.md", "measurements.md")
BARE_DOCS = re.compile(r"(?<![\w/.-])docs/(?!\w)")
def test_no_page_names_a_docs_folder(self):
for name in self.PAGES:
with self.subTest(page=name):
self.assertEqual(self.BARE_DOCS.findall(_page(name)), [])
def test_the_old_wording_is_caught(self):
self.assertTrue(self.BARE_DOCS.search("| `--docs-dir` (output, `docs/` by default) |"))
self.assertFalse(self.BARE_DOCS.search("`website/src/content/docs/`"))
if __name__ == "__main__":
unittest.main()
+17 -1
View File
@@ -299,7 +299,19 @@ class ShownOutputIsPrintedOutput(unittest.TestCase):
(r"^\S.*: \d+/\d+ (OK|present)\b.*\[(md5|sha1|existence)\]$", "}: {', '.join(parts)} [{mode}]"),
(r"^ (OK|UNTESTED|MISSING) \((required|optional)[^)]*\): \S+/\S+", '" UNTESTED ({req}{hle}): {key}: {reason}"'),
(r"^ DISCREPANCY: \S+/\S+: ", '" DISCREPANCY: {key}: {disc}"'),
(r"^ Core files: \d+ in pack, \d+ required missing, \d+ optional missing$", '" Core files: {core_in_pack} in pack'),
# A count of optional files missing is followed by where to list them:
# the sample once showed "1 optional missing" with no such suffix.
(r"^ Core files: \d+ in pack, \d+ required missing, "
r"(0 optional missing|\d+ optional missing \(--verbose lists them\))$",
'" Core files: {core_in_pack} in pack'),
(r"^ Core files held but not packed: \d+ \(.+\)$", '" Core files held but not packed: '),
(r"^ Game data: \d+ in pack, (0 missing|\d+ missing \(--verbose lists them\))$",
'" Game data: {gd_packed} in pack'),
(r"^ No external files: \d+ cores \(--verbose lists them\)$",
'" No external files: {len(exclusions)} cores (--verbose lists them)"'),
(r"^ Ground truth: \d+/\d+ files have emulator validation \(\d+%\)$",
'" Ground truth: {gt_cov[\'with_validation\']}/{gt_cov[\'total\']} files have emulator validation'),
(r"^ \d+ platform-only \(no emulator profile\)$", '" {no_profile} platform-only (no emulator profile)"'),
(r"^ \S", None),
)
@@ -321,6 +333,10 @@ class ShownOutputIsPrintedOutput(unittest.TestCase):
any(re.match(pattern, line) for pattern, _ in self.SHAPES), line
)
def test_an_optional_count_without_its_suffix_is_refused(self):
line = " Core files: 1495 in pack, 0 required missing, 1 optional missing"
self.assertFalse(any(re.match(pattern, line) for pattern, _ in self.SHAPES))
def test_no_severity_column(self):
self.assertNotIn("[CRITICAL]", _read("wiki/troubleshooting.md"))
+2 -2
View File
@@ -6,8 +6,8 @@ every platform's check. Each budget below is a number on the built page:
words before the first action, sentence and block length, words per page, and
a list of words a newcomer does not know or does not need.
Run ``python -m tests.test_site_copy --report docs`` to print the measures of
every page held here.
Run ``python -m tests.test_site_copy --report website/src/content/docs`` to
print the measures of every page held here.
"""
from __future__ import annotations
+2 -1
View File
@@ -16,7 +16,8 @@ A page that relies on a run says so in a **Proof** line. Every run is on the
## What a test does
- A script runs the emulator with no window and no sound.
- It starts a game and runs it for a set time, at least twice.
- It starts a game and runs it for a set time.
- A test that compares files runs each case at least twice.
- It compares the screens with ones we checked by eye.
- It reads the emulator's log to see which BIOS it ran.
+14 -5
View File
@@ -107,9 +107,18 @@ RetroDECK: 2007/2008 OK, 1 untested [md5]
UNTESTED (required): nintendo-ds/dsifirmware.bin: declared hash contradicted by the local file
DISCREPANCY: nintendo-snes/sgb2.boot.rom: RetroDECK says OK but supersnes9x says size mismatch: got 524288, accepted [256]
supersnes9x [size]
Core files: 1495 in pack, 0 required missing, 1 optional missing
Core files: 1795 in pack, 0 required missing, 27 optional missing (--verbose lists them)
Core files held but not packed: 154 (installed with its emulator)
Game data: 5099 in pack, 38 missing (--verbose lists them)
No external files: 78 cores (--verbose lists them)
Ground truth: 96/2008 files have emulator validation (5%)
439 platform-only (no emulator profile)
```
The run prints more `DISCREPANCY` lines than this excerpt shows. The core
and game data lines count what the emulator cores add beyond the platform's
own list. A file held but not packed is left out for the reason in parentheses.
`--verbose` also lists the passing entries that an emulator validates, with
the check and the source lines behind it:
@@ -121,10 +130,10 @@ Recalbox: 351/351 OK [md5]
### Cross-reference section
After per-file results, `verify.py` prints a cross-reference report. This lists
files that emulator cores need but that the platform YAML doesn't declare. These
files are still included in packs automatically, but the report helps identify
gaps in platform coverage data.
The `Core files` and `Game data` lines are the cross-reference: files that
emulator cores need but that the platform YAML does not declare. These files
are still included in packs automatically. A required one the collection lacks
is listed under the counts, and `--verbose` lists the optional ones too.
The cross-reference uses `resolve_platform_cores()` to determine which emulator
profiles are relevant for each platform, then checks whether each profile's files
+53 -28
View File
@@ -10,16 +10,20 @@ built, checked and uploaded from the maintainer's machine.
## CI workflows overview
Both workflows use only official GitHub
actions (`actions/checkout`, `actions/setup-python`, `actions/upload-pages-artifact`,
`actions/deploy-pages`). No third-party actions.
Both workflows use only official GitHub actions (`actions/checkout`,
`actions/setup-python`, `actions/setup-node`, `actions/configure-pages`,
`actions/upload-pages-artifact`, `actions/deploy-pages`), each pinned to a
commit. No third-party actions.
Budget target: ~175 minutes/month on the GitHub free tier.
| Workflow | File | Trigger |
|----------|------|---------|
| Deploy Site | `deploy-site.yml` | Push to main (platforms, emulators, provenance, wiki, scripts, database.json, website), manual |
| Validation | `validate.yml` | PR and push to main touching `bios/**`, `platforms/**`, `emulators/**`, `schemas/**`, `scripts/**`, `tests/**`, `install.py`, `install.sh`, `install.ps1`, or any other input of the site build (`wiki/**`, `docs_assets/**`, `provenance/**`, `exports/**`, `install/**`, `database.json`, `release.json`, `derivable.json`, `website/**`) |
| Deploy Site | `deploy-site.yml` | Push to main touching an input of the site, manual |
| Validation | `validate.yml` | PR and push to main touching the collection, the code, the tests, the installer, `ports/` or an input of the site |
Each workflow's `paths:` lists are the reference, and
`tests/test_workflow_paths.py` holds them to what the site build reads.
Upstream BIOS lists are not scraped on a schedule. A maintainer runs the
scrapers by hand (see [adding a scraper](adding-a-scraper.md)), reviews the
@@ -30,12 +34,13 @@ hosted runner should rebuild and re-upload.
## deploy-site.yml - Deploy documentation site
**Trigger.** Push to `main` when any of these paths change: `platforms/`,
`emulators/`, `provenance/`, `wiki/`, `schemas/`, `tests/`, `docs_assets/`,
`install/`, `install.py`, `database.json`, `release.json`, `website/` (the
Starlight project: configuration, components, styles, lockfile), the
workflow itself, and every script the build runs or imports. Also manual
dispatch.
**Trigger.** Push to `main` when an input of the site changes: the data
(`platforms/`, `emulators/`, `provenance/`, `exports/`, `measurements/`,
`database.json`, `release.json`, `derivable.json`, `install/`), the pages and
their assets (`wiki/`, `docs_assets/`, `website/`), the installer and its two
bootstraps the site serves, `tests/campaign/observations.yml` (the Proof lines
of the emulator pages), the workflow itself, and every script the build runs
or imports. Also manual dispatch.
The list is the set of inputs the site is generated from. A script that
`generate_site.py`, `generate_readme.py` or another build step starts to import
@@ -61,11 +66,17 @@ is missing.
8. Run `validate_site.py --site-dir website/dist` on the rendered HTML
(metadata, headings, image alternatives, duplicate ids, every local link
and fragment again, page and search index budgets)
9. Require the committed README and CONTRIBUTING to match what the generator
9. Run `tests.test_site_bootstrap`: the one-line installers the site serves
pin their own `install.py`, and a copy that refuses every install stops
the deploy, the last good pair staying served
10. Run `website/tools/search_gate.py`: the queries a visitor types must reach
the page that answers them through the site's own search box, and the home
page must stay under its weight budget
11. Require the committed README and CONTRIBUTING to match what the generator
just produced. `write_if_changed()` compares content with the timestamp line
stripped, so a run that only moves the clock leaves the files untouched and
the check stays meaningful
10. Upload artifact, deploy to GitHub Pages
12. Upload artifact, deploy to GitHub Pages
Data contracts are validated with `scripts/validate_schemas.py` at step 3, before
the site is generated. It refuses to run without the checkers for the `date-time`
@@ -98,18 +109,24 @@ scripts stay Python.
**Trigger.** Pull requests and direct pushes to main that modify `bios/**`,
`platforms/**`, `emulators/**`, `schemas/**`, `scripts/**`, `tests/**`,
`install.py`, `install.sh` or `install.ps1` (the bootstraps pin the
installer's SHA-256, so editing one without the other fails the run). The
path lists are spelled out once per event because the workflow parser reads
no YAML anchor.
installer's SHA-256, so editing one without the other fails the run),
`ports/**` (the handheld port bundles the installer), or any input of the site
build. The path lists are spelled out once per event because the workflow
parser reads no YAML anchor, and a test holds the two equal.
**Concurrency.** Per-PR group on a pull request, per-ref on a push, cancel
in-progress either way: a push series collapses to the tip.
**Concurrency.** Per-PR group on a pull request, per-ref on a push. A pull
request cancels its run in progress. A push to main does not: GitHub keeps one
pending run per group and replaces it with each newer push, so main always gets
a finished run and then its tip.
Five jobs, two of which read pull request context and carry an event guard:
**validate-bios** (pull requests only). Diffs the PR to find changed BIOS
files, runs `validate_pr.py --markdown` on each, and posts the validation
report as a PR comment (hash verification, database match status).
**validate-bios** (pull requests only). Diffs the PR to find added,
modified or renamed BIOS files, runs `validate_pr.py --markdown` on them and
writes the report (hash verification, database match status) to the job
summary. The report is also posted as a PR comment when the branch lives in
this repository, since a fork's token cannot write one. The job fails after
the report when a file did not validate.
**validate-configs.** Runs `python scripts/validate_schemas.py --source-only`,
which validates every platform YAML against `schemas/platform.schema.json` and
@@ -125,12 +142,19 @@ A profile written from a clone can cite lines that do not hold the value it
declares while schemas and tests pass it. This job confronts it with its
upstream. An edited profile is not held to it.
**run-tests.** Runs `python -m unittest discover -s tests -t . -v`, then builds the
site as Deploy Site does (`generate_site.py`, `npm run build --prefix website`,
`validate_site.py`) whatever the suite returned. Must pass before a merge, and
again on the commit a direct push puts at the head of main.
**run-tests.** On Python 3.8, 3.9 and 3.12: holds `install.py` to 3.8 with
`vermin`, sets up bubblewrap for the sandbox tests, fetches the platforms' own
files (allowed to fail, the fidelity tests then skip) and the data
directories, generates the site, then runs
`python -m unittest discover -s tests -t . -v` with `RETROBIOS_OFFLINE=1`, the
full-screen installer tests again under 3.8, and the site build
(`npm run build --prefix website`, `validate_site.py`). The site generation,
the suite and the build each run whatever the step before returned, so one
red step does not hide another. Must pass before a merge, and again on the
commit a direct push puts at the head of main.
**label-pr** (pull requests only). Auto-labels the PR based on changed paths:
**label-pr** (pull requests from a branch of this repository). Auto-labels the
PR based on changed paths:
| Path pattern | Label |
|-------------|-------|
@@ -287,8 +311,9 @@ gh release list --json tagName,createdAt \
| tail -n +2 | while read tag; do gh release delete "$tag" --yes --cleanup-tag; done
```
Before a release, since 2026-10-28, a maintainer runs `python scripts/pack_probe.py run
--platform <name>` on the platforms that get a pack, and reads its findings before cutting.
Before a release, a maintainer runs `python scripts/pack_probe.py run --platform
<name>` on the released platforms it supports (RetroArch, Batocera and RetroBat),
and reads its findings before cutting.
## Which platforms get a pack { #which-platforms-get-a-pack }
+14 -9
View File
@@ -257,9 +257,10 @@ in the platform YAML:
Handles inner ZIP verification for MAME/FBNeo ROM sets (checkInsideZip,
md5_composite, inner ROM MD5) and path collision deduplication.
One test per distinct pack contract: RetroArch, Batocera, BizHawk, EmuDeck,
Recalbox, RetroBat, RetroDECK, RomM. Inherited aliases such as Lakka reuse the
same artifact and are not counted twice.
One test walks every platform the registry knows, each a subtest, rather than
a list written by hand: eight names were once written out, and ROCKNIX and
MiSTer FPGA, both released, were never checked. A platform with no pack in
`dist/` is skipped, and so is a pack older than a profile it depends on.
```bash
python -m unittest tests.test_pack_integrity -v
@@ -267,8 +268,8 @@ python -m unittest tests.test_pack_integrity -v
python scripts/generate_pack.py --all --verify-packs --output-dir dist/
```
Integrated as pipeline step 6/8 (runs after consistency check, before
README generation). Requires packs in `dist/`, skip with `--skip-packs`.
The pipeline runs the same check as step 6, after the consistency check and
before the README. It needs packs in `dist/`, and `--skip-packs` skips it.
### test_handheld_launchers.py
@@ -385,7 +386,7 @@ the door open.
The Start here pages and the guide part of each setup page are read by someone
who knows nothing about BIOS files, often on a phone. Each word costs that
reader attention, so `tests/test_site_copy.py` holds them to numbers measured
on the generated pages in `docs/`, code blocks left out:
on the generated pages in `website/src/content/docs/`, code blocks left out:
| Budget | Start here | Setup guide |
|--------|-----------:|------------:|
@@ -412,7 +413,8 @@ a pack is a ZIP and a platform is a setup.
The reasons: a reader holds about four things at once, scans more than reads,
and reads less on a phone. Facts a newcomer does not act on (legal analysis,
source lines, annotated runs) are kept, on the page one level down. To see
the numbers of every page, run `python -m tests.test_site_copy --report docs`.
the numbers of every page, run
`python -m tests.test_site_copy --report website/src/content/docs`.
### Adding a wiki page
@@ -469,8 +471,11 @@ files it points at gives the installer a 404.
The `validate.yml` workflow runs `python -m unittest discover -s tests -t . -v` on both
roads into main: every pull request, and every direct push, that touches
`bios/`, `platforms/`, `emulators/`, `schemas/`, `scripts/`, `tests/`,
`install.py` or an input of the site build such as `wiki/`. The same job then
builds the site with `npm run build --prefix website`, so a page that would fail the
`ports/`, the installer and its bootstraps, or an input of the site build such
as `wiki/` or `measurements/`. The `paths:` lists of `validate.yml` are the
reference, and `tests/test_workflow_paths.py` holds them to the site's inputs. The same job then
generates the site before the suite, since the tests read its pages, and builds
it with `npm run build --prefix website` after, so a page that would fail the
deploy fails the pull request. Work reaches main by push as often as by pull request, so a suite
wired to pull requests alone would guard the road nobody takes. The test job
(`run-tests`) runs in parallel with schema validation, and on a pull request
+41 -44
View File
@@ -43,13 +43,13 @@ Pipeline steps:
| 3 | Verify all platforms (`--strict`) | - |
| 3a | File identity: one content under two identities (`identity.py --strict`, gates the run) | - |
| 3b | Slot arbitration: destinations both layers claim (`slots.py`, reports only) | - |
| 4 | Generate packs | `--skip-packs` |
| 4 | Generate the packs of the platforms marked `release_pack` | `--skip-packs` |
| 4b | Generate install manifests | `--skip-packs` |
| 4c | Generate target manifests | `--skip-packs` |
| 4d | Collected copies only an emulator install provides (`bundled_only.py --strict`, gates the run) | - |
| 4e | Games index: what the installer reads for the games on a card (`generate_games_index.py`, fails under its coverage floor) | - (reads the committed install lists) |
| 5 | Consistency check: counts, paths per archive, every emulator pack | if verify or pack skipped |
| 6 | Pack integrity (extract + hash) | `--skip-packs` |
| 5 | Consistency check: counts, paths per archive, every emulator pack | `--skip-packs`, or no full pack built (`--source platform` or `truth`) |
| 6 | Pack integrity (extract + hash) | `--skip-packs`, or no full pack built |
| 7 | Generate README and CONTRIBUTING, then the installer page's tables and run samples (`generate_installer_doc.py`) | `--skip-docs` |
| 8 | Generate site | `--skip-docs` |
| 8b | Render the site with `npm run build --prefix website`, then `validate_site.py`, as the deploy does | `--skip-docs`, or Node not set up (`npm ci --prefix website`) |
@@ -58,7 +58,7 @@ Pipeline flags:
| Flag | Effect |
|------|--------|
| `--offline` | skip every step that needs the network (2, 2a, 2a2, 2b, 2b2) |
| `--offline` | skip every step that needs the network (2, 2a, 2a2, 2b, 2b2), and pack only the release assets already in `.cache/large/` |
| `--skip-packs` | skip steps 4, 4b, 4c, 6 and the consistency check. README and site are still rewritten unless `--skip-docs` is given too |
| `--skip-docs` | skip steps 7 and 8 |
| `--include-archived` | include archived platforms in verify, packs, truth and export |
@@ -96,8 +96,8 @@ alone, run `python scripts/check_buildbot_system.py`.
`<output-dir>/.lock`, and a second run on the same folder stops at once
rather than read a half-written ZIP. Pack experiments with
`generate_pack.py` use a scratch `--output-dir` under `tmp/`. A pipeline
run also writes `database.json`, `install/`, `README.md` and `docs/` at
fixed paths, so it holds the repository's `.lock` as well, and a second
run also writes `database.json`, `install/`, `README.md`, `CONTRIBUTING.md`
and `website/` at fixed paths, so it holds the repository's `.lock` as well, and a second
pipeline is refused whatever its `--output-dir`.
- **Read the exit status before the output.** A generator that crashed leaves
the previous artifact in place, which reads exactly like a run that changed
@@ -172,7 +172,7 @@ database or source tree, which is how the tests drive it against fixtures.
Build platform-specific BIOS ZIP packs.
```bash
# Full platform packs
# Full platform packs (--all: the platforms marked release_pack)
python scripts/generate_pack.py --all --output-dir dist/
python scripts/generate_pack.py --platform batocera
python scripts/generate_pack.py --emulator dolphin
@@ -643,6 +643,12 @@ python scripts/refresh_data_dirs.py --platform batocera # single platform onl
python scripts/refresh_data_dirs.py --registry path/to/_data_dirs.yml
```
A download that fails or does not read as an archive is tried three times,
after waits of 5 and 20 seconds: a mirror once answered with a page instead of
`ScummVM.zip` and served the archive minutes later. An archive that reads but
holds nothing under the expected path fails at once, since a retry gets the
same bytes.
### check_freshness.py
Ask every transcribed layer whether the local copy is the one upstream serves
@@ -793,7 +799,7 @@ the linked worktrees placed inside the checkout, where sessions create
theirs. One is removed only when no process uses it, it is older than
`--hours`, and nothing tracked changed in it but generated files
(`database.json`, `install/*.json`, `exports/*.json`, `README.md`,
`mkdocs.yml`). One holding real changes is listed, never removed. Branches
`CONTRIBUTING.md`, `website/src/data/*.json`). One holding real changes is listed, never removed. Branches
are always kept. A worktree elsewhere on disk and the automation worktree are
never candidates. `tmp/` entries older than `--hours` that no process uses
are removed, except `tmp/acquire`, `*.bundle` and an entry that holds a
@@ -953,30 +959,26 @@ python scripts/core_probe.py rejudge --dry-run
python scripts/core_probe.py rejudge --emulator beetle_psx
```
`python scripts/core_probe.py <mode> --help` lists every option. These need
more than its one line:
| Option | Effect |
|--------|--------|
| `--system-file DEST=SRC` | copy SRC into a staged system directory as DEST |
| `--content-label` | identity of the content, such as its Redump name, copied into the record |
| `--work-dir`, `--timeout` | scratch directory for the staged runs, and seconds allowed per repetition (default 1800), after which the run is killed and judged a hang |
| `--oracle FILE` | reference frames, error patterns and error screens the verdicts are judged against (default `tests/campaign/oracle.yml`) |
| `--timeout` | seconds allowed per repetition (default 1800), after which the run is killed and judged a hang |
| `--repeat N` | runs of each variant, each in a fresh process (`run` defaults to 1, `check` to 2, `bench` to 5) |
| `--vary DEST=SRC` | (`check`) one variant per occurrence. An empty SRC leaves DEST absent, `@zeros:SIZE` stages SIZE zero bytes and `@head:SIZE:PATH` the first SIZE bytes of PATH |
| `--option KEY=VALUE` | core option. Unset options take the first value the core declares |
| `--frames`, `--warmup` | frames timed, and frames run before timing starts |
| `--press FRAME:BUTTON[:LEN]` | hold a libretro joypad button on port 0 (`a`, `b`, `start`, `up`...). A menu may need 15 frames or more to see it |
| `--joypad-ports N` | ports given a joypad after loading, as RetroArch does (default 2) |
| `--env KEY=VALUE` | environment of the core process. A core that reads the host clock repeats only with the clock fixed, for example `LD_PRELOAD` of a clock shim. `LD_PRELOAD` must name each library by an absolute path to a file, or the run is refused: the loader skips a library it cannot find without a word. A value naming a file is recorded by file name and sha256 |
| `--frame-hash-every K`, `--png-dir` | sha1 of every K-th frame, and those frames as PNG |
| `--state-var SECTION.VAR` | a variable of a Mednafen save state, as an observable |
| `--state-dir` | keep the save state taken at the end of the first repetition |
| `--core-repo`, `--core-commit`, `--build-command` | where the core build comes from, copied into the record |
| `--core-artifact URL` | where a downloaded `.so` came from, such as a buildbot nightly, copied into the record |
| `--core-repo`, `--core-commit`, `--build-command` | where the core build comes from, copied into the record. No core binary is kept in the repository: a record is reproduced by building the core again at that commit, so a nightly recorded without its commit cannot be |
| `--record [DIR]` | append the record as one line of `measurements/<emulator>.jsonl` in the checkout `DIR`, by default this one. `DIR` must be the root of a git checkout, never its `measurements/` folder. Commit it |
| `--emulator` | the profile the record is filed under. By default the profile whose `cores:` names the `.so`, else the core's library name |
| `--subject` | a slug naming the test, shared by its reruns. By default derived from the claim and method, or from the inputs of a run or bench |
| `--supersedes ID` | the id of an earlier record of the same emulator this one corrects |
| `--max-load` | refuse to record when the 1-minute load average is above it (default: a quarter of the CPU count) |
| `--no-trace` | run without strace |
| `--core-artifact URL` | where a downloaded `.so` came from, such as a buildbot nightly, copied into the record |
| `--joypad-ports N` | ports given a joypad after loading, as RetroArch does (default 2). |
| `--state-var SECTION.VAR`, `--state-dir` | a variable of a Mednafen save state as an observable, and a folder that keeps the state of the first repetition to read it again |
| `--max-load` | refuse to record above this 1-minute load average (default a quarter of the CPUs). Never raise it: a busy host computes wrong hashes |
| `--no-trace` | run without strace. The record then says that tracing was unavailable |
| `@FILE` | read arguments from FILE, one per line |
Observables are `frame_sha1` (last frame), `video_sha1` (every frame, chained),
@@ -1164,14 +1166,12 @@ python scripts/region_check.py run \
python scripts/region_check.py report # the outcome of every region record, as JSON
```
| Option | Effect |
|--------|--------|
| `--core-name` | the buildbot name of the core, whose x86_64 nightly is downloaded once into `.cache/region/cores/` and named in the record |
| `--core`, `--core-artifact` | a core `.so` to use instead, and where it was downloaded from |
| `--content-dir` | where romfetch keeps the test games (default `.cache/test-content`) |
| `--variants` | a subset of `full`, `own`, `others` and `pack` |
| `--timeout` | seconds per run (default 900) |
| `--max-load-wait` | seconds a run refused on a busy host is tried again, every 15 seconds, before it is given up (default 1800). The load threshold stays core_probe's |
`region_check.py run --help` lists the options. The nightly `--core-name`
names is downloaded once into `.cache/region/cores/`. `--core` and
`--core-artifact` run a core built elsewhere instead, `--content-dir` points
at another romfetch folder, and `--variants` reruns some of the four runs only. A run refused on a busy
host is tried again every 15 seconds for `--max-load-wait` seconds, and the
load threshold stays core_probe's.
The file the core opened in the `full` run, read from strace, settles the
claim: `confirmed` when its `region:` holds the game's region, `contradicted`
@@ -1218,16 +1218,10 @@ python scripts/standalone_probe.py check pcsx2 \
| Files | `strace -f` inside the sandbox: the files the emulator opened in the staged BIOS folder, and the ones it looked for there and did not find |
| Verdict | `core_probe.judge` on each repetition, with the oracle of `tests/campaign/oracle.yml` looked up by the content's sha1 and the emulator's name, plus the emulator's own error strings this script lists (DuckStation's "No BIOS image found for", PCSX2's "Unable to auto locate a BIOS image"). An emulator that exits before the last sample is a crash |
| Option | Effect |
|--------|--------|
| `--bios-file DEST=SRC` | copy SRC into the staged BIOS folder as DEST |
| `--setting SECTION.KEY=VALUE` | an emulator setting written before the run |
| `--sha256` | the fetched asset to run (default: the newest fetched) |
| `--samples`, `--interval` | screen samples per repetition (default 60), and seconds between two (default 1) |
| `--oracle` | the boot oracle file (default `tests/campaign/oracle.yml`, as `core_probe.py`) |
| `--png-dir` | each distinct sampled screen of the first repetition as PNG |
| `--record`, `--subject`, `--supersedes`, `--max-load`, `--work-dir`, `--no-trace`, `--require-sandbox` | as in `core_probe.py` |
| `check`: `--vary`, `--observable`, `--claim`, `--method`, `--repeat` | as in `core_probe.py`, compared on `verdict` by default |
`standalone_probe.py <mode> --help` lists the options. `--sha256` runs an
older fetched asset than the newest. The ones it shares with `core_probe.py`
(`--record`, `--max-load`, `--no-trace`, `--require-sandbox`) mean the same
there, and `check` compares on `verdict` by default.
Supported: DuckStation and PCSX2. Dolphin, Flycast, PPSSPP, melonDS, xemu and
MAME are installed by EmuDeck from Flathub, Ryujinx from a Codeberg tarball and
@@ -1304,7 +1298,7 @@ before a release (wiki/release-process.md). Its lines stay in
| `generate_event_schema.py` | Write `schemas/install-events.schema.json` from `EVENT_KEYS` in `install.py` (`--check` exits 1 when the file is stale, which `validate_schemas.py` also reports) |
| `generate_installer_doc.py` | Rewrite the generated blocks of `wiki/installer.md` from `install.py`: the tables (options, events, exit codes, keys per route, card markers), the line menu, and the run samples `install.py` prints in a sandbox against the committed `install/*.json`, so a new list rewrites the page (`--check` exits 1 when a block is stale, `--page FILE` writes another copy of the page). Pipeline step 7 |
| `auto_fetch.py` | Fetch missing BIOS files from known sources (4-step pipeline). A download is accepted only when every cryptographic hash the entry declares (sha256, sha1, md5) matches. crc32 and size find a file the database already holds, never accept a download. An entry with no cryptographic hash is reported and not searched |
| `list_platforms.py` | List active platforms (`--include-archived` adds the archived ones, used by CI) |
| `list_platforms.py` | List active platforms (`--include-archived` adds the archived ones) |
| `download.py` | Download a pack from GitHub releases, every part checked (Python, stdlib only) |
| `download.sh` | Same, as a shell one-liner (`curl` + `unzip`) |
| `split_pack.py` | Cut a pack over the release asset limit into parts that are each a ZIP of whole files, each with a marker naming every part, read back before the pack is removed |
@@ -1343,7 +1337,7 @@ Further options, by script:
| `export_native.py` | `--truth-dir`, `--upstream-dir` (where the platforms' own files are cached) |
| `find_pinned_revision.py` | `--repo URL --path FILE --md5 HASH`, `--write PATH`, `--cache-dir` |
| `generate_db.py` | `--provenance-dir` (dump-catalog snapshots joined into the database) |
| `generate_site.py` | `--docs-dir` (output, `docs/` by default) |
| `generate_site.py` | `--site-dir` (the Starlight project, `website/` by default), `--docs-dir` (the pages, `src/content/docs` of that project by default) |
| `mame_ref_audit.py` | `--write` points each stale ref at the line declaring its set, `--offline`, `--cache-dir` |
| `migrate.py` | `--dry-run`, `--include-branches` also reads BIOS files from every remote branch |
| `provenance_report.py` | `--provenance-dir`, `--write-derivable PATH` (with `--derive`, and the pipeline writes `derivable.json`) |
@@ -1473,8 +1467,11 @@ release. `.gitignore` is the list of them: a `bios/` path listed there is a
release asset, whatever its size, and a committed file is served from the
repository even past 50 MB.
`generate_db.py` downloads them from the release when rebuilding the database,
using `fetch_large_file()` from `common.py`. The same function is used by
`generate_pack.py` when a file has a hash mismatch with the local variant.
using `fetch_large_file()` from `largefiles.py`. `generate_pack.py` calls the
same function when the file is absent locally or holds another revision than
the entry declares, and keeps the asset only when it matches a declared hash.
The rules for asset names, uploads and checks are on the
[release process](release-process.md#large-files-management) page.
## Scrapers