+
Some consoles need a small program, the **BIOS**, to start their games: PlayStation 1 and 2, Saturn, Neo Geo, Dreamcast and more. RetroBIOS installs the BIOS and firmware files for AmberELEC, Batocera, BizHawk, EmuDeck, EmuELEC, KNULLI, Lakka, MiSTer FPGA, MinUI, MyMinUI, NextUI, ROCKNIX, Recalbox, RetroArch, RetroBat, RetroDECK, RetroPie, and RomM, checked the way each one checks them.
## Install in one line
@@ -64,6 +66,7 @@ Unzip into the BIOS folder, files straight in it. A big pack comes in parts (`Pa
- **623 emulators profiled**, 589 from their source code and 34 closed-source builds from their binaries: 93% of the files they load cite the file and line that reads them
- **18 setups**, each checked its own way: MD5 for Batocera and Recalbox, the file name for RetroArch, as their own code does
- **532 systems**, 19,947 files in the collection, [what is still missing](https://abdess.github.io/retrobios/gaps/) named file by file
+- **2,222 archives**, each with a recipe that rebuilds it from the ROMs the collection holds: 1,790 byte for byte, 432 with the same files inside ([rebuilding an archive](https://abdess.github.io/retrobios/wiki/archive-recipes/))
- Public data: [JSON, CSV and SQLite](https://abdess.github.io/retrobios/data/), the metadata under MIT
@@ -107,4 +110,4 @@ The scripts and tooling are released under the [MIT License](LICENSE).
The BIOS and firmware files are not covered by that license: they are third-party system software, preserved and provided for personal backup, archival, and interoperability with emulation software. [NOTICE](NOTICE) sets out their status and how to ask for a file to be removed.
The reasoning, and where it is weakest, is in [the project's legal position](https://abdess.github.io/retrobios/wiki/legal/).
-*Auto-generated on 2026-11-09T07:40:17Z*
+*Auto-generated on 2026-11-09T13:12:14Z*
diff --git a/scripts/archive_build.py b/scripts/archive_build.py
index 0b4fdbec2..ac22efd07 100644
--- a/scripts/archive_build.py
+++ b/scripts/archive_build.py
@@ -83,6 +83,17 @@ def load_ledger(path: Path = ARCHIVES_FILE) -> dict[str, dict]:
return {record["path"]: record for record in data.get("archives", [])}
+def rebuild_counts(path: Path = ARCHIVES_FILE) -> dict[str, int]:
+ """How many recipes rebuild ``exact`` and ``canonical``, and ``total``:
+ the figures the README and the site print."""
+ records = load_ledger(path).values()
+ return {
+ "total": len(records),
+ **{kind: sum(1 for r in records if r.get("rebuild") == kind)
+ for kind in ("exact", "canonical")},
+ }
+
+
def ledger_text(records: dict[str, dict]) -> str:
"""One record per line, so a diff names the archive that changed."""
lines = [
diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py
index ef377d211..20f16688d 100644
--- a/scripts/generate_readme.py
+++ b/scripts/generate_readme.py
@@ -38,6 +38,7 @@ from common import (
write_if_changed,
)
from nativemode import check_label
+import archive_build
import release_record
from siteguides import PACKAGE_COMMAND, install_command
from validate_pr import LARGE_FILE_ISSUE_URL
@@ -563,6 +564,32 @@ def _catalog_matched_line(db: dict) -> list[str]:
]
+# The badge row under the links: (alt text, image, link).
+BADGES = (
+ ("PyPI version", "https://img.shields.io/pypi/v/retrobios", "https://pypi.org/project/retrobios/"),
+ ("Python versions", "https://img.shields.io/pypi/pyversions/retrobios",
+ "https://pypi.org/project/retrobios/"),
+ ("Validation",
+ "https://github.com/Abdess/retrobios/actions/workflows/validate.yml/badge.svg?branch=main",
+ "https://github.com/Abdess/retrobios/actions/workflows/validate.yml"),
+ ("License", "https://img.shields.io/github/license/Abdess/retrobios", "LICENSE"),
+)
+
+
+def badge_row() -> str:
+ badges = " ".join(f''
+ for alt, image, link in BADGES)
+ return f'
{badges}
'
+
+
+def archive_line(counts: dict[str, int]) -> str:
+ """The README's line on archive recipes, counted from recipes/archives.json."""
+ return (f"- **{counts['total']:,} archives**, each with a recipe that rebuilds it from the"
+ f" ROMs the collection holds: {counts['exact']:,} byte for byte,"
+ f" {counts['canonical']:,} with the same files inside"
+ f" ([rebuilding an archive]({SITE_URL}wiki/archive-recipes/))")
+
+
def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json") -> str:
total_files = db.get("total_files", 0)
total_size = db.get("total_size", 0)
@@ -629,6 +656,8 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
f'Questions'
"",
"",
+ badge_row(),
+ "",
"Some consoles need a small program, the **BIOS**, to start their games: PlayStation 1"
" and 2, Saturn, Neo Geo, Dreamcast and more. RetroBIOS installs the BIOS and firmware"
f" files for {_names_list(coverages)}, checked the way each one checks them.",
@@ -715,6 +744,7 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
" the file name for RetroArch, as their own code does",
f"- **{len(system_ids)} systems**, {total_files:,} files in the collection,"
f" [what is still missing]({SITE_URL}gaps/) named file by file",
+ archive_line(archive_build.rebuild_counts()),
f"- Public data: [JSON, CSV and SQLite]({SITE_URL}data/), the metadata under MIT",
"",
"",
diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index 6a4cc5978..e0a709f90 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -124,6 +124,7 @@ from siteguides import (
terminal_tabs,
trust_box,
)
+import archive_build
import release_record
import sitebootstrap
import sitemeasurements
@@ -8784,6 +8785,30 @@ def with_romset_table(page: str, profiles: dict) -> str:
return _ROMSET_BLOCK.sub(lambda m: f"{m.group(1)}{table}{m.group(3)}", page, count=1)
+ARCHIVE_RECIPES_PAGE = Path(WIKI_SRC_DIR) / "archive-recipes.md"
+_ARCHIVE_BLOCK = re.compile(
+ r"(\n)(.*?)()", re.S)
+
+
+def archive_recipe_lines(counts: dict[str, int]) -> list[str]:
+ """What an exact and a canonical rebuild are, each with its count."""
+ total = f"{counts['total']:,}"
+ return [
+ f"- **Exact**: the same bytes as the archive in the collection."
+ f" {counts['exact']:,} of {total} archives.",
+ f"- **Canonical**: the same files inside, packed another way."
+ f" {counts['canonical']:,} of {total} archives.",
+ ]
+
+
+def with_archive_counts(page: str, counts: dict[str, int]) -> str:
+ """The archive recipes page with its counts read from recipes/archives.json."""
+ block = "\n".join(archive_recipe_lines(counts)) + "\n"
+ if not _ARCHIVE_BLOCK.search(page):
+ raise ValueError(f"{ARCHIVE_RECIPES_PAGE}: no archive-recipes block to write the counts in")
+ return _ARCHIVE_BLOCK.sub(lambda m: f"{m.group(1)}{block}{m.group(3)}", page, count=1)
+
+
def _render_metadata_rows(profile: dict) -> list[str]:
"""Scalar profile fields, one table row each.
@@ -12673,6 +12698,10 @@ def _generate(args: argparse.Namespace) -> None:
faq = _REPO_ROOT / ROMSET_FAQ_PAGE
if faq.is_file():
write_if_changed(str(faq), with_romset_table(faq.read_text(encoding="utf-8"), profiles))
+ recipes_page = _REPO_ROOT / ARCHIVE_RECIPES_PAGE
+ if recipes_page.is_file():
+ write_if_changed(str(recipes_page), with_archive_counts(
+ recipes_page.read_text(encoding="utf-8"), archive_build.rebuild_counts()))
if wiki_src.is_dir():
for src_file in wiki_src.glob("*.md"):
shutil.copy2(src_file, wiki_dest / src_file.name)
diff --git a/tests/test_doc_surfaces.py b/tests/test_doc_surfaces.py
index ee2c44c90..b6ab15d7d 100644
--- a/tests/test_doc_surfaces.py
+++ b/tests/test_doc_surfaces.py
@@ -636,5 +636,74 @@ class ContributorChecksFetchThePlatformsFiles(unittest.TestCase):
self.assertIn("export_native.py --all --fetch", _read("wiki/testing-guide.md"))
+class ArchiveRecipesAreCountedFromTheRecipes(unittest.TestCase):
+ """Every archive has a recipe in recipes/archives.json: the README and
+ the site's page say how many rebuild exact and canonical, counted from
+ that file, never typed."""
+
+ def setUp(self) -> None:
+ import archive_build # noqa: PLC0415
+
+ records = archive_build.load_ledger().values()
+ self.counts = {
+ "total": len(records),
+ "exact": sum(r["rebuild"] == "exact" for r in records),
+ "canonical": sum(r["rebuild"] == "canonical" for r in records),
+ }
+ self.assertEqual(archive_build.rebuild_counts(), self.counts)
+ self.assertEqual(self.counts["exact"] + self.counts["canonical"], self.counts["total"])
+
+ def test_the_readme_line(self) -> None:
+ from generate_readme import archive_line # noqa: PLC0415
+
+ line = archive_line(self.counts)
+ self.assertIn(line, _read("README.md").splitlines())
+ self.assertIn(f"**{self.counts['total']:,} archives**", line)
+ self.assertIn(f"{self.counts['exact']:,} byte for byte", line)
+ self.assertIn(f"{self.counts['canonical']:,} with the same files inside", line)
+ self.assertIn(f"({SITE_URL}wiki/archive-recipes/)", line)
+
+ def test_the_page_counts(self) -> None:
+ import generate_site # noqa: PLC0415
+
+ page = _read("wiki/archive-recipes.md")
+ self.assertEqual(generate_site.with_archive_counts(page, self.counts), page,
+ "wiki/archive-recipes.md counts are stale: run scripts/generate_site.py")
+ for line in generate_site.archive_recipe_lines(self.counts):
+ self.assertIn(line, page)
+
+ def test_the_page_command_is_the_tools(self) -> None:
+ command = re.search(r"python scripts/romset_recipes.py (.+)", _read("wiki/archive-recipes.md"))
+ source = _read("scripts/romset_recipes.py")
+ for flag in re.findall(r"--[a-z-]+", command.group(1)):
+ with self.subTest(flag=flag):
+ self.assertIn(f'"{flag}"', source)
+
+
+class TheReadmeBadges(unittest.TestCase):
+ """One row of four badges under the links, each linking its source."""
+
+ EXPECTED = (
+ '
'
+ ' '
+ ' '
+ f' '
+ ''
+ "
"
+ )
+
+ def test_exactly_these_four(self) -> None:
+ readme = _read("README.md")
+ self.assertEqual(len(re.findall(r"Get the files" in line)
+ self.assertEqual(lines[links + 2], self.EXPECTED)
+
+ def test_the_workflow_and_the_license_exist(self) -> None:
+ self.assertTrue((REPO_ROOT / ".github" / "workflows" / "validate.yml").is_file())
+ self.assertTrue((REPO_ROOT / "LICENSE").is_file())
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_site_copy.py b/tests/test_site_copy.py
index 5d2bb9662..999a4503b 100644
--- a/tests/test_site_copy.py
+++ b/tests/test_site_copy.py
@@ -41,6 +41,7 @@ START_PAGES = {
# Reference pages written for anyone, held to the Start here budgets.
START_LEVEL_PAGES = {
"wiki/measurements.md": 300,
+ "wiki/archive-recipes.md": 160,
}
# Level 1: the guide part of each setup page, above its Reference heading.
SETUP_CEILING = 220
diff --git a/wiki/archive-recipes.md b/wiki/archive-recipes.md
new file mode 100644
index 000000000..a5091b9bf
--- /dev/null
+++ b/wiki/archive-recipes.md
@@ -0,0 +1,37 @@
+---
+level: reference
+order: 45
+group: Checking files
+terms: recipe, rebuild, archive, zip, romset, exact, canonical
+---
+
+# Rebuilding an archive
+
+Arcade emulators read their BIOS files from a ZIP archive.
+
+The collection keeps a **recipe** for each archive. It lists the files
+inside, in order, with their fingerprints. The recipe and those files make
+the archive again. No copy from elsewhere is needed.
+
+## Rebuild one archive
+
+In a copy of the project:
+
+```bash
+python scripts/romset_recipes.py --build neogeo.zip --output-dir rebuilt
+```
+
+Each archive of that name is made under `rebuilt/`, in the folders it has in
+the collection. It is checked against its recipe before it is kept.
+
+## Exact or canonical
+
+
+- **Exact**: the same bytes as the archive in the collection. 1,790 of 2,222 archives.
+- **Canonical**: the same files inside, packed another way. 432 of 2,222 archives.
+
+
+An emulator reads the files inside, so both work the same. A setup that
+checks the whole archive by its fingerprint needs the exact one.
+
+Every option of the command: [tools](tools.md#archive-recipes).
From 4c0c480bdd51fdb508d8f95ebf9c6d99a2840995 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 9 Nov 2026 14:23:35 +0100
Subject: [PATCH 03/17] docs: tell agents how to drive the installer
llms.txt now gives the package commands, the plan as JSON events from
--dry-run --events, the exit codes and the plan, consent, act pattern
with --yes. Tests hold each fact to install.py and to the exit code table
of the installer page.
---
scripts/siteagents.py | 35 ++++++++++++++++++++++++++++
tests/test_site_agents.py | 49 +++++++++++++++++++++++++++++++++++++++
2 files changed, 84 insertions(+)
diff --git a/scripts/siteagents.py b/scripts/siteagents.py
index 60b366943..f05d0bcfa 100644
--- a/scripts/siteagents.py
+++ b/scripts/siteagents.py
@@ -19,6 +19,8 @@ import json
from collections.abc import Callable
from pathlib import Path
+from siteguides import PACKAGE_COMMAND
+
SITE = "https://abdess.github.io/retrobios/"
PUBLISHER = "abdess.github.io"
SH = "curl -fsSL https://abdess.github.io/retrobios/i | sh -s --"
@@ -31,6 +33,22 @@ QUOTING = (f"A folder goes in single quotes, attached to its option. One that ho
"command. On a page of the site, the `device_setup_command` WebMCP tool writes the "
"command and refuses such a folder.")
+UVX = PACKAGE_COMMAND
+# EVENT_SCHEMA of install.py, and the schema generate_event_schema.py writes
+# from EVENT_KEYS, as the site publishes it. tests/test_site_agents.py holds
+# both to install.py.
+EVENT_SCHEMA = 1
+EVENTS_SCHEMA_PATH = "api/v1/schemas/install-events.schema.json"
+# Every code of the Exit status table of wiki/installer.md, in short.
+EXIT_CODES = (
+ (0, "everything asked for is installed or already in place"),
+ (1, "a failure: a download, a file list or a folder that cannot be used"),
+ (2, "nothing was done: an option refused, every file failed, or the user quit"),
+ (3, "`--check` found files missing or wrong, nothing having failed"),
+ (130, "stopped with Ctrl+C"),
+ (141, "the output went to a reader that closed early"),
+)
+
START_PAGES = (
("Which pack is needed", "which-pack/", "pick a device or setup, get the folder its BIOS files go in, "
"one command that installs and checks them, or a ZIP pack"),
@@ -108,6 +126,23 @@ def llms_txt(platforms: list[tuple[str, str]]) -> str:
"- A game the installer cannot identify gets every BIOS of its system, and the run names it.",
f"- The same steps as an Agent Skill: [SKILL.md]({SITE}{SKILL_PATH}).",
"",
+ "## Driving the installer for a user",
+ "",
+ f"With Python 3.8 or newer, the installer is also a package: `{UVX} --for-games "
+ f"--required-only --dry-run`, or `pipx run retrobios` with the same options.",
+ "",
+ "1. Plan: add `--dry-run --events`. Nothing is written, and standard output carries "
+ f"one JSON object per line, event schema {EVENT_SCHEMA} "
+ f"([JSON Schema]({SITE}{EVENTS_SCHEMA_PATH})): a `file` event per file with its "
+ "`state`, `check` with the totals and `bytes_needed`, then `summary` with `pending`.",
+ "2. Consent: show the user what will be installed, where, and the size, and ask.",
+ "3. Act, only on a yes: the same command with `--yes` in place of `--dry-run`.",
+ "",
+ "- `--yes` asks nothing: use it in a run without a terminal. A run that needs an "
+ "answer (two setups found, none found) stops and names the option to give.",
+ "- The exit code, also the `code` of the last `end` event:",
+ *(f" - `{code}`: {meaning}" for code, meaning in EXIT_CODES),
+ "",
"## Start",
"",
*(f"- [{title}]({SITE}{path}): {what}" for title, path, what in START_PAGES),
diff --git a/tests/test_site_agents.py b/tests/test_site_agents.py
index 93af6f9d1..dbbbf4eb2 100644
--- a/tests/test_site_agents.py
+++ b/tests/test_site_agents.py
@@ -50,6 +50,55 @@ class TheGuideLeadsToTheFewestFiles(unittest.TestCase):
self.assertTrue(target.is_file() or (target / "index.html").is_file(), url)
+class TheGuideDrivesTheInstaller(unittest.TestCase):
+ """What an agent needs to run the installer for a user: the package
+ command, the plan as JSON, the exit codes, and plan, consent, act. Each
+ fact is held to install.py, so the guide cannot drift from it."""
+
+ def setUp(self) -> None:
+ import generate_installer_doc # noqa: PLC0415
+
+ self.doc = generate_installer_doc
+ self.text = siteagents.llms_txt([("retroarch", "RetroArch")])
+ self.section = self.text.split("\n## Driving the installer for a user\n", 1)[1].split("\n## ", 1)[0]
+
+ def test_the_exit_codes_are_the_installer_page_table(self) -> None:
+ page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
+ table = page.split("", 1)[1].split("")[0]
+ in_table = [int(code) for code in re.findall(r"^\| `(\d+)` \|", table, re.M)]
+ self.assertEqual(in_table, self.doc.exit_codes())
+ self.assertEqual([code for code, _ in siteagents.EXIT_CODES], in_table)
+ listed = [int(code) for code in re.findall(r"^ - `(\d+)`: ", self.section, re.M)]
+ self.assertEqual(listed, in_table)
+
+ def test_the_event_schema_is_the_installers(self) -> None:
+ import json # noqa: PLC0415
+
+ self.assertEqual(siteagents.EVENT_SCHEMA, self.doc._install.EVENT_SCHEMA)
+ schema = json.loads((REPO_ROOT / "schemas" / "install-events.schema.json").read_text(encoding="utf-8"))
+ self.assertEqual(schema["$id"], siteagents.SITE + siteagents.EVENTS_SCHEMA_PATH)
+ self.assertIn(f"event schema {siteagents.EVENT_SCHEMA} ", self.section)
+
+ def test_every_option_and_event_named_exists(self) -> None:
+ options = {flag for action in self.doc._install.build_parser()._actions
+ for flag in action.option_strings}
+ for flag in set(re.findall(r"(? None:
+ self.assertIn("`uvx retrobios --for-games --required-only --dry-run`", self.section)
+ self.assertIn("`pipx run retrobios`", self.section)
+ self.assertIn("`--dry-run --events`", self.section)
+ steps = re.findall(r"^\d\. (\w+)", self.section, re.M)
+ self.assertEqual(steps, ["Plan", "Consent", "Act"])
+
+
class TheSkillIsAnAgentSkill(unittest.TestCase):
"""ARD reserves text/markdown for agent skills (Lighthouse warns on a
plain Markdown entry): the setup steps are published as one."""
From 232f27f5db0c113ccb0f4294a57dbd2137538896 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 9 Nov 2026 14:25:51 +0100
Subject: [PATCH 04/17] docs: say a refused option writes no event
An agent reading the events learns that an option refused before the run starts ends on code 2 with no end event.
---
scripts/siteagents.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/scripts/siteagents.py b/scripts/siteagents.py
index f05d0bcfa..867df60d5 100644
--- a/scripts/siteagents.py
+++ b/scripts/siteagents.py
@@ -131,7 +131,7 @@ def llms_txt(platforms: list[tuple[str, str]]) -> str:
f"With Python 3.8 or newer, the installer is also a package: `{UVX} --for-games "
f"--required-only --dry-run`, or `pipx run retrobios` with the same options.",
"",
- "1. Plan: add `--dry-run --events`. Nothing is written, and standard output carries "
+ "1. Plan: run it with `--dry-run --events`. Nothing is written, and standard output carries "
f"one JSON object per line, event schema {EVENT_SCHEMA} "
f"([JSON Schema]({SITE}{EVENTS_SCHEMA_PATH})): a `file` event per file with its "
"`state`, `check` with the totals and `bytes_needed`, then `summary` with `pending`.",
@@ -140,7 +140,8 @@ def llms_txt(platforms: list[tuple[str, str]]) -> str:
"",
"- `--yes` asks nothing: use it in a run without a terminal. A run that needs an "
"answer (two setups found, none found) stops and names the option to give.",
- "- The exit code, also the `code` of the last `end` event:",
+ "- The exit code, also the `code` of the last `end` event. An option refused at once "
+ "writes no event at all:",
*(f" - `{code}`: {meaning}" for code, meaning in EXIT_CODES),
"",
"## Start",
From 40ea1cdaedcb0e98b46621e41bd035709c0a857e Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 9 Nov 2026 16:13:33 +0100
Subject: [PATCH 05/17] fix: read a wrapped path whole in screen tests
A folder under the checkout is as long as the checkout's own path. From a deep clone the screen broke it after a separator, as it should, and eight tests looked for it on one line.
---
tests/test_install_tui.py | 19 +++++++++++++------
1 file changed, 13 insertions(+), 6 deletions(-)
diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py
index 996b81917..4b375a283 100644
--- a/tests/test_install_tui.py
+++ b/tests/test_install_tui.py
@@ -366,6 +366,13 @@ HOME = Path("/home/user")
SYSTEM_DIR = HOME / ".config" / "retroarch" / "system"
+def screen_text(lines) -> str:
+ """The screen as one line of words, a path broken after a separator
+ joined again: a folder under the checkout is as long as the checkout's
+ own path, and a deep clone wrapped it across two lines."""
+ return re.sub(r"(?<=[/\\]) ", "", " ".join(" ".join(lines).split()))
+
+
def fresh_state() -> install.TuiState:
state = install.TuiState(os_type="linux", home=HOME)
state.setups = [("retroarch", SYSTEM_DIR, "RetroArch"),
@@ -1856,7 +1863,7 @@ class TicksBarAndPaths(unittest.TestCase):
state.stack = ["folder"]
self.assertEqual(install._choose(state, "minui", card / "Bios"), [])
self.assertEqual(state.screen, "cardroot")
- text = " ".join(line.strip() for line in install.tui_view(state, 120, 40))
+ text = screen_text(install.tui_view(state, 120, 40))
self.assertIn(f"under the card's root: {card}", text)
self.assertEqual(press(state, "enter"), [("fetch", "minui")])
self.assertEqual(state.dest, card)
@@ -2343,7 +2350,7 @@ class CardsSpaceAndConfirm(unittest.TestCase):
state, effects = install.tui_start(_args(), "linux")
label = f"muOS SD card ({root.name})"
self.assertEqual(state.setups, [("retroarch", root / "MUOS" / "bios", label)])
- text = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
+ text = screen_text(install.tui_view(state, 120, 40))
self.assertIn(f"> {label}", text)
self.assertIn(f"BIOS folder: {root / 'MUOS' / 'bios'}", text)
(root / "MUOS" / "bios").mkdir()
@@ -4135,7 +4142,7 @@ class AFolderThisUserCannotOpen(unittest.TestCase):
self.dest = self.lock / "system"
def said(self, state: install.TuiState) -> str:
- return " ".join(" ".join(install.tui_view(state, 200, 40)).split())
+ return screen_text(install.tui_view(state, 200, 40))
def test_typed_it_stays_on_the_path_screen(self):
state = fresh_state()
@@ -6507,7 +6514,7 @@ class OneCardReading(unittest.TestCase):
self.assertEqual(state.screen, "checking" if one else "create")
if not one:
self.assertIn(note.split(":")[0],
- " ".join(" ".join(install.tui_view(state, 120, 40)).split()))
+ screen_text(install.tui_view(state, 120, 40)))
install.tui_update(state, Key("enter"))
self.assertEqual(state.card_note, note)
@@ -11117,7 +11124,7 @@ class TapsGameDataAndExitCodes(_CardFixtures, unittest.TestCase):
_title, lines = install.tui_outcome(state)
self.assertIn(f"BIOS folder: {card / 'Bios'}", lines)
state.stack = ["checking"]
- self.assertIn(str(card / "Bios"), " ".join(install.tui_view(state, 120, 40)))
+ self.assertIn(str(card / "Bios"), screen_text(install.tui_view(state, 120, 40)))
# Root cause 7: colour roles read the words.
@@ -11263,7 +11270,7 @@ class TapsGameDataAndExitCodes(_CardFixtures, unittest.TestCase):
folder = next(i for i, line in enumerate(lines) if line.startswith("BIOS folder:"))
named = next(i for i, line in enumerate(lines) if line.startswith("Named by"))
# Nothing between the folder, wrapped or not, and what named it.
- self.assertTrue(all(line.startswith("/") for line in lines[folder + 1:named]), lines)
+ self.assertEqual("".join(lines[folder:named]), f"BIOS folder: {state.dest}", lines)
self.assertLess(named, next(i for i, line in enumerate(lines)
if line.startswith(("For ", "Copy them"))))
From a1f1fd0bab0893e1aae6a5305b7b54d8cde562f1 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 9 Nov 2026 17:12:50 +0100
Subject: [PATCH 06/17] feat: add yabadabadoo profile
---
emulators/yabadabadoo.yml | 86 +++++++++++++++++++++++++++++++++++++++
1 file changed, 86 insertions(+)
create mode 100644 emulators/yabadabadoo.yml
diff --git a/emulators/yabadabadoo.yml b/emulators/yabadabadoo.yml
new file mode 100644
index 000000000..1f27a4d77
--- /dev/null
+++ b/emulators/yabadabadoo.yml
@@ -0,0 +1,86 @@
+emulator: "Yabadabadoo"
+type: libretro
+core_classification: community_fork
+source: "https://github.com/ProRapSuperstarOriginalMaster/Yabadabadoo"
+upstream: "https://github.com/libretro/yabause"
+profiled_date: "2026-11-09"
+source_commit: "4c06da9779c485b532f43b3f18c4b81cb93cf11b"
+upstream_commit: "8926b0c6c347f8c5c755911ddb0ac695420ffbf8"
+pin_frozen: "ROCKNIX 20261101 builds this revision, tagged v0.3. Later tags of the fork are not part of that build."
+pin_build:
+ - platform: rocknix
+ repo: "https://github.com/ROCKNIX/distribution"
+ revision: "20261101"
+ path: "projects/ROCKNIX/packages/emulators/libretro/yabadabadoo-lr/package.mk"
+ variable: PKG_VERSION
+ value: "4c06da9779c485b532f43b3f18c4b81cb93cf11b"
+core_version: "0.3"
+version_basis:
+ kind: file
+ path: "yabause/src/libretro/libretro.c"
+ pattern: 'library_name\s+=\s+"Yabadabadoo";\s*\n\s*info->library_version\s+=\s+"([^ "]+)'
+display_name: "Sega - Saturn (Yabadabadoo)"
+cores:
+ - "yabadabadoo"
+built_by: [rocknix]
+systems:
+ - sega-saturn
+
+notes: |
+ Fork of the libretro Yabause core (master), tuned for ARMv7 handhelds.
+ The core is yabadabadoo_libretro.so.
+ ref: yabause/src/libretro/Makefile:69, yabause/src/libretro/Makefile:265-282
+
+ Saturn BIOS, read from the frontend's system directory. retro_load_game
+ first tries saturn_bios.bin, sega_101.bin, then mpr-17933.bin to learn
+ whether any BIOS is present. YabauseInit then reads the disc's area code
+ and asks bios_for_region for the file to load: for J, T and K discs (and
+ discs with no area code) sega_101.bin, saturn_bios.bin, mpr-17933.bin, for
+ every other area mpr-17933.bin, saturn_bios.bin, sega_101.bin. The
+ yabadabadoo_bios option forces the Japanese or the USA/Europe order.
+ ref: yabause/src/libretro/libretro.c:262-284, yabause/src/libretro/libretro.c:1089-1099,
+ yabause/src/libretro/libretro.c:1793-1807, yabause/src/libretro/libretro.c:1870-1871,
+ yabause/src/yabause.c:346-363, yabause/src/smpc.c:92-101, yabause/src/cs2.c:4186-4214
+
+ The image is read by T123Load, which refuses a file above 512 KiB, and a
+ BIOS that fails to load stops YabauseInit. No hash is checked.
+ ref: yabause/src/memory.c:1264-1267, yabause/src/memory.h:196-218
+
+ Builds with the SH-2 recompiler (DYNAREC != 0, the miyoomini platform sets
+ 3) refuse to start a game without a BIOS and show a screen naming the
+ files instead. Builds without it run the HLE BIOS. ROCKNIX builds the
+ miyoomini platform for its 32-bit RetroArch.
+ ref: yabause/src/libretro/libretro.c:192-204, yabause/src/libretro/libretro.c:1809-1821,
+ yabause/src/libretro/Makefile:7, yabause/src/libretro/Makefile:281,
+ yabause/src/libretro/Makefile.common:145-146
+
+ The cartridge option offers only the 1M and 4M RAM carts and no cartridge
+ or MPEG ROM path is passed, so no other firmware is read.
+ ref: yabause/src/libretro/libretro.c:1078-1087, yabause/src/libretro/libretro.c:1898-1902
+
+files:
+ - name: "saturn_bios.bin"
+ system: sega-saturn
+ required: true
+ max_size: 524288
+ validation: [max_size]
+ note: "Any region. Second choice for every disc region."
+ source_ref: "yabause/src/libretro/libretro.c:270-271, yabause/src/libretro/libretro.c:1793"
+
+ - name: "sega_101.bin"
+ system: sega-saturn
+ required: false
+ region: [japan, asia-ntsc, south-korea]
+ max_size: 524288
+ validation: [max_size]
+ note: "Saturn BIOS v1.01 (Japan). First choice for J, T and K discs."
+ source_ref: "yabause/src/libretro/libretro.c:270, yabause/src/libretro/libretro.c:1797"
+
+ - name: "mpr-17933.bin"
+ system: sega-saturn
+ required: false
+ region: [north-america, europe, oceania, asia-pal, brazil, latin-america]
+ max_size: 524288
+ validation: [max_size]
+ note: "Saturn BIOS v1.00 (USA/Europe). First choice for U, B, A, E and L discs."
+ source_ref: "yabause/src/libretro/libretro.c:271, yabause/src/libretro/libretro.c:1801"
From 2377985df712a7d037c3cd68dba851a8c60b9761 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 9 Nov 2026 17:16:29 +0100
Subject: [PATCH 07/17] docs: read the dsperate font check from code
prepare_dsi_hle refuses a TWLFontTable.dat under 256 bytes and reads its layout from bytes 0x84 and 0x86, warning on a mismatch. No hash is checked, so the three retail NAND sha1s go, and one entry states the size floor and the three layouts.
---
emulators/dsperate.yml | 32 +++++---------------------------
1 file changed, 5 insertions(+), 27 deletions(-)
diff --git a/emulators/dsperate.yml b/emulators/dsperate.yml
index ba5534c8c..d0b9066c9 100644
--- a/emulators/dsperate.yml
+++ b/emulators/dsperate.yml
@@ -140,34 +140,12 @@ files:
system: nintendo-dsi
required: false
hle_fallback: true
- size: 863296
- sha1: "c1101d7c9224acfa0350b5fc62da97d4c6d36141"
+ min_size: 256
+ validation: [size]
config_key: "paths.dsi_font"
- description: "DSi system font (Japan, Americas, Europe, Australia)"
- note: "Used for DSiWare run without a NAND. Absent, DSperate's own font table is generated. ROCKNIX's ini leaves the key unset. A file under 256 bytes is refused. No hash is checked, only the layout bytes 0x84 and 0x86, and a table whose layout differs from the title's region draws a warning. This is the normal layout (0x84 = 3, 0x86 = 0), read from nand:/sys/TWLFontTable.dat of a retail DSi NAND dump. The FAT16 partition is AES-CTR encrypted with the normal key ROL128((KeyX XOR KeyY) + 0xFFFEFB4E295902582A680F5F1A4F3E79, 42), where KeyX is built from the console ID in the nocash footer and KeyY is 0x0AB9DC76 0xBD4DC4D3 0x202DDD1D 0xE1A00005. The counter is the first 16 bytes of SHA-1 of the footer's eMMC CID, and each 16-byte block is byte-reversed before and after AES. The USA, Europe, Japan and Australia consoles, four different console IDs, give the same bytes."
- source_ref: "src/core/nds_dsi.cpp:310-327, src/core/io/dsi_font.cpp:38-43, src/core/io/dsi_nand_fs.cpp:786-800, src/core/io/dsi_aes.cpp:38-46, src/frontend/sdl/main.cpp:1774-1778"
-
- - name: "TWLFontTable.dat"
- system: nintendo-dsi
- required: false
- hle_fallback: true
- size: 581664
- sha1: "5170aff7e9766baadbbf2943c41f5634e82aaf2d"
- config_key: "paths.dsi_font"
- description: "DSi system font (China)"
- note: "The Chinese layout (0x84 = 9, 0x86 = 4), which Chinese DSiWare needs to find its fonts. Read from nand:/sys/TWLFontTable.dat of a retail Chinese DSi NAND dump, decrypted as for the normal table."
- source_ref: "src/core/nds_dsi.cpp:313-320, src/core/io/dsi_font.cpp:38-43, src/core/io/dsi_nand_fs.cpp:786-800, src/core/io/dsi_aes.cpp:38-46"
-
- - name: "TWLFontTable.dat"
- system: nintendo-dsi
- required: false
- hle_fallback: true
- size: 162688
- sha1: "f0a37b073eb0208d3a54c9a6bb94292dbec1192a"
- config_key: "paths.dsi_font"
- description: "DSi system font (Korea)"
- note: "The Korean layout (0x84 = 9, 0x86 = 5), which Korean DSiWare needs to find its fonts. Read from nand:/sys/TWLFontTable.dat of a retail Korean DSi NAND dump, decrypted as for the normal table."
- source_ref: "src/core/nds_dsi.cpp:313-320, src/core/io/dsi_font.cpp:38-43, src/core/io/dsi_nand_fs.cpp:786-800, src/core/io/dsi_aes.cpp:38-46"
+ description: "DSi system font"
+ note: "Used for DSiWare run without a NAND. A path that names no file falls back to DSperate's own tables, linked into the binary. ROCKNIX's ini leaves the key unset. A file under 256 bytes stops the session. No hash is checked. The layout is read from bytes 0x84 and 0x86: 3 and 0 for the normal table (Japan, Americas, Europe, Australia), 9 and 4 for the Chinese one, 9 and 5 for the Korean one. Chinese and Korean titles need their own layout and every other title the normal one. Another layout only draws a warning, and the title does not find its fonts."
+ source_ref: "src/core/nds_dsi.cpp:311-323, src/core/io/dsi_font.cpp:38-43, src/frontend/sdl/main.cpp:1774-1778"
- name: "usrcheat.dat"
system: nintendo-ds
From b270a24d7baf1d4d752ec97acd236771f60e0c18 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 9 Nov 2026 17:18:44 +0100
Subject: [PATCH 08/17] feat: take a platform's own libretro builds
A list naming retroarch now takes the libretro profiles whose built_by names the platform: ROCKNIX ships yabadabadoo with its RetroArch and named it nowhere, so its files never reached the ROCKNIX pack. Only change across the registry: rocknix + yabadabadoo. Also quotes the installer's current unproven-drive message.
---
scripts/common.py | 12 +++++++++---
tests/test_e2e.py | 17 +++++++++++++++++
wiki/adding-a-platform.md | 4 +++-
wiki/installer.md | 2 +-
4 files changed, 30 insertions(+), 5 deletions(-)
diff --git a/scripts/common.py b/scripts/common.py
index 968320769..3b9e25538 100644
--- a/scripts/common.py
+++ b/scripts/common.py
@@ -2891,15 +2891,21 @@ def resolve_platform_cores(
If target_cores is provided, result is intersected with it.
"""
cores_config = config.get("cores")
+ platform_key = str(config.get("_key") or "")
def every_libretro() -> set[str]:
- # A build only some platforms compile (built_by) is reached by name:
- # the buildbot RetroArch installs from does not publish it.
+ # A build only some platforms compile (built_by) is absent from the
+ # buildbot RetroArch installs from, so it is reached by name, or as one
+ # of the libretro cores of a platform that compiles it: ROCKNIX ships
+ # yabadabadoo with its RetroArch and names it nowhere else.
return {
name
for name, p in profiles.items()
if "libretro" in p.get("type", "") and p.get("type") != "alias"
- and not p.get("built_by")
+ and (
+ not p.get("built_by")
+ or (platform_key and platform_key in p["built_by"])
+ )
}
if cores_config == "all_libretro":
diff --git a/tests/test_e2e.py b/tests/test_e2e.py
index ada7bd5ce..80d21535e 100644
--- a/tests/test_e2e.py
+++ b/tests/test_e2e.py
@@ -1183,6 +1183,23 @@ class TestE2E(unittest.TestCase):
recalbox = {"cores": ["mame0278"], "systems": {}}
self.assertEqual(resolve_platform_cores(recalbox, profiles), {"mame0278"})
+ def test_a_platform_s_own_libretro_build_comes_with_its_retroarch(self):
+ """ROCKNIX ships yabadabadoo with its RetroArch and names it nowhere
+ else: a list naming retroarch takes the libretro builds whose
+ built_by names that platform, and no other platform's."""
+ profiles = {
+ "yabause": {"type": "libretro", "systems": ["saturn"], "files": []},
+ "yabadabadoo": {"type": "libretro", "built_by": ["rocknix"],
+ "systems": ["saturn"], "files": []},
+ }
+ rocknix = {"_key": "rocknix", "cores": ["retroarch"], "systems": {}}
+ self.assertEqual(resolve_platform_cores(rocknix, profiles),
+ {"yabause", "yabadabadoo"})
+ other = {"_key": "batocera", "cores": ["retroarch"], "systems": {}}
+ self.assertEqual(resolve_platform_cores(other, profiles), {"yabause"})
+ everyone = {"_key": "retroarch", "cores": "all_libretro", "systems": {}}
+ self.assertEqual(resolve_platform_cores(everyone, profiles), {"yabause"})
+
def test_resolve_cores_explicit_list(self):
"""Explicit cores list matches against profile dict keys."""
config = {"cores": ["fbneo", "opera"], "systems": {"arcade": {"files": []}}}
diff --git a/wiki/adding-a-platform.md b/wiki/adding-a-platform.md
index 3e39160d4..05311d75f 100644
--- a/wiki/adding-a-platform.md
+++ b/wiki/adding-a-platform.md
@@ -198,7 +198,9 @@ resolved. Three strategies exist:
platform, that profile answers for the name. Used by Batocera, Recalbox, RetroBat,
RomM, KNULLI, AmberELEC, EmuELEC, MinUI, NextUI, MyMinUI, RetroPie, EmuDeck,
RetroDECK, ROCKNIX. A list that names `retroarch` (the last
- four) also takes every libretro profile, as `all_libretro` does. An empty list
+ four) also takes every libretro profile, as `all_libretro` does, plus the
+ libretro builds whose `built_by` names this platform (ROCKNIX's
+ `yabadabadoo`). An empty list
(BizHawk, MiSTer FPGA) resolves no profile, and the pack holds the platform's own
files.
- **all_libretro**: `cores: all_libretro` - every profile whose `type` contains
diff --git a/wiki/installer.md b/wiki/installer.md
index 1db08b4ff..7c0a297a7 100644
--- a/wiki/installer.md
+++ b/wiki/installer.md
@@ -1323,7 +1323,7 @@ their systems`), and its `bios` folder comes first at the folder question,
under Enter, before this computer's usual folder. A drive with a `bios` folder
and none of those folders, a data drive or a stick, is numbered with a note
(`DATA has a bios folder but no folder a handheld system writes. Pick it below
-if your files go there.`) and never taken under Enter. Another system's card or SHARE drive, and a
+if the BIOS files go there.`) and never taken under Enter. Another system's card or SHARE drive, and a
drive two systems fit, are left out, and a line names them: each is reached
through its own setup. A folder typed is read from the current
one when it is not a full path, and named in full after. `q` quits there too
From eb75c2d1f565e2bba0797119a0bff20ac8f3b94c Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 9 Nov 2026 17:19:16 +0100
Subject: [PATCH 09/17] docs: read uqm content checks from the engine
The engine checks no hash: only the Windows installer compares the packages.nsh MD5s, so validation and verification go. The content pack stays required from LoadKernel, which exits without a .rmp index at the content root and names the pack.
---
emulators/uqm.yml | 37 ++++++++++++++++---------------------
1 file changed, 16 insertions(+), 21 deletions(-)
diff --git a/emulators/uqm.yml b/emulators/uqm.yml
index 3fd73e99c..3d351956a 100644
--- a/emulators/uqm.yml
+++ b/emulators/uqm.yml
@@ -14,7 +14,6 @@ version_basis:
display_name: "The Ur-Quan Masters"
cores: [uqm]
systems: [ur-quan-masters, star-control-2]
-verification: md5
notes: |
The Ur-Quan Masters (UQM) is the open-source port of Toys for Bob's
@@ -26,16 +25,18 @@ notes: |
prepareContentDir() in src/options.c:136 searches for content/
relative to the executable (default locations are CONTENTDIR, "",
"content", "../../content", while macOS also probes the app bundle). A
- sentinel file content/version must exist (lines 138-150) or the game
+ sentinel file content/version must exist (lines 138-182) or the game
logs "Could not find content" and exits.
- mountContentDir() (src/options.c:329) mounts content/ at the uio root
- and then auto-mounts every .uqm file found under content/packages/ as
- an overlay archive (lines 354-359). The engine itself does not treat
- individual .uqm packages as mandatory: the fatal check is the
- content/ tree. In practice content/packages/uqm-0.8.0-content.uqm is
- "Core Data" in the Windows installer (uqm-installer.nsi:289-306,
- SectionIn 1 2 3 4 6) and the game is unplayable without it.
+ mountContentDir() (src/options.c:330-361) mounts content/ at the uio
+ root and then mounts every .zip or .uqm file under content/packages/
+ below it (line 357, mountDirZips at 463-489). The archive name is not
+ read. LoadKernel then needs at least one .rmp index at the content
+ root (src/uqm/setup.c:113-115), and without one the game logs "Could
+ not load basic content", names uqm-0.8.0-content.uqm and exits
+ (src/uqm/starcon.c:194-204). The content pack carries uqm.rmp and
+ base/. A content/ tree that already holds them unpacked passes the
+ same check, as the tree at this pin does.
3DO music (uqm-0.8.0-3domusic.uqm) and voice overs
(uqm-0.8.0-voice.uqm) are optional sections (installer lines 315,
@@ -50,9 +51,10 @@ notes: |
(uqm.key, menu.key) are shipped verbatim in the source repository
and are not external data, so they are not listed here.
- MD5 and size values come from build/win32_install/packages.nsh,
- autogenerated by procpkgs.sh at release time. Sizes are the KiB
- values the NSIS installer uses with AddSize.
+ The engine checks no hash. The MD5 values come from
+ build/win32_install/packages.nsh, autogenerated by procpkgs.sh at
+ release time, and only the Windows installer compares them, offering
+ a fresh download on a mismatch (uqm-installer.nsi:132-190).
files:
- name: uqm-0.8.0-content.uqm
@@ -62,10 +64,9 @@ files:
required: true
installed_on: [retropie]
md5: "6cbc9d51fa63e07c0f4d1d061136d816"
- validation: [md5]
description: "UQM core game data archive"
- note: "Published apart from the source release. The RetroPie scriptmodule downloads it into content/packages."
- source_ref: "sc2/build/win32_install/packages.nsh:3-5; sc2/build/win32_install/uqm-installer.nsi:289-306"
+ note: "Published apart from the source release. Carries uqm.rmp and base/: without a .rmp index at the content root the game exits with \"Could not load basic content\". Any .zip or .uqm in content/packages is mounted, the name is not read. The RetroPie scriptmodule downloads it into content/packages."
+ source_ref: "sc2/src/uqm/starcon.c:194-204, sc2/src/uqm/setup.c:113-115, sc2/src/options.c:354-359, sc2/src/options.c:463-489, sc2/src/options.c:491-515, sc2/build/win32_install/packages.nsh:3-5"
- name: uqm-0.8.0-3domusic.uqm
path: uqm/content/addons/uqm-0.8.0-3domusic.uqm
@@ -74,7 +75,6 @@ files:
required: false
installed_on: [retropie]
md5: "9e5801d45ca12028b486cdeb83568c02"
- validation: [md5]
description: "UQM 3DO music track pack"
note: "The RetroPie scriptmodule downloads it into content/addons."
source_ref: "sc2/build/win32_install/packages.nsh:6-8; sc2/build/win32_install/uqm-installer.nsi:315-327, sc2/src/options.c:393-402, 518-551, sc2/src/uqm/setup.c:118-132"
@@ -86,7 +86,6 @@ files:
required: false
installed_on: [retropie]
md5: "fc89f77d7b66c2669abca6c157f5259a"
- validation: [md5]
description: "UQM alien-race voice-over pack"
note: "The RetroPie scriptmodule downloads it into content/addons."
source_ref: "sc2/build/win32_install/packages.nsh:9-11; sc2/build/win32_install/uqm-installer.nsi:329-341, sc2/src/options.c:393-402, 518-551, sc2/src/uqm/setup.c:118-132"
@@ -97,7 +96,6 @@ files:
category: game_data
required: false
md5: "09f242d8d72166d1d5ccbd3d99c93e7d"
- validation: [md5]
description: "Precursor Remix disc 1 (modern remixes)"
source_ref: "sc2/build/win32_install/packages.nsh:12-14; sc2/build/win32_install/uqm-installer.nsi:345-358, sc2/src/options.c:393-402, 518-551, sc2/src/uqm/setup.c:118-132"
@@ -107,7 +105,6 @@ files:
category: game_data
required: false
md5: "fbc8bdcb709939d559d8c7216ad15cc2"
- validation: [md5]
description: "Precursor Remix disc 2"
source_ref: "sc2/build/win32_install/packages.nsh:15-17, sc2/src/options.c:393-402, 518-551, sc2/src/uqm/setup.c:118-132"
@@ -117,7 +114,6 @@ files:
category: game_data
required: false
md5: "5ccc6d4ac301ae98e172ac6835dcdead"
- validation: [md5]
description: "Precursor Remix disc 3"
source_ref: "sc2/build/win32_install/packages.nsh:18-20, sc2/src/options.c:393-402, 518-551, sc2/src/uqm/setup.c:118-132"
@@ -127,6 +123,5 @@ files:
category: game_data
required: false
md5: "3fb63f4ac514343ed4b4b5b194c413fb"
- validation: [md5]
description: "Precursor Remix disc 4-1"
source_ref: "sc2/build/win32_install/packages.nsh:21-23, sc2/src/options.c:393-402, 518-551, sc2/src/uqm/setup.c:118-132"
From da9808e26e2336a00545c7d03cfff3940c71e9ad Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 9 Nov 2026 17:21:09 +0100
Subject: [PATCH 10/17] docs: restore installer prose lost in a merge
The merge of 6c1446886 took one side of wiki/installer.md for its generated samples and dropped four hand-written changes from the other (folder browser text, --restore row, muOS card text, a kept-file sample). Redone as a three-way merge, samples regenerated.
---
wiki/installer.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/wiki/installer.md b/wiki/installer.md
index 7c0a297a7..fc77eb2e3 100644
--- a/wiki/installer.md
+++ b/wiki/installer.md
@@ -185,7 +185,7 @@ downloads, the folder and, for a list with files read from the frontend's save
directory, that directory. Each row names the option it sets, and **Show the
command line** gives the same choice as one command for a script. **Preview the
file list** shows what would be downloaded and writes nothing. **Copy the BIOS
-files from your old card or a folder** opens the folder browser on the first
+files from an old card or a folder** opens the folder browser on the first
mounted card, and adds [`--from`](#copy-from-an-old-card): the plan then says how
many files that folder holds and how many are left to download, or that it
holds none of them. After an install
@@ -662,7 +662,7 @@ nothing is replaced. The end of the run says how many files were kept, their
size, the folder, and the command that puts them back:
```
-Kept 1 file you had (512.0 KB), which the collection's copies replaced, in /home/me/.config/retroarch/retrobios-backup/20261022-153000
+Kept 1 file already there (512.0 KB), which the collection's copies replaced, in /home/me/.config/retroarch/retrobios-backup/20261022-153000
To put them back: python3 install.py --restore /home/me/.config/retroarch/retrobios-backup/20261022-153000
```
@@ -1007,7 +1007,7 @@ what the options name, everything by default.
| `--from-pack PATH` | Install from a pack already downloaded: its ZIP, one of its parts (the others are found in the same folder), or the folder holding them. Makes no network request and checks every file's hash. See [Without a network](#without-a-network-offline-install) |
| `--offline` | Make no network request. `--check`, `--from`, `--from-pack` and `--manifest` work without one, the file list stops the run at once when none of them gives it, and a file the run would download fails at once. `RETROBIOS_INSTALL_OFFLINE=1` does the same. `--target` and `--list-targets` read the core list per board a pack carries, or `targets/.json` beside `--manifest`, and are refused when neither gives one |
| `--manifest FILE` | Read the file list from FILE, a copy of `install/.json`, instead of downloading it. See [Without a network](#without-a-network-offline-install) |
-| `--restore FOLDER` | Put back the files a run replaced and kept in FOLDER, a dated folder inside `retrobios-backup`. Takes no other option but `--dry-run` and `--no-input`. See [Files you already had](#files-you-already-had) |
+| `--restore FOLDER` | Put back the files a run replaced and kept in FOLDER, a dated folder inside `retrobios-backup`. Takes no other option but `--dry-run` and `--no-input`. See [Files already in the folder](#files-you-already-had) |
| `--tui` | Open the full-screen interface, or stop with the reason it cannot open here (code 2) instead of falling back to the questions. Wins over `RETROBIOS_PLAIN` and `ACCESSIBLE`. See [The full-screen interface](#the-full-screen-interface) |
| `--plain` | Ask the questions line by line instead of the full-screen interface, the mode for a screen reader. See [The full-screen interface](#the-full-screen-interface) |
| `--version` | Print the installer's identity, the first 12 hex digits of the SHA-256 the bootstraps pin |
@@ -1573,7 +1573,7 @@ it names the pack and the folder. The releases carry no pack for ROCKNIX,
Knulli or AmberELEC: there RetroBIOS needs Wi-Fi, or the computer route.
On a two-card device, RetroBIOS without Wi-Fi first asks to **copy the BIOS
-files from your other card**: muOS's `MUOS/bios` on the card the system does
+files from the other card**: muOS's `MUOS/bios` on the card the system does
not read, or the other of `/roms/bios` and `/roms2/bios` on ArkOS and dArkOS.
A says yes, as [`--from`](#copy-from-an-old-card) does. The list of files
still comes from the pack, so the pack is needed too.
From d9aaa9bdba381aa599861c6c98ad1c9c1151ef69 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 9 Nov 2026 17:23:08 +0100
Subject: [PATCH 11/17] docs: split xemu system files by build
The standalone runs without the MCPX ROM and the disk image, queuing an error, and stops only without a flash ROM: vl.c clears autostart. bootrom_path, flashrom_path and hdd_path have no default, so their names are labels and the standalone entries lose path. The core still refuses to load without the MCPX ROM or the flash.
---
emulators/xemu.yml | 69 +++++++++++++++++++++++++++++-----------------
1 file changed, 44 insertions(+), 25 deletions(-)
diff --git a/emulators/xemu.yml b/emulators/xemu.yml
index 55f44153d..b61eff341 100644
--- a/emulators/xemu.yml
+++ b/emulators/xemu.yml
@@ -99,47 +99,67 @@ notes: |
files:
- name: mcpx_1.0.bin
- path: "xemu/mcpx_1.0.bin"
- standalone_path: mcpx_1.0.bin
- required: true
+ mode: standalone
+ required: false
size: 512
validation: [size]
config_key: sys.files.bootrom_path
+ found_by: content
description: "MCPX boot ROM"
note: >-
Passed as the bootrom machine property and read over the last 512 bytes
of the flash image, which the machine maps as a writable overlay at the
- top of the address space. qemu_init drops the path with an error when
- the file cannot be opened or is not 512 bytes, and the machine then runs
- the flash alone. The core reads system/xemu/mcpx_1.0.bin, else
- mcpx_1.0.bin at the root of the system directory, and refuses to load
- when neither is there.
+ top of the address space. bootrom_path has no default, so the name is a
+ label. qemu_init drops a path that cannot be opened or is not 512 bytes
+ and queues an error, and the machine then runs the flash alone, as it
+ does when the path is empty.
+ source_ref: "system/vl.c:2983-3006, hw/xbox/xbox.c:143-171, hw/xbox/xbox.c:468-470, config_spec.yml:362-366"
+
+ - name: mcpx_1.0.bin
+ mode: libretro
+ path: "xemu/mcpx_1.0.bin"
+ required: true
+ size: 512
+ validation: [size]
+ description: "MCPX boot ROM"
+ note: >-
+ Read from system/xemu/mcpx_1.0.bin, else mcpx_1.0.bin at the root of
+ the system directory. The core refuses to load when neither is there.
source_ref:
- standalone: "system/vl.c:2983-3006, hw/xbox/xbox.c:143-171, hw/xbox/xbox.c:468-470"
libretro: "ui/libretro.c:3370-3415, ui/libretro.c:3418-3427"
- name: Complex_4627.bin
- path: "xemu/Complex_4627.bin"
- standalone_path: Complex_4627.bin
- aliases: [Complex_4627v1.03.bin]
+ mode: standalone
required: true
size: [262144, 524288, 1048576]
validation: [size]
size_note: "Any size that is a positive multiple of 64 KiB is mapped, and the image is mirrored across the 16 MB flash window"
config_key: sys.files.flashrom_path
+ found_by: content
description: "Xbox flash ROM image"
note: >-
- Passed as -bios and opened through qemu_find_file. An image whose size
- is not a multiple of 65536 bytes counts as a failed load, like a missing
- file: the 256 KB region is filled with 0xFF and the machine starts
- anyway. An unreadable path clears autostart in the standalone, which
- takes any file the settings name. The core tries
- Complex_4627v1.03.bin, then Complex_4627.bin, in system/xemu/ and
- then at the root of the system directory, and refuses to load when
- none is there.
+ Passed as -bios and opened through qemu_find_file. flashrom_path has no
+ default, so the name is a label. A path that cannot be opened, an empty
+ one included, queues an error and clears autostart, so the machine does
+ not start. An image whose size is not a multiple of 65536 bytes counts
+ as a failed load in the machine: the 256 KB region is filled with 0xFF.
+ source_ref: "system/vl.c:2527-2535, system/vl.c:3041-3053, hw/xbox/xbox.c:63-123, config_spec.yml:362-366"
+
+ - name: Complex_4627.bin
+ mode: libretro
+ path: "xemu/Complex_4627.bin"
+ aliases: [Complex_4627v1.03.bin]
+ required: true
+ size: [262144, 524288, 1048576]
+ validation: [size]
+ size_note: "Any size that is a positive multiple of 64 KiB is mapped, and the image is mirrored across the 16 MB flash window"
+ description: "Xbox flash ROM image"
+ note: >-
+ The core tries Complex_4627v1.03.bin, then Complex_4627.bin, in
+ system/xemu/ and then at the root of the system directory, and refuses
+ to load when none is there.
source_ref:
- standalone: "system/vl.c:3041-3053, hw/xbox/xbox.c:63-123"
- libretro: "ui/libretro.c:3370-3415, ui/libretro.c:3429-3436"
+ libretro: "ui/libretro.c:3370-3415, ui/libretro.c:3429-3436, hw/xbox/xbox.c:63-123"
- name: xbox_eeprom.bin
path: "xemu/xbox_eeprom.bin"
@@ -167,8 +187,7 @@ files:
- name: xbox_hdd.qcow2
mode: standalone
- path: xbox_hdd.qcow2
- required: true
+ required: false
installed_on: [batocera, emudeck, recalbox, retrodeck, rocknix]
config_key: sys.files.hdd_path
found_by: content
@@ -176,7 +195,7 @@ files:
note: >-
Attached as the IDE drive at index 0 with locked=on when the path is set
and the file opens. An unreadable file only queues an error and an empty
- path attaches no drive. Snapshots are stored inside the image.
+ path attaches no drive: the machine starts either way. Snapshots are stored inside the image.
hdd_path has no default, so the name is a label: it is the file of
the formatted image the project publishes apart from the xemu release
(xemu-hdd-image 1.0, xbox_hdd.qcow2.zip), which carries its own
From 3d662cbd15e3432efe52a2d83b423304d5efe3a8 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 9 Nov 2026 17:25:10 +0100
Subject: [PATCH 12/17] docs: read the triforce font load from code
LoadFileToIPL reads a standalone font with no size check, keeping what fits in the IPL image. The sizes stay as the IPL dump fonts' lengths and the notes say so. No HLE stands in for a missing font: the IPL read raises an alert and the game runs on, so hle_fallback goes.
---
emulators/triforce.yml | 30 +++++++++++++++++++-----------
1 file changed, 19 insertions(+), 11 deletions(-)
diff --git a/emulators/triforce.yml b/emulators/triforce.yml
index a1c90f9e7..52bb92546 100644
--- a/emulators/triforce.yml
+++ b/emulators/triforce.yml
@@ -279,21 +279,23 @@ files:
the byte-swapped data (Source/Core/Core/HW/DSPLLE/DSPLLE.cpp:100-102).
# -- GameCube fonts --
- # Sys/GC/ only. Skipped when an IPL dump is available, the fonts are then read
- # out of the dump at 0x1aff00 and 0x1fcf00 instead.
+ # Sys/GC/ only. With Load IPL dump on and a GC//IPL.bin found, the
+ # fonts are read out of the dump at 0x1aff00 and 0x1fcf00 instead.
- name: "font_western.bin"
path: "Sys/GC/font_western.bin"
description: "Windows-1252 font used by GameCube and Wii text rendering"
size: 9589
required: false
installed_on: [retrobat]
- hle_fallback: true
- source_ref: "source Source/Core/Common/CommonPaths.h:142, source Source/Core/Core/HW/EXI/EXI_DeviceIPL.cpp:131, 203-247"
+ source_ref: "source Source/Core/Common/CommonPaths.h:142, source Source/Core/Core/HW/EXI/EXI_DeviceIPL.cpp:131, 161-176, 203-247, 330-344"
note: >
- Size is the official font length the code reads from an IPL dump, 0x2575
- (Source/Core/Core/HW/EXI/EXI_DeviceIPL.cpp:234-236). A standalone file is
- read without any check. The fork's tree carries no font, RetroBat's
- dolphin-triforce package installs the free replacement here.
+ Read without any size check into the IPL image at 0x1fcf00, keeping at
+ most 0x3100 bytes. The size is the IPL dump font's, 0x2575, the length
+ the code reads out of a dump (EXI_DeviceIPL.cpp:234-236). The fork's
+ tree carries no font, and RetroBat's dolphin-triforce package installs
+ Dolphin's free replacement here. Without a font a game that reads it
+ gets an alert that text may be missing or the game may crash, and runs
+ on.
- name: "font_japanese.bin"
path: "Sys/GC/font_japanese.bin"
@@ -301,9 +303,15 @@ files:
size: 303693
required: false
installed_on: [retrobat]
- hle_fallback: true
- source_ref: "source Source/Core/Common/CommonPaths.h:143, source Source/Core/Core/HW/EXI/EXI_DeviceIPL.cpp:130, 203-247"
- note: "Size is the official font length read from an IPL dump, 0x4a24d. The fork's tree carries no font, RetroBat's dolphin-triforce package installs the free replacement here."
+ source_ref: "source Source/Core/Common/CommonPaths.h:143, source Source/Core/Core/HW/EXI/EXI_DeviceIPL.cpp:130, 161-176, 203-247, 330-344"
+ note: >
+ Read without any size check into the IPL image at 0x1aff00, keeping at
+ most 0x50100 bytes. The size is the IPL dump font's, 0x4a24d, the length
+ the code reads out of a dump (EXI_DeviceIPL.cpp:234-236). The fork's
+ tree carries no font, and RetroBat's dolphin-triforce package installs
+ Dolphin's free replacement here. Without a font a game that reads it
+ gets an alert that text may be missing or the game may crash, and runs
+ on.
# -- GBA BIOS --
- name: "gba_bios.bin"
From a22d9fd499a78e95453e833ba6753b2fe24f29f8 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 9 Nov 2026 17:27:48 +0100
Subject: [PATCH 13/17] docs: add the dsperate-headless dsi boot blob
dsperate-headless reads a 0x154-byte console data blob through --dsi-boot and stops on any other size. The flag has no default, so the name is a label. Without it the bytes come from the NAND's TWLCFG and HWINFO files, are generated for DSiWare run without a NAND, or stay zero in card mode.
---
emulators/dsperate.yml | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/emulators/dsperate.yml b/emulators/dsperate.yml
index d0b9066c9..cf175c2a9 100644
--- a/emulators/dsperate.yml
+++ b/emulators/dsperate.yml
@@ -136,6 +136,16 @@ files:
note: "Read only by --dsi-mode and NAND title shortcuts. DSiWare run from the game list does without it. Opened read-only, rejected without the \"DSi eMMC CID/CPU\" footer."
source_ref: "src/frontend/sdl/main.cpp:1153-1166, src/frontend/sdl/main.cpp:1331-1337, src/core/nds_dsi.cpp:112-118, src/core/io/dsi_sd.cpp:105-138"
+ - name: "dsi_boot.bin"
+ system: nintendo-dsi
+ required: false
+ size: 340
+ validation: [size]
+ found_by: content
+ description: "DSi console data blob"
+ note: "Read only by dsperate-headless, through --dsi-boot, which has no default, so the name is a label. A file of any size other than 0x154 bytes stops the run. The blob is TWLCFG bytes 0x88-0x1AF, HWINFO_N bytes 0x88-0x9B and HWINFO_S bytes 0x88-0x9F, copied to 0x02000400, 0x02000600 and 0x02FFFD68 when a DSi title starts. Without it the same bytes are read from shared1/TWLCFG0.dat or TWLCFG1.dat (the higher save count at byte 0x81) and sys/HWINFO_N.dat and sys/HWINFO_S.dat of an attached NAND, generated for DSiWare run without a NAND, and left zero in card mode."
+ source_ref: "src/frontend/headless/main.cpp:304, src/frontend/headless/main.cpp:386, src/frontend/headless/main.cpp:508, src/core/nds_dsi.cpp:107-111, src/core/nds_dsi.cpp:294, src/core/nds_dsi.cpp:340-343, src/core/nds_dsi.cpp:454-464, src/core/io/dsi_nand_launch.cpp:147-167, src/core/io/dsi_nand_synth.cpp:114-120"
+
- name: "TWLFontTable.dat"
system: nintendo-dsi
required: false
From 7aacbbe214b40c0674c98324c0b2e168524251af Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 9 Nov 2026 17:34:03 +0100
Subject: [PATCH 14/17] docs: name the jar the freej2me core joins
The note named freej2me-lr.jar only in its ROCKNIX sentence, so a
launcher record naming the jar read as a platform choice.
freej2me_libretro.c:488 joins it to the system directory.
---
emulators/freej2me.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/emulators/freej2me.yml b/emulators/freej2me.yml
index 45a5bc303..d54327710 100644
--- a/emulators/freej2me.yml
+++ b/emulators/freej2me.yml
@@ -31,4 +31,4 @@ files:
required: true
installed_on: [retrobat, rocknix]
source_ref: "src/libretro/freej2me_libretro.c:487-490 (GET_SYSTEM_DIRECTORY + fill_pathname_join + java -jar)"
- note: "Not a BIOS dump. Java emulator loaded by the native core at startup via execvp('java', '-jar', path). Built from source via 'ant'. Must be in the system directory root. ROCKNIX builds the core and the jar from freej2me-plus 1.52, which keeps the freej2me-lr.jar name, and copies the jar into its BIOS folder before each launch when it is missing."
+ note: "Not a BIOS dump. Java emulator the native core joins to the system directory as freej2me-lr.jar and starts via execvp('java', '-jar', path). Built from source via 'ant'. Must be in the system directory root. ROCKNIX builds the core and the jar from freej2me-plus 1.52, which keeps the freej2me-lr.jar name, and copies the jar into its BIOS folder before each launch when it is missing."
From 82a6b2f8333b9f472be91aa741253dcf5ebed7eb Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 9 Nov 2026 17:34:03 +0100
Subject: [PATCH 15/17] feat: read the emuelec launchers
emuelec_launchers.py reads emuelecRunEmu.sh and the scripts and
configs it starts at the tag batocera-systems is read at (v4.8): 36
records for RetroArch, retrorun, freej2me, MAME, PICO-8, Flycast,
Amiberry, DuckStation, Yabasanshiro, jzIntv, Mupen64Plus, Hatari,
AdvanceMAME, ScummVM, Dolphin and the MIDI synths.
---
platforms/emuelec.yml | 358 +++++++++++++
scripts/scraper/emuelec_launchers.py | 755 +++++++++++++++++++++++++++
scripts/scraper/emuelec_scraper.py | 14 +
tests/test_launchers_emuelec.py | 655 +++++++++++++++++++++++
4 files changed, 1782 insertions(+)
create mode 100644 scripts/scraper/emuelec_launchers.py
create mode 100644 tests/test_launchers_emuelec.py
diff --git a/platforms/emuelec.yml b/platforms/emuelec.yml
index bc476e3b0..0e208d183 100644
--- a/platforms/emuelec.yml
+++ b/platforms/emuelec.yml
@@ -4226,3 +4226,361 @@ systems:
native_name: Phillips CDI
native_id: cdi
name: Phillips CDI
+launchers:
+- emulator: retroarch
+ build: libretro
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/bios
+ passes_to: "retroarch.cfg system_directory (--config)"
+ fails_when_missing: false
+ source_ref: "packages/sx05re/libretro_base/retroarch/package.mk:117, packages/sx05re/emuelec/bin/emuelecRunEmu.sh:380"
+- emulator: retrorun
+ build: libretro
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/bios
+ passes_to: retrorun -d
+ fails_when_missing: false
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:110, 466, 476"
+- emulator: freej2me
+ build: libretro
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/bios/jdk
+ passes_to: environment JAVA_HOME
+ fails_when_missing: false
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:129-133, packages/sx05re/libretro/freej2me/scripts/freej2me.sh:11"
+- emulator: freej2me
+ build: libretro
+ kind: copies
+ names:
+ - freej2me-lr.jar
+ search_dir: /usr/lib/libretro
+ passes_to: "${HOME}/roms/bios"
+ fails_when_missing: false
+ depends_on_option: /storage/roms/bios/jdk/eeversion exists
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:129-130, packages/sx05re/libretro/freej2me/scripts/freej2me.sh:11-12"
+- emulator: mame
+ build: libretro
+ kind: copies
+ names:
+ - fm*
+ search_dir: /usr/config/emuelec/configs/mame/hash
+ passes_to: /storage/roms/bios/mame/hash
+ fails_when_missing: false
+ depends_on_option: /storage/roms/bios/mame/hash/fmtowns_cd.xml absent
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:6-8"
+- emulator: mame
+ build: libretro
+ kind: copies
+ names:
+ - app*
+ search_dir: /usr/config/emuelec/configs/mame/hash
+ passes_to: /storage/roms/bios/mame/hash
+ fails_when_missing: false
+ depends_on_option: /storage/roms/bios/mame/hash/apple2_flop_orig.xml absent
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:11-13"
+- emulator: mame
+ build: libretro
+ kind: copies
+ names:
+ - fm*
+ search_dir: /usr/config/emuelec/configs/mame/ini
+ passes_to: /storage/roms/bios/mame/ini
+ fails_when_missing: false
+ depends_on_option: /storage/roms/bios/mame/ini/fmtownsux.ini absent
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:16-18"
+- emulator: mame
+ build: libretro
+ kind: copies
+ names:
+ - mame*
+ search_dir: /usr/config/emuelec/configs/mame/ini
+ passes_to: /storage/roms/bios/mame/ini
+ fails_when_missing: false
+ depends_on_option: /storage/roms/bios/mame/ini/mame.ini absent
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23"
+- emulator: mame
+ build: libretro
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/bios/mame
+ passes_to: mame.ini homepath
+ fails_when_missing: false
+ depends_on_option: mame_read_config = enabled
+ source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:10, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\
+ \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23"
+- emulator: mame
+ build: libretro
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/bios/mame/bios
+ passes_to: mame.ini rompath
+ fails_when_missing: false
+ depends_on_option: mame_read_config = enabled
+ source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:11, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\
+ \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23"
+- emulator: mame
+ build: libretro
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/bios/mame/hash
+ passes_to: mame.ini hashpath
+ fails_when_missing: false
+ depends_on_option: mame_read_config = enabled
+ source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:12, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\
+ \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23"
+- emulator: mame
+ build: libretro
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/bios/mame/samples
+ passes_to: mame.ini samplepath
+ fails_when_missing: false
+ depends_on_option: mame_read_config = enabled
+ source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:13, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\
+ \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23"
+- emulator: mame
+ build: libretro
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/bios/mame/artwork
+ passes_to: mame.ini artpath
+ fails_when_missing: false
+ depends_on_option: mame_read_config = enabled
+ source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:14, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\
+ \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23"
+- emulator: mame
+ build: libretro
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/bios/mame/ini
+ passes_to: mame.ini inipath
+ fails_when_missing: false
+ depends_on_option: mame_read_config = enabled
+ source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:16, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\
+ \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23"
+- emulator: mame
+ build: libretro
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/bios/mame/cheat
+ passes_to: mame.ini cheatpath
+ fails_when_missing: false
+ depends_on_option: mame_read_config = enabled
+ source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:18, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\
+ \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23"
+- emulator: mame
+ build: libretro
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/bios/mame/crosshair
+ passes_to: mame.ini crosshairpath
+ fails_when_missing: false
+ depends_on_option: mame_read_config = enabled
+ source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:19, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\
+ \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23"
+- emulator: mame
+ build: libretro
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/bios/mame/plugins
+ passes_to: mame.ini pluginspath
+ fails_when_missing: false
+ depends_on_option: mame_read_config = enabled
+ source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:20, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\
+ \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23"
+- emulator: pico8
+ build: standalone
+ kind: moves
+ names:
+ - pico8_dyn
+ case_insensitive: false
+ search_dir: /storage/roms/bios/pico-8
+ passes_to: /emuelec/bin/pico-8
+ fails_when_missing: false
+ depends_on_option: /storage/roms/bios/pico-8 exists
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:305-307, packages/sx05re/emuelec/bin/pico8.sh:9-13,\
+ \ 40-42"
+- emulator: flycastsa
+ build: standalone
+ kind: links
+ names: []
+ search_dir: /storage/roms/bios/dc
+ passes_to: symlink /storage/.local/share/flycast -> /storage/roms/bios/dc
+ fails_when_missing: false
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:198-200, packages/sx05re/emulators/flycastsa/scripts/flycast.sh:11,\
+ \ 14"
+- emulator: flycast_dojo
+ build: standalone
+ kind: links
+ names: []
+ search_dir: /storage/roms/bios/dc_dojo
+ passes_to: symlink /storage/.local/share/flycast-dojo -> /storage/roms/bios/dc_dojo
+ fails_when_missing: false
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:201-203, packages/sx05re/emulators/flycast-dojo/scripts/flycastdojo.sh:11-12,\
+ \ 28-30"
+- emulator: amiberry
+ build: standalone
+ kind: links
+ names: []
+ search_dir: /storage/roms/bios
+ passes_to: symlink /usr/config/amiberry/kickstarts -> /storage/roms/bios
+ fails_when_missing: false
+ source_ref: "packages/sx05re/emulators/amiberry/package.mk:36"
+- emulator: amiberry-lite
+ build: standalone
+ kind: links
+ names: []
+ search_dir: /storage/roms/bios
+ passes_to: symlink /usr/config/amiberry-lite/kickstarts -> /storage/roms/bios
+ fails_when_missing: false
+ source_ref: "packages/sx05re/emulators/amiberry-lite/package.mk:36"
+- emulator: timidity
+ build: standalone
+ kind: links
+ names: []
+ search_dir: /storage/roms/bios/timidity/freepats
+ passes_to: symlink /usr/config/timidity/freepats -> /storage/roms/bios/timidity/freepats
+ fails_when_missing: false
+ source_ref: "packages/sx05re/tools/sound/timidity/package.mk:38"
+- emulator: duckstation
+ build: standalone
+ kind: scans_dir
+ names: []
+ search_dir: /storage/roms/bios
+ passes_to: "settings.ini [BIOS] SearchDirectory (-settings), [BIOS] PathNTSCU, PathNTSCJ,\
+ \ PathPAL unset"
+ fails_when_missing: false
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:207-209, packages/sx05re/emulators/duckstation/config/settings.ini:6-9,\
+ \ packages/sx05re/emulators/duckstation/package.mk:56, packages/sx05re/emulators/duckstation/scripts/duckstation.sh:40"
+- emulator: yabasanshiroSA
+ build: standalone
+ kind: fixed_name
+ names:
+ - saturn_bios.bin
+ case_insensitive: false
+ search_dir: /storage/roms/bios
+ passes_to: yabasanshiro -b
+ fails_when_missing: false
+ depends_on_option: hlebios != 1
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:343-345, packages/sx05re/emulators/yabasanshiroSA_1_11/scripts/yabasanshiro.sh:30-34,\
+ \ 49"
+- emulator: yabasanshiroSA1_5
+ build: standalone
+ kind: fixed_name
+ names:
+ - saturn_bios.bin
+ case_insensitive: false
+ search_dir: /storage/roms/bios
+ passes_to: yabasanshiro1_5 -b
+ fails_when_missing: false
+ depends_on_option: hlebios != 1
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:346-348, packages/sx05re/emulators/yabasanshiroSA_1_5/scripts/yabasanshiro1_5.sh:29-33,\
+ \ 47"
+- emulator: jzintv
+ build: standalone
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/bios
+ passes_to: jzintv -p
+ fails_when_missing: false
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:337-339, packages/sx05re/emulators/jzintv/scripts/jzintv.sh:49"
+- emulator: rice, glide64mk2
+ build: standalone
+ kind: names_searched
+ names:
+ - "N64DD IPLROM (J).n64"
+ - IPL.n64
+ case_insensitive: false
+ search_dir: /storage/roms/bios/Mupen64plus
+ passes_to: mupen64plus --dd-ipl-rom
+ fails_when_missing: false
+ depends_on_option: "-f \"${GAMEDIR}/${GAMENAME}.ndd\""
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:232-237, packages/sx05re/emulators/mupen64plussa/mupen64plussa-core/m64p.sh:16,\
+ \ 65-68, 96-98"
+- emulator: HATARISA
+ build: standalone
+ kind: fixed_name
+ names:
+ - tos.img
+ search_dir: /storage/roms/bios
+ passes_to: "Atari-ST-default.cfg [ROM] szTosImageFileName (--configfile)"
+ fails_when_missing: false
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:172-174, packages/sx05re/emulators/hatarisa/config/conf/Atari-ST-default.cfg:200,\
+ \ packages/sx05re/emulators/hatarisa/package.mk:39, packages/sx05re/emulators/hatarisa/scripts/hatari.start:36,\
+ \ 66, 73"
+- emulator: AdvanceMame
+ build: standalone
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/bios/advmame/samples
+ passes_to: advmame.rc dir_sample
+ fails_when_missing: false
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:213-215, packages/sx05re/emulators/advancemame/config/advmame.rc_oga:9,\
+ \ packages/sx05re/emulators/advancemame/config/advmame.rc_gf:9, packages/sx05re/emulators/advancemame/config/advmame.rc:12,\
+ \ packages/sx05re/emulators/advancemame/package.mk:45-49, packages/sx05re/emulators/advancemame/bin/advmame.sh:80"
+- emulator: SCUMMVMSA
+ build: standalone
+ kind: passes_path
+ names: []
+ search_dir: /storage/.config/scummvm/extra
+ passes_to: scummvm --extrapath
+ fails_when_missing: false
+ depends_on_option: /storage/.config/scummvm/extra exists
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:251-255, packages/sx05re/emulators/scummvmsa/bin/scummvm.start:38-39,\
+ \ 45"
+- emulator: scummvm
+ build: libretro
+ kind: copies
+ names:
+ - scummvm.ini
+ search_dir: /storage/.config/scummvm
+ passes_to: /storage/roms/bios/scummvm.ini
+ fails_when_missing: false
+ depends_on_option: scummvm.start add
+ source_ref: "packages/sx05re/emulators/scummvmsa/bin/scummvm.start:9, 49-51, 54"
+- emulator: dolphin
+ build: standalone
+ kind: passes_path
+ names: []
+ search_dir: /storage/roms/dolphin
+ passes_to: environment XDG_DATA_HOME
+ fails_when_missing: false
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:270-272, packages/sx05re/emulators/dolphinSA/scripts/dolphin.sh:28"
+- emulator: fluidsynth
+ build: standalone
+ kind: fixed_name
+ slot: fluidsynth
+ names:
+ - sc/SC-55.sf2
+ case_insensitive: false
+ search_dir: /storage/roms/bios
+ passes_to: fluidsynth -is
+ fails_when_missing: false
+ depends_on_option: ra_midi_output = fluidsynth
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:123-125, packages/sx05re/emuelec/bin/emuelec-utils:54,\
+ \ 66-67"
+- emulator: mt32d
+ build: standalone
+ kind: passes_path
+ slot: cm32l
+ names: []
+ search_dir: /roms/bios
+ passes_to: mt32d -f
+ fails_when_missing: false
+ depends_on_option: ra_midi_output = cm32l
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:123-125, packages/sx05re/emuelec/bin/emuelec-utils:54,\
+ \ 75-76"
+- emulator: mt32d
+ build: standalone
+ kind: passes_path
+ slot: mt32
+ names: []
+ search_dir: /roms/bios
+ passes_to: mt32d -f
+ fails_when_missing: false
+ depends_on_option: ra_midi_output = mt32
+ source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:123-125, packages/sx05re/emuelec/bin/emuelec-utils:54,\
+ \ 79-80"
diff --git a/scripts/scraper/emuelec_launchers.py b/scripts/scraper/emuelec_launchers.py
new file mode 100644
index 000000000..4698c8795
--- /dev/null
+++ b/scripts/scraper/emuelec_launchers.py
@@ -0,0 +1,755 @@
+"""EmuELEC's launcher layer, read from its packages at the pinned tag.
+
+emuelecRunEmu.sh starts every game. A libretro core runs in RetroArch (or
+retrorun) with /storage/roms/bios as its system directory. A standalone
+emulator runs from the script the `case ${PLATFORM}` arm names for the core
+ES passes (`"${EMU}" = "flycastsa"`), and that script links, moves, copies
+or passes the file. Some packages ship the config the emulator reads
+(duckstation, hatari, advancemame, mame), others link a BIOS folder at
+install time (amiberry, timidity). The emulator of each record is the core
+name the dispatch tests, or the package that installs the link.
+"""
+
+from __future__ import annotations
+
+import posixpath
+import re
+from collections.abc import Callable
+from dataclasses import dataclass, field
+
+try:
+ from .configgen_launchers import Launcher
+ from .rocknix_launchers import fixed_paths
+ from .shell_launchers import (
+ LauncherPatternMissing,
+ Link,
+ RepoTree,
+ Script,
+ describe_keys,
+ settings,
+ source_ref,
+ words,
+ )
+except ImportError:
+ from configgen_launchers import Launcher
+ from rocknix_launchers import fixed_paths
+ from shell_launchers import (
+ LauncherPatternMissing,
+ Link,
+ RepoTree,
+ Script,
+ describe_keys,
+ settings,
+ source_ref,
+ words,
+ )
+
+SX05RE = "packages/sx05re"
+BIN = f"{SX05RE}/emuelec/bin"
+RUN_EMU = f"{BIN}/emuelecRunEmu.sh"
+UTILS = f"{BIN}/emuelec-utils"
+EMULATORS = f"{SX05RE}/emulators"
+LIBRETRO = f"{SX05RE}/libretro"
+RETROARCH_MK = f"{SX05RE}/libretro_base/retroarch/package.mk"
+BIOS_ROOT = "/storage/roms/bios"
+
+_EMU_TEST = re.compile(r'\$\{?EMU\}?"?\s*==?\s*"([^"]+)"')
+_ARM = re.compile(r'^\s*((?:"[^"]+"\|?)+)\)\s*$')
+_RUNTHIS = re.compile(r"""^\s*RUNTHIS\+?=(['"])(.*)\1\s*$""")
+_SCRIPT = re.compile(r"[\w./-]+\.(?:sh|start)$")
+_ADVMAME_INSTALL = re.compile(r"cp -r \$\{PKG_DIR\}/config/(advmame\.rc\w*) \S+/advmame\.rc$")
+
+
+@dataclass
+class Dispatch:
+ """The cores emuelecRunEmu.sh hands to one script, and the lines that do it."""
+
+ emulators: list[str] = field(default_factory=list)
+ lines: set[int] = field(default_factory=set)
+
+
+class Runs:
+ """emuelecRunEmu.sh, read once: which core starts which script."""
+
+ def __init__(self, tree: RepoTree) -> None:
+ self.script = Script(RUN_EMU, tree.read(RUN_EMU))
+ self.by_script: dict[str, Dispatch] = {}
+ emu: tuple[str, int] | None = None
+ arm: tuple[list[str], int] | None = None
+ for number, line in self.script.code():
+ if found := _ARM.match(line):
+ arm = ([p.strip('"') for p in found.group(1).split("|") if p], number)
+ emu = None
+ continue
+ if re.match(r"\s*(;;|esac\b)", line):
+ arm, emu = None, None
+ continue
+ if found := _EMU_TEST.search(line):
+ emu = (found.group(1).removesuffix("_libretro"), number)
+ continue
+ if re.match(r"\s*fi\b", line):
+ emu = None
+ continue
+ run = _RUNTHIS.match(line)
+ command = words(run.group(2) if run else line)
+ name = next(
+ (posixpath.basename(w.text) for w in command if _SCRIPT.fullmatch(w.text)), None
+ )
+ if name is None or (emu is None and arm is None):
+ continue
+ names, at = ([emu[0]], emu[1]) if emu else arm # type: ignore[misc]
+ entry = self.by_script.setdefault(name, Dispatch())
+ entry.emulators.extend(n for n in names if n not in entry.emulators)
+ entry.lines |= {at, number}
+ if not self.by_script:
+ raise self.script.missing("script started for a core")
+
+ def of(self, name: str) -> Dispatch:
+ if name not in self.by_script:
+ raise self.script.missing(f"start of {name}")
+ return self.by_script[name]
+
+ def emulator(self, name: str) -> str:
+ return ", ".join(self.of(name).emulators)
+
+ def ref(self, name: str) -> tuple[str, set[int]]:
+ return (RUN_EMU, set(self.of(name).lines))
+
+
+def _read(tree: RepoTree, path: str) -> Script:
+ return Script(path, tree.read(path))
+
+
+def _under(path: str, root: str = BIOS_ROOT) -> bool:
+ return path.rstrip("/") == root or path.startswith(root + "/")
+
+
+def retroarch(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ """system_directory, written by sed into the retroarch.cfg the package installs."""
+ mk = _read(tree, RETROARCH_MK)
+ sed_line, sed = mk.find(
+ r"s/# system_directory =/system_directory =((?:\\/|[^/])+)/", "system_directory sed"
+ )
+ run_line, _ = runs.script.find(
+ r"RUNTHIS='\$\{RABIN\}.* -L /tmp/cores/\$\{EMU\}\.so --config \$\{RACONF\}",
+ "RetroArch start with --config",
+ )
+ return [
+ Launcher(
+ emulator="retroarch",
+ build="libretro",
+ kind="passes_path",
+ names=[],
+ source_ref=source_ref([(mk.path, {sed_line}), (RUN_EMU, {run_line})]),
+ search_dir=sed.group(1).replace("\\/", "/"),
+ passes_to="retroarch.cfg system_directory (--config)",
+ )
+ ]
+
+
+def retrorun(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ script = runs.script
+ base_line, base = script.find(r'^\s*RUNTHIS="(\w+)"\s*$', "retrorun command")
+ test_line, _ = script.find(
+ rf'if \[\[ "\$\{{EMULATOR\}}" = "{base.group(1)}" \]\]', f"{base.group(1)} test"
+ )
+ run_line, run = script.find(r"RUNTHIS\+=.*\s(-d)\s+(\S+)\s", "retrorun -d", base_line)
+ return [
+ Launcher(
+ emulator=base.group(1),
+ build="libretro",
+ kind="passes_path",
+ names=[],
+ source_ref=source_ref([(RUN_EMU, {test_line, base_line, run_line})]),
+ search_dir=run.group(2),
+ passes_to=f"{base.group(1)} {run.group(1)}",
+ )
+ ]
+
+
+def freej2me(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ """JAVA_HOME set for the core, and the jar its script copies beside the JDK."""
+ script = runs.script
+ test_line, test = script.find(r'if \[ \$\{EMU\} == "(\w+)_libretro" \]', "core test")
+ call_line, call = script.find(r"^\s*(\S+\.sh)\s*$", "script call", test_line)
+ java_line, java = script.find(r"^\s*(JAVA_HOME)='([^']+)'", "JAVA_HOME", test_line)
+ export_line, _ = script.find(rf"^\s*export {java.group(1)}\s*$", "export", java_line)
+ core = test.group(1)
+ path = f"{LIBRETRO}/{core}/scripts/{call.group(1)}"
+ started = _read(tree, path)
+ guard_line, guard = started.find(r'if \[\[ -f "([^"]+)" \]\]; then$', "JDK version test")
+ copy_line, copy = started.find(r"^\s*cp -rf (\S+) (\S+)$", "jar copy", guard_line)
+ source = copy.group(1)
+ return [
+ Launcher(
+ emulator=core,
+ build="libretro",
+ kind="passes_path",
+ names=[],
+ source_ref=source_ref(
+ [(RUN_EMU, {test_line, call_line, java_line, export_line}), (path, {guard_line})]
+ ),
+ search_dir=java.group(2),
+ passes_to=f"environment {java.group(1)}",
+ ),
+ Launcher(
+ emulator=core,
+ build="libretro",
+ kind="copies",
+ names=[posixpath.basename(source)],
+ source_ref=source_ref(
+ [(RUN_EMU, {test_line, call_line}), (path, {guard_line, copy_line})]
+ ),
+ search_dir=posixpath.dirname(source),
+ passes_to=copy.group(2),
+ depends_on_option=f"{guard.group(1)} exists",
+ ),
+ ]
+
+
+def pico8(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ """The folder the user drops in bios/ is moved where the binary runs from."""
+ path = f"{BIN}/pico8.sh"
+ script = _read(tree, path)
+ guard_line, guard = script.find(r'if \[\[ -e "([^"]+)" \]\]; then$', "new version test")
+ folder = guard.group(1)
+ copy_line, copy = script.find(
+ rf"^\s*cp -rf {re.escape(folder)}/\* (\S+)$", "copy of the folder", guard_line
+ )
+ target = copy.group(1)
+ remove_line, _ = script.find(rf"^\s*rm -rf {re.escape(folder)}$", "removal", copy_line)
+ mode_line, mode = script.find(
+ rf"^\s*chmod \+x {re.escape(target)}/([^/\s]+)$", "executable bit", remove_line
+ )
+ binary = mode.group(1)
+ starts = {
+ n for n, line in script.code() if re.match(rf"\s*{re.escape(target)}/{binary} -", line)
+ }
+ if not starts:
+ raise script.missing(f"start of {target}/{binary}")
+ name = posixpath.basename(path)
+ return [
+ Launcher(
+ emulator=runs.emulator(name),
+ build="standalone",
+ kind="moves",
+ names=[binary],
+ source_ref=source_ref(
+ [runs.ref(name), (path, {guard_line, copy_line, remove_line, mode_line, *starts})]
+ ),
+ case_insensitive=False,
+ search_dir=folder,
+ passes_to=target,
+ fails_when_missing=False,
+ depends_on_option=f"{folder} exists",
+ )
+ ]
+
+
+def link_record(emulator: str, script: Script, link: Link, extra: set[int]) -> Launcher:
+ return Launcher(
+ emulator=emulator,
+ build="standalone",
+ kind="links",
+ names=list(link.names),
+ source_ref=source_ref([(script.path, link.lines | extra)]),
+ expands=link.expands or None,
+ search_dir=link.bios_side,
+ passes_to=f"symlink {link.link} -> {link.target}",
+ fails_when_missing=False,
+ )
+
+
+def _link_guard(script: Script, link: Link) -> set[int]:
+ """The `[ ! -L ]` test the link waits for, when it is the one just above."""
+ first = max(link.lines)
+ tests = [
+ number
+ for number, line in script.code()
+ if number < first
+ and re.search(r"\[ ! -L ", line)
+ and link.link in script.expand(line, number, set())
+ ]
+ return {tests[-1]} if tests and first - tests[-1] <= 4 else set()
+
+
+# Start scripts whose ln -s commands put a BIOS folder where the emulator reads.
+LINK_SCRIPTS = (
+ f"{EMULATORS}/flycastsa/scripts/flycast.sh",
+ f"{EMULATORS}/flycast-dojo/scripts/flycastdojo.sh",
+)
+
+# Packages whose package.mk links a BIOS folder into the installed tree.
+LINK_PACKAGES = (
+ f"{EMULATORS}/amiberry",
+ f"{EMULATORS}/amiberry-lite",
+ f"{SX05RE}/tools/sound/timidity",
+)
+
+
+def links(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ records = []
+ for path in LINK_SCRIPTS:
+ script = _read(tree, path)
+ found = script.links(BIOS_ROOT)
+ if not found:
+ raise script.missing(f"ln -s of a path under {BIOS_ROOT}")
+ name = posixpath.basename(path)
+ for link in found:
+ record = link_record(runs.emulator(name), script, link, _link_guard(script, link))
+ record.source_ref = f"{source_ref([runs.ref(name)])}, {record.source_ref}"
+ records.append(record)
+ for package in LINK_PACKAGES:
+ script = _read(tree, f"{package}/package.mk")
+ found = script.links(BIOS_ROOT, fixed={"INSTALL": ""})
+ if not found:
+ raise script.missing(f"ln -s of a path under {BIOS_ROOT}")
+ records.extend(
+ link_record(posixpath.basename(package), script, link, set()) for link in found
+ )
+ return records
+
+
+def duckstation(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ """The settings.ini the package ships hands a directory and leaves the paths empty."""
+ package = f"{EMULATORS}/duckstation"
+ path = f"{package}/config/settings.ini"
+ found = {(s.section, s.key): s for s in settings(tree.read(path))}
+ folder_key = ("BIOS", "SearchDirectory")
+ file_keys = (("BIOS", "PathNTSCU"), ("BIOS", "PathNTSCJ"), ("BIOS", "PathPAL"))
+ if folder_key not in found:
+ raise LauncherPatternMissing(path, "[BIOS] SearchDirectory")
+ set_files = [k for k in file_keys if k in found and found[k].value]
+ if set_files:
+ raise LauncherPatternMissing(path, f"empty [BIOS] {set_files[0][1]}")
+ mk = _read(tree, f"{package}/package.mk")
+ mk_line, _ = mk.find(r"cp -rf \$\{PKG_DIR\}/config/\* ", "config install")
+ script = _read(tree, f"{package}/scripts/duckstation.sh")
+ run_line, _ = script.find(r'-settings "\$\{CONFIG_DIR\}/settings\.ini"', "-settings")
+ name = posixpath.basename(script.path)
+ lines = {found[k].line for k in (folder_key, *file_keys) if k in found}
+ unset = describe_keys("", file_keys).strip()
+ return [
+ Launcher(
+ emulator=runs.emulator(name),
+ build="standalone",
+ kind="scans_dir",
+ names=[],
+ source_ref=source_ref(
+ [runs.ref(name), (path, lines), (mk.path, {mk_line}), (script.path, {run_line})]
+ ),
+ search_dir=found[folder_key].value,
+ passes_to=f"settings.ini [BIOS] SearchDirectory (-settings), {unset} unset",
+ )
+ ]
+
+
+YABASANSHIRO_SCRIPTS = (
+ f"{EMULATORS}/yabasanshiroSA_1_11/scripts/yabasanshiro.sh",
+ f"{EMULATORS}/yabasanshiroSA_1_5/scripts/yabasanshiro1_5.sh",
+)
+
+
+def yabasanshiro(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ """The BIOS flag, set when the HLE option is off and the file exists."""
+ records = []
+ for path in YABASANSHIRO_SCRIPTS:
+ script = _read(tree, path)
+ option_line, option = script.find(r"^\s*(\w+)=\$\(get_ee_setting (\w+) ", "HLE option")
+ test_line, test = script.find(
+ rf'if \[ "\$\{{{option.group(1)}\}}" != (\w+) \]', "HLE option test", option_line
+ )
+ exists_line, exists = script.find(r'if \[ -e "([^"]+)" \]', "BIOS test", test_line)
+ bios = exists.group(1)
+ flag_line, flag = script.find(
+ rf'^\s*(\w+)="(-\w) {re.escape(bios)}"$', "BIOS flag", exists_line
+ )
+ run_line, run = script.find(
+ rf"\{{\s*(\S+) .*\$\{{{flag.group(1)}\}}", "start with the flag", flag_line
+ )
+ name = posixpath.basename(path)
+ records.append(
+ Launcher(
+ emulator=runs.emulator(name),
+ build="standalone",
+ kind="fixed_name",
+ names=[posixpath.basename(bios)],
+ source_ref=source_ref(
+ [
+ runs.ref(name),
+ (path, {option_line, test_line, exists_line, flag_line, run_line}),
+ ]
+ ),
+ case_insensitive=False,
+ search_dir=posixpath.dirname(bios),
+ passes_to=f"{run.group(1)} {flag.group(2)}",
+ fails_when_missing=False,
+ depends_on_option=f"{option.group(2)} != {test.group(1)}",
+ )
+ )
+ return records
+
+
+def jzintv(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ path = f"{EMULATORS}/jzintv/scripts/jzintv.sh"
+ script = _read(tree, path)
+ run_line, run = script.find(r"^\s*exec (\S+) .*\s(-p)\s+(\S+)\s", "start with -p")
+ name = posixpath.basename(path)
+ return [
+ Launcher(
+ emulator=runs.emulator(name),
+ build="standalone",
+ kind="passes_path",
+ names=[],
+ source_ref=source_ref([runs.ref(name), (path, {run_line})]),
+ search_dir=run.group(3).rstrip("/"),
+ passes_to=f"{run.group(1)} {run.group(2)}",
+ )
+ ]
+
+
+def mupen64plus(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ """The 64DD IPL names, tried in order, passed only beside a .ndd disk."""
+ path = f"{EMULATORS}/mupen64plussa/mupen64plussa-core/m64p.sh"
+ script = _read(tree, path)
+ fallbacks = [
+ (number, found)
+ for number, line in script.code()
+ if (found := re.search(r'\[ ! -f "\$\{(\w+)\}" \] && \1="([^"]+)"', line))
+ ]
+ if not fallbacks:
+ raise script.missing("fallback name test")
+ var = fallbacks[0][1].group(1)
+ first = script.value(var, fallbacks[0][0])
+ if first is None:
+ raise script.missing(f"assignment of {var}")
+ used = {first.line, *(n for n, _ in fallbacks)}
+ paths = [
+ script.expand(v, n, used)
+ for n, v in [(first.line, first.value), *((n, f.group(2)) for n, f in fallbacks)]
+ ]
+ folders = {posixpath.dirname(p) for p in paths}
+ if len(folders) != 1:
+ raise script.missing(f"one folder for the {var} names")
+ test_line, test = script.find(
+ rf'if \[ -f "([^"]+)" \] && \[ -f "\$\{{{var}\}}" \]', "disk and IPL test"
+ )
+ passes = [
+ (number, found)
+ for number, line in script.code()
+ if (found := re.search(rf'^\s*(\S+) .*(--[\w-]+) "\$\{{{var}\}}"', line))
+ ]
+ if not passes:
+ raise script.missing(f"flag that hands {var}")
+ flags = sorted({f"{f.group(1)} {f.group(2)}" for _, f in passes})
+ name = posixpath.basename(path)
+ return [
+ Launcher(
+ emulator=runs.emulator(name),
+ build="standalone",
+ kind="names_searched",
+ names=[posixpath.basename(p) for p in paths],
+ source_ref=source_ref(
+ [runs.ref(name), (path, used | {test_line} | {n for n, _ in passes})]
+ ),
+ case_insensitive=False,
+ search_dir=folders.pop(),
+ passes_to=", ".join(flags),
+ fails_when_missing=False,
+ depends_on_option=f'-f "{test.group(1)}"',
+ )
+ ]
+
+
+def hatari(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ package = f"{EMULATORS}/hatarisa"
+ script = _read(tree, f"{package}/scripts/hatari.start")
+ copy_line, copy = script.find(r'^\s*cp \S+/([\w.-]+\.cfg) "\$(\w+)"', "default config copy")
+ run_lines = {
+ n
+ for n, line in script.code()
+ if n > copy_line and f'--configfile "${copy.group(2)}"' in line
+ }
+ if not run_lines:
+ raise script.missing(f"--configfile ${copy.group(2)}")
+ path = f"{package}/config/conf/{copy.group(1)}"
+ found = settings(tree.read(path))
+ keys = [
+ (s.section, s.key)
+ for s in found
+ if _under(s.value) and "." in posixpath.basename(s.value)
+ ]
+ if not keys:
+ raise LauncherPatternMissing(path, f"key naming a file under {BIOS_ROOT}")
+ value, lines = fixed_paths(found, keys, path)
+ mk = _read(tree, f"{package}/package.mk")
+ mk_line, _ = mk.find(r"cp -R \$\{PKG_DIR\}/config/\* ", "config install")
+ name = posixpath.basename(script.path)
+ return [
+ Launcher(
+ emulator=runs.emulator(name),
+ build="standalone",
+ kind="fixed_name",
+ names=list(value.names),
+ source_ref=source_ref(
+ [runs.ref(name), (path, lines), (mk.path, {mk_line}),
+ (script.path, {copy_line, *run_lines})]
+ ),
+ search_dir=value.search_dir,
+ passes_to=f"{describe_keys(copy.group(1), value.keys)} (--configfile)",
+ )
+ ]
+
+
+def advancemame(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ """Folder keys of the advmame.rc package.mk installs, one per device branch."""
+ package = f"{EMULATORS}/advancemame"
+ mk = _read(tree, f"{package}/package.mk")
+ installs = [
+ (number, found.group(1))
+ for number, line in mk.code()
+ if (found := _ADVMAME_INSTALL.search(line))
+ ]
+ if not installs:
+ raise mk.missing("advmame.rc install")
+ by_value: dict[tuple[tuple[str, str], ...], list[tuple[str, set[int], int]]] = {}
+ for mk_line, file in installs:
+ path = f"{package}/config/{file}"
+ found = [s for s in settings(tree.read(path), separator=" ") if _under(s.value)]
+ if not found:
+ raise LauncherPatternMissing(path, f"key naming a folder under {BIOS_ROOT}")
+ value = tuple((s.key, s.value) for s in found)
+ by_value.setdefault(value, []).append((path, {s.line for s in found}, mk_line))
+ if len(by_value) != 1:
+ raise mk.missing("one set of BIOS folders for every device")
+ ((value, configs),) = by_value.items()
+ script = _read(tree, f"{package}/bin/advmame.sh")
+ run_line, _ = script.find(r"^\s*(?:\w+=\S+ )*advmame ", "advmame start")
+ name = posixpath.basename(script.path)
+ refs = [
+ runs.ref(name),
+ *((path, lines) for path, lines, _ in configs),
+ (mk.path, {mk_line for _, _, mk_line in configs}),
+ (script.path, {run_line}),
+ ]
+ return [
+ Launcher(
+ emulator=runs.emulator(name),
+ build="standalone",
+ kind="passes_path",
+ names=[],
+ source_ref=source_ref(refs),
+ search_dir=folder,
+ passes_to=f"advmame.rc {key}",
+ )
+ for key, folder in value
+ ]
+
+
+def mame(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ """The files mame.sh copies into bios/mame, and the folders mame.ini hands the core."""
+ package = f"{LIBRETRO}/mame"
+ script = _read(tree, f"{package}/scripts/mame.sh")
+ name = posixpath.basename(script.path)
+ emulator = runs.emulator(name)
+ records = []
+ guard: tuple[int, str] | None = None
+ for number, line in script.code():
+ if found := re.search(r'if \[ ! -f "([^"]+)" \]', line):
+ guard = (number, found.group(1))
+ continue
+ copy = re.match(r'\s*cp -rf "([^"]+)/"(\S+) "([^"]+)"\s*$', line)
+ if copy is None:
+ continue
+ if guard is None:
+ raise script.missing(f"test before the copy at line {number}")
+ if not _under(copy.group(3)):
+ continue
+ records.append(
+ Launcher(
+ emulator=emulator,
+ build="libretro",
+ kind="copies",
+ names=[copy.group(2)],
+ source_ref=source_ref([runs.ref(name), (script.path, {guard[0], number})]),
+ search_dir=copy.group(1),
+ passes_to=copy.group(3),
+ fails_when_missing=False,
+ depends_on_option=f"{guard[1]} absent",
+ )
+ )
+ if not records:
+ raise script.missing(f"copy into {BIOS_ROOT}")
+ ini = next((r for r in records if r.names == ["mame*"]), None)
+ if ini is None:
+ raise script.missing("copy of mame.ini")
+ option_path = f"{package}/config/MAME/MAME.opt"
+ option = next(
+ (s for s in settings(tree.read(option_path)) if s.key == "mame_read_config"), None
+ )
+ if option is None:
+ raise LauncherPatternMissing(option_path, "mame_read_config")
+ ini_path = f"{package}/config/ini/mame.ini"
+ # MAME reads from the folders its *path options name and writes to its
+ # *_directory ones.
+ folders = [
+ (s, [p for p in s.value.split(";") if _under(p)])
+ for s in settings(tree.read(ini_path), separator=" ")
+ if s.key.endswith("path")
+ ]
+ folders = [(s, parts) for s, parts in folders if parts]
+ if not folders:
+ raise LauncherPatternMissing(ini_path, f"path under {BIOS_ROOT}")
+ for setting, parts in folders:
+ for part in parts:
+ records.append(
+ Launcher(
+ emulator=emulator,
+ build="libretro",
+ kind="passes_path",
+ names=[],
+ source_ref=source_ref(
+ [(ini_path, {setting.line}), (option_path, {option.line})]
+ ) + f", {ini.source_ref}",
+ search_dir=part.rstrip("/"),
+ passes_to=f"mame.ini {setting.key}",
+ depends_on_option=f"mame_read_config = {option.value}",
+ )
+ )
+ return records
+
+
+def scummvm(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ """--extrapath for the standalone build, and the scummvm.ini copied for the core."""
+ path = f"{EMULATORS}/scummvmsa/bin/scummvm.start"
+ script = _read(tree, path)
+ name = posixpath.basename(path)
+ test_line, test = script.find(r'^\s*if \[ -d "([^"]+)" \]; then\s*$', "extra folder test")
+ set_line, set_ = script.find(r'^\s*(\w+)="(--[\w-]+)=([^"]+)"', "extra path", test_line)
+ run_line, run = script.find(rf"^\s*(\S+) .*\${set_.group(1)}\b", "start with it", set_line)
+ core_line, core = script.find(r"-L /tmp/cores/(\w+)_libretro\.so", "libretro start")
+ arm_line, arm = script.find(r'^\s*"(\w+)"\)', "setup arm", core_line)
+ copy_line, copy = script.find(r"^\s*cp (\S+) (\S+)$", "copy into the BIOS folder", arm_line)
+ used = {copy_line}
+ source = script.expand(copy.group(1), copy_line, used)
+ target = script.expand(copy.group(2), copy_line, used)
+ if not _under(target):
+ raise script.missing(f"copy into {BIOS_ROOT}")
+ return [
+ Launcher(
+ emulator=runs.emulator(name),
+ build="standalone",
+ kind="passes_path",
+ names=[],
+ source_ref=source_ref([runs.ref(name), (path, {test_line, set_line, run_line})]),
+ search_dir=set_.group(3),
+ passes_to=f"{run.group(1)} {set_.group(2)}",
+ depends_on_option=f"{test.group(1)} exists",
+ ),
+ Launcher(
+ emulator=core.group(1),
+ build="libretro",
+ kind="copies",
+ names=[posixpath.basename(source)],
+ source_ref=source_ref([(path, {core_line, arm_line} | used)]),
+ search_dir=posixpath.dirname(source),
+ passes_to=target,
+ fails_when_missing=False,
+ depends_on_option=f"{name} {arm.group(1)}",
+ ),
+ ]
+
+
+def dolphin(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ path = f"{EMULATORS}/dolphinSA/scripts/dolphin.sh"
+ script = _read(tree, path)
+ run_line, run = script.find(r"\b(XDG_DATA_HOME)=(\S+) .*\bdolphin-emu", "XDG_DATA_HOME start")
+ name = posixpath.basename(path)
+ return [
+ Launcher(
+ emulator=runs.emulator(name),
+ build="standalone",
+ kind="passes_path",
+ names=[],
+ source_ref=source_ref([runs.ref(name), (path, {run_line})]),
+ search_dir=run.group(2),
+ passes_to=f"environment {run.group(1)}",
+ )
+ ]
+
+
+def midi(tree: RepoTree, runs: Runs) -> list[Launcher]:
+ """The MIDI synth set_midi_source starts for the ra_midi_output setting."""
+ script = runs.script
+ setting_line, setting = script.find(
+ r'^\s*(\w+)=\$\(get_ee_setting "(\w*midi\w*)" ', "MIDI setting"
+ )
+ call_line, _ = script.find(
+ rf'set_midi_source "\$\{{{setting.group(1)}\}}"', "set_midi_source call", setting_line
+ )
+ utils = _read(tree, UTILS)
+ head_line, _ = utils.find(r"^\s*function set_midi_source\(\)", "set_midi_source")
+ end_line, _ = utils.find(r"^\s*esac\b", "end of the source case", head_line)
+ records = []
+ arm: tuple[int, str] | None = None
+ for number, line in utils.code():
+ if number <= head_line or number >= end_line:
+ continue
+ if found := re.match(r"\s*([\w-]+)\)\s*$", line):
+ arm = (number, found.group(1))
+ continue
+ command = [w.text for w in words(line)]
+ paths = [
+ (command[i - 1], value)
+ for i, value in enumerate(command)
+ if i and command[i - 1].startswith("-") and re.search(r"(^|/)bios(/|$)", value)
+ ]
+ if not paths or arm is None:
+ continue
+ ((flag, value),) = paths
+ is_file = "." in posixpath.basename(value) and _under(value)
+ records.append(
+ Launcher(
+ emulator=command[0],
+ build="standalone",
+ kind="fixed_name" if is_file else "passes_path",
+ names=[posixpath.relpath(value, BIOS_ROOT)] if is_file else [],
+ source_ref=source_ref(
+ [(RUN_EMU, {setting_line, call_line}), (UTILS, {head_line, arm[0], number})]
+ ),
+ slot=arm[1],
+ case_insensitive=False if is_file else None,
+ search_dir=BIOS_ROOT if is_file else value.rstrip("/"),
+ passes_to=f"{command[0]} {flag}",
+ fails_when_missing=False,
+ depends_on_option=f"{setting.group(2)} = {arm[1]}",
+ )
+ )
+ if not records:
+ raise utils.missing("MIDI synth started with a BIOS path")
+ return records
+
+
+EXTRACTORS: tuple[Callable[[RepoTree, Runs], list[Launcher]], ...] = (
+ retroarch,
+ retrorun,
+ freej2me,
+ mame,
+ pico8,
+ links,
+ duckstation,
+ yabasanshiro,
+ jzintv,
+ mupen64plus,
+ hatari,
+ advancemame,
+ scummvm,
+ dolphin,
+ midi,
+)
+
+
+def extract_launchers(tree: RepoTree) -> list[dict]:
+ """Every launcher record the extractors find, in extractor order."""
+ runs = Runs(tree)
+ return [record.entry() for extractor in EXTRACTORS for record in extractor(tree, runs)]
diff --git a/scripts/scraper/emuelec_scraper.py b/scripts/scraper/emuelec_scraper.py
index 065fa4eaa..a0729952e 100644
--- a/scripts/scraper/emuelec_scraper.py
+++ b/scripts/scraper/emuelec_scraper.py
@@ -43,6 +43,7 @@ from .batocera_scraper import (
platform_systems,
requirements_from_systems,
)
+from .emuelec_launchers import extract_launchers
from .es_emulators import (
SystemEmulators,
attach_system_cores,
@@ -50,6 +51,7 @@ from .es_emulators import (
json_systems,
system_cores,
)
+from .shell_launchers import RepoTree
PLATFORM_NAME = "emuelec"
@@ -120,6 +122,14 @@ BUILDS_OF = {
PORTS_SYSTEM = "ports"
+def launcher_revision(url: str) -> str:
+ """The tag or commit a batocera-systems URL is read at."""
+ found = re.search(rf"/{REPO}/([^/]+)/packages/", url)
+ if found is None:
+ raise ValueError(f"{url} names no {REPO} revision")
+ return found.group(1)
+
+
def port_engines(text: str) -> list[tuple[str, str, str | None, bool]]:
"""(port, engine, check_bios key, libretro?) for each entry of ports.yaml.
@@ -215,6 +225,10 @@ class Scraper(BaseScraper):
"standalone_cores": standalone,
"dual_mode_cores": dual,
"systems": systems,
+ # Read at the revision the BIOS list comes from.
+ "launchers": extract_launchers(
+ RepoTree.github(REPO, launcher_revision(self.url))
+ ),
}
diff --git a/tests/test_launchers_emuelec.py b/tests/test_launchers_emuelec.py
new file mode 100644
index 000000000..18cf54160
--- /dev/null
+++ b/tests/test_launchers_emuelec.py
@@ -0,0 +1,655 @@
+"""EmuELEC's launcher layer read from its packages.
+
+Each fixture is a cut-down copy of the lines an extractor reads at EmuELEC
+v4.8 (02b45fe53b85c45c75dbad8e44a648092dec2e72). The records must name the
+core emuelecRunEmu.sh dispatches, carry the names in the order the code
+tries them, point at the fixture's own lines, and a construct that is gone
+must stop the scrape.
+"""
+
+from __future__ import annotations
+
+import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
+
+import sys
+import textwrap
+import unittest
+from pathlib import Path
+
+import yaml
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO_ROOT / "scripts"))
+
+from scraper import emuelec_launchers as el # noqa: E402
+from scraper.emuelec_scraper import launcher_revision # noqa: E402
+from scraper.shell_launchers import LauncherPatternMissing, RepoTree # noqa: E402
+
+EMU = el.EMULATORS
+
+
+def text(block: str) -> str:
+ return textwrap.dedent(block).lstrip("\n")
+
+
+RUN_EMU = text(
+ """
+ if [[ "${EMULATOR}" = "libretro" ]]; then
+ EMU="${CORE}_libretro"
+ fi
+ if [[ "${EMULATOR}" = "retrorun" ]]; then
+ EMU="${CORE}_libretro"
+ fi
+ MIDI_OUTPUT=$(get_ee_setting "ra_midi_output" "${PLATFORM}" "${BASEROMNAME}")
+ if [[ ! -z "${MIDI_OUTPUT}" ]]; then
+ emuelec-utils set_midi_source "${MIDI_OUTPUT}" "${EMULATOR}"
+ fi
+ if [ ${EMU} == "freej2me_libretro" ]; then
+ freej2me.sh
+
+ JAVA_HOME='/storage/roms/bios/jdk'
+ export JAVA_HOME
+ fi
+ case ${PLATFORM} in
+ "atarist")
+ if [ "${EMU}" = "HATARISA" ]; then
+ RUNTHIS='${TBASH} hatari.start "${ROMNAME}"'
+ fi
+ ;;
+ "dreamcast"|"naomi")
+ if [ "${EMU}" = "flycastsa" ]; then
+ RUNTHIS='${TBASH} flycast.sh "${ROMNAME}"'
+ elif [ "${EMU}" = "flycast_dojo" ]; then
+ RUNTHIS='flycastdojo.sh "${ROMNAME}"'
+ fi
+ ;;
+ "psx")
+ if [ "${EMU}" = "duckstation" ]; then
+ RUNTHIS='${TBASH} duckstation.sh "${ROMNAME}"'
+ fi
+ ;;
+ "mame")
+ if [ "${EMU}" = "AdvanceMame" ]; then
+ RUNTHIS='${TBASH} advmame.sh "${PLATFORM}" "${ROMNAME}"'
+ fi
+ ;;
+ "n64")
+ if [ "${EMU}" = "rice" ]; then
+ RUNTHIS='${TBASH} m64p.sh "${ROMNAME}"'
+ elif [ "${EMU}" = "glide64mk2" ]; then
+ RUNTHIS='${TBASH} m64p.sh "${ROMNAME}" m64p_gl64mk2'
+ fi
+ ;;
+ "scummvm")
+ if [ "${EMU}" = "SCUMMVMSA" ]; then
+ RUNTHIS='${TBASH} scummvm.start sa "${ROMNAME}"'
+ fi
+ ;;
+ "wii"|"gamecube")
+ if [ "${EMU}" = "dolphin" ]; then
+ RUNTHIS='${TBASH} dolphin.sh "${ROMNAME}"'
+ fi
+ ;;
+ "pico8")
+ RUNTHIS='${TBASH} pico8.sh "${ROMNAME}"'
+ ;;
+ "intellivision")
+ if [ "${EMU}" = "jzintv" ]; then
+ RUNTHIS='jzintv.sh "${ROMNAME}"'
+ fi
+ ;;
+ "saturn")
+ if [ "${EMU}" = "yabasanshiroSA" ]; then
+ RUNTHIS='yabasanshiro.sh "${ROMNAME}"'
+ elif [ "${EMU}" = "yabasanshiroSA1_5" ]; then
+ RUNTHIS='yabasanshiro1_5.sh "${ROMNAME}"'
+ fi
+ ;;
+ esac
+ case ${PLATFORM} in
+ "arcade"|"mame")
+ if [ "$EMU" = "mame_libretro" ]; then
+ mame.sh
+ fi
+ ;;
+ esac
+ RUNTHIS='${RABIN} ${VERBOSE} ${RA_ARGS} -L /tmp/cores/${EMU}.so --config ${RACONF} "${ROMNAME}"'
+ RUNTHIS="retrorun"
+ RUNTHIS+=' ${CMD_ROTATE} --triggers -g -d /storage/roms/bios /tmp/cores/${EMU}.so "${ROMNAME}"'
+"""
+)
+
+UTILS = text(
+ """
+ function set_midi_source()
+ {
+ case ${midi_source} in
+ fluidsynth)
+ fluidsynth -is /storage/roms/bios/sc/SC-55.sf2 &
+ ;;
+ timidity)
+ timidity -iA --module 1 &
+ ;;
+ mt32)
+ mt32d -o 2 -i 24 -f /roms/bios &
+ ;;
+ esac
+ }
+"""
+)
+
+RETROARCH_MK = text(
+ """
+ sed -i -e "s/# savestate_directory =/savestate_directory =\\/storage\\/roms\\/savestates/" ${INSTALL}/etc/retroarch.cfg
+ sed -i -e "s/# system_directory =/system_directory =\\/storage\\/roms\\/bios/" ${INSTALL}/etc/retroarch.cfg
+"""
+)
+
+FREEJ2ME_SH = text(
+ """
+ install_java
+
+ if [[ -f "/storage/roms/bios/jdk/eeversion" ]]; then
+ cp -rf /usr/lib/libretro/freej2me-lr.jar ${HOME}/roms/bios
+ else
+ exit 1
+ fi
+"""
+)
+
+PICO8_SH = text(
+ """
+ if [[ -e "/storage/roms/bios/pico-8" ]]; then
+ mkdir -p /emuelec/bin/pico-8
+ cp -rf /storage/roms/bios/pico-8/* /emuelec/bin/pico-8
+ rm -rf /storage/roms/bios/pico-8
+ chmod +x /emuelec/bin/pico-8/pico8_dyn
+ fi
+ if [[ "${CART}" == *"/splore"* ]]; then
+ /emuelec/bin/pico-8/pico8_dyn -splore -home /emuelec/configs/pico-8
+ else
+ /emuelec/bin/pico-8/pico8_dyn -run "${CART}" -home /emuelec/configs/pico-8
+ fi
+"""
+)
+
+FLYCAST_SH = text(
+ """
+ if [ ! -L "/storage/.local/share/flycast" ]; then
+ mkdir -p "/storage/roms/bios/dc"
+ rm -rf "/storage/.local/share/flycast"
+ ln -sf "/storage/roms/bios/dc" "/storage/.local/share/flycast"
+ fi
+"""
+)
+
+FLYCASTDOJO_SH = text(
+ """
+ LOCAL_DATA="/storage/roms/bios/dc_dojo"
+ LOCAL_HOME="/storage/.local/share/flycast-dojo"
+ if [ ! -L "${LOCAL_HOME}" ]; then
+ rm -rf "${LOCAL_HOME}"
+ ln -sf "${LOCAL_DATA}" "${LOCAL_HOME}"
+ fi
+"""
+)
+
+AMIBERRY_MK = text(
+ """
+ cp -a ${PKG_DIR}/config/* ${INSTALL}/usr/config/amiberry/
+ ln -s /storage/roms/bios ${INSTALL}/usr/config/amiberry/kickstarts
+"""
+)
+
+TIMIDITY_MK = text(
+ """
+ ln -sf /storage/roms/bios/timidity/freepats ${INSTALL}/usr/config/timidity/freepats
+ ln -sf /storage/.config/timidity/freepats.cfg ${INSTALL}/etc/timidity/freepats.cfg
+"""
+)
+
+DUCKSTATION_INI = text(
+ """
+ [BIOS]
+ SearchDirectory = /storage/roms/bios
+ PathNTSCU =
+ PathNTSCJ =
+ PathPAL =
+"""
+)
+
+DUCKSTATION_MK = text(
+ """
+ cp -rf ${PKG_DIR}/config/* ${INSTALL}/usr/config/emuelec/configs/duckstation
+"""
+)
+
+DUCKSTATION_SH = text(
+ """
+ CONFIG_DIR="/emuelec/configs/duckstation"
+ duckstation-nogui -fullscreen -settings "${CONFIG_DIR}/settings.ini" -- "${1}"
+"""
+)
+
+
+def yabasanshiro_sh(program: str) -> str:
+ return text(
+ f"""
+ BIOS=""
+ HLEBIOS=$(get_ee_setting hlebios saturn "${{ROMNAME}}")
+ if [ "${{HLEBIOS}}" != 1 ]; then
+ if [ -e "/storage/roms/bios/saturn_bios.bin" ]; then
+ BIOS="-b /storage/roms/bios/saturn_bios.bin"
+ else
+ BIOS=""
+ fi
+ fi
+ {{ {program} -r 2 -i "${{1}}" ${{BIOS}}; }} > /emuelec/logs/emuelec.log 2>&1
+"""
+ )
+
+
+JZINTV_SH = text(
+ """
+ exec jzintv -f1 -z "$RES" -p /storage/roms/bios/ "$ROM" $KBD_OPT
+"""
+)
+
+M64P_SH = text(
+ """
+ BIOSDIR="/storage/roms/bios/Mupen64plus"
+ IPLROM="${BIOSDIR}/N64DD IPLROM (J).n64"
+ [ ! -f "${IPLROM}" ] && IPLROM="${BIOSDIR}/IPL.n64"
+ if [ -f "${GAMEDIR}/${GAMENAME}.ndd" ] && [ -f "${IPLROM}" ]; then
+ RENMODE="1"
+ else
+ IPLROM=""
+ fi
+ mupen64plus --fullscreen --dd-ipl-rom "${IPLROM}" --dd-disk "${DDDISK}" "${FILE}" ;;
+"""
+)
+
+HATARI_START = text(
+ """
+ HATARI_TMP_CONFIG="$HATARI_TMP_DIR"/.hatari-temp.cfg
+ cp $HATARI_CONFIG_DIR/Atari-ST-default.cfg "$HATARI_TMP_CONFIG"
+ hatari --configfile "$HATARI_TMP_CONFIG" --disk-a "$1"
+"""
+)
+
+HATARI_CFG = text(
+ """
+ [HardDisk]
+ szHardDiskDirectory = /storage/.hatari
+
+ [ROM]
+ szTosImageFileName = /storage/roms/bios/tos.img
+ szCartridgeImageFileName =
+"""
+)
+
+HATARI_MK = text(
+ """
+ cp -R ${PKG_DIR}/config/* ${INSTALL}/usr/config/hatari
+"""
+)
+
+ADVMAME_MK = text(
+ """
+ if [ "${DEVICE}" == "OdroidGoAdvance" ]; then
+ cp -r ${PKG_DIR}/config/advmame.rc_oga ${INSTALL}/usr/config/emuelec/configs/advmame/advmame.rc
+ else
+ cp -r ${PKG_DIR}/config/advmame.rc ${INSTALL}/usr/config/emuelec/configs/advmame/advmame.rc
+ fi
+"""
+)
+
+ADVMAME_RC = text(
+ """
+ dir_rom /storage/roms/mame
+ dir_sample /storage/roms/bios/advmame/samples
+"""
+)
+
+ADVMAME_SH = text(
+ """
+ SDL_AUDIODRIVER=alsa advmame ${ARG} -quiet
+"""
+)
+
+MAME_SH = text(
+ """
+ if [ ! -f "/storage/roms/bios/mame/hash/fmtowns_cd.xml" ]; then
+ mkdir -p /storage/roms/bios/mame/hash
+ cp -rf "/usr/config/emuelec/configs/mame/hash/"fm* "/storage/roms/bios/mame/hash"
+ fi
+ if [ ! -f "/storage/roms/bios/mame/ini/mame.ini" ]; then
+ mkdir -p /storage/roms/bios/mame/ini
+ cp -rf "/usr/config/emuelec/configs/mame/ini/"mame* "/storage/roms/bios/mame/ini"
+ fi
+ if [ ! -f "/storage/.config/retroarch/config/MAME/MAME.opt" ]; then
+ cp -rf "/usr/config/emuelec/configs/mame/MAME/"MAME* "/storage/.config/retroarch/config/MAME"
+ fi
+"""
+)
+
+MAME_INI = text(
+ """
+ rompath /storage/roms/bios/mame/bios;/storage/roms/mame;/storage/roms/arcade
+ samplepath /storage/roms/bios/mame/samples
+ cfg_directory /storage/roms/bios/mame/mame/cfg
+"""
+)
+
+MAME_OPT = text(
+ """
+ mame_boot_to_bios = "disabled"
+ mame_read_config = "enabled"
+"""
+)
+
+SCUMMVM_START = text(
+ """
+ CONFIG_DIR="/storage/.config/scummvm"
+ if [ -d "/storage/.config/scummvm/extra" ]; then
+ EXTRA="--extrapath=/storage/.config/scummvm/extra"
+ fi
+ case $1 in
+ "sa")
+ scummvm --fullscreen --joystick=0 $EXTRA "${GAME}"
+ ;;
+ "libretro")
+ retroarch -L /tmp/cores/scummvm_libretro.so --config ${RATMPCONF} "${GAME}"
+ ;;
+ "add")
+ scummvm --add --path="/storage/roms/scummvm" --recursive
+ cp ${CONFIG_DIR}/scummvm.ini /storage/roms/bios/scummvm.ini
+ ;;
+ esac
+"""
+)
+
+DOLPHIN_SH = text(
+ """
+ XDG_CONFIG_HOME=/emuelec/configs XDG_DATA_HOME=/storage/roms/dolphin /usr/bin/dolphin-emu-nogui -p fbdev "${1}"
+"""
+)
+
+FILES = {
+ el.RUN_EMU: RUN_EMU,
+ el.UTILS: UTILS,
+ el.RETROARCH_MK: RETROARCH_MK,
+ f"{el.LIBRETRO}/freej2me/scripts/freej2me.sh": FREEJ2ME_SH,
+ f"{el.BIN}/pico8.sh": PICO8_SH,
+ f"{EMU}/flycastsa/scripts/flycast.sh": FLYCAST_SH,
+ f"{EMU}/flycast-dojo/scripts/flycastdojo.sh": FLYCASTDOJO_SH,
+ f"{EMU}/amiberry/package.mk": AMIBERRY_MK,
+ f"{EMU}/amiberry-lite/package.mk": AMIBERRY_MK.replace("amiberry/", "amiberry-lite/"),
+ f"{el.SX05RE}/tools/sound/timidity/package.mk": TIMIDITY_MK,
+ f"{EMU}/duckstation/config/settings.ini": DUCKSTATION_INI,
+ f"{EMU}/duckstation/package.mk": DUCKSTATION_MK,
+ f"{EMU}/duckstation/scripts/duckstation.sh": DUCKSTATION_SH,
+ el.YABASANSHIRO_SCRIPTS[0]: yabasanshiro_sh("yabasanshiro"),
+ el.YABASANSHIRO_SCRIPTS[1]: yabasanshiro_sh("yabasanshiro1_5"),
+ f"{EMU}/jzintv/scripts/jzintv.sh": JZINTV_SH,
+ f"{EMU}/mupen64plussa/mupen64plussa-core/m64p.sh": M64P_SH,
+ f"{EMU}/hatarisa/scripts/hatari.start": HATARI_START,
+ f"{EMU}/hatarisa/config/conf/Atari-ST-default.cfg": HATARI_CFG,
+ f"{EMU}/hatarisa/package.mk": HATARI_MK,
+ f"{EMU}/advancemame/package.mk": ADVMAME_MK,
+ f"{EMU}/advancemame/config/advmame.rc": ADVMAME_RC,
+ f"{EMU}/advancemame/config/advmame.rc_oga": ADVMAME_RC,
+ f"{EMU}/advancemame/bin/advmame.sh": ADVMAME_SH,
+ f"{el.LIBRETRO}/mame/scripts/mame.sh": MAME_SH,
+ f"{el.LIBRETRO}/mame/config/ini/mame.ini": MAME_INI,
+ f"{el.LIBRETRO}/mame/config/MAME/MAME.opt": MAME_OPT,
+ f"{EMU}/scummvmsa/bin/scummvm.start": SCUMMVM_START,
+ f"{EMU}/dolphinSA/scripts/dolphin.sh": DOLPHIN_SH,
+}
+
+
+def tree(**changes: str | None) -> RepoTree:
+ files = dict(FILES)
+ for path, content in changes.items():
+ if content is None:
+ files.pop(path, None)
+ else:
+ files[path] = content
+ return RepoTree.from_files(files)
+
+
+def records(extractor, **changes: str | None) -> list[dict]:
+ built = tree(**changes)
+ return [r.entry() for r in extractor(built, el.Runs(built))]
+
+
+def ref(path: str, lines: str) -> str:
+ return f"{path}:{lines}"
+
+
+class Dispatch(unittest.TestCase):
+ def test_each_script_is_named_after_the_core_that_starts_it(self):
+ runs = el.Runs(tree())
+ self.assertEqual(runs.emulator("flycast.sh"), "flycastsa")
+ self.assertEqual(runs.emulator("flycastdojo.sh"), "flycast_dojo")
+ self.assertEqual(runs.emulator("m64p.sh"), "rice, glide64mk2")
+ self.assertEqual(runs.emulator("mame.sh"), "mame")
+ self.assertEqual(runs.emulator("freej2me.sh"), "freej2me")
+
+ def test_an_arm_without_a_core_test_names_its_platform(self):
+ self.assertEqual(el.Runs(tree()).emulator("pico8.sh"), "pico8")
+
+ def test_a_script_no_longer_started_raises(self):
+ runs = el.Runs(tree())
+ with self.assertRaises(LauncherPatternMissing):
+ runs.of("gone.sh")
+
+ def test_a_dispatch_without_any_script_raises(self):
+ with self.assertRaises(LauncherPatternMissing):
+ el.Runs(tree(**{el.RUN_EMU: "echo nothing\n"}))
+
+
+class LibretroRecords(unittest.TestCase):
+ def test_retroarch_system_directory_comes_from_the_sed(self):
+ (record,) = records(el.retroarch)
+ self.assertEqual(record["search_dir"], "/storage/roms/bios")
+ self.assertEqual(record["kind"], "passes_path")
+ self.assertEqual(
+ record["source_ref"], f"{ref(el.RETROARCH_MK, '2')}, {ref(el.RUN_EMU, '80')}"
+ )
+
+ def test_retrorun_is_handed_the_folder_with_d(self):
+ (record,) = records(el.retrorun)
+ self.assertEqual(
+ (record["emulator"], record["search_dir"], record["passes_to"]),
+ ("retrorun", "/storage/roms/bios", "retrorun -d"),
+ )
+ self.assertEqual(record["source_ref"], ref(el.RUN_EMU, "4, 81-82"))
+
+ def test_freej2me_gets_java_home_and_its_jar_copied(self):
+ java, jar = records(el.freej2me)
+ self.assertEqual(java["passes_to"], "environment JAVA_HOME")
+ self.assertEqual(java["search_dir"], "/storage/roms/bios/jdk")
+ self.assertEqual(jar["kind"], "copies")
+ self.assertEqual(jar["names"], ["freej2me-lr.jar"])
+ self.assertEqual(jar["passes_to"], "${HOME}/roms/bios")
+ self.assertEqual(jar["depends_on_option"], "/storage/roms/bios/jdk/eeversion exists")
+
+ def test_mame_copies_its_own_files_and_reads_only_path_options(self):
+ found = records(el.mame)
+ copies = [r for r in found if r["kind"] == "copies"]
+ self.assertEqual([r["names"] for r in copies], [["fm*"], ["mame*"]])
+ folders = [(r["passes_to"], r["search_dir"]) for r in found if r["kind"] == "passes_path"]
+ self.assertEqual(
+ folders,
+ [
+ ("mame.ini rompath", "/storage/roms/bios/mame/bios"),
+ ("mame.ini samplepath", "/storage/roms/bios/mame/samples"),
+ ],
+ )
+ self.assertTrue(all(r["depends_on_option"] == "mame_read_config = enabled"
+ for r in found if r["kind"] == "passes_path"))
+
+ def test_mame_without_the_read_config_option_raises(self):
+ with self.assertRaises(LauncherPatternMissing):
+ records(el.mame, **{f"{el.LIBRETRO}/mame/config/MAME/MAME.opt": "x = 1\n"})
+
+
+class StandaloneRecords(unittest.TestCase):
+ def test_pico8_moves_the_folder_and_runs_the_binary(self):
+ (record,) = records(el.pico8)
+ self.assertEqual(record["kind"], "moves")
+ self.assertEqual(record["names"], ["pico8_dyn"])
+ self.assertEqual(record["search_dir"], "/storage/roms/bios/pico-8")
+ self.assertEqual(record["passes_to"], "/emuelec/bin/pico-8")
+ self.assertEqual(
+ record["source_ref"],
+ f"{ref(el.RUN_EMU, '57-58')}, {ref(f'{el.BIN}/pico8.sh', '1-5, 8-10')}",
+ )
+
+ def test_pico8_without_the_removal_is_not_a_move(self):
+ changed = PICO8_SH.replace(" rm -rf /storage/roms/bios/pico-8\n", "")
+ with self.assertRaises(LauncherPatternMissing):
+ records(el.pico8, **{f"{el.BIN}/pico8.sh": changed})
+
+ def test_links_from_scripts_and_packages(self):
+ found = records(el.links)
+ self.assertEqual(
+ [(r["emulator"], r["search_dir"]) for r in found],
+ [
+ ("flycastsa", "/storage/roms/bios/dc"),
+ ("flycast_dojo", "/storage/roms/bios/dc_dojo"),
+ ("amiberry", "/storage/roms/bios"),
+ ("amiberry-lite", "/storage/roms/bios"),
+ ("timidity", "/storage/roms/bios/timidity/freepats"),
+ ],
+ )
+ self.assertEqual(
+ found[1]["source_ref"],
+ f"{ref(el.RUN_EMU, '26-27')}, "
+ f"{ref(f'{EMU}/flycast-dojo/scripts/flycastdojo.sh', '1-5')}",
+ )
+ self.assertEqual(
+ found[2]["passes_to"], "symlink /usr/config/amiberry/kickstarts -> /storage/roms/bios"
+ )
+
+ def test_a_script_without_a_bios_link_raises(self):
+ with self.assertRaises(LauncherPatternMissing):
+ records(el.links, **{f"{EMU}/flycastsa/scripts/flycast.sh": "flycast \"${1}\"\n"})
+
+ def test_duckstation_scans_the_folder_its_config_names(self):
+ (record,) = records(el.duckstation)
+ self.assertEqual((record["kind"], record["search_dir"]), ("scans_dir", "/storage/roms/bios"))
+
+ def test_duckstation_with_a_fixed_bios_path_raises(self):
+ changed = DUCKSTATION_INI.replace("PathPAL =", "PathPAL = /storage/roms/bios/scph5502.bin")
+ with self.assertRaises(LauncherPatternMissing):
+ records(el.duckstation, **{f"{EMU}/duckstation/config/settings.ini": changed})
+
+ def test_yabasanshiro_passes_the_bios_unless_hle_is_chosen(self):
+ new, old = records(el.yabasanshiro)
+ self.assertEqual((new["emulator"], old["emulator"]), ("yabasanshiroSA", "yabasanshiroSA1_5"))
+ self.assertEqual(new["names"], ["saturn_bios.bin"])
+ self.assertEqual(new["passes_to"], "yabasanshiro -b")
+ self.assertEqual(old["passes_to"], "yabasanshiro1_5 -b")
+ self.assertEqual(new["depends_on_option"], "hlebios != 1")
+ self.assertIs(new["fails_when_missing"], False)
+
+ def test_jzintv_is_handed_the_bios_folder(self):
+ (record,) = records(el.jzintv)
+ self.assertEqual((record["search_dir"], record["passes_to"]), ("/storage/roms/bios", "jzintv -p"))
+
+ def test_mupen64plus_tries_its_ipl_names_in_order(self):
+ (record,) = records(el.mupen64plus)
+ self.assertEqual(record["kind"], "names_searched")
+ self.assertEqual(record["names"], ["N64DD IPLROM (J).n64", "IPL.n64"])
+ self.assertEqual(record["search_dir"], "/storage/roms/bios/Mupen64plus")
+ self.assertEqual(record["passes_to"], "mupen64plus --dd-ipl-rom")
+ self.assertEqual(record["depends_on_option"], '-f "${GAMEDIR}/${GAMENAME}.ndd"')
+
+ def test_mupen64plus_without_a_fallback_raises(self):
+ changed = M64P_SH.replace('[ ! -f "${IPLROM}" ] && IPLROM="${BIOSDIR}/IPL.n64"\n', "")
+ with self.assertRaises(LauncherPatternMissing):
+ records(el.mupen64plus, **{f"{EMU}/mupen64plussa/mupen64plussa-core/m64p.sh": changed})
+
+ def test_hatari_reads_the_tos_named_by_its_default_config(self):
+ (record,) = records(el.hatari)
+ self.assertEqual(record["names"], ["tos.img"])
+ self.assertEqual(
+ record["passes_to"], "Atari-ST-default.cfg [ROM] szTosImageFileName (--configfile)"
+ )
+
+ def test_advancemame_one_record_when_every_device_agrees(self):
+ (record,) = records(el.advancemame)
+ self.assertEqual(record["search_dir"], "/storage/roms/bios/advmame/samples")
+ self.assertNotIn("device", record)
+
+ def test_advancemame_devices_that_disagree_raise(self):
+ changed = ADVMAME_RC.replace("bios/advmame", "bios/other")
+ with self.assertRaises(LauncherPatternMissing):
+ records(el.advancemame, **{f"{EMU}/advancemame/config/advmame.rc_oga": changed})
+
+ def test_scummvm_extrapath_and_the_ini_copied_for_the_core(self):
+ extra, ini = records(el.scummvm)
+ self.assertEqual(extra["passes_to"], "scummvm --extrapath")
+ self.assertEqual(extra["depends_on_option"], "/storage/.config/scummvm/extra exists")
+ self.assertEqual((ini["emulator"], ini["build"]), ("scummvm", "libretro"))
+ self.assertEqual(ini["search_dir"], "/storage/.config/scummvm")
+ self.assertEqual(ini["passes_to"], "/storage/roms/bios/scummvm.ini")
+
+ def test_dolphin_data_home(self):
+ (record,) = records(el.dolphin)
+ self.assertEqual(record["passes_to"], "environment XDG_DATA_HOME")
+ self.assertEqual(record["search_dir"], "/storage/roms/dolphin")
+
+ def test_midi_sources_that_read_the_bios_folder(self):
+ found = records(el.midi)
+ self.assertEqual(
+ [(r["emulator"], r["kind"], r["names"], r["search_dir"]) for r in found],
+ [
+ ("fluidsynth", "fixed_name", ["sc/SC-55.sf2"], "/storage/roms/bios"),
+ ("mt32d", "passes_path", [], "/roms/bios"),
+ ],
+ )
+ self.assertEqual(found[0]["depends_on_option"], "ra_midi_output = fluidsynth")
+ self.assertEqual(
+ found[1]["source_ref"], f"{ref(el.RUN_EMU, '7-9')}, {ref(el.UTILS, '1, 10-11')}"
+ )
+
+ def test_every_extractor_runs_on_the_fixtures(self):
+ found = el.extract_launchers(tree())
+ self.assertEqual(len(found), 26)
+
+
+class Revision(unittest.TestCase):
+ def test_the_revision_is_the_one_the_bios_list_is_read_at(self):
+ url = (
+ "https://raw.githubusercontent.com/EmuELEC/EmuELEC/v4.8/"
+ "packages/sx05re/emuelec/bin/batocera/batocera-systems"
+ )
+ self.assertEqual(launcher_revision(url), "v4.8")
+
+ def test_a_url_without_a_revision_raises(self):
+ with self.assertRaises(ValueError):
+ launcher_revision("https://example.org/batocera-systems")
+
+
+class PlatformRecords(unittest.TestCase):
+ """The launchers platforms/emuelec.yml carries, as the scraper wrote them."""
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.config = yaml.safe_load((REPO_ROOT / "platforms" / "emuelec.yml").read_text())
+
+ def test_the_platform_carries_launchers(self):
+ self.assertTrue(self.config.get("launchers"))
+
+ def test_every_reference_is_in_the_emuelec_packages(self):
+ for record in self.config["launchers"]:
+ for part in record["source_ref"].split(", "):
+ if ":" in part:
+ self.assertTrue(part.startswith(f"{el.SX05RE}/"), record)
+
+
+if __name__ == "__main__":
+ unittest.main()
From b5c517b07a9f41ad888159d0d3cedd07aed53ba4 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Mon, 9 Nov 2026 17:35:05 +0100
Subject: [PATCH 16/17] feat: add dsi boot blobs cut from the dsi nands
Each blob is TWLCFG 0x88-0x1AF, HWINFO_N 0x88-0x9B and HWINFO_S 0x88-0x9F, read from a retail NAND dump by DSperate's own nand_boot_blobs at ddf2910e. The primary comes from dsi_nand.bin, the seven others from the regional dumps and the dsi_nand.bin variant.
---
.../Nintendo DSi/.variants/dsi_boot.bin.1b256a83 | Bin 0 -> 340 bytes
.../Nintendo DSi/.variants/dsi_boot.bin.2596bd02 | Bin 0 -> 340 bytes
.../Nintendo DSi/.variants/dsi_boot.bin.2d287331 | Bin 0 -> 340 bytes
.../Nintendo DSi/.variants/dsi_boot.bin.e4615a20 | Bin 0 -> 340 bytes
.../Nintendo DSi/.variants/dsi_boot.bin.ee798394 | Bin 0 -> 340 bytes
.../Nintendo DSi/.variants/dsi_boot.bin.f8a307b5 | Bin 0 -> 340 bytes
.../Nintendo DSi/.variants/dsi_boot.bin.fa8e7a47 | Bin 0 -> 340 bytes
bios/Nintendo/Nintendo DSi/dsi_boot.bin | Bin 0 -> 340 bytes
emulators/dsperate.yml | 2 +-
9 files changed, 1 insertion(+), 1 deletion(-)
create mode 100644 bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.1b256a83
create mode 100644 bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.2596bd02
create mode 100644 bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.2d287331
create mode 100644 bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.e4615a20
create mode 100644 bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.ee798394
create mode 100644 bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.f8a307b5
create mode 100644 bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.fa8e7a47
create mode 100644 bios/Nintendo/Nintendo DSi/dsi_boot.bin
diff --git a/bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.1b256a83 b/bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.1b256a83
new file mode 100644
index 0000000000000000000000000000000000000000..3123f5377e793b26451fe7304cb1d1eb8aa120d7
GIT binary patch
literal 340
zcmd;J00BlYiOPZsu`9skK#X`M3uXm{X}r&Q9xRxnz{ms=<7Z$*7yuF&j-Xnc>DmMP
k!*h+8HqYKPU*tL4O9nNlKN&;94Gjz|%*_nV41q#60LIS{b^rhX
literal 0
HcmV?d00001
diff --git a/bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.2596bd02 b/bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.2596bd02
new file mode 100644
index 0000000000000000000000000000000000000000..8e045199293c5b59e2f9c032571682ee15925941
GIT binary patch
literal 340
zcmd;PU|?i0WEAA^Tdu$W1dJd88HfooFa!CnZhju3K-L4s1B?m^4|p$dK3FhEfsqNM
zl$(K(F_fX0A(f#B#6V$5oBrfU!E56?AX+B|#He39pD
ZFB#OJUSJFfH#9J?FgG(aGXx6R005hq7^MIJ
literal 0
HcmV?d00001
diff --git a/bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.2d287331 b/bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.2d287331
new file mode 100644
index 0000000000000000000000000000000000000000..913379a4a168c87d0e30db7c4c47c4bdcf199d47
GIT binary patch
literal 340
zcmd;J00BlYiOPZsamX_;!)0NN5Bwjv|L>opz{ms=;b&k(7ywf;%;@SgQKoC(+z!pP
k;@LBI%RIH`Y%dwWo(KAqImFS!(7?db(%itnh=IWd0G-Sb{Qv*}
literal 0
HcmV?d00001
diff --git a/bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.e4615a20 b/bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.e4615a20
new file mode 100644
index 0000000000000000000000000000000000000000..58e8b3c7071f352a068482c8f223682566ea0bc8
GIT binary patch
literal 340
zcmd;JKn0AbTm}Yq1r(89CUIs3g(%)DJP#JkQD9^ON%Au=VrWCrGz>WP)#6Oo{<X=1^^U;_Y~rV%Ir
literal 0
HcmV?d00001
diff --git a/bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.ee798394 b/bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.ee798394
new file mode 100644
index 0000000000000000000000000000000000000000..a3febd85b8139356b84f7f63885ca42499785c2b
GIT binary patch
literal 340
zcmd;J00A~I2_hKbEGUCr0YzjdlRC44LIm$^o(Bth)ESvTlKc#e7}`)Y4FgV%oEX!!
oFCK^HS~2gPxpBJMbGDZZ4p4uwggCny8W@;a7@JxeF)-Kw0Mwrm7ytkO
literal 0
HcmV?d00001
diff --git a/bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.f8a307b5 b/bios/Nintendo/Nintendo DSi/.variants/dsi_boot.bin.f8a307b5
new file mode 100644
index 0000000000000000000000000000000000000000..d53c89ec03bdbc9d5e2543d69557786f9e31d19f
GIT binary patch
literal 340
zcmd;J00DL|2_hKbEGUCbk%1XqjNt?S2k!s-d(;`3KvMh+j2PN5v
Date: Mon, 9 Nov 2026 17:45:53 +0100
Subject: [PATCH 17/17] docs: profile fluidsynth as emuelec runs it
FluidSynth 2.0.6 (EmuELEC fluidsynth-git, PKG_SHA256 matched) loads
any argument whose header is RIFF sfbk and names no SoundFont.
SC-55.sf2 is named only by emuelec-utils:67 and no EmuELEC package
or script installs or fetches it, so the entry is unsourceable: user
supplied, no canonical file.
---
emulators/fluidsynth.yml | 63 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 63 insertions(+)
create mode 100644 emulators/fluidsynth.yml
diff --git a/emulators/fluidsynth.yml b/emulators/fluidsynth.yml
new file mode 100644
index 000000000..f5de51329
--- /dev/null
+++ b/emulators/fluidsynth.yml
@@ -0,0 +1,63 @@
+emulator: "FluidSynth"
+type: utility
+core_classification: other
+source: "https://github.com/FluidSynth/fluidsynth"
+upstream: "https://github.com/FluidSynth/fluidsynth"
+profiled_date: "2026-11-09"
+source_commit: "f78486a50bd785200412ea16434b38edc4a45a6c"
+upstream_commit: "f78486a50bd785200412ea16434b38edc4a45a6c"
+pin_frozen: "EmuELEC, the only platform whose launch code starts it, builds this release (EmuELEC packages/sx05re/tools/sound/fluidsynth-git/package.mk:5-9 at tag v4.8, PKG_SHA256 e97e63c1). Later commits are not part of that build."
+pin_build:
+ - platform: emuelec
+ repo: "https://github.com/EmuELEC/EmuELEC"
+ revision: "v4.8"
+ path: "packages/sx05re/tools/sound/fluidsynth-git/package.mk"
+ variable: PKG_VERSION
+ value: "2.0.6"
+core_version: "2.0.6"
+version_basis:
+ kind: tag
+ tag: "v2.0.6"
+ distance: 0
+display_name: "FluidSynth (SoundFont MIDI synthesizer)"
+cores:
+ - fluidsynth
+systems: []
+
+notes: |
+ A SoundFont 2 software synthesizer run as a program. -s starts its TCP
+ command server and -i turns off the command shell on the terminal.
+ ref: src/fluidsynth.c:543-544, src/fluidsynth.c:635-637
+
+ The program names no SoundFont. Every non-option argument whose header
+ reads RIFF then sfbk is loaded as a SoundFont, in argument order. An
+ argument that cannot be opened is skipped with a message and the synth
+ keeps running with no instrument loaded.
+ ref: src/fluidsynth.c:764-778, src/sfloader/fluid_sffile.c:335-375
+
+ At start it executes one command file: the -f argument, else
+ $HOME/.fluidsynth, else /etc/fluidsynth.conf. The second name is built
+ whenever HOME is set, so the system file is read only without HOME.
+ ref: src/fluidsynth.c:870-884, src/bindings/fluid_cmd.c:608-648
+
+files:
+ - name: "SC-55.sf2"
+ description: "SoundFont handed on the command line"
+ required: false
+ unsourceable: "user supplied, no canonical file"
+ note: >-
+ The code opens no file by name and accepts any SoundFont 2 file. The
+ entry name is a label for the file EmuELEC names on the command line
+ (EmuELEC packages/sx05re/emuelec/bin/emuelec-utils:67 at v4.8), which
+ no EmuELEC package installs or downloads.
+ source_ref: "src/fluidsynth.c:764-778, src/sfloader/fluid_sffile.c:335-375"
+
+ - name: "default.sf2"
+ description: "SoundFont named by synth.default-soundfont"
+ required: false
+ unsourceable: "a path the build fixes for a SoundFont the system installs. FluidSynth ships none, and a same-named file of another program must not stand in"
+ note: >-
+ DEFAULT_SOUNDFONT is ${CMAKE_INSTALL_PREFIX}/share/soundfonts/default.sf2
+ outside Windows. It is loaded only when MIDI files are played and no
+ SoundFont was loaded from the command line.
+ source_ref: "cmake_admin/DefaultDirs.cmake:4-11, src/synth/fluid_synth.c:212-214, src/fluidsynth.c:837-859"