diff --git a/platforms/_guides.yml b/platforms/_guides.yml index d04de3e1..7bd61954 100644 --- a/platforms/_guides.yml +++ b/platforms/_guides.yml @@ -63,6 +63,17 @@ device_groups: uses: retroarch runs_on: Android folder: '`RetroArch/system/` on the internal storage' + # Kinds of handheld that run a setup of their own: a tile each beside the + # setups of the group, leading to where their folder is written. + tiles: + - name: Android + # goretroid.com/products/retroid-pocket-5-handheld: "OS: Android 13". + # ayntec.com/products/ayn-odin-3: "Android 15". + text: Retroid, AYN Odin and other Android handhelds + link: platforms/retroarch.md#where-the-files-go + - name: Linux handhelds + text: ArkOS, muOS, Onion, spruceOS + link: platforms/linux-handhelds.md fpga: label: MiSTer FPGA description: >- @@ -114,6 +125,89 @@ terminals: `bios` folder after `--dest`. shell: sh +# The guide for the handheld systems that run RetroArch, one row for each +# system the installer detects it on (_registry.yml, retroarch install.detect). +# A folder is the one seen on the card from a computer, and a device list the +# one the system's own site, wiki or README gives. Each is written only with +# the first-party page that states it (`*_source`), and left out when no such +# page states it: the row then says the command finds the folder. +handheld_guide: + page: linux-handhelds + title: Linux handhelds + uses: retroarch + summary: >- + ArkOS, muOS, Onion, spruceOS and the other systems below run **RetroArch**, an app + with a small emulator for each console. They use its BIOS files. + systems: + - name: ArkOS + folder: '`bios` on the **EASYROMS** part of the card' + folder_source: https://github.com/christianhaitian/arkos/wiki/ArkOS-Emulators-and-Ports-information + devices: [RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, R35S, RGB10] + devices_source: https://github.com/christianhaitian/arkos/wiki + - name: muOS + folder: '`MUOS/bios` on the card' + folder_source: https://github.com/MustardOS/internal/blob/main/share/web/tools/bios-checker.html + devices: [RG35XX H, RG35XX Plus, RG35XX SP, RG35XX 2024, RG34XX H, RG40XX H, RG40XX V, + RG28XX H, RGCUBEXX, TrimUI Brick, TrimUI Smart Pro] + devices_source: https://muos.dev/devices + - name: Onion + folder: '`BIOS` at the root of the card' + folder_source: https://onionui.github.io/docs/emulators + devices: [Miyoo Mini, Miyoo Mini+] + devices_source: https://github.com/OnionUI/Onion + - name: spruceOS + folder: '`BIOS` at the root of the card' + folder_source: https://github.com/spruceUI/spruceOS/wiki/10.-Adding-BIOS + devices: [Miyoo A30, Miyoo Flip, Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip, + TrimUI Smart Pro, TrimUI Brick, Anbernic RG35XX Plus, RG40XX H, Powkiddy RGB30] + devices_source: https://github.com/spruceUI/spruceOS/wiki/Supported-Devices + - name: Allium + devices: [Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip] + devices_source: https://github.com/goweiwen/Allium + - name: Anbernic stock firmware + - name: ArchR + folder: '`/storage/roms/bios`, on a part Windows and macOS cannot open' + folder_source: https://arch-r.io/docs/roms/ + devices: [R36S, R33S, R36S Soysauce, Odroid Go Advance, Odroid Go Super, RG351V, + RG351M, GameForce Chi, MagicX XU10, Powkiddy RGB10, Powkiddy RGB10X, K36] + devices_source: https://github.com/archr-linux/Arch-R + - name: CrossMix-OS + folder: '`BIOS` at the root of the card' + folder_source: https://github.com/cizia64/CrossMix-OS/wiki/Emulators + devices: [TrimUI Smart Pro] + devices_source: https://github.com/cizia64/CrossMix-OS + - name: dArkOS + folder: '`bios` on the **EASYROMS** part of the card' + folder_source: https://github.com/christianhaitian/dArkOS/wiki/dArkOS-Emulators-and-Ports-information + devices: [Odroid Go Advance, RGB10, RG351MP, G350] + devices_source: https://github.com/christianhaitian/dArkOS/wiki + - name: JELOS + - name: KOLOS + devices: [RG353P, RG353M, RG353V, RG353VS, RG503, Powkiddy RK2023, RGB30, RGB10 Max 3] + devices_source: https://github.com/AnonLinuxOS/KOLOS + - name: Koriki + folder: '`BIOS` at the root of the card' + folder_source: https://github.com/Rparadise-Team/Koriki/wiki/En:-02.-Content-installation + devices: [Miyoo Mini] + devices_source: https://github.com/Rparadise-Team/Koriki + - name: MiyooCFW + devices: [BittBoy, Pocket Go, PowKiddy Q90, PowKiddy V90, PowKiddy Q20 Mini, Sup M3, XYC Q8] + devices_source: https://github.com/MiyooCFW/docs/blob/main/docs/index.md + - name: plumOS + folder: '`bios` on the **PLUMOS** part of the card' + folder_source: https://github.com/game-de-it/plumOS-XU20V32/blob/main/docs/developer/storage-and-updates.md + devices: [MagicX XU20 V32] + devices_source: https://github.com/game-de-it/plumOS-XU20V32 + - name: RetroOZ + devices: [OGS, OGA, RGB10Max, RGB10Max2] + devices_source: https://github.com/southoz/RetroOZ/wiki + - name: UnofficialOS + folder: '`roms/bios` on the games card' + folder_source: https://github.com/RetroGFX/UnofficialOS/wiki/Emulators-and-Game-Engines + devices: [R35S, R36S, RG351M, RG351P, RG351V, RG353M, RG353P, RG353V, RG503, RG552, + RGB30, RGB20 Pro, Odroid Go Advance, Odroid Go Super] + devices_source: https://github.com/RetroGFX/UnofficialOS/wiki + guides: retroarch: summary: >- diff --git a/scripts/siteguides.py b/scripts/siteguides.py index 991e1e21..c8bbe383 100644 --- a/scripts/siteguides.py +++ b/scripts/siteguides.py @@ -11,6 +11,7 @@ parts the pages share. The registry keeps the folder the pack unzips into from __future__ import annotations +import re import textwrap from dataclasses import dataclass, fields from pathlib import Path @@ -67,11 +68,65 @@ class Guide: finish: str = "" +@dataclass(frozen=True) +class Tile: + """A tile beside the setups of a device group that is no setup of its + own: a kind of device that runs one (Android handhelds run RetroArch). + *link* is a site path, like the setup tiles' ``platforms/.md``.""" + + name: str + text: str + link: str + + +@dataclass(frozen=True) +class HandheldSystem: + """One handheld system on the handheld guide: a name the installer + detects, the BIOS folder seen on the card from a computer, and the + devices the system's own site lists. Each value comes with the address + of the page that states it, and is left out when no first-party page does.""" + + name: str + folder: str = "" + folder_source: str = "" + devices: tuple[str, ...] = () + devices_source: str = "" + + +@dataclass(frozen=True) +class HandheldGuide: + """The guide page for the systems that run a setup on a handheld + (ArkOS, muOS, Onion... run RetroArch). *devices* are the names a reader + types, searched on that page; *systems* one row each.""" + + page: str + title: str + summary: str + uses: str + systems: tuple[HandheldSystem, ...] + + @property + def path(self) -> str: + return f"platforms/{self.page}.md" + + @property + def devices(self) -> tuple[str, ...]: + seen: dict[str, None] = {} + for system in self.systems: + seen.update(dict.fromkeys(system.devices)) + return tuple(seen) + + _GUIDE_KEYS = {f.name for f in fields(Guide)} _REQUIRED = ("summary", "folders", "run") -_TOP_KEYS = {"install_base", "trust_items", "device_groups", "terminals", "guides"} -_GROUP_KEYS = {"label", "description", "anchor", "also_runs"} +_TOP_KEYS = {"install_base", "trust_items", "device_groups", "terminals", "guides", + "handheld_guide"} +_GROUP_KEYS = {"label", "description", "anchor", "also_runs", "tiles"} _ALSO_KEYS = ("setup", "summary", "uses", "runs_on", "folder") +_TILE_KEYS = ("name", "text", "link") +_HANDHELD_KEYS = {"page", "title", "summary", "uses", "systems"} +_SYSTEM_KEYS = {"name", "folder", "folder_source", "devices", "devices_source"} +_SOURCE = re.compile(r"https://\S+") _TERMINAL_KEYS = ("label", "how", "shell") @@ -152,6 +207,72 @@ def _guide(name: str, data: object, extract_to: str, terminals: dict) -> Guide: return Guide(**values) +def detected_systems(registry: dict, platform: str) -> list[tuple[str, str]]: + """Named systems a platform runs on, handhelds that run RetroArch for one, + with the BIOS folder the installer writes on each, read from the + detection rules of the registry.""" + seen: dict[str, str] = {} + for rule in (registry.get(platform, {}).get("install") or {}).get("detect") or []: + name, folder = rule.get("name"), rule.get("bios_path") + if name and folder and rule.get("installs", True) and name not in seen: + seen[name] = folder + return sorted(seen.items(), key=lambda item: item[0].casefold()) + + +def _source(value: object, where: str) -> str: + text = _text(value, where) + if not _SOURCE.fullmatch(text): + raise GuideError(f"{where}: expected one https address, found {text!r}") + return text + + +def _handheld_system(data: object, where: str) -> HandheldSystem: + if not isinstance(data, dict): + raise GuideError(f"{where}: expected a mapping") + _unknown(data, _SYSTEM_KEYS, where) + values: dict = {"name": _text(data.get("name"), f"{where}.name")} + for value, source in (("folder", "folder_source"), ("devices", "devices_source")): + if (value in data) != (source in data): + raise GuideError(f"{where}: {value} and {source} go together, " + "a value is written only with the page that states it") + if value in data: + values[value] = (_texts(data[value], f"{where}.{value}") if value == "devices" + else _text(data[value], f"{where}.{value}")) + values[source] = _source(data[source], f"{where}.{source}") + return HandheldSystem(**values) + + +def _handheld_guide(data: object, registry: dict, guides: dict, where: str) -> HandheldGuide | None: + """The handheld guide, checked against the systems the registry detects + the setup on: one row for each, and none it does not detect.""" + if data is None: + return None + if not isinstance(data, dict): + raise GuideError(f"{where}: expected a mapping") + _unknown(data, _HANDHELD_KEYS, where) + uses = _text(data.get("uses"), f"{where}.uses") + if uses not in guides: + raise GuideError(f"{where}.uses: {uses!r} is not a platform with a guide") + page = _text(data.get("page"), f"{where}.page") + if page in registry: + raise GuideError(f"{where}.page: {page!r} is the page of a registered platform") + raw = data.get("systems") + if not isinstance(raw, list) or not raw: + raise GuideError(f"{where}.systems: expected a list of systems") + systems = tuple(_handheld_system(row, f"{where}.systems[{i}]") for i, row in enumerate(raw)) + named = [system.name for system in systems] + detected = [name for name, _folder in detected_systems(registry, uses)] + if sorted(named, key=str.casefold) != detected: + raise GuideError( + f"{where}.systems: one row per system the registry detects {uses} on, " + f"missing {sorted(set(detected) - set(named))}, " + f"not detected {sorted(set(named) - set(detected))}") + return HandheldGuide( + page=page, title=_text(data.get("title"), f"{where}.title"), + summary=_text(data.get("summary"), f"{where}.summary"), uses=uses, systems=systems, + ) + + def load_guides(path: Path = GUIDES_FILE, registry: dict | None = None) -> dict: """The guides file, checked against the registry. @@ -183,6 +304,7 @@ def load_guides(path: Path = GUIDES_FILE, registry: dict | None = None) -> dict: groups: dict[str, tuple[str, str]] = {} anchors: dict[str, str] = {} also_runs: dict[str, tuple[tuple[str, str, str, str, str], ...]] = {} + tiles: dict[str, tuple[Tile, ...]] = {} for key, entry in (data.get("device_groups") or {}).items(): at = f"{path.name}: device_groups.{key}" if not isinstance(entry, dict): @@ -203,6 +325,15 @@ def load_guides(path: Path = GUIDES_FILE, registry: dict | None = None) -> dict: rows.append(values) if rows: also_runs[key] = tuple(rows) + group_tiles = [] + for i, row in enumerate(entry.get("tiles") or []): + row_at = f"{at}.tiles[{i}]" + if not isinstance(row, dict): + raise GuideError(f"{row_at}: expected a mapping") + _unknown(row, _TILE_KEYS, row_at) + group_tiles.append(Tile(*(_text(row.get(k), f"{row_at}.{k}") for k in _TILE_KEYS))) + if group_tiles: + tiles[key] = tuple(group_tiles) unregistered = sorted(set(raw_guides) - set(registry)) if unregistered: @@ -228,8 +359,11 @@ def load_guides(path: Path = GUIDES_FILE, registry: dict | None = None) -> dict: "device_groups": groups, "group_anchors": anchors, "also_runs": also_runs, + "tiles": tiles, "terminals": terminals, "guides": guides, + "handheld_guide": _handheld_guide(data.get("handheld_guide"), registry, guides, + f"{path.name}: handheld_guide"), } @@ -238,24 +372,14 @@ INSTALL_BASE: str = _LOADED["install_base"] DEVICE_GROUPS: dict[str, tuple[str, str]] = _LOADED["device_groups"] GROUP_ANCHORS: dict[str, str] = _LOADED["group_anchors"] ALSO_RUNS: dict[str, tuple[tuple[str, str, str, str, str], ...]] = _LOADED["also_runs"] +GROUP_TILES: dict[str, tuple[Tile, ...]] = _LOADED["tiles"] +HANDHELD_GUIDE: HandheldGuide | None = _LOADED["handheld_guide"] TERMINALS: dict[str, tuple[str, str, str]] = _LOADED["terminals"] TRUST_ITEMS: tuple[str, ...] = _LOADED["trust_items"] GUIDES: dict[str, Guide] = _LOADED["guides"] _REGISTRY: dict = _LOADED["registry"] -def detected_systems(registry: dict, platform: str) -> list[tuple[str, str]]: - """Named systems a platform runs on, handhelds that run RetroArch for one, - with the BIOS folder the installer writes on each, read from the - detection rules of the registry.""" - seen: dict[str, str] = {} - for rule in (registry.get(platform, {}).get("install") or {}).get("detect") or []: - name, folder = rule.get("name"), rule.get("bios_path") - if name and folder and rule.get("installs", True) and name not in seen: - seen[name] = folder - return sorted(seen.items(), key=lambda item: item[0].casefold()) - - def folder_list(name: str, registry: dict | None = None) -> list[tuple[str, str]]: """Every folder a guide names, as (where, path): the common ones, the rarer builds, and the systems the registry detects when the guide says so."""