From add2d871901c7fb7f8f4218e226caee66c533692 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:47:34 +0200 Subject: [PATCH] feat: cite the handheld systems that run retroarch One row per system the installer detects RetroArch on, with the card folder and the devices each system's own pages list, every value carrying its source. Android and Linux handhelds become tiles of the handheld group. --- platforms/_guides.yml | 94 ++++++++++++++++++++++++++ scripts/siteguides.py | 152 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 232 insertions(+), 14 deletions(-) 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."""