diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index df4b4a93..0902c30f 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -83,8 +83,12 @@ from siteguides import (
ALSO_RUNS,
DEVICE_GROUPS,
GROUP_ANCHORS,
+ GROUP_TILES,
GUIDES,
+ HANDHELD_GUIDE,
Guide,
+ HandheldGuide,
+ HandheldSystem,
FENCE_TITLES,
INSTALL_BASE,
TERMINALS,
@@ -450,13 +454,17 @@ def _next_block(links: list[str]) -> list[str]:
return ["## Next steps", "", *(f"- {link}" for link in links), ""]
-def _tiles(registry: dict, members: list[str], display: dict[str, str], prefix: str = "") -> list[str]:
- """The setup tiles of one device group: logo, name and the hardware it runs on."""
+def _tiles(registry: dict, members: list[str], display: dict[str, str], prefix: str = "",
+ group: str = "") -> list[str]:
+ """The setup tiles of one device group: logo, name and the hardware it
+ runs on, then the group's other tiles: a kind of device that runs one of
+ the setups (Android handhelds, Linux handhelds)."""
return markup.platform_tiles([
- (display[name], f"{prefix}platforms/{name}.md",
- str(registry.get(name, {}).get("logo") or ""),
- GUIDES[name].runs_on or _runs_on(registry.get(name, {}), GUIDES[name]))
- for name in members
+ *((display[name], f"{prefix}platforms/{name}.md",
+ str(registry.get(name, {}).get("logo") or ""),
+ GUIDES[name].runs_on or _runs_on(registry.get(name, {}), GUIDES[name]))
+ for name in members),
+ *((tile.name, f"{prefix}{tile.link}", "", tile.text) for tile in GROUP_TILES.get(group, ())),
])
@@ -468,10 +476,10 @@ def setup_picker(registry: dict, display: dict[str, str], prefix: str = "") -> l
alone: list[str] = []
for group, (label, _description) in DEVICE_GROUPS.items():
members = _group_keys(registry, group, display)
- if len(members) == 1:
+ if len(members) == 1 and not GROUP_TILES.get(group):
alone += members
elif members:
- lines += [f"**{label}**", "", *_tiles(registry, members, display, prefix)]
+ lines += [f"**{label}**", "", *_tiles(registry, members, display, prefix, group)]
if alone:
lines += ["**Other setups**", "", *_tiles(registry, alone, display, prefix)]
return lines
@@ -510,13 +518,15 @@ def generate_home(
"",
f"Free. No account. Works with {works_with}.",
"",
- "- A game says BIOS missing? [When a game does not start](wiki/troubleshooting.md)",
- "- Have a file already? Reference: [Check it](files/index.md)",
+ "- BIOS missing, even with the file there? "
+ "[When a game does not start](wiki/troubleshooting.md)",
+ "- Reference: [Check your file](files/index.md)",
"",
"## Do I need it?",
"",
"- **Yes**, if your emulator says a BIOS is missing.",
- "- **Yes** for PlayStation 1 and 2, Saturn, Dreamcast and Neo Geo.",
+ "- **Yes** for PlayStation 1 and 2, Saturn and Neo Geo.",
+ "- **Better with it** for Dreamcast: some games need it.",
"- **Usually not** for NES, Super Nintendo, Mega Drive and Game Boy.",
"",
"Details: [Every console, one by one](systems/index.md)",
@@ -635,8 +645,8 @@ def release_line(platform_name: str, record: dict) -> list[str]:
built = _release_date(tag)
lines = [
f"Pack {tag}{f', built {built}' if built else ''}. "
- f"[SHA256SUMS.txt]({base}/SHA256SUMS.txt) "
- f"[signature]({base}/SHA256SUMS.txt.sig)",
+ f"[SHA256SUMS.txt]({base}/SHA256SUMS.txt) and its "
+ f"[signature]({base}/SHA256SUMS.txt.sig).",
]
config = load_platform_config(platform_name, "platforms")
page_version = str(config.get("version") or "")
@@ -648,9 +658,42 @@ def release_line(platform_name: str, record: dict) -> list[str]:
return lines
+# What a reader on another version of the setup does with the pack. Our data
+# says which list the pack was made from, not what an older list lacks.
+OTHER_VERSION = ("Another version? After copying, `--check` names any file missing "
+ "or different.")
+
+
+def setup_version(platform_name: str, record: dict | None = None) -> str:
+ """The setup and version whose BIOS list the pack was made from
+ ("Batocera 43.1"): the version in the pack's name, else the version the
+ platform YAML was read at. Empty when neither says, or when the
+ platform has no YAML to read a version from."""
+ try:
+ config = load_platform_config(platform_name, "platforms")
+ except FileNotFoundError:
+ return ""
+ pack_name = release_record.pack_name_for(platform_name, record or {})
+ version = (_pack_version(pack_name) if pack_name else "") or str(config.get("version") or "")
+ if not version:
+ return ""
+ return f"{config.get('platform', platform_name)} {version.removeprefix('v')}"
+
+
+def version_line(platform_name: str, record: dict, named: bool = False) -> str:
+ """The version the pack fits, and what to do on another. *named* when
+ the release line already named the pack's version."""
+ made_from = setup_version(platform_name, record)
+ if named or not made_from:
+ return OTHER_VERSION
+ return f"Made from the BIOS list of {made_from}. {OTHER_VERSION}"
+
+
def _pack_buttons(platform_name: str, label: str = "Pack") -> str:
"""Buttons to the files of the platform's pack in the recorded release,
- and the release they come from under them."""
+ the release they come from, and the setup version its list was read at.
+ "Does this pack fit my version?" is the first question a frontend user
+ asks."""
record = release_record.load_record()
buttons = download_links(
platform_name,
@@ -659,13 +702,9 @@ def _pack_buttons(platform_name: str, label: str = "Pack") -> str:
label,
button=lambda text, url: markup.button(text, url, primary=True),
)
- return "\n\n".join([buttons, *release_line(platform_name, record)])
-
-
-def _released_files(platform_name: str) -> str:
- """File count of the pack the latest release serves, as a table cell."""
- files, _size = release_totals(platform_name, release_record.load_record())
- return f"{files:,}" if files else "-"
+ released = release_line(platform_name, record)
+ return "\n\n".join([buttons, *released,
+ version_line(platform_name, record, named=len(released) > 1)])
# The home tile counts the whole collection. Labelled "Files" above a table
@@ -2222,15 +2261,14 @@ def decorate_markdown_pages(docs: Path) -> dict[str, str]:
def generate_platform_index(coverages: dict, registry: dict | None = None) -> str:
- """The Your setup tab: every platform by the device it runs on, then the
- figures of each one under a reference heading."""
+ """The Your setup tab: every platform by the device it runs on, and one
+ line to the table that sets them side by side (setup_table), which is
+ reference matter and sits with how each setup checks its files."""
registry = registry or {}
- total_present = sum(c["present"] for c in coverages.values())
-
lines = [
"# Your setup",
"",
- "Pick what you play on. Its page gives the folder, the command and the ZIP.",
+ "Each setup's page gives the folder, the command and the ZIP.",
"",
]
for group, (label, description) in DEVICE_GROUPS.items():
@@ -2243,71 +2281,67 @@ def generate_platform_index(coverages: dict, registry: dict | None = None) -> st
(f"[{coverages[name]['platform']}]({name}.md)", [GUIDES[name].summary])
for name in members
])
-
lines += [
- "## Reference: every platform at a glance { #reference }",
+ f"Reference: [Every setup side by side](../{SETUP_TABLE_LINK})",
"",
- level_label("reference"),
- "",
- f"{setup_count()} supported platforms with {total_present:,} verified files.",
- "",
- "| Platform | On its BIOS list | Files in the pack | Checked by | Status | Download |",
- "|----------|------:|------:|-----------|--------|----------|",
]
-
- mode_labels = {
- "md5": 'MD5',
- "sha1": 'SHA1',
- "existence": 'existence',
- }
-
- archived_any = False
- for name, cov in sorted(coverages.items(), key=lambda x: x[1]["platform"]):
- display = cov["platform"]
-
- mode_html = mode_labels.get(
- cov["mode"],
- f'{cov["mode"]}',
- )
- status = registry.get(name, {}).get("status", "active")
- if status == "archived":
- archived_any = True
- status_html = 'archived'
- else:
- status_html = 'active'
-
- lines.append(
- f"| [{display}]({name}.md) | "
- f"{cov['present']:,} | {_released_files(name)} | "
- f"{mode_html} | {status_html} | "
- f"{_pack_cell(name, release_record.load_record(), '../')} |"
- )
-
- lines.extend(
- [
- "",
- "The BIOS list is what the platform publishes. The pack adds the "
- "files its emulators load and the data directories they read, "
- "which is the count an extraction shows. "
- "Checked by is the test each platform runs on its own, replicated "
- "from its source code.",
- "",
- "Reference: [How each platform checks files](../wiki/verification-modes.md).",
- ]
- )
-
- if archived_any:
- lines.extend(
- [
- "",
- "An archived platform keeps its configuration and still gets a "
- "pack, but upstream is no longer scraped on a schedule.",
- ]
- )
-
return "\n".join(lines) + "\n"
+# Where the side-by-side table of setups is published: with how each setup
+# checks its files, the column a reader compares.
+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]:
+ """Every setup side by side: the files on its own list, the files in its
+ ZIP, and how it checks them. Written under the Reference level, on the
+ page that explains each way of checking."""
+ registry = registry or {}
+ lines = [
+ f"## Every setup at a glance {{ #{SETUP_TABLE_ANCHOR} }}",
+ "",
+ "| Setup | Files on its own list | Files in the ZIP | How it checks |",
+ "|-------|------:|------:|---------------|",
+ ]
+ archived = False
+ for name, cov in sorted(coverages.items(), key=lambda item: item[1]["platform"].casefold()):
+ status = registry.get(name, {}).get("status")
+ archived |= status == "archived"
+ listed = f"{cov['total']:,}" if cov["total"] else "No list of its own"
+ files, _size = release_totals(name, release_record.load_record())
+ zipped = f"{files:,}" if files else "No ZIP yet"
+ 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'])} |"
+ )
+ lines += [
+ "",
+ "The ZIP adds the files its emulators load to the files on the setup's own list.",
+ "",
+ ]
+ if archived:
+ lines += ["An archived setup keeps its page and its ZIP. Its list is no longer "
+ "read again on a schedule.", ""]
+ return lines
+
+
+def with_setup_table(page: str, coverages: dict, registry: dict | None = None) -> str:
+ """The comparison table placed before the Next steps of its page, or at
+ its end."""
+ table = "\n".join(setup_table(coverages, registry))
+ marker = "\n## Next steps"
+ if marker in page:
+ head, tail = page.split(marker, 1)
+ return f"{head.rstrip()}\n\n{table}{marker}{tail}"
+ return f"{page.rstrip()}\n\n{table}"
+
+
# A platform system whose file list passes this much Markdown gets a page
# of its own: RetroDECK's arcade list was 750 KB of a 1.1 MB page.
PLATFORM_SECTION_BUDGET = 200_000
@@ -2799,6 +2833,17 @@ def _folder_rows(rows: tuple[tuple[str, str], ...], indent: str = "") -> list[st
]
+# The second most asked question about BIOS files (14 % of the community
+# sample): the file is there and the setup still reports it missing.
+# `--check` lists every file whose hash is wrong by name (install.py, the
+# "wrong hash" lines of check_lines).
+MISSING_IN_PLACE = "In place but reported missing? `--check` names a file with the wrong contents."
+
+# Under "From your computer": a device with no network installs from the ZIP
+# (`--from-pack`). The id is the one wiki/installer.md gives its section.
+OFFLINE_LINE = ("No internet on the device? "
+ "[Install from the ZIP](../wiki/installer.md#without-a-network).")
+
# Shown in the "On the device" tab of a guide that has no `terminal:` of its own.
DEVICE_TERMINAL_FALLBACK = "Or fill the folder from your computer, with the ZIP."
@@ -2851,13 +2896,24 @@ def _guide_download(name: str, display: str, guide: Guide, extract_to: str | Non
"",
]
elif record.get("tag"):
+ made_from = setup_version(name)
lines += [
- f"No {display} ZIP yet. The command installs the same files.",
+ f"No {display} ZIP yet. The command installs the same files"
+ + (f", from the BIOS list of {made_from}." if made_from else "."),
"",
]
else:
lines += [_pack_buttons(name, f"Download {display} Pack"), ""]
- return lines
+ return [*lines, *only_some_consoles(name, "../")]
+
+
+def only_some_consoles(name: str, prefix: str = "") -> list[str]:
+ """The way to a smaller download, in the words Get your files uses."""
+ return [
+ f"Only some consoles? [Pick them]({prefix}which-pack.md?platform={name}"
+ "#only-some-consoles), or press **c** when the command asks.",
+ "",
+ ]
def _guide_install(name: str, guide: Guide, heading: str) -> list[str]:
@@ -2883,6 +2939,72 @@ def _guide_install(name: str, guide: Guide, heading: str) -> list[str]:
]
+def devices_meta(devices: tuple[str, ...]) -> str:
+ """The device names a reader types, as the `devices` search field of the
+ page: an attribute on an empty element, commas out (the search engine
+ splits its keys on commas), so no excerpt shows them."""
+ names = " ".join(name.replace(",", " ") for name in devices)
+ return f''
+
+
+# Devices named in a row of the handheld guide. The rest stay in the
+# page's search field and on the list the row links.
+HANDHELD_ROW_DEVICES = 6
+
+
+def _handheld_row(system: HandheldSystem) -> str:
+ shown = ", ".join(system.devices[:HANDHELD_ROW_DEVICES])
+ more = len(system.devices) - HANDHELD_ROW_DEVICES
+ devices = (f"{shown}{f' and {more} more' if more > 0 else ''} "
+ f"([list]({system.devices_source}))" if system.devices else "")
+ folder = (f"{system.folder} ([source]({system.folder_source}))" if system.folder
+ else "Not written by the system. The command finds it on the device.")
+ return f"| {system.name} | {devices} | {folder} |"
+
+
+def generate_handheld_guide(guide: HandheldGuide) -> str:
+ """The guide for the handheld systems that run a setup (ArkOS, muOS,
+ Onion... run RetroArch): one row per system the installer detects, the
+ folder seen on the card from a computer and the devices, each cited from
+ the system's own pages, then that setup's ZIP and the command for a card.
+
+ A handheld owner starts from the device name. The systems were named
+ only in a closed note, with a path seen from inside the device."""
+ uses = GUIDES[guide.uses]
+ display = load_platform_config(guide.uses, "platforms").get("platform", guide.uses)
+ args = {"windows": f"--platform {guide.uses} --dest E:\\BIOS",
+ "macos": f"--platform {guide.uses} --dest /path/to/card/BIOS",
+ "linux": f"--platform {guide.uses} --dest /path/to/card/BIOS"}
+ return "\n".join([
+ f"# {guide.title}",
+ "",
+ guide.summary,
+ "",
+ devices_meta(guide.devices),
+ "",
+ "## Where the files go { #where-the-files-go }",
+ "",
+ "Put the SD card in a computer. Find your system below.",
+ "",
+ "| System | Devices | BIOS folder on the card |",
+ "|--------|---------|-------------------------|",
+ *(_handheld_row(system) for system in guide.systems),
+ "",
+ *_guide_download(guide.uses, display, uses, None, "## Download { #download }"),
+ "## Fill the card from a computer { #install }",
+ "",
+ "On Windows, note the card's letter, like **E:**. Put its BIOS folder after "
+ "`--dest`, written as in the table.",
+ "",
+ *terminal_tabs(("windows", "macos", "linux"), args),
+ *trust_box("../"),
+ *_next_block([
+ f"[{display}]({guide.uses}.md): every folder and every file",
+ "[When a game does not start](../wiki/troubleshooting.md)",
+ ]),
+ ]) + "\n"
+
+
def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list: bool) -> list[str]:
"""The guide part of a platform page: folder, command, pack, check, next.
@@ -2916,6 +3038,8 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list:
lines += [
*_guide_download(name, display, guide, extract_to,
"## From your computer { #download }"),
+ OFFLINE_LINE,
+ "",
*_guide_install(name, guide, "## On the device { #install }"),
]
else:
@@ -2934,6 +3058,8 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list:
"",
_check_sentence(display, cov["mode"], cov["config"], no_list),
"",
+ MISSING_IN_PLACE,
+ "",
*([guide.finish, ""] if guide.finish else []),
*_next_block([
"[First install, step by step](../wiki/first-install.md)",
@@ -7327,8 +7453,10 @@ def _pack_cell(name: str, record: dict, prefix: str = "") -> str:
pack = release_record.pack_for(name, record)
if record.get("tag") and not (pack and pack.get("assets")):
return f"No ZIP yet. [Use the command]({prefix}platforms/{name}.md#install)."
- display = load_platform_config(name, "platforms").get("platform", name)
- links = download_links(name, record, REPO_URL, display)
+ # Small buttons, one per part, the size after the last: links wrapped
+ # mid-label ("part 1 of / 3") in a narrow cell.
+ links = download_links(name, record, REPO_URL, "ZIP",
+ button=lambda text, url: markup.button(text, url))
size = pack.get("download_size") if pack else None
return f"{links} ({_fmt_size(size)})" if size else links
@@ -7353,17 +7481,62 @@ def _own_root_folders(registry: dict) -> dict[str, str]:
return folders
+def _folder_row(key: str, display: dict[str, str], registry: dict, record: dict,
+ own_root: dict[str, str]) -> str:
+ """One setup's row: where it runs, its BIOS folder and its ZIP. A setup
+ whose ZIP holds its own top folder says where to unzip it, and the
+ BIOS folder itself when it has no ZIP to unzip."""
+ guide = GUIDES[key]
+ pack = release_record.pack_for(key, record)
+ zipped = bool(pack and pack.get("assets"))
+ folder = (f"Unzip into `{own_root[key]}`" if key in own_root and zipped
+ else _folder_cell(key, guide))
+ return (f"| [{display[key]}](platforms/{key}.md) | "
+ f"{guide.runs_on or _runs_on(registry[key], guide)} | {folder} | "
+ f"{_pack_cell(key, record)} |")
+
+
+def _folder_tables(registry: dict, record: dict, display: dict[str, str]) -> list[str]:
+ """Every setup's folder and ZIP, one table per device group, the groups
+ of one setup together under "Other setups" as on the tiles. A row for
+ each setup that runs another (LaunchBox, Android) under its group."""
+ own_root = _own_root_folders(registry)
+ header = ["| Setup | Runs on | BIOS folder | ZIP |", "|-------|---------|-------------|-----|"]
+ lines: list[str] = []
+ alone: list[str] = []
+ for group, (label, _description) in DEVICE_GROUPS.items():
+ members = _group_keys(registry, group, display)
+ if len(members) == 1 and not ALSO_RUNS.get(group):
+ alone += members
+ continue
+ if not members:
+ continue
+ lines += [f'**{label}**', "", *header]
+ lines += [_folder_row(key, display, registry, record, own_root) for key in members]
+ lines += [
+ f"| {setup} | {runs_on} | {folder}, same as "
+ f"[{display[uses]}](platforms/{uses}.md) | {_pack_cell(uses, record)} |"
+ for setup, _summary, uses, runs_on, folder in ALSO_RUNS.get(group, ())
+ ]
+ lines.append("")
+ if alone:
+ anchors = "".join(f''
+ for key in alone)
+ lines += [f"{anchors}**Other setups**", "", *header,
+ *(_folder_row(key, display, registry, record, own_root) for key in alone), ""]
+ return lines
+
+
def generate_which_pack() -> str:
"""The device picker: every registered platform as a tile under the
- device it runs on, then one table per device with its BIOS folder and
- its ZIP, the console picker, and the command for a setup not listed.
+ device it runs on, the ZIP advice with every folder and ZIP in one closed
+ table, the console picker, and the command for a setup not listed.
- The tiles are the choice. A second set of cards, five tables and the
- picker offered the same choice three times."""
+ The tiles are the choice. Open tables under them listed every setup a
+ second time and made the page 11,800 px tall on a phone."""
registry = load_platform_registry("platforms")
record = release_record.load_record()
display = {key: load_platform_config(key, "platforms").get("platform", key) for key in registry}
- own_root = _own_root_folders(registry)
all_tabs = ("windows", "macos", "linux", "steamdeck", "android")
lines = [
"# Get your files",
@@ -7375,52 +7548,19 @@ def generate_which_pack() -> str:
*setup_picker(registry, display),
"## Folders and ZIP files { #manual-download }",
"",
- "- A big ZIP comes in parts, like `Pack.part1of2.zip`. Download every part.",
- "- Unzip them all into the BIOS folder of your setup. Put the files "
- "straight in it, not in a new folder.",
- "- Some files inside stay zipped, like `neogeo.zip`. Leave them as they are.",
- "- **Unzip into** means the ZIP holds its own top folder. Unzip it there.",
+ "- A big ZIP comes in parts. Download every part.",
+ "- Unzip them into your BIOS folder. Put the files straight in it, "
+ "not in a new folder.",
+ "- Leave the ZIPs inside, like `neogeo.zip`, as they are.",
+ "- A few ZIPs hold their own top folder. Their setup's page says where "
+ "to unzip them.",
"",
+ *markup.details("note", "Every folder and ZIP in one table",
+ _folder_tables(registry, record, display)),
]
- no_zip = False
- for group, (label, _description) in DEVICE_GROUPS.items():
- members = _group_keys(registry, group, display)
- if not members:
- continue
- lines += [
- f"### {label} {{ #{GROUP_ANCHORS[group]} }}",
- "",
- "| Setup | Runs on | BIOS folder | ZIP |",
- "|-------|---------|-------------|-----|",
- ]
- for key in members:
- guide = GUIDES[key]
- folder = (f"Unzip into `{own_root[key]}`" if key in own_root
- else _folder_cell(key, guide))
- pack = release_record.pack_for(key, record)
- no_zip |= bool(record.get("tag") and not (pack and pack.get("assets")))
- lines.append(
- f"| [{display[key]}](platforms/{key}.md) | "
- f"{guide.runs_on or _runs_on(registry[key], guide)} | {folder} | "
- f"{_pack_cell(key, record)} |"
- )
- for setup, _summary, uses, runs_on, folder in ALSO_RUNS.get(group, ()):
- lines.append(
- f"| {setup} | {runs_on} | {folder}, same as "
- f"[{display[uses]}](platforms/{uses}.md) | {_pack_cell(uses, record)} |"
- )
- lines.append("")
- if group == "handheld":
- lines += markup.details("note", "Other handhelds that run RetroArch", [
- "They use the RetroArch files, in the folder below. The command "
- "finds it.",
- "",
- "| System | Runs | BIOS folder |",
- "|--------|------|-------------|",
- *(f"| {name} | [RetroArch](platforms/retroarch.md) | `{folder}` |"
- for name, folder in _detected_systems(registry, "retroarch")),
- ])
- if no_zip:
+ no_zip = any(not (pack and pack.get("assets"))
+ for pack in (release_record.pack_for(key, record) for key in registry))
+ if record.get("tag") and no_zip:
lines += ["Setups added after the last release have no ZIP until the next one.", ""]
lines += [
f"The ZIPs come from the [latest release]({RELEASE_URL}). The command may "
@@ -7529,6 +7669,10 @@ def _platform_nav(coverages: dict, registry: dict) -> list:
for name in sorted(coverages, key=lambda n: coverages[n]["platform"].casefold())
if registry.get(name, {}).get("device_group") == group
]
+ # The handheld guide sits with the group whose tile leads to it.
+ members += [{HANDHELD_GUIDE.title: HANDHELD_GUIDE.path}
+ for tile in GROUP_TILES.get(group, ())
+ if HANDHELD_GUIDE and tile.link == HANDHELD_GUIDE.path]
if members:
nav.append({label: members})
return nav
@@ -7890,6 +8034,8 @@ def _generate(args: argparse.Namespace) -> None:
str(docs / "platforms" / "index.md"),
generate_platform_index(coverages, registry),
)
+ if HANDHELD_GUIDE:
+ write_if_changed(str(docs / HANDHELD_GUIDE.path), generate_handheld_guide(HANDHELD_GUIDE))
for name, cov in coverages.items():
# The emulators this platform runs, not every one of the system.
runs = resolve_platform_cores(cov["config"], profiles)
@@ -8016,6 +8162,10 @@ def _generate(args: argparse.Namespace) -> None:
for src_file in wiki_src.glob("*.md"):
shutil.copy2(src_file, wiki_dest / src_file.name)
_record(wiki_dest / src_file.name)
+ table_page = docs / SETUP_TABLE_PAGE
+ if table_page.is_file():
+ write_if_changed(str(table_page), with_setup_table(
+ table_page.read_text(encoding="utf-8"), coverages, registry))
# data-model.md is generated (contains live DB stats)
write_if_changed(
str(wiki_dest / "data-model.md"), generate_wiki_data_model(db, profiles)
diff --git a/tests/test_doc_surfaces.py b/tests/test_doc_surfaces.py
index 21cfd770..86c51ed9 100644
--- a/tests/test_doc_surfaces.py
+++ b/tests/test_doc_surfaces.py
@@ -169,7 +169,8 @@ class DownloadLinksNameTheFile(unittest.TestCase):
self.assertNotIn("releases/latest", row)
for pack in RECORD["packs"].values():
for asset in pack["assets"]:
- self.assertIn(f"/releases/download/{RECORD['tag']}/{asset})", page)
+ self.assertRegex(page, re.escape(f"/releases/download/{RECORD['tag']}/{asset}")
+ + r"[)\"]")
def test_a_platform_the_release_lacks_links_no_release(self):
"""KNULLI, AmberELEC and EmuELEC were registered after the recorded
diff --git a/tests/test_site_copy.py b/tests/test_site_copy.py
index ebf80951..c4f7bd06 100644
--- a/tests/test_site_copy.py
+++ b/tests/test_site_copy.py
@@ -37,7 +37,10 @@ START_PAGES = {
"wiki/glossary.md": 200,
}
# Level 1: the guide part of each setup page, above its Reference heading.
-SETUP_CEILING = 220
+# 220 until each page also answered three asked questions in about 50 words:
+# which setup version the ZIP was made from (7 % of the community sample),
+# how to get only some consoles, and a file in place but reported missing (14 %).
+SETUP_CEILING = 280
# The setup index is a card per setup, one sentence each.
SETUP_INDEX_CEILING = 300
diff --git a/tests/test_site_funnel.py b/tests/test_site_funnel.py
index 2a58bfa4..68d744bf 100644
--- a/tests/test_site_funnel.py
+++ b/tests/test_site_funnel.py
@@ -134,8 +134,11 @@ class EveryPlatformOnThePicker(unittest.TestCase):
def test_handheld_systems_that_run_retroarch_are_named(self):
"""muOS, ArkOS and the rest are detected as RetroArch by the
installer and were named on no page a user reads."""
- page = _which_pack()
- self.assertIn("| muOS | [RetroArch](platforms/retroarch.md) | `/run/muos/storage/bios` |", page)
+ from siteguides import HANDHELD_GUIDE
+
+ page = generate_site.generate_handheld_guide(HANDHELD_GUIDE)
+ self.assertIn("| muOS |", page)
+ self.assertIn("`MUOS/bios` on the card", page)
self.assertNotIn("| TrimUI |", page)
diff --git a/tests/test_site_r2_entry.py b/tests/test_site_r2_entry.py
index ccc4c1c1..9a01f98f 100644
--- a/tests/test_site_r2_entry.py
+++ b/tests/test_site_r2_entry.py
@@ -115,7 +115,6 @@ class OneSetupCount(unittest.TestCase):
self.assertEqual(siteguides.setup_count(), count)
self.assertIn(f"and {count - 3} more.", _page("index.md"))
self.assertIn(f'{count}', _page("explore.md"))
- self.assertIn(f"{count} supported platforms", _page("platforms/index.md"))
class SetupTiles(unittest.TestCase):
@@ -123,7 +122,8 @@ class SetupTiles(unittest.TestCase):
for name in ("index.md", "which-pack.md"):
text = _page(name)
with self.subTest(page=name):
- self.assertEqual(text.count('class="rb-card rb-tile"'), len(REGISTRY))
+ extra = sum(len(tiles) for tiles in siteguides.GROUP_TILES.values())
+ self.assertEqual(text.count('class="rb-card rb-tile"'), len(REGISTRY) + extra)
for key in REGISTRY:
self.assertIn(f'href="/retrobios/platforms/{key}/"', text)
diff --git a/tests/test_site_r3_entry.py b/tests/test_site_r3_entry.py
new file mode 100644
index 00000000..a7fb1aca
--- /dev/null
+++ b/tests/test_site_r3_entry.py
@@ -0,0 +1,273 @@
+"""Entry pages and guides (round 3, package 4).
+
+A handheld owner found no tile for Android, ArkOS, muOS, Onion or spruceOS:
+the systems sat in a closed note with a path seen from inside the device.
+The setup version a pack fits was named only under Reference, the setup page
+offered the full pack only, Get your files listed every setup twice, the Your
+setup index ended on a table in project words, and the home page said a
+Dreamcast needs its BIOS while Flycast boots without one.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+import unittest
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO_ROOT / "scripts"))
+sys.path.insert(0, str(REPO_ROOT))
+
+import generate_site # noqa: E402
+import release_record # noqa: E402
+import siteguides # noqa: E402
+from common import load_platform_config, load_platform_registry # noqa: E402
+from tests import sitepages # noqa: E402
+
+REGISTRY = load_platform_registry(str(REPO_ROOT / "platforms"))
+GUIDE = siteguides.HANDHELD_GUIDE
+
+
+def _page(name: str) -> str:
+ sitepages.require()
+ return sitepages.page(name).read_text(encoding="utf-8")
+
+
+def _guide_part(text: str) -> str:
+ """The guide part of a setup page, glossary marks taken off."""
+ match = re.search(r"^## Reference\b", text, re.M)
+ text = text[: match.start()] if match else text
+ return re.sub(r"?abbr[^>]*>", "", text)
+
+
+def _display(key: str) -> str:
+ return load_platform_config(key, str(REPO_ROOT / "platforms")).get("platform", key)
+
+
+class HandheldWayIn(unittest.TestCase):
+ def test_android_and_linux_tiles_on_home_and_get_your_files(self):
+ tiles = siteguides.GROUP_TILES.get("handheld", ())
+ self.assertEqual([tile.name for tile in tiles], ["Android", "Linux handhelds"])
+ for name in ("index.md", "which-pack.md"):
+ text = _page(name)
+ with self.subTest(page=name):
+ self.assertIn('href="/retrobios/platforms/retroarch/#where-the-files-go"', text)
+ self.assertIn('href="/retrobios/platforms/linux-handhelds/"', text)
+ self.assertIn(">Retroid, AYN Odin and other Android handhelds", text)
+
+ def test_the_closed_note_is_gone(self):
+ self.assertNotIn("Other handhelds that run RetroArch", _page("which-pack.md"))
+
+ def test_one_row_per_detected_system(self):
+ detected = [name for name, _folder in siteguides.detected_systems(REGISTRY, GUIDE.uses)]
+ self.assertEqual(sorted((s.name for s in GUIDE.systems), key=str.casefold), detected)
+ text = _page(GUIDE.path)
+ for system in GUIDE.systems:
+ with self.subTest(system=system.name):
+ self.assertIn(f"| {system.name} |", text)
+
+ def test_every_folder_and_device_list_is_cited(self):
+ for system in GUIDE.systems:
+ with self.subTest(system=system.name):
+ if system.folder:
+ self.assertTrue(system.folder_source.startswith("https://"))
+ if system.devices:
+ self.assertTrue(system.devices_source.startswith("https://"))
+ for name in ("ArkOS", "muOS", "Onion", "spruceOS"):
+ system = next(s for s in GUIDE.systems if s.name == name)
+ with self.subTest(system=name):
+ self.assertTrue(system.folder and system.devices)
+
+ def test_a_value_without_its_page_is_refused(self):
+ with self.assertRaises(siteguides.GuideError):
+ siteguides._handheld_system({"name": "Onion", "folder": "`BIOS`"}, "x")
+ with self.assertRaises(siteguides.GuideError):
+ siteguides._handheld_system(
+ {"name": "Onion", "devices": ["Miyoo Mini"], "devices_source": "a blog"}, "x")
+
+ def test_the_guide_rejects_a_missing_or_unknown_system(self):
+ data = {"page": "x", "title": "X", "summary": "S", "uses": "retroarch",
+ "systems": [{"name": "Onion"}]}
+ with self.assertRaises(siteguides.GuideError):
+ siteguides._handheld_guide(data, REGISTRY, siteguides.GUIDES, "x")
+
+ def test_devices_are_a_search_field_with_no_text(self):
+ text = _page(GUIDE.path)
+ match = re.search(r'', text)
+ self.assertIsNotNone(match)
+ self.assertIn("R36S", match[1])
+ self.assertIn("Miyoo Mini", match[1])
+ self.assertNotIn(",", match[1])
+ # The names typed most often are also on the page itself.
+ self.assertIn("R36S", text.split(match[0], 1)[1])
+
+ def test_the_guide_is_in_the_handheld_group_of_the_navigation(self):
+ coverages = {key: {"platform": _display(key)} for key in REGISTRY}
+ nav = generate_site.generate_nav(coverages, {}, {}, REGISTRY)
+ setup = next(tab["Your setup"] for tab in nav if "Your setup" in tab)
+ groups = {next(iter(item)): next(iter(item.values()))
+ for item in setup if isinstance(item, dict)}
+ label = siteguides.DEVICE_GROUPS["handheld"][0]
+ self.assertIn({GUIDE.title: GUIDE.path}, groups[label])
+
+ def test_the_guide_fills_the_card_and_never_its_root(self):
+ text = _page(GUIDE.path)
+ self.assertIn("--platform retroarch --dest E:\\BIOS", text)
+ self.assertNotRegex(text, r"--dest E:\\\s")
+
+
+class PackNamesItsVersion(unittest.TestCase):
+ def test_the_line_under_the_release(self):
+ record = release_record.load_record()
+ if not release_record.pack_for("batocera", record):
+ self.skipTest("release.json has no Batocera pack")
+ block = generate_site._pack_buttons("batocera")
+ version = generate_site._pack_version(release_record.pack_name_for("batocera", record))
+ self.assertIn(f"Made from the BIOS list of Batocera {version}. "
+ + generate_site.OTHER_VERSION, block)
+
+ def test_a_version_gap_is_said_once(self):
+ record = {"tag": "v2026.10.10", "packs": {"RetroBat_1.0_BIOS_Pack.zip": {
+ "assets": ["RetroBat_1.0_BIOS_Pack.zip"], "platforms": ["retrobat"]}}}
+ released = generate_site.release_line("retrobat", record)
+ line = generate_site.version_line("retrobat", record, named=len(released) > 1)
+ self.assertEqual(line, generate_site.OTHER_VERSION)
+
+ def test_the_v_prefix_is_dropped(self):
+ self.assertEqual(generate_site.setup_version("retroarch", {}),
+ f"RetroArch {load_platform_config('retroarch', 'platforms')['version'].lstrip('v')}")
+
+ def test_every_setup_page_names_a_version(self):
+ for key in REGISTRY:
+ with self.subTest(platform=key):
+ text = _guide_part(_page(f"platforms/{key}.md"))
+ self.assertRegex(text, r"(?:from the BIOS list of|This pack was built for) \S")
+
+
+class SetupPagesOfferPartOfThePack(unittest.TestCase):
+ def test_pick_them_and_press_c(self):
+ for key in REGISTRY:
+ with self.subTest(platform=key):
+ text = _guide_part(_page(f"platforms/{key}.md"))
+ self.assertIn(f"/retrobios/which-pack/?platform={key}#only-some-consoles", text)
+ self.assertIn("or press **c** when the command asks.", text)
+
+ def test_the_anchor_exists(self):
+ self.assertIn("## Only some consoles?", _page("which-pack.md"))
+
+ def test_a_file_in_place_but_missing_is_answered(self):
+ for key in REGISTRY:
+ with self.subTest(platform=key):
+ self.assertIn("In place but reported missing?",
+ _guide_part(_page(f"platforms/{key}.md")))
+
+
+class OfflineLine(unittest.TestCase):
+ def test_under_from_your_computer(self):
+ text = _page("platforms/batocera.md")
+ start = text.index("## From your computer")
+ line = text.index("No internet on the device?")
+ self.assertLess(start, line)
+ self.assertLess(line, text.index("## On the device"))
+ self.assertIn("/retrobios/wiki/installer/#without-a-network", text)
+ self.assertIn("{ #without-a-network }", (REPO_ROOT / "wiki" / "installer.md").read_text(
+ encoding="utf-8"))
+
+
+class GetYourFilesListsEachSetupOnce(unittest.TestCase):
+ def setUp(self):
+ self.text = _page("which-pack.md")
+
+ def test_the_tables_are_in_one_closed_block(self):
+ block = re.search(r'\s*Every folder and ZIP '
+ r'in one table
(.*?) ', self.text, re.S)
+ self.assertIsNotNone(block)
+ outside = self.text.replace(block[0], "")
+ self.assertNotIn("| Setup | Runs on | BIOS folder | ZIP |", outside)
+ for key in REGISTRY:
+ with self.subTest(platform=key):
+ self.assertEqual(block[1].count(f"| [{_display(key)}](/retrobios/platforms/{key}/) |"), 1)
+
+ def test_single_setup_groups_share_one_table(self):
+ self.assertIn("**Other setups**", self.text)
+ self.assertNotIn("**MiSTer FPGA**", self.text)
+ self.assertNotIn("**Self-hosted game library**", self.text)
+
+ def test_no_unzip_into_without_a_zip(self):
+ record = release_record.load_record()
+ for line in self.text.splitlines():
+ if "Unzip into" in line:
+ key = re.search(r"/retrobios/platforms/([a-z0-9]+)/", line)[1]
+ with self.subTest(platform=key):
+ self.assertTrue(release_record.pack_for(key, record))
+
+ def test_parts_are_buttons_with_the_size_after(self):
+ rows = [line for line in self.text.splitlines() if "Part 1 of" in line]
+ self.assertTrue(rows)
+ for row in rows:
+ self.assertRegex(row, r'Part 1 of \d')
+ self.assertRegex(row, r" \(\d[\d.]* [KMG]B\) \|$")
+
+ def test_the_zip_note(self):
+ for line in ("A big ZIP comes in parts. Download every part.",
+ "Leave the ZIPs inside, like `neogeo.zip`, as they are.",
+ "Their setup's page says where to unzip them."):
+ self.assertIn(line, self.text)
+ self.assertNotIn("**Unzip into** means", self.text)
+
+
+class YourSetupIndexInPlainWords(unittest.TestCase):
+ def test_the_index_links_the_table(self):
+ text = _page("platforms/index.md")
+ self.assertNotIn("| Platform |", text)
+ self.assertNotIn("at a glance", text.split("---", 2)[-1].lower().replace(
+ "every setup at a glance", ""))
+ self.assertIn("/retrobios/wiki/verification-modes/#every-setup-at-a-glance", text)
+
+ def test_the_table_on_verification_modes(self):
+ text = _page("wiki/verification-modes.md")
+ self.assertIn("| Setup | Files on its own list | Files in the ZIP | How it checks |", text)
+ table = text.split("## Every setup at a glance", 1)[1].split("\n## ", 1)[0]
+ self.assertNotIn("existence", table)
+ self.assertIn("| name only |", table)
+ self.assertIn("No list of its own", table)
+ self.assertLess(text.index("## Every setup at a glance"), text.index("## Next steps"))
+
+ def test_insertion_keeps_next_steps_last(self):
+ page = "# T\n\nBody.\n\n## Next steps\n\n- [A](a.md)\n"
+ out = generate_site.with_setup_table(page, {}, {})
+ self.assertTrue(out.rstrip().endswith("- [A](a.md)"))
+ self.assertIn("## Every setup at a glance", out)
+
+
+class HomeCopy(unittest.TestCase):
+ def test_dreamcast_and_the_check_link(self):
+ home = re.sub(r"?abbr[^>]*>", "", _page("index.md"))
+ self.assertIn("- **Yes** for PlayStation 1 and 2, Saturn and Neo Geo.", home)
+ self.assertIn("- **Better with it** for Dreamcast: some games need it.", home)
+ # The level label opens its line, never the middle of a sentence.
+ self.assertNotIn("? Reference:", home)
+ self.assertIn("- Reference: [Check your file]", home)
+ self.assertIn("BIOS missing, even with the file there?", home)
+
+ def test_flycast_boots_without_the_dreamcast_bios(self):
+ import yaml
+
+ profile = yaml.safe_load((REPO_ROOT / "emulators" / "flycast.yml").read_text(encoding="utf-8"))
+ boot = next(f for f in profile["files"] if f.get("name") == "dc_boot.bin")
+ self.assertFalse(boot.get("required"))
+ self.assertTrue(boot.get("hle_fallback"))
+
+
+class DownloadLine(unittest.TestCase):
+ def test_checksums_and_signature_read_as_two(self):
+ record = release_record.load_record()
+ if not release_record.pack_for("batocera", record):
+ self.skipTest("release.json has no Batocera pack")
+ line = generate_site.release_line("batocera", record)[0]
+ self.assertRegex(line, r"\[SHA256SUMS\.txt\]\([^)]+\) and its \[signature\]\([^)]+\)\.$")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_which_pack.py b/tests/test_which_pack.py
index 7d214096..fef42ee7 100644
--- a/tests/test_which_pack.py
+++ b/tests/test_which_pack.py
@@ -32,7 +32,7 @@ class WhichPackRows(unittest.TestCase):
record = release_record.load_record()
page = generate_site.generate_which_pack()
row = re.compile(r"^\| \[[^\]]+\]\(platforms/([a-z0-9]+)\.md\) \|(.*)$", re.M)
- asset = re.compile(r"/releases/download/[^/]+/([^)\s]+)\)")
+ asset = re.compile(r"/releases/download/[^/]+/([^)\s\"]+)[)\"]")
checked = 0
for key, rest in row.findall(page):
linked = set(asset.findall(rest))
diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md
index 2e67ef63..117ddcf8 100644
--- a/wiki/testing-guide.md
+++ b/wiki/testing-guide.md
@@ -54,7 +54,7 @@ Every module in `tests/`, by the area it guards.
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_offline`, `test_install_selection` |
| Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` |
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
-| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
+| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_entry`, `test_site_r3_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
## Test architecture