mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-12 21:13:49 -06:00
chore: merge site and docs batch
This commit is contained in:
commit
4ee0fd67a2
48 files changed
+1366
-332
No files matched your search
+2
-11
@@ -53,11 +53,9 @@ Usage:
|
||||
python scripts/campaign.py findings [--all] [--json]
|
||||
python scripts/campaign.py review KEY --status applied|rejected --reason TEXT [--set FIELD=YAML]
|
||||
python scripts/campaign.py gate
|
||||
python scripts/campaign.py install-timer [--enable]
|
||||
|
||||
install-timer is opt-in: a contributor who wants a schedule on their own
|
||||
machine installs the timer. It writes the units and enables nothing unless
|
||||
--enable is passed.
|
||||
A maintainer starts each pass for a question that names its profiles: no
|
||||
job runs it on a schedule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -1941,18 +1939,11 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
help="with --status applied, a field to write into the entry")
|
||||
review_p.add_argument("--index", type=int, help="the entry's position when several share its name")
|
||||
sub.add_parser("gate", help="tests, schemas, verify and pack of the changed profiles")
|
||||
timer_p = sub.add_parser("install-timer",
|
||||
help="opt-in: write the autorun units, the campaign job's timer among them")
|
||||
timer_p.add_argument("--enable", action="store_true",
|
||||
help="also reload systemd and enable the timers (never by default)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
paths = Paths.default()
|
||||
if args.command == "gate":
|
||||
return gate(paths.root, changed_profiles(paths.root))
|
||||
if args.command == "install-timer":
|
||||
command = [sys.executable, str(ROOT / "scripts" / "autorun.py"), "install-timers"]
|
||||
return subprocess.run(command + (["--enable"] if args.enable else []), check=False).returncode
|
||||
if args.command == "review":
|
||||
updates: dict[str, object] = {}
|
||||
for item in args.set:
|
||||
|
||||
@@ -1242,8 +1242,10 @@ def _check_installer_pins() -> Finding:
|
||||
|
||||
def check_source_paths(profiles: dict) -> Finding:
|
||||
"""The site links a cited path only where source-paths.json records it
|
||||
in the tree the profile pins. A recorded tree no profile pins any more
|
||||
means a pin moved and `sitesources.py --fetch` was not run after it."""
|
||||
in the tree the profile pins. A recorded tree no profile pins any more,
|
||||
or a pinned tree the record lacks, means a pin moved or a profile was
|
||||
added and `sitesources.py --fetch` was not run after it. Every tree a run
|
||||
tries is recorded, read or not, so a lacking one was never tried."""
|
||||
import sitesources # noqa: PLC0415
|
||||
|
||||
pinned = {
|
||||
@@ -1256,14 +1258,18 @@ def check_source_paths(profiles: dict) -> Finding:
|
||||
except (OSError, ValueError) as exc:
|
||||
return Finding("profiles", "source-paths.json", ERROR, detail=str(exc))
|
||||
orphaned = sorted(recorded - pinned)
|
||||
unrecorded = sorted(pinned - recorded)
|
||||
problems = [f"{len(found)} {words}" for found, words in (
|
||||
(orphaned, "recorded trees no profile pins"),
|
||||
(unrecorded, "pinned trees not recorded"),
|
||||
) if found]
|
||||
return Finding(
|
||||
"profiles",
|
||||
"source-paths.json",
|
||||
STALE if orphaned else OK,
|
||||
STALE if problems else OK,
|
||||
local=f"{len(recorded)} trees",
|
||||
detail=(f"{len(orphaned)} recorded trees no profile pins: run "
|
||||
"scripts/sitesources.py --fetch") if orphaned
|
||||
else "every recorded tree is a pinned one",
|
||||
detail=(", ".join(problems) + ": run scripts/sitesources.py --fetch") if problems
|
||||
else "every pinned tree is recorded, and only those",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ from hashing import parse_hash_list
|
||||
from validation import (
|
||||
case_folded_slots,
|
||||
declaration_settles,
|
||||
inner_rom_check,
|
||||
installed_with_emulator,
|
||||
outside_gap_scope,
|
||||
read_from_save_dir,
|
||||
@@ -476,7 +477,14 @@ class FileConsumers:
|
||||
entry, self._db, self._zip_contents, dest_hint=dest,
|
||||
data_dir_registry=self._data_dir_registry,
|
||||
)
|
||||
if not path or status in ("not_found", "hash_mismatch"):
|
||||
if not path or status == "not_found":
|
||||
return None
|
||||
# Batocera pins naomi2.zip by the md5 of a ROM inside it: the resolver
|
||||
# hands back the archive as a mismatch, and the member settles it as
|
||||
# the builder does.
|
||||
if status == "hash_mismatch" and not (
|
||||
entry.get("zipped_file") and inner_rom_check(entry, path) == "ok"
|
||||
):
|
||||
return None
|
||||
return self._sha1_by_path.get(path)
|
||||
|
||||
|
||||
+18
-16
@@ -34,6 +34,7 @@ from common import (
|
||||
unique_emulator_profiles,
|
||||
write_if_changed,
|
||||
)
|
||||
from nativemode import check_label
|
||||
import release_record
|
||||
from siteguides import install_command
|
||||
from verify import verify_platform
|
||||
@@ -534,11 +535,10 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
|
||||
emulator_count = len(profiles)
|
||||
comp = compute_composition(db)
|
||||
missing_total = len(set().union(*(c["missing_names"] for c in coverages.values())))
|
||||
# "Not in the collection yet" implies somebody could still put it there.
|
||||
# Most of this figure cannot be: per-user registration keys, slots the
|
||||
# emulator expects the user to fill, dumps nobody has made. Counting those
|
||||
# beside genuinely acquirable files overstates what is actionable, so the
|
||||
# two are reported apart.
|
||||
# The entries marked `unsourceable:` are never served by name, so a
|
||||
# homonym cannot take their place: retail game data, per-user keys, slots
|
||||
# the user fills. Each stays an acquisition target with its reason, and
|
||||
# is counted apart from the files still to be found.
|
||||
unsourceable_total = len(
|
||||
set().union(*(c["unsourceable_names"] for c in coverages.values()))
|
||||
)
|
||||
@@ -667,9 +667,10 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
|
||||
"",
|
||||
(
|
||||
f"- **{acquirable_total:,} files** the platforms' emulators load"
|
||||
f" are still to be found, and {unsourceable_total:,} more cannot"
|
||||
f" be sourced at all (per-user keys, user-filled slots, dumps"
|
||||
f" nobody has made). Both are named in the"
|
||||
f" are still to be found, and {unsourceable_total:,} more the"
|
||||
f" collection does not hold and does not serve by name (retail"
|
||||
f" data, per-user keys, user-filled slots), each with its reason."
|
||||
f" Both are named in the"
|
||||
f" [gap analysis]({SITE_URL}gaps/)"
|
||||
if missing_total
|
||||
else "- **Nothing missing**: every file the platforms'"
|
||||
@@ -737,15 +738,9 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
|
||||
]
|
||||
)
|
||||
|
||||
mode_labels = {
|
||||
"md5": "MD5 hash",
|
||||
"sha1": "SHA1 hash",
|
||||
"existence": "file presence",
|
||||
}
|
||||
|
||||
for name, cov in sorted(coverages.items(), key=lambda x: x[1]["platform"]):
|
||||
display = f"{cov['platform']} *" if name in archived else cov["platform"]
|
||||
checked = mode_labels.get(cov["mode"], cov["mode"])
|
||||
checked = check_label(cov["mode"], cov["config"].get("hash_rule"))
|
||||
core_total = (
|
||||
cov["core_present"] + cov["core_missing"] + cov["core_unsourceable"]
|
||||
)
|
||||
@@ -793,6 +788,9 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
|
||||
"# Firmware only: the game data a list or a core names is left out, each file named",
|
||||
"python scripts/generate_pack.py --platform emuelec --no-game-data",
|
||||
"",
|
||||
"# The bare minimum: only the files each core needs to start",
|
||||
"python scripts/generate_pack.py --platform batocera --required-only",
|
||||
"",
|
||||
"# List available emulators and systems",
|
||||
"python scripts/generate_pack.py --list-emulators",
|
||||
"python scripts/generate_pack.py --list-systems",
|
||||
@@ -973,12 +971,16 @@ the checks CI runs on a pull request (Python 3.12 or newer):
|
||||
pip install pyyaml "jsonschema[format-nongpl]" markdown
|
||||
python scripts/validate_pr.py --changed # the BIOS files the branch adds
|
||||
python scripts/validate_schemas.py --source-only # profiles and platform files
|
||||
python scripts/profile_sync.py --changed --strict # each profile the branch adds or edits
|
||||
python scripts/profile_sync.py --added --base upstream/main --strict # each profile the branch adds
|
||||
python scripts/refresh_data_dirs.py # data directories the pack-building tests read
|
||||
python scripts/generate_site.py # the pages the site tests read
|
||||
python -m unittest discover -s tests -t .
|
||||
```
|
||||
|
||||
`upstream` is this repository as a remote of your fork
|
||||
(`git remote add upstream https://github.com/Abdess/retrobios`). A profile you
|
||||
only edit is not held to `--strict`: its older refs are reviewed by hand.
|
||||
|
||||
The full pipeline is not needed for a contribution: it rebuilds every pack
|
||||
in `dist/`, which is why maintainers run it before a release.
|
||||
|
||||
|
||||
+280
-64
@@ -64,7 +64,7 @@ from common import (
|
||||
from artifacts import ArtifactLockBusy, artifact_lock
|
||||
from artifacts import json_text as _json_text
|
||||
from cross_reference import FileConsumers, held_copy
|
||||
from nativemode import digest_algorithm, reads_file_contents
|
||||
from nativemode import check_label, digest_algorithm, reads_file_contents
|
||||
|
||||
|
||||
|
||||
@@ -1664,6 +1664,7 @@ def generate_data_exports(
|
||||
),
|
||||
})
|
||||
write_if_changed(str(downloads / "retrobios.dat"), collection_dat(db, generated_at))
|
||||
platform_dats = _write_platform_dats(downloads / "dat", db, members, generated_at)
|
||||
write_if_changed(
|
||||
str(downloads / "files.csv"),
|
||||
_csv_document(
|
||||
@@ -1741,6 +1742,7 @@ def generate_data_exports(
|
||||
(downloads / "files.csv", "File hashes CSV", "text/csv", None),
|
||||
(downloads / "retrobios.dat", "Collection DAT (Logiqx XML, for clrmamepro and RomVault)",
|
||||
"application/xml", None),
|
||||
*((path, f"Setup DAT: {path.stem}", "application/xml", None) for path in platform_dats),
|
||||
(downloads / "platform-files.csv", "Platform declarations CSV", "text/csv", None),
|
||||
(downloads / "cross-reference.csv", "Cross-reference CSV", "text/csv", None),
|
||||
(downloads / "gaps.csv", "Verification and coverage gaps CSV", "text/csv", None),
|
||||
@@ -1847,6 +1849,15 @@ def _publish_manifests(api: Path) -> list[tuple]:
|
||||
_record(targets / source.name)
|
||||
assets.append((targets / source.name, f"Target list: {source.stem}",
|
||||
"application/json", "schemas/target-manifest.schema.json"))
|
||||
# Every change each updated platform file carries, for a maintainer who
|
||||
# reviews them by script: the Exports page linked only the files.
|
||||
exports = api / "exports"
|
||||
exports.mkdir(parents=True, exist_ok=True)
|
||||
for source in sorted((_REPO_ROOT / "exports").glob("*.json")):
|
||||
shutil.copy2(source, exports / source.name)
|
||||
_record(exports / source.name)
|
||||
assets.append((exports / source.name, f"Updated platform file, its changes: {source.stem}",
|
||||
"application/json", None))
|
||||
release = _REPO_ROOT / "release.json"
|
||||
if release.is_file():
|
||||
shutil.copy2(release, api / "release.json")
|
||||
@@ -1979,6 +1990,14 @@ def _is_target_list(item: dict) -> bool:
|
||||
return item["url"].startswith("api/v1/targets/")
|
||||
|
||||
|
||||
def _is_setup_dat(item: dict) -> bool:
|
||||
return item["url"].startswith("downloads/dat/")
|
||||
|
||||
|
||||
def _is_export_report(item: dict) -> bool:
|
||||
return item["url"].startswith("api/v1/exports/")
|
||||
|
||||
|
||||
def _served_schemas() -> list[tuple[str, str]]:
|
||||
"""(file name, title) of every schema api/v1/schemas/ serves, from the folder
|
||||
the export copies: the page linked 7 of 11."""
|
||||
@@ -2117,7 +2136,7 @@ def generate_data_page(exports: list[dict], measurements_sqlite: Path | None = N
|
||||
]
|
||||
for item in exports:
|
||||
if (item["media_type"] != "application/json" or _is_manifest(item)
|
||||
or _is_target_list(item)):
|
||||
or _is_target_list(item) or _is_export_report(item)):
|
||||
continue
|
||||
schema = (
|
||||
f"[JSON Schema](api/v1/{item['schema']})"
|
||||
@@ -2224,6 +2243,10 @@ def generate_data_page(exports: list[dict], measurements_sqlite: Path | None = N
|
||||
f"[{item['url'].rsplit('/', 1)[-1].removesuffix('.json')}]({item['url']})"
|
||||
for item in targets) + "." if targets else "",
|
||||
"",
|
||||
"`api/v1/exports/<platform>.json` lists every change of a setup's "
|
||||
"[updated file](exports.md), the report that page is written from, copied "
|
||||
"byte for byte like the manifests.",
|
||||
"",
|
||||
"| Field | Meaning |",
|
||||
"|-------|---------|",
|
||||
*_schema_field_rows(manifest_schema, MANIFEST_TOP_FIELDS),
|
||||
@@ -2287,7 +2310,7 @@ def generate_data_page(exports: list[dict], measurements_sqlite: Path | None = N
|
||||
"|--------|--------|-----:|--------|",
|
||||
])
|
||||
for item in exports:
|
||||
if item["media_type"] == "application/json":
|
||||
if item["media_type"] == "application/json" or _is_setup_dat(item):
|
||||
continue
|
||||
lines.append(
|
||||
f"| [{item['title']}]({item['url']}) | `{item['media_type']}` | "
|
||||
@@ -2302,8 +2325,14 @@ def generate_data_page(exports: list[dict], measurements_sqlite: Path | None = N
|
||||
"per `bios/<Manufacturer>/<Console>/` folder of the collection, named "
|
||||
"`Manufacturer\\Console` so the tools look in that subfolder, each file a "
|
||||
"rom named by its path below that folder. It audits a copy of the "
|
||||
"collection's `bios/` tree, or rebuilds that layout from your own dumps. A "
|
||||
"platform pack has its own layout and is checked with `install.py --check`.",
|
||||
"collection's `bios/` tree, or rebuilds that layout from your own dumps.",
|
||||
"",
|
||||
"A setup's DAT, `downloads/dat/<platform>.dat`, describes its BIOS folder "
|
||||
"once installed: one game named after the folder (`bios`, `system`), each "
|
||||
"file a rom named by its place below it, as its manifest lists it. It audits "
|
||||
"that folder with the same tools. Setup DATs: " + ", ".join(
|
||||
f"[{item['url'].rsplit('/', 1)[-1].removesuffix('.dat')}]({item['url']})"
|
||||
for item in exports if _is_setup_dat(item)) + ".",
|
||||
"",
|
||||
"The SQLite file contains indexed tables for content hashes, provenance "
|
||||
"catalog matches, platform declarations, emulator profiles and current "
|
||||
@@ -2611,8 +2640,6 @@ def collection_dat(db: dict, generated_at: str) -> str:
|
||||
folder, subfolders joined with a backslash as Logiqx DATs write them. An auditor checking a BIOS tree against the
|
||||
collection had JSON, CSV and SQLite, and a converter to write.
|
||||
"""
|
||||
import xml.etree.ElementTree as ET # noqa: PLC0415
|
||||
|
||||
games: dict[str, list[dict]] = {}
|
||||
files = db.get("files", {})
|
||||
# Every copy, not the one path the database keeps: an audit of bios/
|
||||
@@ -2628,12 +2655,64 @@ def collection_dat(db: dict, generated_at: str) -> str:
|
||||
games.setdefault("\\".join(folder), []).append(
|
||||
{**files[sha1], "_rom": "\\".join(parts[1 + len(folder):])}
|
||||
)
|
||||
return _logiqx(SITE_NAME, f"{SITE_NAME} collection ({sum(map(len, games.values()))} files)",
|
||||
games, generated_at)
|
||||
|
||||
|
||||
def _write_platform_dats(folder: Path, db: dict, members: dict, generated_at: str) -> list[Path]:
|
||||
"""downloads/dat/<platform>.dat for each install manifest."""
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
digests = _data_member_digests(members)
|
||||
written = []
|
||||
for source in sorted((_REPO_ROOT / "install").glob("*.json")):
|
||||
manifest = json.loads(source.read_text(encoding="utf-8"))
|
||||
path = folder / f"{source.stem}.dat"
|
||||
write_if_changed(str(path), platform_dat(manifest, db, digests, generated_at))
|
||||
written.append(path)
|
||||
return written
|
||||
|
||||
|
||||
def platform_dat(manifest: dict, db: dict, member_digests: dict[str, dict[str, str]],
|
||||
generated_at: str) -> str:
|
||||
"""What one setup's BIOS folder holds once installed, as a Logiqx DAT.
|
||||
|
||||
One game named after the folder (`bios`, `system`), each file a rom
|
||||
named by its place below it, as the install manifest writes it: the
|
||||
files of the setup's list and those its cores load, and the members of
|
||||
its data directories. A Batocera user auditing share/bios against the
|
||||
collection DAT met thousands of sets laid out by manufacturer.
|
||||
"""
|
||||
files = db.get("files", {})
|
||||
roms: list[dict] = []
|
||||
for entry in manifest.get("files") or []:
|
||||
if entry.get("root") == "save":
|
||||
continue # installed in the save directory, not this folder
|
||||
sha1 = str(entry.get("sha1", ""))
|
||||
roms.append({**files.get(sha1, {}), "sha1": sha1, "size": entry.get("size", 0),
|
||||
"_rom": str(entry["dest"]).replace("/", "\\")})
|
||||
for tree in manifest.get("data_directories") or []:
|
||||
dest = str(tree.get("dest") or "").strip("/")
|
||||
for member, info in (tree.get("files") or {}).items():
|
||||
sha1 = str(info.get("sha1", ""))
|
||||
path = f"{dest}/{member}" if dest else member
|
||||
roms.append({**member_digests.get(sha1, {}), "sha1": sha1,
|
||||
"size": info.get("size", 0), "_rom": path.replace("/", "\\")})
|
||||
folder = str(manifest.get("base_destination") or "bios").strip("/").rsplit("/", 1)[-1]
|
||||
name = f"{SITE_NAME} - {manifest.get('display_name') or manifest['platform']}"
|
||||
unique = list({rom["_rom"]: rom for rom in reversed(roms)}.values())
|
||||
return _logiqx(name, f"{name} ({len(unique)} files)", {folder: unique}, generated_at)
|
||||
|
||||
|
||||
def _logiqx(name: str, description: str, games: dict[str, list[dict]], generated_at: str) -> str:
|
||||
"""Games of roms as a Logiqx datafile, each rom's `_rom` its name."""
|
||||
import xml.etree.ElementTree as ET # noqa: PLC0415
|
||||
|
||||
root = ET.Element("datafile")
|
||||
header = ET.SubElement(root, "header")
|
||||
version = generated_at[:10].replace("-", "")
|
||||
for tag, text in (
|
||||
("name", SITE_NAME),
|
||||
("description", f"{SITE_NAME} collection ({sum(map(len, games.values()))} files)"),
|
||||
("name", name),
|
||||
("description", description),
|
||||
("version", version),
|
||||
("date", generated_at[:10]),
|
||||
("author", SITE_NAME),
|
||||
@@ -3194,8 +3273,6 @@ def generate_platform_index(coverages: dict, registry: dict | None = None) -> st
|
||||
SETUP_TABLE_PAGE = "wiki/verification-modes.md"
|
||||
SETUP_TABLE_ANCHOR = "every-setup-at-a-glance"
|
||||
SETUP_TABLE_LINK = f"{SETUP_TABLE_PAGE}#{SETUP_TABLE_ANCHOR}"
|
||||
# How a setup checks a file, in the words of a reader.
|
||||
_HOW_IT_CHECKS = {"existence": "name only", "md5": "MD5", "sha1": "SHA-1"}
|
||||
|
||||
|
||||
def setup_table(coverages: dict, registry: dict | None = None) -> list[str]:
|
||||
@@ -3220,7 +3297,7 @@ def setup_table(coverages: dict, registry: dict | None = None) -> list[str]:
|
||||
lines.append(
|
||||
f"| [{cov['platform']}](../platforms/{name}.md)"
|
||||
f"{' (archived)' if status == 'archived' else ''} | {listed} | {zipped} | "
|
||||
f"{_HOW_IT_CHECKS.get(cov['mode'], cov['mode'])} |"
|
||||
f"{check_label(cov['mode'], cov['config'].get('hash_rule'))} |"
|
||||
)
|
||||
lines += [
|
||||
"",
|
||||
@@ -3263,18 +3340,49 @@ PLATFORM_PART_BUDGET = 700
|
||||
PLATFORM_REFERENCE_BUDGET = 600
|
||||
|
||||
|
||||
# Every part page a platform list has had, with the system it held:
|
||||
# platforms/batocera-atari-st.md -> platforms/batocera.md#atari-st. When a
|
||||
# budget or a list changes, a part can fold back into its platform page,
|
||||
# and its address then redirects there instead of ending on the 404 page.
|
||||
PLATFORM_PARTS_FILE = (Path(__file__).resolve().parent.parent
|
||||
/ "website" / "src" / "data" / "platform-parts.json")
|
||||
# Every part page the site has published, with the page it belongs to:
|
||||
# platforms/batocera-atari-st.md -> platforms/batocera.md#atari-st,
|
||||
# gaps-files-to-source-7.md -> gaps.md. When a budget or a list changes, a
|
||||
# part can fold back or the list shrink, and its address then redirects to
|
||||
# that page instead of ending on the 404 page.
|
||||
PUBLISHED_PARTS_FILE = (Path(__file__).resolve().parent.parent
|
||||
/ "website" / "src" / "data" / "published-parts.json")
|
||||
|
||||
|
||||
def _published_platform_parts() -> dict[str, str]:
|
||||
if not PLATFORM_PARTS_FILE.is_file():
|
||||
def _published_parts() -> dict[str, str]:
|
||||
if not PUBLISHED_PARTS_FILE.is_file():
|
||||
return {}
|
||||
return json.loads(PLATFORM_PARTS_FILE.read_text(encoding="utf-8"))
|
||||
return json.loads(PUBLISHED_PARTS_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _parent_page(link: str) -> str:
|
||||
"""The page a `parent:` link names (/platforms/batocera/), under docs/."""
|
||||
path, _, fragment = link.partition("#")
|
||||
# Written with the site's base (/retrobios/emulators/x/) or without it.
|
||||
path = path.removeprefix(sitestarlight.BASE.rstrip("/"))
|
||||
stem = path.strip("/") or "index"
|
||||
return f"{stem}.md" + (f"#{fragment}" if fragment else "")
|
||||
|
||||
|
||||
def record_parts(docs: Path, present: set[str], published: dict[str, str]) -> dict[str, str]:
|
||||
"""``published`` with every page of this run that names its parent.
|
||||
|
||||
Only platform parts were recorded: gaps-files-to-source-7 or
|
||||
mame-files-10, gone once their list shrank, ended on the 404 page. A
|
||||
part already recorded keeps its target, which may name a section.
|
||||
"""
|
||||
found = dict(published)
|
||||
for rel in sorted(present):
|
||||
page = docs / rel
|
||||
if not page.is_file():
|
||||
page = page.with_suffix(".mdx")
|
||||
try:
|
||||
parent = _front_matter(page.read_text(encoding="utf-8")).get("parent")
|
||||
except OSError:
|
||||
continue
|
||||
if isinstance(parent, dict) and parent.get("link") and rel not in found:
|
||||
found[rel] = _parent_page(str(parent["link"]))
|
||||
return dict(sorted(found.items()))
|
||||
|
||||
|
||||
# A list moved off a reference page (gaps, provenance) is cut in parts of
|
||||
@@ -3406,20 +3514,14 @@ def _served_line(f: dict, declared_sha1: str = "") -> str:
|
||||
return link if sha1 in declared else f"{link} (sha1 `{sha1[:8]}`)"
|
||||
|
||||
|
||||
# Platforms whose launcher records are read from the repository and revision
|
||||
# of their BIOS list: batocera_scraper reads configgen at its tag,
|
||||
# retrobat_scraper reads emulatorLauncher at the list's commit
|
||||
# (launcher_revision). The others read another repository.
|
||||
_LAUNCHERS_IN_LIST_REPO = frozenset({"batocera", "retrobat"})
|
||||
|
||||
|
||||
def _launcher_refs(platform: str, config: dict) -> dict[str, list[str]]:
|
||||
def _launcher_refs(config: dict) -> dict[str, list[str]]:
|
||||
"""Entry name, casefolded -> each launcher record that opens it, rendered."""
|
||||
from launchers import _names as launcher_names
|
||||
from sitesources import launcher_ref_markdown, list_source
|
||||
|
||||
source = (list_source(str(config.get("source") or ""))
|
||||
if platform in _LAUNCHERS_IN_LIST_REPO else None)
|
||||
# Every scraper reads its launchers in the repository and at the revision
|
||||
# of the list it scrapes, so `source` pins each source_ref.
|
||||
source = list_source(str(config.get("source") or ""))
|
||||
found: dict[str, list[str]] = {}
|
||||
for record in config.get("launchers") or []:
|
||||
if not isinstance(record, dict) or not record.get("source_ref"):
|
||||
@@ -3436,21 +3538,23 @@ def _launcher_refs(platform: str, config: dict) -> dict[str, list[str]]:
|
||||
# The hash a row shows first, when the list does not say which it checks.
|
||||
_ROW_HASHES = ("sha1", "md5", "sha256", "crc32")
|
||||
# An untested entry whose reason is a hash the bytes do not match.
|
||||
_WRONG_HASH = re.compile(r"hash|mismatch|expected|must match", re.IGNORECASE)
|
||||
_OTHER_HASH = re.compile(r"hash|mismatch|expected|must match", re.IGNORECASE)
|
||||
# A list this long gets a filter box above it, as a table of RECORD_ROWS
|
||||
# rows does (website/src/plugins/rehype-stack-tables.mjs).
|
||||
FILTER_ROWS = 20
|
||||
|
||||
|
||||
def _status_chip(f: dict) -> str:
|
||||
"""The row's status as a chip, its word and its colour saying the same."""
|
||||
"""The row's status as a chip, its word and its colour saying the same. A
|
||||
declared hash the held copy does not have is "Other hash": the two differ,
|
||||
which says nothing of whose is right (a platform may pin its own bundle)."""
|
||||
status = f.get("status")
|
||||
if status == "ok":
|
||||
word, tone = "OK", "success"
|
||||
elif status == "missing":
|
||||
word, tone = "Missing", "danger"
|
||||
elif status == "untested":
|
||||
word = "Wrong hash" if _WRONG_HASH.search(str(f.get("reason") or "")) else "Untested"
|
||||
word = "Other hash" if _OTHER_HASH.search(str(f.get("reason") or "")) else "Untested"
|
||||
tone = "warning"
|
||||
else:
|
||||
word, tone = str(status), "muted"
|
||||
@@ -3631,7 +3735,7 @@ def _render_platform_systems(
|
||||
# Per-system detail sections (collapsible for large platforms)
|
||||
use_collapsible = len(by_system) > 10
|
||||
by_system_declared = _declared_by_system(config_files)
|
||||
launched = _launcher_refs(platform[0] if platform else "", config or {})
|
||||
launched = _launcher_refs(config or {})
|
||||
|
||||
sections: list[dict] = []
|
||||
for sys_id, files in sorted(by_system.items()):
|
||||
@@ -3760,7 +3864,10 @@ def _platform_data_links(name: str) -> str:
|
||||
f"[JSON](../api/v1/platforms/{name}.json)",
|
||||
]
|
||||
if (_REPO_ROOT / "install" / f"{name}.json").is_file():
|
||||
links.append(f"[install manifest]({REPO_URL}/blob/main/install/{name}.json)")
|
||||
# The site's own copy, byte for byte (_publish_manifests).
|
||||
links.append(f"[install manifest](../api/v1/manifests/{name}.json)")
|
||||
# The installed BIOS folder for clrmamepro and RomVault.
|
||||
links.append(f"[DAT](../downloads/dat/{name}.dat)")
|
||||
return ", ".join(links)
|
||||
|
||||
|
||||
@@ -3978,6 +4085,32 @@ def _network_share(name: str, guide: Guide) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _share_tabs(name: str, share: str) -> tuple[dict[str, str], dict[str, str]]:
|
||||
"""The command per computer for a network folder (\\\\RECALBOX\\share\\bios),
|
||||
and how a Mac or a Linux desktop opens that folder first. Only Windows
|
||||
had a line: a Mac user fell back to SSH or to taking the card out.
|
||||
|
||||
macOS mounts smb://recalbox/share at /Volumes/share. A Linux desktop
|
||||
mounts it where its file manager chooses, so that tab names the folder.
|
||||
"""
|
||||
host, _, rest = share.lstrip("\\").partition("\\")
|
||||
volume, _, below = rest.partition("\\")
|
||||
url = f"smb://{host.lower()}/{volume}"
|
||||
tail = "/" + below.replace("\\", "/") if below else ""
|
||||
args = {
|
||||
"windows": f"--platform {name} --dest {share}",
|
||||
"macos": f"--platform {name} --dest /Volumes/{volume}{tail}",
|
||||
"linux": f"--platform {name} --dest /path/to/{volume}{tail}",
|
||||
}
|
||||
how = {
|
||||
"macos": (f"In Finder, Go > Connect to Server: `{url}`. Then paste the line in "
|
||||
"**Terminal**."),
|
||||
"linux": (f"Open `{url}` in your file manager. Paste the line in a terminal, "
|
||||
f"with the folder it opened for `/path/to/{volume}`."),
|
||||
}
|
||||
return args, how
|
||||
|
||||
|
||||
def _offline_line(name: str) -> list[str]:
|
||||
"""The offline line, on a setup whose ZIP a release publishes: the
|
||||
installer reads that ZIP with no network (`--from-pack`)."""
|
||||
@@ -4038,10 +4171,16 @@ def _setup_routes(name: str, display: str, page_link: bool = False) -> list[str]
|
||||
if share or (card and guide.run == ("device",)):
|
||||
computer = ["## From your computer { #from-your-computer }", ""]
|
||||
if share:
|
||||
args, how = _share_tabs(name, share)
|
||||
# The setup page stays in its word budget: its Mac and Linux
|
||||
# lines are on the get/ page, the one the command is shared from.
|
||||
tabs = ("windows",) if page_link else ("windows", "macos", "linux")
|
||||
computer += [
|
||||
"Needs the device on your network. Run the command on the computer.",
|
||||
"",
|
||||
*terminal_tabs(("windows",), {"windows": f"--platform {name} --dest {share}"}),
|
||||
*terminal_tabs(tabs, args, how=how),
|
||||
*([f"[Mac or Linux](../{get_page_link(name)}#from-your-computer)", ""]
|
||||
if page_link else []),
|
||||
SAFE_LINK,
|
||||
"",
|
||||
]
|
||||
@@ -4295,9 +4434,8 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list:
|
||||
return lines
|
||||
|
||||
|
||||
# How a platform checks a file, in words.
|
||||
_CHECK_WORDS = {"existence": "name only", "md5": "MD5", "sha1": "SHA-1", "sha256": "SHA-256",
|
||||
"crc32": "CRC32"}
|
||||
# A hash a platform's list writes, in words.
|
||||
_HASH_WORDS = {"md5": "MD5", "sha1": "SHA-1", "sha256": "SHA-256", "crc32": "CRC32"}
|
||||
|
||||
|
||||
def generate_platform_page(
|
||||
@@ -4377,7 +4515,7 @@ def generate_platform_page(
|
||||
*_stat(f"{cov['present']}/{cov['total']}", f"Coverage ({pct})"),
|
||||
*_stat(str(cov["verified"]), "Verified"),
|
||||
*_stat(str(cov["missing"]), "Missing"),
|
||||
*_stat(f'<span class="rb-badge {mode_badge}">{_CHECK_WORDS.get(mode, mode)}</span>',
|
||||
*_stat(f'<span class="rb-badge {mode_badge}">{check_label(mode, config.get("hash_rule"))}</span>',
|
||||
"Checked by"),
|
||||
"</div>",
|
||||
"",
|
||||
@@ -4387,7 +4525,7 @@ def generate_platform_page(
|
||||
# The hash the list writes, when it is not the one the platform checks:
|
||||
# RetroArch checks presence and its System.dat writes sha1.
|
||||
if hash_type and hash_type != mode:
|
||||
lines.append(f"| Hashes in the list | {_CHECK_WORDS.get(hash_type, hash_type)} |")
|
||||
lines.append(f"| Hashes in the list | {_HASH_WORDS.get(hash_type, hash_type)} |")
|
||||
if version:
|
||||
lines.append(f"| Version | {version} |")
|
||||
# base_destination is only the root the platform's own list writes its
|
||||
@@ -4524,13 +4662,15 @@ def generate_files_page() -> str:
|
||||
"Tools such as clrmamepro and RomVault print one, and so do emulator logs "
|
||||
"and the BIOS lists of setups.",
|
||||
"",
|
||||
"Paste it below. The page shows the file in the collection that has it, the "
|
||||
"setups that list it and the emulators that check for it.",
|
||||
"Paste it below, alone or in the line md5sum or certutil prints. The page "
|
||||
"shows the file in the collection that has it, the setups that list it and "
|
||||
"the emulators that check for it.",
|
||||
"",
|
||||
'<form class="rb-file-lookup" action="./" method="get">',
|
||||
'<label for="rb-hash">SHA1, MD5, CRC32 or SHA256</label> ',
|
||||
# No pattern: the line md5sum or certutil prints is taken whole, and
|
||||
# the page reads the hash out of it.
|
||||
'<input id="rb-hash" name="hash" size="66" autocomplete="off" spellcheck="false" '
|
||||
'pattern="\\s*(0x)?([0-9a-fA-F]{8}|[0-9a-fA-F]{32}|[0-9a-fA-F]{40}|[0-9a-fA-F]{64})\\s*" '
|
||||
'required> ',
|
||||
'<button type="submit">Find</button>',
|
||||
"</form>",
|
||||
@@ -4681,6 +4821,34 @@ def _console_system_names(profiles: dict, coverages: dict) -> dict[tuple[str, st
|
||||
return {key: next(iter(names)) for key, names in found.items() if len(names) == 1}
|
||||
|
||||
|
||||
def _data_member_digests(
|
||||
members: dict, data_registry: dict | None = None, root: Path = _REPO_ROOT,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""sha1 -> every hash the lookup takes, for each data directory member
|
||||
whose copy in the tree's local cache has the sha1 the manifest records.
|
||||
The manifests carry sha1 alone: the MD5 of Dolphin's ApprovedInis.json
|
||||
found nothing although the pack ships it."""
|
||||
from common import load_data_dir_registry # noqa: PLC0415
|
||||
from hashing import compute_hashes # noqa: PLC0415
|
||||
|
||||
if data_registry is None:
|
||||
data_registry = load_data_dir_registry(str(root / "platforms"))
|
||||
found: dict[str, dict[str, str]] = {}
|
||||
for ref, tree in sorted(members.items()):
|
||||
cache = (data_registry.get(ref) or {}).get("local_cache")
|
||||
if not cache:
|
||||
continue
|
||||
for member, info in sorted(tree.items()):
|
||||
sha1 = str(info.get("sha1", ""))
|
||||
path = root / cache / member
|
||||
if not sha1 or sha1 in found or not path.is_file():
|
||||
continue
|
||||
digests = compute_hashes(path, frozenset(HASH_ALGORITHMS))
|
||||
if digests.get("sha1") == sha1:
|
||||
found[sha1] = {algo: digests[algo].lower() for algo in HASH_ALGORITHMS}
|
||||
return found
|
||||
|
||||
|
||||
def file_lookup_records(
|
||||
db: dict,
|
||||
coverages: dict,
|
||||
@@ -4702,15 +4870,19 @@ def file_lookup_records(
|
||||
str(info.get("sha1", "")): member.rsplit("/", 1)[-1]
|
||||
for tree in (members or {}).values() for member, info in sorted(tree.items())
|
||||
}
|
||||
member_digests = _data_member_digests(members or {})
|
||||
records: list[dict] = []
|
||||
for item in locations:
|
||||
sha1 = item["sha1"]
|
||||
entry = files.get(sha1)
|
||||
if entry is None:
|
||||
# A data directory member: the manifests record its sha1 only.
|
||||
records.append({"value": sha1, "algorithm": "sha1", "layer": "data-directory",
|
||||
"name": member_names.get(sha1, ""), "sha1": sha1,
|
||||
"url": item["url"]})
|
||||
# A data directory member: the manifests record its sha1, the
|
||||
# tree on disk the other three.
|
||||
digests = member_digests.get(sha1) or {"sha1": sha1}
|
||||
records.extend({"value": digests[algorithm], "algorithm": algorithm,
|
||||
"layer": "data-directory", "name": member_names.get(sha1, ""),
|
||||
"sha1": sha1, "url": item["url"]}
|
||||
for algorithm in HASH_ALGORITHMS if algorithm in digests)
|
||||
continue
|
||||
parts = str(entry.get("path", "")).split("/")
|
||||
held_by = named.get((_slug(parts[1]), parts[2])) if len(parts) > 3 else None
|
||||
@@ -4726,12 +4898,17 @@ def file_lookup_records(
|
||||
url = _site_path(f"platforms/{key}.md#{system_id.replace(' ', '-')}")
|
||||
system_label = _lookup_system_name(system_id)
|
||||
for entry in system.get("files") or []:
|
||||
# A hash of a ROM inside the archive (zipped_file) designates
|
||||
# no collection file by itself: the archive holding it is the one.
|
||||
archive = (consumers.sha1_of(entry)
|
||||
if consumers is not None and entry.get("zipped_file") else None)
|
||||
for algorithm in HASH_ALGORITHMS:
|
||||
for value in _hash_values(entry.get(algorithm)):
|
||||
records.append({
|
||||
"value": value, "algorithm": algorithm, "layer": "platform",
|
||||
"owner": key, "name": str(entry.get("name", "")), "url": url,
|
||||
**({"system_name": system_label} if system_label else {}),
|
||||
**({"sha1": archive} if archive else {}),
|
||||
})
|
||||
for key, profile in sorted(profiles.items()):
|
||||
if profile.get("type") in ("alias", "test"):
|
||||
@@ -4823,8 +5000,9 @@ def _held_under_every_hash(records: list[dict], files: dict, indexes: dict) -> l
|
||||
for record in records:
|
||||
if record["layer"] not in ("platform", "emulator"):
|
||||
continue
|
||||
sha1 = _collection_sha1(files, indexes, record["algorithm"], record["value"])
|
||||
if sha1 is None:
|
||||
sha1 = record.get("sha1") or _collection_sha1(
|
||||
files, indexes, record["algorithm"], record["value"])
|
||||
if sha1 not in files:
|
||||
continue
|
||||
record["sha1"] = sha1
|
||||
for algorithm in HASH_ALGORITHMS:
|
||||
@@ -5316,6 +5494,8 @@ def generate_provenance_page(
|
||||
"matching one of these hashes is welcome: see "
|
||||
"[Contributing](contributing.md).",
|
||||
"",
|
||||
markup.button("Download the list (CSV)", "downloads/provenance-wanted.csv"),
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -10034,6 +10214,16 @@ def _declared_hashes(config: dict, detail: dict) -> dict[str, list[str]]:
|
||||
return {}
|
||||
|
||||
|
||||
def _declared_destination(config: dict, detail: dict) -> str:
|
||||
"""Where the platform's list puts a reported file, its name when the
|
||||
entry names no folder."""
|
||||
system = (config.get("systems") or {}).get(detail.get("system", "")) or {}
|
||||
for entry in system.get("files") or []:
|
||||
if isinstance(entry, dict) and entry.get("name") == detail.get("name"):
|
||||
return str(entry.get("destination") or detail.get("name", ""))
|
||||
return str(detail.get("name", ""))
|
||||
|
||||
|
||||
def short_hash(value: str) -> str:
|
||||
"""A hash in a table cell: its first 12 characters, the whole value in
|
||||
the title. Full MD5 and SHA1 values pushed the gaps tables past the
|
||||
@@ -10093,8 +10283,13 @@ def _render_problem_files(
|
||||
entry = missing_files.setdefault(fname, {
|
||||
"name": fname, "required": p["required"],
|
||||
"platforms": [], "reason": p["reason"], "categories": set(),
|
||||
"where": {},
|
||||
})
|
||||
entry["platforms"].append(p["platform"])
|
||||
# The platform's entry for the file, and the folder it expects.
|
||||
entry["where"].setdefault(
|
||||
(p["platform"], p.get("platform_key", ""), p.get("system", "")), set(),
|
||||
).add(p.get("destination") or fname)
|
||||
entry["categories"].add(p.get("category", "bios"))
|
||||
if p["required"]:
|
||||
entry["required"] = True
|
||||
@@ -10142,7 +10337,9 @@ def _render_problem_files(
|
||||
"load, `game_data` comes from a copy of the game, `unread` is "
|
||||
"opened by none of them. Only a missing `bios` entry stops a "
|
||||
"console from starting. A name a list declares in several "
|
||||
"folders is one row.",
|
||||
"folders is one row. Each platform links to its entry, which "
|
||||
"names the cores examined, and is followed by where its list "
|
||||
"puts the file.",
|
||||
"",
|
||||
"| File | Category | Required | Platforms |",
|
||||
"|------|----------|----------|-----------|",
|
||||
@@ -10150,7 +10347,12 @@ def _render_problem_files(
|
||||
for fname in sorted(missing_files):
|
||||
f = missing_files[fname]
|
||||
req = "yes" if f["required"] else "no"
|
||||
plats = ", ".join(sorted(set(f["platforms"])))
|
||||
plats = "; ".join(
|
||||
(f"[{display}](platforms/{key}.md#{system.replace(' ', '-')})"
|
||||
if key and system else display)
|
||||
+ " " + ", ".join(_code(dest) for dest in sorted(dests))
|
||||
for (display, key, system), dests in sorted(f["where"].items())
|
||||
)
|
||||
cats = f["categories"]
|
||||
category = "bios" if "bios" in cats else ", ".join(sorted(cats))
|
||||
lines.append(f"| `{fname}` | {category} | {req} | {plats} |")
|
||||
@@ -10308,6 +10510,22 @@ def _upload_folder(profile: dict) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def reading_platforms(emu_name: str, profile: dict, name: str,
|
||||
configs: dict[str, dict]) -> set[str]:
|
||||
"""Of the platforms running an emulator (name -> config), those whose
|
||||
build of it reads the entry: BlastEm's cdbios.bin is read by the
|
||||
standalone build only, and RetroBat hands that build other names."""
|
||||
from common import platform_layouts, standalone_set # noqa: PLC0415
|
||||
from validation import filter_files_by_mode, unread_on # noqa: PLC0415
|
||||
|
||||
return {
|
||||
platform for platform, config in configs.items()
|
||||
if any(f.get("name") == name and not unread_on(f, platform)
|
||||
for view, standalone in platform_layouts(emu_name, profile, standalone_set(config))
|
||||
for f in filter_files_by_mode(view.get("files") or [], standalone))
|
||||
}
|
||||
|
||||
|
||||
def generate_gap_analysis(
|
||||
profiles: dict,
|
||||
coverages: dict,
|
||||
@@ -10373,6 +10591,7 @@ def generate_gap_analysis(
|
||||
"discrepancy": d.get("discrepancy", ""),
|
||||
"system": d.get("system", ""),
|
||||
"declared": _declared_hashes(cov.get("config") or {}, d),
|
||||
"destination": _declared_destination(cov.get("config") or {}, d),
|
||||
"path": d.get("path") or "",
|
||||
})
|
||||
|
||||
@@ -10412,12 +10631,6 @@ def generate_gap_analysis(
|
||||
"|----------|-----------------:|-------------------------:|------------|",
|
||||
])
|
||||
|
||||
mode_labels = {
|
||||
"md5": "MD5 hash",
|
||||
"sha1": "SHA1 hash",
|
||||
"existence": "file presence",
|
||||
}
|
||||
|
||||
for pname, cov in sorted(coverages.items(), key=lambda x: x[1]["platform"]):
|
||||
display = cov["platform"]
|
||||
core_total = (
|
||||
@@ -10427,7 +10640,7 @@ def generate_gap_analysis(
|
||||
f"| [{display}](platforms/{pname}.md) "
|
||||
f"| {_ratio_cell(cov['present'], cov['total'], _missing_required_firmware(cov))} "
|
||||
f"| {_ratio_cell(cov['core_present'], core_total)} "
|
||||
f"| {mode_labels.get(cov['mode'], cov['mode'])} |"
|
||||
f"| {check_label(cov['mode'], cov['config'].get('hash_rule'))} |"
|
||||
)
|
||||
lines.extend([
|
||||
"",
|
||||
@@ -10645,6 +10858,7 @@ def generate_gap_analysis(
|
||||
for emu_name in matched:
|
||||
emu_to_platforms.setdefault(emu_name, set()).add(pname)
|
||||
|
||||
|
||||
# One row per file to find: a name two emulators share is one file
|
||||
# only when both declare the same hash. Keyed by name alone, Fallout
|
||||
# 2's master.dat vanished behind Fallout 1's, and the three Sonic
|
||||
@@ -10703,7 +10917,9 @@ def generate_gap_analysis(
|
||||
key=lambda x: (not x["required"], x["name"]),
|
||||
):
|
||||
plats = sorted(set().union(
|
||||
*(emu_to_platforms.get(key, set()) for key in m["emu_keys"])
|
||||
*(reading_platforms(key, profiles[key], m["name"], {
|
||||
p: coverages[p]["config"] for p in emu_to_platforms.get(key, set())
|
||||
}) for key in m["emu_keys"])
|
||||
))
|
||||
plat_badges = (
|
||||
" ".join(
|
||||
@@ -12010,7 +12226,7 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
)
|
||||
if HANDHELD_GUIDE:
|
||||
write_if_changed(str(docs / HANDHELD_GUIDE.path), generate_handheld_guide(HANDHELD_GUIDE))
|
||||
published_parts = _published_platform_parts()
|
||||
published_parts = _published_parts()
|
||||
for name, cov in coverages.items():
|
||||
# The emulators this platform runs, not every one of the system.
|
||||
runs = resolve_platform_cores(cov["config"], profiles)
|
||||
@@ -12199,8 +12415,6 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
stale += _sweep_top_pages(docs)
|
||||
|
||||
print("Adding page metadata and structured data...")
|
||||
write_if_changed(str(PLATFORM_PARTS_FILE),
|
||||
json.dumps(dict(sorted(published_parts.items())), indent=1) + "\n")
|
||||
lastmod = decorate_markdown_pages(docs)
|
||||
|
||||
nav = generate_nav(coverages, manufacturers, profiles, registry,
|
||||
@@ -12211,6 +12425,8 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
path.relative_to(docs).with_suffix(".md").as_posix()
|
||||
for path in docs.rglob("*.md*") if path.suffix in (".md", ".mdx")
|
||||
} - {"404.md"}
|
||||
published_parts = record_parts(docs, present, published_parts)
|
||||
write_if_changed(str(PUBLISHED_PARTS_FILE), json.dumps(published_parts, indent=1) + "\n")
|
||||
generated = site / "src" / "generated"
|
||||
generated.mkdir(parents=True, exist_ok=True)
|
||||
write_if_changed(str(generated / "sidebar.json"),
|
||||
|
||||
@@ -40,6 +40,18 @@ SIZE_AND_ANY_HASH = "size_and_any_hash"
|
||||
HASH_RULES = (SIZE_AND_ANY_HASH,)
|
||||
|
||||
|
||||
#: How a platform checks a file, in a reader's words. Every table and badge
|
||||
#: that says it reads this, so a page cannot call RomM's rule an MD5 check.
|
||||
_CHECK_LABELS = {"existence": "name only", "md5": "MD5", "sha1": "SHA-1"}
|
||||
|
||||
|
||||
def check_label(mode: str | None, hash_rule: str | None = None) -> str:
|
||||
"""The words for what a platform's own check compares."""
|
||||
if hash_rule == SIZE_AND_ANY_HASH:
|
||||
return "size, then any hash"
|
||||
return _CHECK_LABELS[normalize(mode)]
|
||||
|
||||
|
||||
def normalize(mode: str | None) -> str:
|
||||
"""Return a declared mode, falling back to the schema default."""
|
||||
return mode if mode in MODES else DEFAULT_MODE
|
||||
|
||||
+20
-2
@@ -16,6 +16,21 @@ def _detect_systems(detect: list[dict]) -> list[str]:
|
||||
return [name for name in order if name in seen]
|
||||
|
||||
|
||||
def _from_pack(platform_name: str, posix: bool, windows: bool,
|
||||
dest: tuple[str, str] = ("", "")) -> list[str]:
|
||||
"""The same command, reading the files from the ZIP in hand. The
|
||||
one-liner alone downloads every file again."""
|
||||
args = f"--platform {platform_name} --from-pack"
|
||||
lines = [" Already have this ZIP? Add --from-pack and its path: the files",
|
||||
" come from it, not from the network. For a pack in parts, name one part.", ""]
|
||||
if posix:
|
||||
lines.append(f" curl -fsSL {INSTALL_SH} | sh -s -- {args} /path/to/pack.zip{dest[0]}")
|
||||
if windows:
|
||||
lines.append(f" &([scriptblock]::Create((irm {INSTALL_PS1}))) {args} C:\\path\\to\\pack.zip"
|
||||
f"{dest[1]}")
|
||||
return [*lines, ""]
|
||||
|
||||
|
||||
def _card_section(platform_name: str, display: str) -> str:
|
||||
"""The one-line install for a frontend whose files live on its SD card.
|
||||
|
||||
@@ -32,6 +47,8 @@ def _card_section(platform_name: str, display: str) -> str:
|
||||
f" &([scriptblock]::Create((irm {INSTALL_PS1}))) --platform {platform_name} --dest E:\\\n\n"
|
||||
f" The installer writes each file where {display} hands it to the core\n"
|
||||
" and checks every file.\n\n"
|
||||
+ "\n".join(_from_pack(platform_name, True, True, (" --dest /path/to/card", " --dest E:\\")))
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -52,7 +69,7 @@ def _card_manual(display: str) -> str:
|
||||
|
||||
|
||||
def _automatic_section(
|
||||
display: str, detect: list[dict], standalone_copies: list[dict] | None,
|
||||
platform_name: str, display: str, detect: list[dict], standalone_copies: list[dict] | None,
|
||||
) -> str:
|
||||
"""The one-line install, for every system the registry detects the
|
||||
platform on. Written per platform by hand, it left ROCKNIX and MiSTer
|
||||
@@ -82,6 +99,7 @@ def _automatic_section(
|
||||
f" The installer finds the {display} BIOS folder on this machine and",
|
||||
" installs there, checking every file. --dest names another folder.",
|
||||
"",
|
||||
*_from_pack(platform_name, bool(posix), "windows" in systems),
|
||||
]
|
||||
# On a desktop: an emulator folder under the home of a console's own
|
||||
# system is not one its frontend reads.
|
||||
@@ -222,7 +240,7 @@ def _build_readme(
|
||||
else:
|
||||
guide = (
|
||||
"INSTALLATION GUIDE\n\n"
|
||||
+ _automatic_section(platform_display, detect, standalone_copies)
|
||||
+ _automatic_section(platform_name, platform_display, detect, standalone_copies)
|
||||
+ (pack_manual(platform_name) or _generic_manual(bios_paths))
|
||||
)
|
||||
|
||||
|
||||
+3
-2
@@ -1008,8 +1008,9 @@ def main() -> None:
|
||||
# Step 2 refreshed the trees once: refreshing again here let the
|
||||
# pack and the manifest describe two revisions of a moving tree.
|
||||
manifest_cmd.append("--no-refresh-data")
|
||||
if args.target:
|
||||
manifest_cmd.extend(["--target", args.target])
|
||||
# No --target here: install/ holds the published lists, and every
|
||||
# reader (site picker, release check, record) takes each file there
|
||||
# as a whole platform. The per-target core lists are step 4c.
|
||||
ok, _ = run(manifest_cmd, STEP_LABELS["generate_manifests"])
|
||||
results["generate_manifests"] = ok
|
||||
all_ok = all_ok and ok
|
||||
|
||||
@@ -17,6 +17,8 @@ EXPORTS_DIR = "exports"
|
||||
REPO_BLOB = "https://github.com/Abdess/retrobios/blob/main"
|
||||
REPO_TREE = "https://github.com/Abdess/retrobios/tree/main"
|
||||
REPO_RAW = "https://raw.githubusercontent.com/Abdess/retrobios/main"
|
||||
# Where the site publishes each report, beside the API (generate_site).
|
||||
API_EXPORTS = "api/v1/exports"
|
||||
|
||||
# Report key, then how a line of it reads on the page.
|
||||
CHANGE_KINDS = (
|
||||
@@ -318,16 +320,20 @@ def generate_exports_page(reports: list[dict], names: dict[str, str]) -> str:
|
||||
originals = report.get("originals") or {}
|
||||
files = report["files"]
|
||||
raw = [f"{REPO_RAW}/{EXPORTS_DIR}/{platform}/{name}" for name in files]
|
||||
# Every change as JSON, the report the page is written from.
|
||||
changes = markup.button("Every change (JSON)", f"{API_EXPORTS}/{platform}.json")
|
||||
if len(files) == 1:
|
||||
lines.extend([
|
||||
markup.button("Download the updated file", raw[0], primary=True) + " "
|
||||
+ markup.button("See it on GitHub", f"{REPO_BLOB}/{EXPORTS_DIR}/{platform}/{files[0]}"),
|
||||
+ markup.button("See it on GitHub", f"{REPO_BLOB}/{EXPORTS_DIR}/{platform}/{files[0]}")
|
||||
+ " " + changes,
|
||||
"",
|
||||
])
|
||||
else:
|
||||
lines.extend([
|
||||
markup.button(f"Browse the {len(files)} updated files",
|
||||
f"{REPO_TREE}/{EXPORTS_DIR}/{platform}", primary=True),
|
||||
f"{REPO_TREE}/{EXPORTS_DIR}/{platform}", primary=True)
|
||||
+ " " + changes,
|
||||
"",
|
||||
])
|
||||
if len(originals) > 1:
|
||||
|
||||
+21
-17
@@ -311,9 +311,9 @@ def build_source_paths(
|
||||
if tree is None:
|
||||
unread.add(tree_key(repo, pin))
|
||||
continue
|
||||
found = paths & tree
|
||||
if found:
|
||||
held.setdefault(tree_key(repo, pin), set()).update(found)
|
||||
# Recorded even when it holds no cited path, so a pinned tree
|
||||
# missing from the record is one no run has read.
|
||||
held.setdefault(tree_key(repo, pin), set()).update(paths & tree)
|
||||
return {key: sorted(paths) for key, paths in sorted(held.items())}, sorted(unread)
|
||||
|
||||
|
||||
@@ -323,12 +323,13 @@ def keep_unread(
|
||||
"""Carry over what the last record held for the trees this run could not read.
|
||||
|
||||
A tree key names a pinned sha, so what it held then it holds now. A run
|
||||
that GitLab rate-limited dropped about twenty trees it never read.
|
||||
that GitLab rate-limited dropped about twenty trees it never read. A tree
|
||||
no run could read is recorded empty: confirming no path, it links none,
|
||||
and check_freshness tells it from a pin no run has tried.
|
||||
"""
|
||||
kept = dict(held)
|
||||
for key in unread:
|
||||
if key not in kept and key in previous:
|
||||
kept[key] = previous[key]
|
||||
kept.setdefault(key, previous.get(key, []))
|
||||
return dict(sorted(kept.items()))
|
||||
|
||||
|
||||
@@ -526,16 +527,18 @@ def link_prose_citations(profile: dict, text: str) -> str:
|
||||
"""Prose file:line citations as pinned links, where the target is certain.
|
||||
|
||||
profile_sync keeps these citations on their pin, and the page printed
|
||||
4,328 of them as plain text. A citation links only when the profile reads
|
||||
one repository and the path is rooted (it holds a directory) or is one a
|
||||
structured source_ref of the profile already uses: a bare name could be
|
||||
anywhere in the tree, and a link to the wrong file is a false citation.
|
||||
4,328 of them as plain text. A citation links only when the path is
|
||||
rooted (it holds a directory) or is one a structured source_ref of the
|
||||
profile already uses: a bare name could be anywhere in the tree, and a
|
||||
link to the wrong file is a false citation. Of the repositories a profile
|
||||
reads, the one its role word names is taken, else the one tree holding
|
||||
the path: held by both, the citation does not say which it means.
|
||||
Text already in backticks or in a link is left alone.
|
||||
"""
|
||||
candidates = _forge_sources(profile)
|
||||
if len(candidates) != 1 or ":" not in text:
|
||||
if not candidates or ":" not in text:
|
||||
return text
|
||||
repo, pin = candidates[0]
|
||||
by_role = role_sources(profile)
|
||||
structured = {
|
||||
part.path
|
||||
for entry in profile.get("files") or []
|
||||
@@ -553,14 +556,15 @@ def link_prose_citations(profile: dict, text: str) -> str:
|
||||
continue
|
||||
if "/" not in path and path not in structured:
|
||||
continue
|
||||
role = parts[0].role
|
||||
if role and (role_sources(profile).get(role) or (None,))[0] != repo:
|
||||
# `upstream a.c:5` names a repository this link would not reach.
|
||||
continue
|
||||
if any(lo <= path_span[0] < hi for lo, hi in guarded):
|
||||
continue
|
||||
if not tree_holds(repo, pin, path):
|
||||
role = parts[0].role
|
||||
# `upstream a.c:5` names its repository: none other is linked.
|
||||
named = [by_role[role]] if role in by_role else [] if role else candidates
|
||||
holders = [tree for tree in named if tree_holds(*tree, path)]
|
||||
if len(holders) != 1:
|
||||
continue
|
||||
repo, pin = holders[0]
|
||||
for index, ((lo, hi), part) in enumerate(zip(spans, parts)):
|
||||
start = path_span[0] if index == 0 else lo
|
||||
url = _source_permalink(repo, pin, part.path, part.start, part.end)
|
||||
|
||||
@@ -819,12 +819,13 @@ def redirects(profiles: dict, present: set[str],
|
||||
that is already a page's address, is left out. Addresses are written
|
||||
under the site root, without its base. ``moved`` adds pages that moved
|
||||
by generation, old page to new page, the new one with an optional
|
||||
#fragment."""
|
||||
#fragment. A page MOVED_PAGES names keeps that target over the part
|
||||
record: cross-reference-minui is answered on MinUI's own page."""
|
||||
def address(rel: str) -> str:
|
||||
page, _, fragment = rel.partition("#")
|
||||
return "/" + page_url(page)[len(BASE):] + (f"#{fragment}" if fragment else "")
|
||||
|
||||
out = {address(old): address(new) for old, new in {**MOVED_PAGES, **(moved or {})}.items()
|
||||
out = {address(old): address(new) for old, new in {**(moved or {}), **MOVED_PAGES}.items()
|
||||
if old not in present and new.partition("#")[0] in present}
|
||||
pages = {rel.casefold() for rel in present}
|
||||
claims: dict[str, set[str]] = {}
|
||||
|
||||
Reference in new issue
Block a user