feat: detect handheld systems by their own files

AmberELEC, EmuELEC, JELOS, KOLOS, UnofficialOS, ArchR, PAN4ELEC, dArkOS,
RetroOZ, spruceOS, CrossMix-OS, Onion, Allium, Koriki, MiyooCFW and plumOS
are found by what their builds write, named as they name themselves, and
installed to the system_directory of their RetroArch config, which follows
ArkOS and dArkOS to a second card. TrimUI stock, MinUI and a plumOS build
without a known folder are named and not installed to. Two unrelated
systems matching stop a run without a terminal.
This commit is contained in:
Abdessamad Derraz committed 2026-10-22 03:29:41 +02:00
1 parent ff2137de9d
commit e27a583975
6 files changed
+897 -142

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "fe8de4eda0919c268d4d2c0d571c7d0dc1cf675d7604263766f5f99a35aa3724"
$defaultInstallSha256 = "9fbee0f382ca68880697ed573b79ecc1ffca793a406a3f258dad16e40d2845d6"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+359 -67
View File
@@ -41,7 +41,7 @@ import xml.etree.ElementTree as ET
import zipfile
from collections.abc import Callable, Iterator
from pathlib import Path, PurePosixPath
from typing import BinaryIO, TextIO
from typing import BinaryIO, NamedTuple, TextIO
# Manifests are read from the same ref the bootstrap verified this installer
# against, so a file list and the code reading it always come from one commit.
@@ -481,7 +481,7 @@ def detect_os() -> str:
def _parse_os_release() -> dict[str, str]:
"""Parse /etc/os-release KEY=value format."""
result: dict[str, str] = {}
path = Path("/etc/os-release")
path = _on_device("/etc/os-release")
if not path.exists():
return result
try:
@@ -535,7 +535,7 @@ def _enter_wsl() -> None:
os.environ[variable] = str(_host_path(value))
def _expand_retroarch_path(value: str, app_dir: Path) -> Path:
def _expand_retroarch_path(value: str, app_dir: Path, home: Path | None = None) -> Path:
"""Expand the notations RetroArch writes into its config values.
fill_pathname_expand_special (libretro-common/file/file_path.c) maps a
@@ -543,14 +543,14 @@ def _expand_retroarch_path(value: str, app_dir: Path) -> Path:
directory, dropping the two leading characters in both cases.
"""
if value[:1] == "~":
return Path(str(Path.home())) / value[2:]
return (home or Path(str(Path.home()))) / value[2:]
if value[:1] == ":":
return app_dir / value[2:]
return _host_path(os.path.expandvars(os.path.expanduser(value)))
def _retroarch_cfg_dir(
cfg_path: Path, key: str, app_dir: Path, default: str
cfg_path: Path, key: str, app_dir: Path, default: str, home: Path | None = None
) -> Path | None:
"""A directory setting of retroarch.cfg, as RetroArch expands it.
@@ -569,7 +569,7 @@ def _retroarch_cfg_dir(
value = value.strip().strip('"').strip("'")
if not value or value == "default":
return app_dir / default
return _expand_retroarch_path(value, app_dir)
return _expand_retroarch_path(value, app_dir, home)
return None
@@ -661,18 +661,19 @@ _RETROARCH_CONFIGS: dict[Path, tuple[Path, Path]] = {}
def _parse_retroarch_system_dir(
cfg_path: Path, app_dir: Path | None = None
cfg_path: Path, app_dir: Path | None = None, home: Path | None = None
) -> Path | None:
"""Parse system_directory from retroarch.cfg.
app_dir is RetroArch's application directory, used to expand ':' values.
It defaults to the directory holding the config, which is where a portable
install keeps both.
install keeps both. home is what '~' means to that RetroArch, the user's
home when not given.
"""
if not cfg_path.exists():
return None
app_dir = app_dir or cfg_path.parent
system_dir = _retroarch_cfg_dir(cfg_path, "system_directory", app_dir, "system")
system_dir = _retroarch_cfg_dir(cfg_path, "system_directory", app_dir, "system", home)
if system_dir is not None:
_RETROARCH_CONFIGS.setdefault(system_dir, (cfg_path, app_dir))
return system_dir
@@ -817,70 +818,345 @@ def _parse_ps1_var(path: Path, key: str) -> str | None:
return None
# Embedded systems, first match wins. The registry publishes the same rules
# as each manifest's `detect`, and a test holds the two equal. MiSTer is a
# path rule below.
_EMBEDDED_RULES: tuple[tuple[str, str, tuple[str, ...], str], ...] = (
# ROCKNIX writes OS_NAME and no ID since 2025 (scripts/image). Older
# images wrote ID=rocknix.
("rocknix", "os_release", ("OS_NAME=rocknix", "ID=rocknix"), "/storage/roms/bios"),
# Knulli (knulli-linux 57afa662): OS_NAME in os-release
# (board/scripts/post-build-script.sh:233-236) and knulli.version
# (package/system/knulli-system/knulli-system.mk:98-102).
("batocera", "os_release", ("OS_NAME=knulli",), "/userdata/bios"),
("batocera", "file_exists", ("/usr/share/knulli/knulli.version",), "/userdata/bios"),
("lakka", "os_release", ("ID=lakka",), "/storage/system"),
# batocera.linux 38845725, batocera-system.mk:21-22 (the same lines in
# batocera-30): batocera.arch and batocera.version.
("batocera", "file_exists", ("/usr/share/batocera/batocera.version",), "/userdata/bios"),
("recalbox", "file_exists", ("/recalbox/recalbox.version",), "/recalbox/share/bios"),
("recalbox", "file_exists", ("/usr/bin/recalbox-settings",), "/recalbox/share/bios"),
("retroarch", "file_exists", ("/opt/muos",), "/run/muos/storage/bios"),
("retroarch", "file_exists", ("/mnt/mmc/MUOS",), "/mnt/mmc/MUOS/bios"),
("retroarch", "file_exists", ("/home/ark", "/opt/system"), "/roms/bios"),
("retroarch", "file_exists", ("/mnt/vendor/bin/dmenu.bin",), "/mnt/mmc/bios"),
)
# The root the embedded rules read under: "/" on a device, a folder holding a
# copy of one in the tests.
_SYSTEM_ROOT = Path("/")
def _detect_embedded() -> list[tuple[str, Path]]:
"""Check for embedded Linux retrogaming OSes."""
release = {k: v.lower() for k, v in _parse_os_release().items()}
for platform_name, method, values, bios in _EMBEDDED_RULES:
if method == "os_release":
# Any of the KEY=value pairs, the value compared without case.
matched = any(
release.get(key) == value.lower()
for key, _, value in (pair.partition("=") for pair in values)
)
else:
matched = all(Path(value).exists() for value in values)
if matched:
return [(platform_name, Path(bios))]
return []
def _on_device(path: str) -> Path:
"""A path of the running system, read under _SYSTEM_ROOT."""
if _SYSTEM_ROOT == Path("/"):
return Path(path)
return _SYSTEM_ROOT / path.lstrip("/")
class _OsRule(NamedTuple):
"""One piece of evidence that names an embedded system.
manifest is the platform whose files it gets, "" for a system the
installer does not lay out. name is the system's own name. values are the
KEY=value pairs of os_release (any one), the files of file_exists (all),
or the file and the pattern of file_matches. bios is the folder used when
none of configs (retroarch.cfg files, read in order, `*` allowed) names a
system_directory. home is what `~` means to that system's RetroArch.
base is the system this one is built on: both match, this one is meant.
"""
manifest: str
name: str
method: str
values: tuple[str, ...]
bios: str | None
configs: tuple[str, ...] = ()
home: str = ""
base: str = ""
# Systems whose own platform is not published yet. Their files come from the
# platform whose core set they ship, and this is the one place that changes
# when the manifest of their own is published.
_STANDIN_MANIFESTS = {
"AmberELEC": "retroarch",
"EmuELEC": "retroarch",
"KNULLI": "batocera",
}
# Where the ELEC family keeps RetroArch: the config runemu writes and copies
# back on every launch, and HOME=/storage for the services that start it.
_ELEC_CONFIGS = ("/storage/.config/retroarch/retroarch.cfg",)
# ArkOS and dArkOS: the 64-bit and the 32-bit RetroArch. "Switch to SD2 for
# Roms" rewrites /roms/ to /roms2/ in both (darkos daecf488,
# scripts/Switch to SD2 for Roms.sh:91-92), so a second card is read here.
_ARK_CONFIGS = ("/home/ark/.config/retroarch/retroarch.cfg",
"/home/ark/.config/retroarch32/retroarch.cfg")
_PLYMOUTH_TITLE = "/usr/share/plymouth/themes/text.plymouth"
# muOS runs RetroArch on the config it copies from its default on first launch
# (internal f41f4a89, script/control/retroarch.sh:8-20). The default names
# /run/muos/storage/bios, a bind mount of MUOS/bios on the second card when
# that card holds one, else on the first (script/device/bind.sh:4,116-150),
# and loads cores from /opt/muos/share/core (retroarch.default.cfg:2806,3151).
MUOS_ROOT = Path("/opt/muos")
MUOS_RETROARCH_CONFIGS = (
MUOS_ROOT / "share" / "info" / "config" / "retroarch.cfg",
MUOS_ROOT / "share" / "emulator" / "retroarch" / "retroarch.default.cfg",
"/opt/muos/share/info/config/retroarch.cfg",
"/opt/muos/share/emulator/retroarch/retroarch.default.cfg",
)
# Embedded systems, by what their own builds write. The registry publishes the
# same rules as each manifest's `detect`, and a test holds the two equal.
# MiSTer is a path rule below.
_EMBEDDED_RULES: tuple[_OsRule, ...] = (
# ROCKNIX writes OS_NAME and no ID since 2025 (scripts/image). Older
# images wrote ID=rocknix.
_OsRule("rocknix", "ROCKNIX", "os_release", ("OS_NAME=rocknix", "ID=rocknix"),
"/storage/roms/bios"),
# Knulli (knulli-linux 57afa662): OS_NAME in os-release
# (board/scripts/post-build-script.sh:233-236) and knulli.version
# (package/system/knulli-system/knulli-system.mk:98-102).
_OsRule(_STANDIN_MANIFESTS["KNULLI"], "KNULLI", "os_release", ("OS_NAME=knulli",),
"/userdata/bios", base="Batocera"),
_OsRule(_STANDIN_MANIFESTS["KNULLI"], "KNULLI", "file_exists",
("/usr/share/knulli/knulli.version",), "/userdata/bios", base="Batocera"),
_OsRule("lakka", "Lakka", "os_release", ("ID=lakka",), "/storage/system"),
# batocera.linux 38845725, batocera-system.mk:21-22 (the same lines in
# batocera-30): batocera.arch and batocera.version.
_OsRule("batocera", "Batocera", "file_exists", ("/usr/share/batocera/batocera.version",),
"/userdata/bios"),
_OsRule("recalbox", "Recalbox", "file_exists", ("/recalbox/recalbox.version",),
"/recalbox/share/bios"),
_OsRule("recalbox", "Recalbox", "file_exists", ("/usr/bin/recalbox-settings",),
"/recalbox/share/bios"),
_OsRule("retroarch", "muOS", "file_exists", ("/opt/muos",), "/run/muos/storage/bios",
configs=MUOS_RETROARCH_CONFIGS),
_OsRule("retroarch", "muOS", "file_exists", ("/mnt/mmc/MUOS",), "/mnt/mmc/MUOS/bios"),
# AmberELEC 5a3882cb: scripts/image:150-152 writes NAME="${DISTRONAME}",
# AmberELEC in distributions/AmberELEC/options:17. CHAos (tech4bot/AmberELEC
# c0a4b0c6) is built from the same tree and writes the same name
# (distributions/AmberELEC/options:14). runemu.py:22 launches with
# /storage/.config/retroarch/retroarch.cfg, whose default is "~/roms/bios"
# (packages/games/tools/retroarch/sources/retroarch.cfg:611), and
# emustation.service:6 sets HOME=/storage.
_OsRule(_STANDIN_MANIFESTS["AmberELEC"], "AmberELEC", "os_release", ("NAME=AmberELEC",),
"/storage/roms/bios", _ELEC_CONFIGS, "/storage"),
# PAN4ELEC (Kegg1701/P4ELEC 9c19bfb2), an overlay on an AmberELEC image:
# AmberELEC/usr/bin/batocera-info:101 prints its name.
_OsRule(_STANDIN_MANIFESTS["AmberELEC"], "PAN4ELEC", "file_matches",
("/usr/bin/batocera-info", r"OPERATING SYSTEM: PAN4ELEC"),
"/storage/roms/bios", _ELEC_CONFIGS, "/storage", base="AmberELEC"),
# EmuELEC d4c7b2f1: scripts/image:172-176 NAME and ID from DISTRONAME,
# EmuELEC in distributions/EmuELEC/options:4. setsettings.sh:22 names the
# config, and libretro_base/retroarch/package.mk:117 sets its
# system_directory to /storage/roms/bios.
_OsRule(_STANDIN_MANIFESTS["EmuELEC"], "EmuELEC", "os_release", ("NAME=EmuELEC",),
"/storage/roms/bios", _ELEC_CONFIGS, "/storage"),
# JELOS 7b2e116c: scripts/image:118-120 OS_NAME="${DISTRONAME}", JELOS in
# distributions/JELOS/options:17. runemu.sh:37 names the config, whose
# default is "~/roms/bios" (retroarch/sources/RK3566/retroarch.cfg:716),
# and emustation.service:8 sets HOME=/storage.
_OsRule("retroarch", "JELOS", "os_release", ("OS_NAME=JELOS",),
"/storage/roms/bios", _ELEC_CONFIGS, "/storage"),
# KOLOS (AnonLinuxOS/KOLOS 7daf5950) keeps OS_NAME=JELOS
# (distributions/JELOS/options:17) and adds jelos-ready.service
# ("KOLOS system ready script"), installed by scripts/install:100-102.
_OsRule("retroarch", "KOLOS", "file_exists",
("/usr/lib/systemd/system/jelos-ready.service",),
"/storage/roms/bios", _ELEC_CONFIGS, "/storage", base="JELOS"),
# UnofficialOS 22635796: scripts/image:119-120 OS_NAME, UnofficialOS in
# distributions/UnofficialOS/options:17, config at
# packages/unofficialos/sources/scripts/runemu.sh:37.
_OsRule("retroarch", "UnofficialOS", "os_release", ("OS_NAME=UnofficialOS",),
"/storage/roms/bios", _ELEC_CONFIGS, "/storage"),
# Arch R (archr-linux/Arch-R dbd45d45): scripts/image:164-177 NAME and
# OS_NAME, ArchR in distributions/ArchR/options:17. factoryreset:9-10
# names the config, whose default is "~/roms/bios"
# (retroarch/sources/RK3326/retroarch.cfg:718).
_OsRule("retroarch", "ArchR", "os_release", ("OS_NAME=ArchR",),
"/storage/roms/bios", _ELEC_CONFIGS, "/storage"),
# ArkOS: the user and the menu folder, and the boot splash title its
# updates write (christianhaitian/arkos dedf4278, Update-RGB10.sh:76).
_OsRule("retroarch", "ArkOS", "file_exists", ("/home/ark", "/opt/system"), "/roms/bios",
_ARK_CONFIGS),
_OsRule("retroarch", "ArkOS", "file_matches", (_PLYMOUTH_TITLE, r"^title=\s*ArkOS\b"),
"/roms/bios", _ARK_CONFIGS),
# dArkOS daecf488: finishing_touches.sh:359-361 writes "title=dArkOS
# (<date>)", kept by the dArkOSRE, dArkOSen and ArkOS4Clone overlays.
# Default system_directory "/roms/bios" (retroarch.cfg.rgb10:3043).
_OsRule("retroarch", "dArkOS", "file_matches", (_PLYMOUTH_TITLE, r"^title=\s*dArkOS"),
"/roms/bios", _ARK_CONFIGS, base="ArkOS"),
# RetroOZ 0.60 image (no build source is published): title=RetroOZ in
# the splash theme, /opt/.retrooz/version, and /etc/retrooz_version, the
# file PortMaster reads (device_info.txt:95). RetroArch is started with
# --config /home/odroid/.config/retroarch/retroarch.cfg (es_systems.cfg),
# system_directory "/roms/bios" (retroarch32/retroarch.cfg:3046).
_OsRule("retroarch", "RetroOZ", "file_matches", (_PLYMOUTH_TITLE, r"^title=\s*RetroOZ"),
"/roms/bios", ("/home/odroid/.config/retroarch/retroarch.cfg",
"/home/odroid/.config/retroarch32/retroarch.cfg")),
_OsRule("retroarch", "RetroOZ", "file_exists", ("/opt/.retrooz",),
"/roms/bios", ("/home/odroid/.config/retroarch/retroarch.cfg",
"/home/odroid/.config/retroarch32/retroarch.cfg")),
_OsRule("retroarch", "RetroOZ", "file_exists", ("/etc/retrooz_version",),
"/roms/bios", ("/home/odroid/.config/retroarch/retroarch.cfg",
"/home/odroid/.config/retroarch32/retroarch.cfg")),
# TheRA is left undetected: no image or source of its Odroid Go builds is
# published, so neither its markers nor its folder could be read.
# spruceOS 824b1aae: the spruce folder at the card root. RetroArch reads
# Saves/ra-configs/retroarch-<platform>.cfg (spruce/scripts/
# helperFunctions.sh:157-165), seeded with system_directory
# "/mnt/SDCARD/BIOS" (RetroArch/platform/retroarch-Brick.cfg.bak:3279).
_OsRule("retroarch", "spruceOS", "file_exists", ("/mnt/SDCARD/spruce",),
"/mnt/SDCARD/BIOS", ("/mnt/SDCARD/Saves/ra-configs/retroarch-*.cfg",),
base="TrimUI"),
# CrossMix-OS 206d612f: crossmix-version.txt (System/usr/trimui/scripts/
# update_common.sh:20), RetroArch/retroarch.cfg:3202 (patch_ra_cfg.sh:5).
_OsRule("retroarch", "CrossMix-OS", "file_exists",
("/mnt/SDCARD/System/usr/trimui/crossmix-version.txt",),
"/mnt/SDCARD/BIOS", ("/mnt/SDCARD/RetroArch/retroarch.cfg",), base="TrimUI"),
# Onion 07505ea5: Makefile:75-76 writes onionVersion/version.txt, and
# static/build/.tmp_update/script/patch_ra_cfg.sh:6 names the config
# (system_directory at static/configs/RetroArch/.retroarch/retroarch.cfg:3009).
_OsRule("retroarch", "Onion", "file_exists",
("/mnt/SDCARD/.tmp_update/onionVersion/version.txt",),
"/mnt/SDCARD/BIOS", ("/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg",)),
# Allium ee0dda97: its base folder /mnt/SDCARD/.allium
# (crates/common/src/constants.rs:19-34). RetroArch runs with
# HOME=/mnt/SDCARD/RetroArch on .retroarch/retroarch.cfg
# (static/.allium/cores/retroarch/launch_without_savestate_auto_load.sh:2-7),
# and Allium's RetroArch patch makes $ROOT/BIOS the default system folder.
_OsRule("retroarch", "Allium", "file_exists", ("/mnt/SDCARD/.allium",),
"/mnt/SDCARD/BIOS", ("/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg",)),
# Koriki 8513071c: src/systemInfo/main.c:106 reads Koriki/version.txt,
# .tmp_update/runtime.sh:271 installs RetroArch/.retroarch/retroarch.cfg
# (system_directory at base/RetroArch/.retroarch/retroarch.cfg:3191).
_OsRule("retroarch", "Koriki", "file_exists", ("/mnt/SDCARD/Koriki/version.txt",),
"/mnt/SDCARD/BIOS", ("/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg",)),
# MiyooCFW (buildroot d5912eeb): board/miyoo/rootfs/etc/main sets HOME=/mnt
# (line 4), package/miyoo/miyoo-ctl/miyoo-ctl.mk:12 installs miyooctl.
# RetroArch is a DINGUX build: $HOME/.retroarch/retroarch.cfg, and
# $HOME/.retroarch/system when no config names a folder.
_OsRule("retroarch", "MiyooCFW", "file_exists", ("/etc/main", "/usr/bin/miyooctl"),
"/mnt/.retroarch/system", ("/mnt/.retroarch/retroarch.cfg",), "/mnt"),
# plumOS: os-release ID=plumos (game-de-it/plumOS-pixel2_v2 dec872c0,
# rootfs/pixel2/etc/os-release:2, PortMaster device_info.txt:101). Each
# device build appends its own system_directory at launch: the Pixel 2
# build /mnt/plumos-user/bios (package/app-layer-pixel2/bin/
# plumos-retroarch-launch:342, mount point made by rootfs/pixel2/sbin/
# init:29), the MF build <card>/BIOS (plumOS-MF 0095017c,
# plumos-retroarch-launch:15). Only the first is told apart by a file.
_OsRule("retroarch", "plumOS", "file_exists", ("/mnt/plumos-user",),
"/mnt/plumos-user/bios"),
_OsRule("retroarch", "plumOS", "os_release", ("ID=plumos",), None),
# TrimUI's stock firmware publishes no source naming its RetroArch BIOS
# folder. PortMaster finds its libraries in /usr/trimui
# (trimui/launch_hooks.txt:37). Named so that what runs on top of it is
# told apart, never installed to by a guess.
_OsRule("", "TrimUI", "file_exists", ("/usr/trimui",), None),
# MinUI, MyMinUI and NextUI keep their system in .system at the card root
# (MinUI dbf89435, workspace/all/common/defines.h:14-16) and give each core
# Bios/<tag>/ (workspace/all/minarch/minarch.c:2942), a layout the
# installer does not write.
_OsRule("", "MinUI", "file_exists", ("/mnt/SDCARD/.system/res",), None, base="TrimUI"),
_OsRule("", "MinUI", "file_exists", ("/mnt/sdcard/.system/res",), None, base="TrimUI"),
_OsRule("retroarch", "Anbernic stock firmware", "file_exists",
("/mnt/vendor/bin/dmenu.bin",), "/mnt/mmc/bios"),
)
def _muos_system_dir() -> Path | None:
"""The system directory of muOS's RetroArch, read from its config, which
also records the config for finding the installed cores."""
if not MUOS_ROOT.is_dir():
return None
for cfg in MUOS_RETROARCH_CONFIGS:
system_dir = _parse_retroarch_system_dir(cfg)
if system_dir is not None:
return system_dir
return None
# Why a system found here gets no folder: said once per run.
_NO_FOLDER = {
"TrimUI": "its firmware publishes no source naming the folder its RetroArch reads",
"MinUI": "MinUI, MyMinUI and NextUI give each core its own Bios/<tag>/ folder, "
"a layout the installer does not write yet",
}
# (platform, BIOS folder) -> the name the system found there gives itself.
_DETECTED_OS: dict[tuple[str, Path], str] = {}
def _rule_matches(rule: _OsRule, release: dict[str, str]) -> bool:
if rule.method == "os_release":
# Any of the KEY=value pairs, the value compared without case.
return any(
release.get(key) == value.lower()
for key, _, value in (pair.partition("=") for pair in rule.values)
)
if rule.method == "file_matches":
path, pattern = rule.values
try:
text = _on_device(path).read_text(encoding="utf-8", errors="replace")
except OSError:
return False
return re.search(pattern, text, re.IGNORECASE | re.MULTILINE) is not None
return all(_on_device(value).exists() for value in rule.values)
def _config_files(pattern: str) -> list[tuple[Path, Path]]:
"""(file to read, its folder as the system sees it) for a config rule."""
device = pattern.replace("$HOME", str(Path.home()), 1)
folder, _, name = device.rpartition("/")
if "*" not in name:
return [(_on_device(device), Path(folder))]
try:
return [(path, Path(folder)) for path in sorted(_on_device(folder).glob(name))
if path.is_file()]
except OSError:
return []
def _os_system_dir(rules: list[_OsRule]) -> Path | None:
"""The folder a system's RetroArch reads: the system_directory of its
first config that sets one, else the folder its build defaults to.
The config also records where the cores of that installation are.
"""
for rule in rules:
home = Path(rule.home) if rule.home else None
for pattern in rule.configs:
for path, folder in _config_files(pattern):
system_dir = _parse_retroarch_system_dir(path, folder, home)
if system_dir is not None:
seen = _on_device(str(system_dir))
_RETROARCH_CONFIGS.setdefault(seen, _RETROARCH_CONFIGS[system_dir])
return seen
bios = next((rule.bios for rule in rules if rule.bios), None)
return _on_device(bios) if bios else None
def _detect_embedded() -> list[tuple[str, Path]]:
"""The embedded system this is, by what its own build writes.
A system built on another matches both: the derivative is meant. Two
unrelated systems matching are both returned, and os_conflict() names
them, so that no folder is chosen between them in silence.
"""
release = {k: v.lower() for k, v in _parse_os_release().items()}
matched = [rule for rule in _EMBEDDED_RULES if _rule_matches(rule, release)]
bases = {rule.base for rule in matched if rule.base}
found: list[tuple[str, Path]] = []
for name in dict.fromkeys(rule.name for rule in matched):
if name in bases:
continue
rules = [rule for rule in matched if rule.name == name]
folder = _os_system_dir(rules) if rules[0].manifest else None
if folder is None:
reason = _NO_FOLDER.get(
name, "nothing on it names the folder its RetroArch reads")
print(f"{name} found here, but {reason}. Name the folder with --dest.",
file=sys.stderr)
continue
_DETECTED_OS[(rules[0].manifest, folder)] = name
found.append((rules[0].manifest, folder))
return found
def detected_os(plat: str, path: Path) -> str:
"""The name of the system detection found this folder on, or ""."""
return _DETECTED_OS.get((plat, path), "")
def os_conflict(platforms: list[tuple[str, Path]]) -> list[str]:
"""The systems among these when there are two or more: a machine runs one."""
names = list(dict.fromkeys(filter(None, (detected_os(n, p) for n, p in platforms))))
return names if len(names) > 1 else []
def _system_lines() -> list[str]:
"""Each embedded system detection knows, with the files it gets."""
lines = []
for name in dict.fromkeys(rule.name for rule in _EMBEDDED_RULES):
plat = next(rule.manifest for rule in _EMBEDDED_RULES if rule.name == name)
if plat:
lines.append(f"{name}: {plat}")
else:
lines.append(f"{name}: not installed to, {_NO_FOLDER[name]} (use --dest)")
return lines
def found_label(plat: str, path: Path) -> str:
"""How a detected folder is announced: the system's own name, and the
platform whose files it gets when that is another name."""
name = detected_os(plat, path)
shown = display_name(plat)
if not name or name == shown:
return f"{shown} at {path}"
return f"{name} at {path} ({shown} files)"
def _android_storage_root() -> Path:
@@ -1259,9 +1535,7 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]:
found.extend(_detect_android())
if os_type in ("linux", "wsl"):
embedded = _detect_embedded()
muos = _muos_system_dir() if embedded and embedded[0][0] == "retroarch" else None
found.extend([("retroarch", muos)] if muos else embedded)
found.extend(_detect_embedded())
if os_type in ("linux", "wsl", "darwin"):
# EmuDeck on Linux and macOS: functions/all.sh:3-4 and 24 (EmuDeck
@@ -1391,7 +1665,9 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]:
_detect_by_executable(os_type, found)
_detect_by_registry(os_type, found)
_detect_by_path(os_type, found)
return found
# An embedded system's config read again as the home folder's one (HOME is
# /storage on the ELEC family) is the same folder.
return list(dict.fromkeys(found))
def _emudeck_windows_bios() -> Path | None:
@@ -4175,7 +4451,10 @@ def _prompt_platform_choice(
"""Prompt user to choose among detected platforms."""
print("\nInstall for:")
for i, (name, path) in enumerate(platforms, 1):
print(f" {i}) {display_name(name)} ({path})")
system = detected_os(name, path)
label = f"{system}, {display_name(name)} files" if system and system != display_name(name) \
else display_name(name)
print(f" {i}) {label} ({path})")
last = len(platforms) + (1 if len(platforms) > 1 else 0)
if len(platforms) > 1:
print(f" {last}) All")
@@ -4883,11 +5162,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
print("Available platforms:")
for p in AVAILABLE_PLATFORMS:
print(f" {p}")
print("\nSystems recognized by their own files:")
for line in _system_lines():
print(f" {line}")
detected = detect_platforms(os_type)
if detected:
print("\nDetected on this system:")
for name, path in detected:
print(f" {name}: {path}")
system = detected_os(name, path)
print(f" {name}: {path}" + (f" ({system})" if system else ""))
return
# Platform detection or override. A destination taken from the default
@@ -4968,8 +5251,17 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
sys.exit(1)
platforms = _prompt_manual_platform(os_type)
for name, path in platforms:
print(f" Found {display_name(name)} at {path}")
print(f" Found {found_label(name, path)}")
conflict = os_conflict(platforms)
if conflict and not interactive and not listing:
# A machine runs one system: installing to each folder would write
# where nothing reads, and picking one would be a guess.
print(f"Error: {' and '.join(conflict)} are both detected here, and a "
"machine runs one. Name the folder with --dest (and --platform for "
"its file list).",
file=sys.stderr)
sys.exit(1)
if len(platforms) > 1 and not listing and interactive:
platforms = _prompt_platform_choice(platforms)
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="fe8de4eda0919c268d4d2c0d571c7d0dc1cf675d7604263766f5f99a35aa3724"
DEFAULT_INSTALL_SHA256="9fbee0f382ca68880697ed573b79ecc1ffca793a406a3f258dad16e40d2845d6"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+222 -15
View File
@@ -267,34 +267,231 @@ platforms:
method: config_file
config: '%ProgramFiles%\Steam\steamapps\common\RetroArch\retroarch.cfg'
parse_key: system_directory
# Handheld firmwares that run RetroArch as their frontend: muOS,
# ArkOS, and the stock Linux of Anbernic devices (dmenu).
# muOS copies its default config on first launch and RetroArch reads
# /run/muos/storage/bios, bound to MUOS/bios on the second card when
# it holds one, else the first (MustardOS/internal f41f4a89,
# script/control/retroarch.sh:8-20, script/device/bind.sh:4,116-150,
# share/emulator/retroarch/retroarch.default.cfg:2806,3151).
- os: linux
method: config_file
config: /opt/muos/share/info/config/retroarch.cfg
parse_key: system_directory
- os: linux
method: config_file
config: /opt/muos/share/emulator/retroarch/retroarch.default.cfg
parse_key: system_directory
# Handheld systems that run RetroArch as their frontend, each found by
# what its own build writes. A system built on another (derives_from)
# matches both and is the one meant.
# muOS: MustardOS/internal f41f4a89: retroarch.sh:8-20, bind.sh:4,116-150,
# retroarch.default.cfg:2806,3151. /mnt/mmc/MUOS is the older layout.
- os: linux
name: muOS
method: file_exists
file: /opt/muos
bios_path: /run/muos/storage/bios
config: [/opt/muos/share/info/config/retroarch.cfg, /opt/muos/share/emulator/retroarch/retroarch.default.cfg]
- os: linux
name: muOS
method: file_exists
file: /mnt/mmc/MUOS
bios_path: /mnt/mmc/MUOS/bios
# AmberELEC: AmberELEC 5a3882cb scripts/image:150-152, options:17, runemu.py:22,
# retroarch.cfg:611 ("~/roms/bios"), emustation.service:6 (HOME). CHAos
# (tech4bot/AmberELEC c0a4b0c6) writes the same name. Served by RetroArch's
# file list until its own platform is published.
- os: linux
name: AmberELEC
method: os_release
match: [NAME=AmberELEC]
bios_path: /storage/roms/bios
config: [/storage/.config/retroarch/retroarch.cfg]
home: /storage
# PAN4ELEC: Kegg1701/P4ELEC 9c19bfb2, AmberELEC/usr/bin/batocera-info:101.
- os: linux
name: PAN4ELEC
method: file_matches
file: /usr/bin/batocera-info
pattern: 'OPERATING SYSTEM: PAN4ELEC'
bios_path: /storage/roms/bios
config: [/storage/.config/retroarch/retroarch.cfg]
home: /storage
derives_from: AmberELEC
# EmuELEC: EmuELEC d4c7b2f1 scripts/image:172-176, options:4, setsettings.sh:22,
# libretro_base/retroarch/package.mk:117. Served by RetroArch's file list
# until its own platform is published.
- os: linux
name: EmuELEC
method: os_release
match: [NAME=EmuELEC]
bios_path: /storage/roms/bios
config: [/storage/.config/retroarch/retroarch.cfg]
home: /storage
# JELOS: JustEnoughLinuxOS/distribution 7b2e116c scripts/image:118-120,
# options:17, runemu.sh:37, RK3566/retroarch.cfg:716.
- os: linux
name: JELOS
method: os_release
match: [OS_NAME=JELOS]
bios_path: /storage/roms/bios
config: [/storage/.config/retroarch/retroarch.cfg]
home: /storage
# KOLOS: AnonLinuxOS/KOLOS 7daf5950: OS_NAME=JELOS kept, jelos-ready.service
# ("KOLOS system ready script") installed by scripts/install:100-102.
- os: linux
name: KOLOS
method: file_exists
file: /usr/lib/systemd/system/jelos-ready.service
bios_path: /storage/roms/bios
config: [/storage/.config/retroarch/retroarch.cfg]
home: /storage
derives_from: JELOS
# UnofficialOS: RetroGFX/UnofficialOS 22635796 scripts/image:119-120, options:17,
# packages/unofficialos/sources/scripts/runemu.sh:37.
- os: linux
name: UnofficialOS
method: os_release
match: [OS_NAME=UnofficialOS]
bios_path: /storage/roms/bios
config: [/storage/.config/retroarch/retroarch.cfg]
home: /storage
# ArchR: archr-linux/Arch-R dbd45d45 scripts/image:164-177, options:17,
# factoryreset:9-10, RK3326/retroarch.cfg:718.
- os: linux
name: ArchR
method: os_release
match: [OS_NAME=ArchR]
bios_path: /storage/roms/bios
config: [/storage/.config/retroarch/retroarch.cfg]
home: /storage
# ArkOS: christianhaitian/arkos dedf4278 Update-RGB10.sh:76 (splash title).
# Both configs move to /roms2 with "Switch to SD2 for Roms" (darkos
# daecf488, scripts/Switch to SD2 for Roms.sh:91-92).
- os: linux
name: ArkOS
method: file_exists
file: [/home/ark, /opt/system]
bios_path: /roms/bios
config: [/home/ark/.config/retroarch/retroarch.cfg, /home/ark/.config/retroarch32/retroarch.cfg]
- os: linux
name: ArkOS
method: file_matches
file: /usr/share/plymouth/themes/text.plymouth
pattern: '^title=\s*ArkOS\b'
bios_path: /roms/bios
config: [/home/ark/.config/retroarch/retroarch.cfg, /home/ark/.config/retroarch32/retroarch.cfg]
# dArkOS: christianhaitian/darkos daecf488 finishing_touches.sh:359-361,
# retroarch.cfg.rgb10:3043. The dArkOSRE, dArkOSen and ArkOS4Clone overlays
# keep the title.
- os: linux
name: dArkOS
method: file_matches
file: /usr/share/plymouth/themes/text.plymouth
pattern: '^title=\s*dArkOS'
bios_path: /roms/bios
config: [/home/ark/.config/retroarch/retroarch.cfg, /home/ark/.config/retroarch32/retroarch.cfg]
derives_from: ArkOS
# RetroOZ: RetroOZ 0.60 image: splash title, /opt/.retrooz/version,
# retroarch32/retroarch.cfg:3046. /etc/retrooz_version is the file PortMaster
# reads (PortMaster-GUI ccea6b74, PortMaster/device_info.txt:95).
- os: linux
name: RetroOZ
method: file_matches
file: /usr/share/plymouth/themes/text.plymouth
pattern: '^title=\s*RetroOZ'
bios_path: /roms/bios
config: [/home/odroid/.config/retroarch/retroarch.cfg, /home/odroid/.config/retroarch32/retroarch.cfg]
- os: linux
name: RetroOZ
method: file_exists
file: /opt/.retrooz
bios_path: /roms/bios
config: [/home/odroid/.config/retroarch/retroarch.cfg, /home/odroid/.config/retroarch32/retroarch.cfg]
- os: linux
name: RetroOZ
method: file_exists
file: /etc/retrooz_version
bios_path: /roms/bios
config: [/home/odroid/.config/retroarch/retroarch.cfg, /home/odroid/.config/retroarch32/retroarch.cfg]
# spruceOS: spruceOS 824b1aae spruce/scripts/helperFunctions.sh:157-165,
# RetroArch/platform/retroarch-Brick.cfg.bak:3279.
- os: linux
name: spruceOS
method: file_exists
file: /mnt/SDCARD/spruce
bios_path: /mnt/SDCARD/BIOS
config: ['/mnt/SDCARD/Saves/ra-configs/retroarch-*.cfg']
derives_from: TrimUI
# CrossMix-OS: cizia64/CrossMix-OS 206d612f System/usr/trimui/scripts/
# update_common.sh:20, patch_ra_cfg.sh:5, RetroArch/retroarch.cfg:3202.
- os: linux
name: CrossMix-OS
method: file_exists
file: /mnt/SDCARD/System/usr/trimui/crossmix-version.txt
bios_path: /mnt/SDCARD/BIOS
config: [/mnt/SDCARD/RetroArch/retroarch.cfg]
derives_from: TrimUI
# Onion: OnionUI/Onion 07505ea5 Makefile:75-76,
# .tmp_update/script/patch_ra_cfg.sh:6, retroarch.cfg:3009.
- os: linux
name: Onion
method: file_exists
file: /mnt/SDCARD/.tmp_update/onionVersion/version.txt
bios_path: /mnt/SDCARD/BIOS
config: [/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg]
# Allium: goweiwen/Allium ee0dda97 crates/common/src/constants.rs:19-34,
# launch_without_savestate_auto_load.sh:2-7. Its RetroArch patch makes
# $ROOT/BIOS the default system folder.
- os: linux
name: Allium
method: file_exists
file: /mnt/SDCARD/.allium
bios_path: /mnt/SDCARD/BIOS
config: [/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg]
# Koriki: Rparadise-Team/Koriki 8513071c src/systemInfo/main.c:106,
# .tmp_update/runtime.sh:271, RetroArch/.retroarch/retroarch.cfg:3191.
- os: linux
name: Koriki
method: file_exists
file: /mnt/SDCARD/Koriki/version.txt
bios_path: /mnt/SDCARD/BIOS
config: [/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg]
# MiyooCFW: MiyooCFW buildroot d5912eeb board/miyoo/rootfs/etc/main:4 (HOME),
# package/miyoo/miyoo-ctl/miyoo-ctl.mk:12. DINGUX RetroArch defaults to
# $HOME/.retroarch/system.
- os: linux
name: MiyooCFW
method: file_exists
file: [/etc/main, /usr/bin/miyooctl]
bios_path: /mnt/.retroarch/system
config: [/mnt/.retroarch/retroarch.cfg]
home: /mnt
# plumOS: game-de-it/plumOS-pixel2_v2 dec872c0 rootfs/pixel2/etc/os-release:2,
# sbin/init:29, plumos-retroarch-launch:342. Other plumOS builds append a
# folder of their own at launch (plumOS-MF 0095017c,
# plumos-retroarch-launch:15): found, not installed to.
- os: linux
name: plumOS
method: file_exists
file: /mnt/plumos-user
bios_path: /mnt/plumos-user/bios
- os: linux
name: plumOS
method: os_release
match: [ID=plumos]
# TrimUI: the stock firmware publishes no source naming its BIOS folder.
# /usr/trimui is where PortMaster finds its libraries
# (trimui/launch_hooks.txt:37). Found so that what runs on it is told
# apart, never installed to.
- os: linux
name: TrimUI
method: file_exists
file: /usr/trimui
installs: false
# MinUI: MinUI dbf89435 workspace/all/common/defines.h:14-16, minarch.c:2942.
# One Bios/<tag>/ folder per core, not written by the installer.
- os: linux
name: MinUI
method: file_exists
file: /mnt/SDCARD/.system/res
installs: false
derives_from: TrimUI
- os: linux
name: MinUI
method: file_exists
file: /mnt/sdcard/.system/res
installs: false
derives_from: TrimUI
# Anbernic stock firmware: the stock Linux of Anbernic devices.
- os: linux
name: 'Anbernic stock firmware'
method: file_exists
file: /mnt/vendor/bin/dmenu.bin
bios_path: /mnt/mmc/bios
@@ -316,17 +513,23 @@ platforms:
# Knulli, a Batocera derivative (knulli-cfw/knulli-linux 57afa662):
# OS_NAME in os-release (board/scripts/post-build-script.sh:233-236),
# and knulli.version (package/system/knulli-system/knulli-system.mk:98-102).
# Served by Batocera's file list until its own platform is published.
- os: linux
name: KNULLI
method: os_release
match: [OS_NAME=knulli]
bios_path: /userdata/bios
derives_from: Batocera
- os: linux
name: KNULLI
method: file_exists
file: /usr/share/knulli/knulli.version
bios_path: /userdata/bios
derives_from: Batocera
# batocera.linux 38845725, package/batocera/core/batocera-system/
# batocera-system.mk:21-22 writes batocera.arch and batocera.version.
- os: linux
name: Batocera
method: file_exists
file: /usr/share/batocera/batocera.version
bios_path: /userdata/bios
@@ -348,10 +551,12 @@ platforms:
install:
detect:
- os: linux
name: Recalbox
method: file_exists
file: /recalbox/recalbox.version
bios_path: /recalbox/share/bios
- os: linux
name: Recalbox
method: file_exists
file: /usr/bin/recalbox-settings
bios_path: /recalbox/share/bios
@@ -456,6 +661,7 @@ platforms:
install:
detect:
- os: linux
name: Lakka
method: os_release
match: [ID=lakka]
bios_path: /storage/system
@@ -568,6 +774,7 @@ platforms:
install:
detect:
- os: linux
name: ROCKNIX
method: os_release
match: [OS_NAME=rocknix, ID=rocknix]
bios_path: /storage/roms/bios
+265 -44
View File
@@ -1007,15 +1007,25 @@ class DetectionFollowsTheRegistry(unittest.TestCase):
registry = yaml.safe_load(
(Path(__file__).resolve().parents[1] / "platforms" / "_registry.yml").read_text()
)
rules = set()
rules = []
for name, entry in registry["platforms"].items():
for rule in (entry.get("install") or {}).get("detect", []):
if rule["method"] == "os_release":
rules.add((name, "os_release", tuple(rule["match"]), rule["bios_path"]))
values = tuple(rule["match"])
elif rule["method"] == "file_exists":
files = rule["file"] if isinstance(rule["file"], list) else [rule["file"]]
rules.add((name, "file_exists", tuple(files), rule["bios_path"]))
self.assertEqual(set(install._EMBEDDED_RULES), rules)
files = rule["file"]
values = tuple(files if isinstance(files, list) else [files])
elif rule["method"] == "file_matches":
values = (rule["file"], rule["pattern"])
else:
continue
rules.append(install._OsRule(
"" if rule.get("installs") is False else name, rule["name"],
rule["method"], values, rule.get("bios_path"),
tuple(rule.get("config", ())), rule.get("home", ""),
rule.get("derives_from", ""),
))
self.assertEqual(sorted(install._EMBEDDED_RULES, key=repr), sorted(rules, key=repr))
def test_every_config_file_read_is_published(self):
"""The registry published five RetroArch config files and the fixed
@@ -2758,8 +2768,9 @@ class TheInstallerPageNamesEveryEvidence(unittest.TestCase):
def test_every_rule_is_on_the_page(self):
page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
page = page.replace("\\\\", "\\")
expected = [path for _p, method, values, _b in install._EMBEDDED_RULES
if method == "file_exists" for path in values]
expected = [path for rule in install._EMBEDDED_RULES
if rule.method == "file_exists" for path in rule.values]
expected += [rule.name for rule in install._EMBEDDED_RULES]
expected += [path.replace("$HOME", "~").rstrip("/")
for _os, _p, path, _b in install._PATH_RULES]
missing = [path for path in expected if path not in page]
@@ -4196,54 +4207,264 @@ class EmuDeckWindowsIsFoundAsItsScriptsFindIt(unittest.TestCase):
install._host_path("C:/Emulation/bios"))
def _device(test: unittest.TestCase, files: dict[str, str],
release: str = "", home: str = "/root") -> Path:
"""A fake root holding these files, read by the embedded rules."""
root = Path(_tempdir(test))
if release:
files = {**files, "/etc/os-release": release}
for name, text in files.items():
path = root / name.lstrip("/")
if name.endswith("/"):
path.mkdir(parents=True, exist_ok=True)
continue
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
for patcher in (
unittest.mock.patch.object(install, "_SYSTEM_ROOT", root),
unittest.mock.patch.dict(os.environ, {"HOME": home}),
unittest.mock.patch.dict(install._RETROARCH_CONFIGS, clear=True),
unittest.mock.patch.dict(install._DETECTED_OS, clear=True),
):
patcher.start()
test.addCleanup(patcher.stop)
return root
class MuOsIsReadFromItsRetroArchConfig(unittest.TestCase):
"""muOS wrote to /mnt/mmc/MUOS/bios while RetroArch reads
/run/muos/storage/bios, bound to the second card when it holds MUOS/bios,
and --installed-cores found no core in /opt/muos/share/core."""
def _muos(self, *, user_config: bool) -> tuple[Path, Path]:
root = Path(_tempdir(self))
muos = root / "opt" / "muos"
system_dir, cores = root / "run" / "bios", root / "core"
cores.mkdir(parents=True)
configs = (muos / "share/info/config/retroarch.cfg",
muos / "share/emulator/retroarch/retroarch.default.cfg")
default = configs[1]
default.parent.mkdir(parents=True)
default.write_text(f'libretro_directory = "{cores}"\n'
f'system_directory = "{root / "default-bios"}"\n')
if user_config:
configs[0].parent.mkdir(parents=True)
configs[0].write_text(f'libretro_directory = "{cores}"\n'
f'system_directory = "{system_dir}"\n')
for patcher in (
unittest.mock.patch.object(install, "MUOS_ROOT", muos),
unittest.mock.patch.object(install, "MUOS_RETROARCH_CONFIGS", configs),
unittest.mock.patch.object(
install, "_detect_embedded",
lambda: [("retroarch", Path("/run/muos/storage/bios"))]),
unittest.mock.patch.dict(os.environ, {"HOME": str(root / "home")}),
unittest.mock.patch.dict(install._RETROARCH_CONFIGS, clear=True),
):
patcher.start()
self.addCleanup(patcher.stop)
return (system_dir if user_config else root / "default-bios"), cores
USER = "/opt/muos/share/info/config/retroarch.cfg"
DEFAULT = "/opt/muos/share/emulator/retroarch/retroarch.default.cfg"
def _muos(self, files: dict[str, str]) -> Path:
root = _device(self, {"/opt/muos/": "", **files})
found = install._detect_embedded()
self.assertEqual(len(found), 1)
self.assertEqual(install.detected_os(*found[0]), "muOS")
return root
def test_the_users_config_decides(self):
system_dir, cores = self._muos(user_config=True)
found = install.detect_platforms("linux")
self.assertIn(("retroarch", system_dir), found)
self.assertNotIn(("retroarch", Path("/run/muos/storage/bios")), found)
self.assertIn(cores, install.core_dirs("retroarch", system_dir))
root = self._muos({
self.USER: 'libretro_directory = "/opt/muos/share/core"\n'
'system_directory = "/mnt/sdcard/MUOS/bios"\n',
self.DEFAULT: 'system_directory = "/run/muos/storage/bios"\n',
})
self.assertEqual(install._detect_embedded(),
[("retroarch", root / "mnt/sdcard/MUOS/bios")])
self.assertIn(Path("/opt/muos/share/core"),
install.core_dirs("retroarch", root / "mnt/sdcard/MUOS/bios"))
def test_before_the_first_launch_the_default_does(self):
system_dir, cores = self._muos(user_config=False)
self.assertIn(("retroarch", system_dir), install.detect_platforms("linux"))
self.assertIn(cores, install.core_dirs("retroarch", system_dir))
root = self._muos({self.DEFAULT: 'system_directory = "/run/muos/storage/bios"\n'})
self.assertEqual(install._detect_embedded(),
[("retroarch", root / "run/muos/storage/bios")])
def test_without_a_config_the_bound_folder_is_used(self):
rule = next(r for r in install._EMBEDDED_RULES if r[2] == ("/opt/muos",))
self.assertEqual(rule[3], "/run/muos/storage/bios")
root = self._muos({})
self.assertEqual(install._detect_embedded(),
[("retroarch", root / "run/muos/storage/bios")])
class HandheldSystemsByTheirOwnFiles(unittest.TestCase):
"""AmberELEC, EmuELEC, JELOS, ArkOS on a second card and a dozen other
handheld systems were not detected, or got /roms/bios whatever their
RetroArch read: each is found by what its own build writes, and its
folder is the system_directory of its RetroArch config."""
ELEC_CFG = "/storage/.config/retroarch/retroarch.cfg"
ARK_CFG = "/home/ark/.config/retroarch/retroarch.cfg"
TITLE = "/usr/share/plymouth/themes/text.plymouth"
def _found(self, files: dict, release: str = "", home: str = "/root"):
root = _device(self, files, release, home)
with contextlib.redirect_stderr(io.StringIO()) as err:
found = install._detect_embedded()
names = [install.detected_os(plat, path) for plat, path in found]
return root, found, names, err.getvalue()
def _one(self, files: dict, release: str = "", home: str = "/root"):
root, found, names, _err = self._found(files, release, home)
self.assertEqual(len(found), 1, found)
return root, found[0], names[0]
def _elec(self, release: str, name: str, extra: dict | None = None,
plat: str = "retroarch"):
cfg = {self.ELEC_CFG: 'system_directory = "~/roms/bios"\n'}
root, (found_plat, path), found_name = self._one({**cfg, **(extra or {})}, release)
self.assertEqual((found_plat, path, found_name),
(plat, root / "storage/roms/bios", name))
def test_amberelec(self):
self._elec('NAME="AmberELEC"\nID="coreelec"\n', "AmberELEC",
plat=install._STANDIN_MANIFESTS["AmberELEC"])
def test_chaos_names_itself_amberelec(self):
# tech4bot/AmberELEC writes the same os-release as AmberELEC.
self._elec('NAME="AmberELEC"\nID="coreelec"\nCOREELEC_DEVICE="RG552"\n', "AmberELEC")
def test_pan4elec_over_amberelec(self):
self._elec('NAME="AmberELEC"\n', "PAN4ELEC",
{"/usr/bin/batocera-info": 'echo "OPERATING SYSTEM: PAN4ELEC"\n'})
def test_emuelec(self):
self._elec('NAME="EmuELEC"\nID="emuelec"\n', "EmuELEC",
plat=install._STANDIN_MANIFESTS["EmuELEC"])
def test_jelos(self):
self._elec('OS_NAME="JELOS"\nHW_DEVICE="RK3566"\n', "JELOS")
def test_kolos_over_jelos(self):
self._elec('OS_NAME="JELOS"\n', "KOLOS",
{"/usr/lib/systemd/system/jelos-ready.service":
"Description=KOLOS system ready script\n"})
def test_unofficialos(self):
self._elec('OS_NAME="UnofficialOS"\n', "UnofficialOS")
def test_archr(self):
self._elec('NAME="ArchR"\nID="archr"\nOS_NAME="ArchR"\n', "ArchR")
def test_an_elec_config_moved_elsewhere_is_followed(self):
root, (_plat, path), _name = self._one(
{self.ELEC_CFG: 'system_directory = "/storage/roms2/bios"\n'}, 'OS_NAME="JELOS"\n')
self.assertEqual(path, root / "storage/roms2/bios")
def test_arkos_first_and_second_card(self):
base = {"/home/ark/": "", "/opt/system/": "",
self.TITLE: "[ubuntu-text]\ntitle=ArkOS 2.0 (02012025)-3\n"}
for folder in ("/roms/bios", "/roms2/bios"):
with self.subTest(folder=folder):
root, found, name = self._one(
{**base, self.ARK_CFG: f'system_directory = "{folder}"\n'})
self.assertEqual((found, name),
(("retroarch", root / folder.lstrip("/")), "ArkOS"))
def test_arkos_32_bit_config_when_the_64_bit_one_is_missing(self):
root, found, _name = self._one({
"/home/ark/": "", "/opt/system/": "",
"/home/ark/.config/retroarch32/retroarch.cfg": 'system_directory = "/roms2/bios"\n',
})
self.assertEqual(found, ("retroarch", root / "roms2/bios"))
def test_darkos_first_and_second_card(self):
base = {"/home/ark/": "", "/opt/system/": "",
self.TITLE: "title=dArkOS (05102026)\n"}
for folder in ("/roms/bios", "/roms2/bios"):
with self.subTest(folder=folder):
root, found, name = self._one(
{**base, self.ARK_CFG: f'system_directory = "{folder}"\n'})
self.assertEqual((found, name),
(("retroarch", root / folder.lstrip("/")), "dArkOS"))
def test_darkos_without_a_config(self):
root, found, name = self._one({"/home/ark/": "", "/opt/system/": "",
self.TITLE: "title=dArkOS (05102026)\n"})
self.assertEqual((found, name), (("retroarch", root / "roms/bios"), "dArkOS"))
def test_retrooz(self):
cfg = "/home/odroid/.config/retroarch32/retroarch.cfg"
root, found, name = self._one({
"/opt/.retrooz/version": "0.60\n", self.TITLE: "[ubuntu-text]\ntitle=RetroOZ\n",
cfg: 'system_directory = "/roms/bios"\n',
})
self.assertEqual((found, name), (("retroarch", root / "roms/bios"), "RetroOZ"))
root, found, name = self._one({"/etc/retrooz_version": "1\n"})
self.assertEqual((found, name), (("retroarch", root / "roms/bios"), "RetroOZ"))
def test_thera_is_not_detected(self):
_root, found, _names, _err = self._found({self.TITLE: "title= *TheRA RC 1*\n"})
self.assertEqual(found, [])
def test_spruceos_on_a_trimui(self):
root, found, name = self._one({
"/usr/trimui/": "", "/mnt/SDCARD/spruce/": "",
"/mnt/SDCARD/Saves/ra-configs/retroarch-Brick.cfg":
'system_directory = "/mnt/SDCARD/BIOS"\n',
})
self.assertEqual((found, name), (("retroarch", root / "mnt/SDCARD/BIOS"), "spruceOS"))
def test_crossmix_os(self):
root, found, name = self._one({
"/usr/trimui/": "", "/mnt/SDCARD/System/usr/trimui/crossmix-version.txt": "1.3\n",
"/mnt/SDCARD/RetroArch/retroarch.cfg": 'system_directory = "/mnt/SDCARD/BIOS"\n',
})
self.assertEqual((found, name), (("retroarch", root / "mnt/SDCARD/BIOS"), "CrossMix-OS"))
def _miyoo_mini(self, marker: str, name: str):
root, found, found_name = self._one({
marker: "x\n",
"/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg":
'system_directory = "/mnt/SDCARD/BIOS"\n',
})
self.assertEqual((found, found_name), (("retroarch", root / "mnt/SDCARD/BIOS"), name))
def test_onion(self):
self._miyoo_mini("/mnt/SDCARD/.tmp_update/onionVersion/version.txt", "Onion")
def test_allium(self):
self._miyoo_mini("/mnt/SDCARD/.allium/version.txt", "Allium")
def test_koriki(self):
self._miyoo_mini("/mnt/SDCARD/Koriki/version.txt", "Koriki")
def test_miyoocfw_default_and_config(self):
base = {"/etc/main": "export HOME=/mnt\n", "/usr/bin/miyooctl": ""}
root, found, name = self._one(base)
self.assertEqual((found, name),
(("retroarch", root / "mnt/.retroarch/system"), "MiyooCFW"))
root, found, _name = self._one(
{**base, "/mnt/.retroarch/retroarch.cfg": 'system_directory = "~/bios"\n'})
self.assertEqual(found, ("retroarch", root / "mnt/bios"))
def test_plumos(self):
root, found, name = self._one({"/mnt/plumos-user/": ""}, 'NAME="plumOS"\nID=plumos\n')
self.assertEqual((found, name), (("retroarch", root / "mnt/plumos-user/bios"), "plumOS"))
_root, found, _names, err = self._found({}, 'NAME="plumOS"\nID=plumos\n')
self.assertEqual(found, [])
self.assertIn("plumOS found here", err)
def test_trimui_stock_and_minui_are_named_not_installed_to(self):
_root, found, _names, err = self._found({"/usr/trimui/": ""})
self.assertEqual(found, [])
self.assertIn("TrimUI found here", err)
_root, found, _names, err = self._found(
{"/usr/trimui/": "", "/mnt/SDCARD/.system/res/": ""})
self.assertEqual(found, [])
self.assertIn("MinUI found here", err)
self.assertNotIn("TrimUI", err)
def test_two_unrelated_systems_are_refused(self):
_root, found, names, _err = self._found(
{"/mnt/SDCARD/Koriki/version.txt": "1\n"}, 'OS_NAME="JELOS"\n')
self.assertEqual(names, ["JELOS", "Koriki"])
self.assertEqual(install.os_conflict(found), ["JELOS", "Koriki"])
with unittest.mock.patch.object(sys, "argv", ["install.py", "--no-input"]), \
unittest.mock.patch.object(install, "detect_os", return_value="linux"), \
unittest.mock.patch.object(install, "detect_platforms", return_value=found), \
unittest.mock.patch.object(install, "detect_frontends", return_value=[]), \
contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()) as err, \
self.assertRaises(SystemExit) as stop:
install.main()
self.assertEqual(stop.exception.code, 1)
self.assertIn("JELOS and Koriki are both detected here", err.getvalue())
def test_the_system_is_announced_by_its_own_name(self):
root, found, _name = self._one({self.ELEC_CFG: ""}, 'NAME="AmberELEC"\n')
self.assertEqual(install.found_label(*found),
f"AmberELEC at {root / 'storage/roms/bios'} (RetroArch files)")
def test_list_platforms_names_every_system(self):
with unittest.mock.patch.object(sys, "argv", ["install.py", "--list-platforms"]), \
unittest.mock.patch.object(install, "detect_os", return_value="linux"), \
unittest.mock.patch.object(install, "detect_platforms", return_value=[]), \
contextlib.redirect_stdout(io.StringIO()) as out:
install.main()
for rule in install._EMBEDDED_RULES:
self.assertIn(f" {rule.name}: ", out.getvalue())
class BatoceraAndKnulliByWhatTheirBuildsWrite(unittest.TestCase):
+49 -14
View File
@@ -283,21 +283,56 @@ Files the list does not name are never read, moved or deleted.
## Platform detection
Detection reads what the platform itself writes, never a guess from a directory
name. The embedded systems are tested in the order below and the first match
wins, since a machine is only one of them.
Detection reads what the system itself writes, never a guess from a directory
name. A machine runs one embedded system, so every rule below is tested. A
system built on another one (KOLOS on JELOS, dArkOS on ArkOS, spruceOS on a
TrimUI) matches both and is the one meant. Two unrelated systems matching
are both named: at a terminal the run asks which one, otherwise it stops and
asks for `--dest`. The run names the system as it names itself, with the file
list it gets: `Found AmberELEC at /storage/roms/bios (RetroArch files)`.
`--list-platforms` lists every system below.
| Evidence | Platform | BIOS directory |
|----------|----------|----------------|
| `/etc/os-release` `OS_NAME=ROCKNIX` (or `ID=rocknix` on images before 2025) | ROCKNIX | `/storage/roms/bios` |
| `/etc/os-release` `OS_NAME=knulli` or `/usr/share/knulli/knulli.version` | Batocera (Knulli) | `/userdata/bios` |
| `/etc/os-release` `ID=lakka` | Lakka | `/storage/system` |
| `/usr/share/batocera/batocera.version` | Batocera | `/userdata/bios` |
| `/recalbox/recalbox.version` or `/usr/bin/recalbox-settings` | Recalbox | `/recalbox/share/bios` |
| `/opt/muos` | RetroArch (muOS) | `system_directory` of `/opt/muos/share/info/config/retroarch.cfg`, else of the default it is copied from: `/run/muos/storage/bios`, which muOS binds to `MUOS/bios` on the second card when that card holds one, else the first. Cores are read from that config's `libretro_directory` (`/opt/muos/share/core`) |
| `/mnt/mmc/MUOS/` | RetroArch | `/mnt/mmc/MUOS/bios` |
| `/home/ark` and `/opt/system` | RetroArch | `/roms/bios` |
| `/mnt/vendor/bin/dmenu.bin` | RetroArch | `/mnt/mmc/bios` |
Where a system's RetroArch config is named, its `system_directory` is the
folder, `~` being that system's home for RetroArch. The folder in the last
column is used when no config sets one. ArkOS and dArkOS keep two configs
(64-bit and 32-bit RetroArch), and their "Switch to SD2 for Roms" script
rewrites both to `/roms2/bios`: a second card is followed through them.
| Evidence | System | File list | BIOS directory |
|----------|--------|-----------|----------------|
| `/etc/os-release` `OS_NAME=ROCKNIX` (or `ID=rocknix` on images before 2025) | ROCKNIX | ROCKNIX | `/storage/roms/bios` |
| `/etc/os-release` `OS_NAME=knulli` or `/usr/share/knulli/knulli.version` | KNULLI | Batocera | `/userdata/bios` |
| `/etc/os-release` `ID=lakka` | Lakka | Lakka | `/storage/system` |
| `/usr/share/batocera/batocera.version` | Batocera | Batocera | `/userdata/bios` |
| `/recalbox/recalbox.version` or `/usr/bin/recalbox-settings` | Recalbox | Recalbox | `/recalbox/share/bios` |
| `/opt/muos` | muOS | RetroArch | `system_directory` of `/opt/muos/share/info/config/retroarch.cfg`, else of the default it is copied from: `/run/muos/storage/bios`, which muOS binds to `MUOS/bios` on the second card when that card holds one, else the first. Cores are read from that config's `libretro_directory` (`/opt/muos/share/core`) |
| `/mnt/mmc/MUOS/` | muOS | RetroArch | `/mnt/mmc/MUOS/bios` |
| `/etc/os-release` `NAME=AmberELEC` (CHAos writes the same) | AmberELEC | RetroArch | `/storage/.config/retroarch/retroarch.cfg`, else `/storage/roms/bios` |
| `/usr/bin/batocera-info` naming PAN4ELEC, on AmberELEC | PAN4ELEC | RetroArch | as AmberELEC |
| `/etc/os-release` `NAME=EmuELEC` | EmuELEC | RetroArch | as AmberELEC |
| `/etc/os-release` `OS_NAME=JELOS` | JELOS | RetroArch | as AmberELEC |
| `/usr/lib/systemd/system/jelos-ready.service`, on JELOS | KOLOS | RetroArch | as AmberELEC |
| `/etc/os-release` `OS_NAME=UnofficialOS` | UnofficialOS | RetroArch | as AmberELEC |
| `/etc/os-release` `OS_NAME=ArchR` | ArchR | RetroArch | as AmberELEC |
| `/home/ark` and `/opt/system`, or `title=ArkOS` in `/usr/share/plymouth/themes/text.plymouth` | ArkOS | RetroArch | `/home/ark/.config/retroarch/retroarch.cfg` then `retroarch32/retroarch.cfg`, else `/roms/bios` |
| `title=dArkOS` in the same file (the dArkOSRE, dArkOSen and ArkOS4Clone images keep it) | dArkOS | RetroArch | as ArkOS |
| `title=RetroOZ` in the same file, `/opt/.retrooz` or `/etc/retrooz_version` | RetroOZ | RetroArch | `/home/odroid/.config/retroarch/retroarch.cfg` then `retroarch32/retroarch.cfg`, else `/roms/bios` |
| `/mnt/SDCARD/spruce` | spruceOS | RetroArch | `/mnt/SDCARD/Saves/ra-configs/retroarch-*.cfg`, else `/mnt/SDCARD/BIOS` |
| `/mnt/SDCARD/System/usr/trimui/crossmix-version.txt` | CrossMix-OS | RetroArch | `/mnt/SDCARD/RetroArch/retroarch.cfg`, else `/mnt/SDCARD/BIOS` |
| `/mnt/SDCARD/.tmp_update/onionVersion/version.txt` | Onion | RetroArch | `/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg`, else `/mnt/SDCARD/BIOS` |
| `/mnt/SDCARD/.allium` | Allium | RetroArch | as Onion |
| `/mnt/SDCARD/Koriki/version.txt` | Koriki | RetroArch | as Onion |
| `/etc/main` and `/usr/bin/miyooctl` | MiyooCFW | RetroArch | `/mnt/.retroarch/retroarch.cfg`, else `/mnt/.retroarch/system` |
| `/mnt/plumos-user` | plumOS | RetroArch | `/mnt/plumos-user/bios`, which its launcher appends to every RetroArch run |
| `/etc/os-release` `ID=plumos` without the folder above | plumOS | none | its builds choose a folder per launch: named, not installed to |
| `/usr/trimui` | TrimUI | none | no published source names the stock firmware's folder: named, not installed to |
| `/mnt/SDCARD/.system/res` or `/mnt/sdcard/.system/res` | MinUI | none | MinUI, MyMinUI and NextUI give each core its own `Bios/<tag>/`, a layout not written yet: named, not installed to |
| `/mnt/vendor/bin/dmenu.bin` | Anbernic stock firmware | RetroArch | `/mnt/mmc/bios` |
AmberELEC, EmuELEC and KNULLI get the file list of the platform whose cores
they ship until a list of their own is published. TheRA is not detected: no
image or source of its Odroid Go builds is published, so neither its marker
files nor its folder could be read.
Batocera, Recalbox, ROCKNIX and Lakka also name the board they were built for, and
that board narrows the install unless `--target` or `--every-board` says