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.
This commit is contained in:
Abdessamad Derraz committed 2026-10-23 10:47:34 +02:00
1 parent 3d0b013588
commit add2d87190
2 files changed
+232 -14

No files matched your search

+94
View File
@@ -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: >-
+138 -14
View File
@@ -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/<key>.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."""