feat: serve the handheld launchers from the site

build_port's four archives carry install.py pinned by SHA-256, so they
change with the installer, not with the packs. The site now builds them
from its own commit and serves them under get/handheld/. Each get/ page
of Batocera, KNULLI, ROCKNIX and AmberELEC names its file, size, hash,
folder and menu, and get/linux-handhelds/ does the same for muOS, ArkOS,
dArkOS and JELOS. The installer's handheld section opens on a download
table, both tables written from sitehandheld.LAUNCHERS.
This commit is contained in:
Abdessamad Derraz committed 2026-11-10 11:50:29 +01:00
1 parent d457948143
commit d7bd89d42b
9 files changed
+494 -21

No files matched your search

+5
View File
@@ -15,6 +15,7 @@ on:
- "scripts/archive_build.py"
- "scripts/artifacts.py"
- "scripts/auto_fetch.py"
- "scripts/build_port.py"
- "scripts/common.py"
- "scripts/cross_reference.py"
- "scripts/crypto_verify.py"
@@ -52,6 +53,7 @@ on:
- "scripts/siteagents.py"
- "scripts/site_markdown.py"
- "scripts/sitebootstrap.py"
- "scripts/sitehandheld.py"
- "scripts/sitecollection.py"
- "scripts/sitediagrams.py"
- "scripts/siteentries.py"
@@ -89,6 +91,9 @@ on:
- "install.py"
- "install.sh"
- "install.ps1"
# The handheld launchers it serves are built from ports/ at the same
# commit (scripts/sitehandheld.py).
- "ports/**"
- "database.json"
- "release.json"
- "derivable.json"
+9 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Build the handheld launchers in ports/ into their archives in dist/.
"""Build the handheld launchers in ports/ into their archives.
RetroBIOS.zip the PortMaster port (ports/RetroBIOS)
RetroBIOS-batocera.zip Batocera and Knulli, from Ports (ports/batocera)
@@ -17,13 +17,16 @@ the applications folder, so its top folder is the application.
install.py is copied from the repository root, and only when it is the
installer the bootstraps pin, so every launcher runs the same bytes as the
one-line command of the same commit. Members are written as the packs write
one-line command of the same commit. The site builds them at each deploy
and serves them under get/handheld/ (scripts/sitehandheld.py): they are no
release files, and are written to tmp/handheld/ by default, away from the
packs of dist/. Members are written as the packs write
theirs: fixed timestamps, sorted names, the executable bit read from the
source, so the same tree gives the same archives.
Usage:
python scripts/build_port.py
python scripts/build_port.py --output-dir tmp/port
python scripts/build_port.py --output-dir tmp/port # default: tmp/handheld
"""
from __future__ import annotations
@@ -44,7 +47,7 @@ PORT_ZIP = "RetroBIOS.zip"
BATOCERA_ZIP = "RetroBIOS-batocera.zip"
MUOS_APP = "RetroBIOS.muxapp"
TOOLS_ZIP = "RetroBIOS-tools.zip"
# Every archive a release attaches, in the order the release notes list them.
# Every launcher archive the site serves.
ARCHIVES = (PORT_ZIP, BATOCERA_ZIP, MUOS_APP, TOOLS_ZIP)
# Which release pack serves each platform, read by tui.txt offline.
PACKS_TABLE = "packs.txt"
@@ -201,8 +204,8 @@ def main() -> int:
parser = argparse.ArgumentParser(
allow_abbrev=False, description="Build the RetroBIOS handheld launchers"
)
parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "dist",
help="where the archives are written (default: dist/)")
parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "tmp" / "handheld",
help="where the archives are written (default: tmp/handheld/)")
args = parser.parse_args()
try:
with artifact_lock(str(args.output_dir)):
+7 -1
View File
@@ -8,7 +8,8 @@ Each table sits between `<!-- generated: NAME -->` and
`<!-- /generated: NAME -->`. What install.py knows is written from it: the
options and their arguments from its parser, the event kinds and their keys
from EVENT_KEYS, the exit codes from its sys.exit calls, the RETROBIOS_PAD
values and each route's keys from TUI_ROUTES. The prose cells (what an option
values and each route's keys from TUI_ROUTES, the handheld launchers from
sitehandheld.LAUNCHERS. The prose cells (what an option
does, when an event comes) are kept as written, matched by their row. A row
install.py gains has no prose yet: the generator names it and writes nothing,
and the row is added by hand inside the markers, its first cell holding the
@@ -45,6 +46,7 @@ import zipfile
from pathlib import Path, PurePosixPath
from typing import Callable
import sitehandheld
from artifacts import write_text_atomic
ROOT = Path(__file__).resolve().parent.parent
@@ -622,6 +624,10 @@ def render(page: str) -> str:
"card-markers": lambda _body: _table(["On the card", "System", "Files go to"],
card_marker_rows()),
"line-menu": lambda _body: line_menu(),
# Where each handheld launcher goes, written from the site's own
# description of it (sitehandheld.LAUNCHERS).
"handheld": lambda _body: "\n".join(sitehandheld.table_rows()),
"handheld-downloads": lambda _body: "\n".join(sitehandheld.download_rows()),
}
seen = []
with contextlib.ExitStack() as stack:
+1 -1
View File
@@ -5819,7 +5819,7 @@ def _pack_archives(output_dir: str) -> list[str]:
fail conformance, and injecting a manifest would change published bytes.
Every name the builder gives holds `_BIOS_Pack`, then the system tag of
a --system platform pack (`_BIOS_Pack_Playstation.zip`): build_port.py
writes its launcher archives (RetroBIOS.zip) to the same dist/, and
wrote its launcher archives (RetroBIOS.zip) to the same dist/, and
taking every ZIP rewrote them with a manifest after build_port had
printed their hashes.
"""
+15
View File
@@ -127,6 +127,7 @@ from siteguides import (
import archive_build
import release_record
import sitebootstrap
import sitehandheld
import sitemeasurements
# The Starlight project, and where its pages go inside it.
@@ -859,6 +860,7 @@ def generate_get_page(platform_name: str) -> str:
f"[Why no ZIP?](../{NO_ZIP_FAQ})",
"",
]
lines += sitehandheld.platform_block(platform_name, _REPO_ROOT)
lines += _next_block([
f"[{display}](../platforms/{platform_name}.md#install): the folder, "
"and how to run the command on the device",
@@ -886,6 +888,11 @@ def generate_get_index() -> str:
for name in names:
display = load_platform_config(name, str(siteinputs.current().platforms)).get("platform", name)
lines.append(f"- [{get_page_title(name, display)}]({name}.md)")
# The handheld app sits with the group whose tile leads to the
# handheld guide.
if any(HANDHELD_GUIDE and tile.link == HANDHELD_GUIDE.path
for tile in GROUP_TILES.get(group, ())):
lines.append(f"- [{sitehandheld.GROUP_TITLE}]({sitehandheld.GROUP_PAGE}.md)")
lines.append("")
return "\n".join(lines)
@@ -4512,6 +4519,10 @@ def generate_handheld_guide(guide: HandheldGuide) -> str:
"",
*_guide_download(guide.uses, display, uses, None, "## Download { #download }"),
*_offline_line(guide.uses),
f"On {sitehandheld.joined(sitehandheld.group_systems(), 'or')}: "
f"[{sitehandheld.GROUP_TITLE}]"
f"(../get/{sitehandheld.GROUP_PAGE}.md).",
"",
"## Fill the card from a computer { #install }",
"",
"On Windows, note the card's letter, like **E:**. Put its BIOS folder after "
@@ -12472,6 +12483,8 @@ def _generate(args: argparse.Namespace) -> None:
(public / "robots.txt").unlink(missing_ok=True)
# The one-line commands' bootstraps, at the site's root.
sitebootstrap.write_bootstraps(public, _REPO_ROOT)
# The handheld launchers, built from the same commit (sitehandheld).
sitehandheld.write_launchers(public, _REPO_ROOT)
images_dest = public / "assets" / "images"
images_dest.mkdir(parents=True, exist_ok=True)
@@ -12820,6 +12833,8 @@ def _generate(args: argparse.Namespace) -> None:
get_dir.mkdir(parents=True, exist_ok=True)
for name in platform_names:
write_if_changed(str(get_dir / f"{name}.md"), generate_get_page(name))
write_if_changed(str(get_dir / f"{sitehandheld.GROUP_PAGE}.md"),
sitehandheld.group_page(_REPO_ROOT))
write_if_changed(str(get_dir / "index.md"), generate_get_index())
print("Generating contributing page...")
+258
View File
@@ -0,0 +1,258 @@
"""The handheld launchers the site serves, and where each one goes.
The four archives scripts/build_port.py writes carry install.py pinned by
SHA-256, so they change with the installer, not with the BIOS packs. The
site builds them from the commit it is built from and serves them under
get/handheld/, beside the bootstraps that pin the same installer
(sitebootstrap.py). build_archive refuses an install.py that install.sh
does not pin: a stale pin stops the site build, the last good archives
staying served.
LAUNCHERS is the one description of where each archive goes on the card
and which menu starts it. The handheld tables of wiki/installer.md are
written from it (scripts/generate_installer_doc.py), and so is the block
each get/<platform>/ page and the handheld page carry.
"""
from __future__ import annotations
import hashlib
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
import sitemarkup as markup
from artifacts import write_bytes_atomic
from build_port import ARCHIVES, BATOCERA_ZIP, MUOS_APP, PORT_ZIP, TOOLS_ZIP, build_archive
REPO_ROOT = Path(__file__).resolve().parent.parent
# Below the site's root: get/handheld/RetroBIOS.zip.
SERVED_DIR = "get/handheld"
# The page of the systems no registered platform answers for. It shares
# its name with the handheld guide (platforms/linux-handhelds.md), so the
# sidebar files it with that guide, as get/batocera.md is with
# platforms/batocera.md.
GROUP_PAGE = "linux-handhelds"
GROUP_TITLE = "RetroBIOS app for handhelds"
@dataclass(frozen=True)
class Launcher:
"""One row of the installer's handheld table.
*systems* are named as the installer page names them. *copy_to* is
where the file goes, and *places* gives a system its own folder when
the row's systems differ there. *lead* opens the row's first cell.
"""
archive: str
systems: tuple[str, ...]
copy_to: str
start: str
extracted: bool = False
lead: str = ""
places: tuple[tuple[str, tuple[str, ...]], ...] = field(default=())
def folder_for(self, system: str) -> str:
return next((folder for folder, systems in self.places if system in systems),
self.copy_to)
LAUNCHERS: tuple[Launcher, ...] = (
Launcher(MUOS_APP, ("muOS",),
"`ARCHIVE` at the root of the card, then install it from the Archive Manager",
"Applications"),
Launcher(PORT_ZIP, ("ArkOS", "dArkOS", "ROCKNIX", "AmberELEC", "JELOS", "KNULLI"),
"PortMaster's `autoinstall` folder", "Ports, after opening PortMaster once",
lead="Any other system with [PortMaster](https://portmaster.games/)",
places=(("`/roms/ports/PortMaster/autoinstall/`", ("ROCKNIX", "AmberELEC")),
("`/roms/tools/PortMaster/autoinstall/`", ("ArkOS",)),
("`/userdata/system/.local/share/PortMaster/autoinstall/`", ("KNULLI",)))),
Launcher(BATOCERA_ZIP, ("Batocera", "KNULLI"), "`roms/ports` on the SHARE partition",
"Ports, after updating the game list", extracted=True),
Launcher(TOOLS_ZIP, ("ArkOS", "dArkOS"), "`tools` on the EASYROMS partition",
"Options, then Tools", extracted=True),
Launcher(TOOLS_ZIP, ("ROCKNIX", "JELOS"),
"`/storage/.config/modules`, over the network share or SSH", "Tools",
extracted=True),
Launcher(TOOLS_ZIP, ("AmberELEC",), "`/storage/.config/distribution/modules`", "Tools",
extracted=True),
)
# The registered platform whose get/ page answers for a system. A system
# outside this map is on the handheld page.
PLATFORM_OF = {"Batocera": "batocera", "KNULLI": "knulli", "ROCKNIX": "rocknix",
"AmberELEC": "amberelec"}
def systems() -> list[str]:
"""Every system a launcher runs on, in table order."""
return list(dict.fromkeys(name for launcher in LAUNCHERS for name in launcher.systems))
def group_systems() -> list[str]:
return [name for name in systems() if name not in PLATFORM_OF]
def joined(names: list[str], last: str = "and") -> str:
"""muOS, ArkOS and JELOS."""
return names[0] if len(names) == 1 else f"{', '.join(names[:-1])} {last} {names[-1]}"
def page_of(system: str) -> str:
"""The site page that holds the download for *system*."""
return f"get/{PLATFORM_OF.get(system, GROUP_PAGE)}.md"
def launchers_for(system: str) -> list[Launcher]:
"""The launchers of *system*, its own menu first and PortMaster last."""
own = [launcher for launcher in LAUNCHERS if system in launcher.systems]
return sorted(own, key=lambda launcher: launcher.archive == PORT_ZIP)
@dataclass(frozen=True)
class Served:
data: bytes
@property
def size(self) -> int:
return len(self.data)
@property
def sha256(self) -> str:
return hashlib.sha256(self.data).hexdigest()
_built: dict[tuple[str, str], Served] = {}
def served(name: str, root: Path = REPO_ROOT) -> Served:
"""The bytes of archive *name* as build_port builds it from *root*,
built once per run: the page states the size and hash of the file the
same run writes."""
key = (str(root.resolve()), name)
if key not in _built:
scratch_root = root / "tmp"
scratch_root.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=scratch_root, prefix="handheld-") as scratch:
path = build_archive(name, Path(scratch) / name, root)
_built[key] = Served(path.read_bytes())
return _built[key]
def write_launchers(public: Path, root: Path = REPO_ROOT) -> list[Path]:
"""Write every launcher archive under the site's public folder, each
only when its bytes moved."""
folder = public / SERVED_DIR
folder.mkdir(parents=True, exist_ok=True)
written = []
for name in ARCHIVES:
path = folder / name
data = served(name, root).data
if not path.is_file() or path.read_bytes() != data:
write_bytes_atomic(str(path), data)
written.append(path)
return written
def table_rows() -> list[str]:
"""The handheld table of the installer page, each system linked to the
page that holds its download. The page sits in wiki/."""
def name(system: str) -> str:
return f"[{system}](../{page_of(system)})"
rows = ["| System | File | Copy it to | Start it from |",
"|--------|------|------------|---------------|"]
for launcher in LAUNCHERS:
names = ", ".join(name(system) for system in launcher.systems)
first = f"{launcher.lead}: {names}" if launcher.lead else names
file = f"`{launcher.archive}`" + (", extracted" if launcher.extracted else "")
copy_to = launcher.copy_to
if launcher.places:
copy_to += ": " + ", ".join(
f"{folder} on {' and '.join(where)}" for folder, where in launcher.places)
rows.append(f"| {first} | {file} | {copy_to} | {launcher.start} |")
return rows
def _anchor(system: str) -> str:
"""Where *system*'s downloads sit on its page."""
return "on-the-handheld" if system in PLATFORM_OF else system.casefold()
def download_rows() -> list[str]:
"""The short table that opens the installer's handheld section: each
system, its files as links to the site's copies, and its page. The
page sits in wiki/."""
rows = ["| System | Download | Steps |", "|--------|----------|-------|"]
for system in systems():
files = ", ".join(
f"[{launcher.archive}](../{SERVED_DIR}/{launcher.archive})"
+ (" (PortMaster)" if launcher.archive == PORT_ZIP else "")
for launcher in launchers_for(system))
page = f"../{page_of(system)}#{_anchor(system)}"
rows.append(f"| {system} | {files} | [{system} steps]({page}) |")
return rows
def _launcher_block(launcher: Launcher, system: str, root: Path) -> list[str]:
archive = served(launcher.archive, root)
verb = "Unzip it into" if launcher.extracted else "Copy it to"
via = " (with PortMaster)" if launcher.archive == PORT_ZIP else ""
return [
markup.button(f"Download {launcher.archive}", f"handheld/{launcher.archive}",
primary=True) + via,
"",
f"- {verb} {launcher.folder_for(system)}.",
f"- Start it from {launcher.start}.",
f"- {_size(archive.size)}. SHA-256: `{archive.sha256}`",
"",
]
def _size(count: int) -> str:
if count >= 1024 * 1024:
return f"{count / 1024 ** 2:.1f} MB"
return f"{count / 1024:.0f} KB"
APP_LINE = "RetroBIOS also runs on the handheld, with the controller."
# The installer's handheld section opens on the download of every system.
INSTALLER_SECTION = "wiki/installer.md#on-a-handheld-the-retrobios-port"
DETAILS_LINK = f"[Every system's file](../{INSTALLER_SECTION})"
def platform_block(platform: str, root: Path = REPO_ROOT) -> list[str]:
"""The app section of get/<platform>/, empty for a platform no launcher
runs on."""
system = next((name for name, key in PLATFORM_OF.items() if key == platform), None)
if system is None:
return []
lines = ["## On the handheld { #on-the-handheld }", "", APP_LINE, ""]
for launcher in launchers_for(system):
lines += _launcher_block(launcher, system, root)
return [*lines, DETAILS_LINK, ""]
def group_page(root: Path = REPO_ROOT) -> str:
"""get/linux-handhelds/: the app for the systems no setup page answers
for, then the setup pages that carry it for the others."""
names = group_systems()
listed = joined(names)
lines = [
"---", "search: exclude",
f'description: "The RetroBIOS app for {listed}: the file to put on the card '
'and the menu that starts it."',
"---", "",
f"# {GROUP_TITLE}", "",
f"{APP_LINE} Each system has its file below.", "",
]
for system in names:
lines += [f"## {system}", ""]
for launcher in launchers_for(system):
lines += _launcher_block(launcher, system, root)
others = [f"[{name}]({PLATFORM_OF[name]}.md#on-the-handheld)"
for name in sorted(PLATFORM_OF, key=str.casefold)]
lines += ["## Other systems", "", f"On {joined(others, 'or')}: on its page.", "",
DETAILS_LINK, ""]
return "\n".join(lines)
+168
View File
@@ -0,0 +1,168 @@
"""The handheld launchers the site serves.
The four archives build_port.py writes carry install.py pinned by SHA-256,
so they left the release (owner, 2026-11-10): the site builds them from its
own commit, serves them under get/handheld/, and the get/ page of each
system names the file, its size and hash, where it goes and which menu
starts it.
"""
from __future__ import annotations
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
import contextlib
import hashlib
import re
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import build_port # noqa: E402
import common # noqa: E402
import sitehandheld # noqa: E402
PLATFORMS = str(REPO_ROOT / "platforms")
def _pages() -> dict[str, str]:
"""Every page that carries a launcher, by site path."""
import generate_site # noqa: PLC0415
with contextlib.chdir(REPO_ROOT):
pages = {f"get/{key}.md": generate_site.generate_get_page(key)
for key in sorted(set(sitehandheld.PLATFORM_OF.values()))}
pages[f"get/{sitehandheld.GROUP_PAGE}.md"] = sitehandheld.group_page(REPO_ROOT)
return pages
class TheSiteServesTheLaunchers(unittest.TestCase):
def setUp(self):
scratch = REPO_ROOT / "tmp"
scratch.mkdir(exist_ok=True)
self.tmp = Path(tempfile.mkdtemp(dir=scratch, prefix="site-handheld-"))
self.addCleanup(shutil.rmtree, self.tmp, True)
def test_the_written_archives_are_the_ones_build_port_writes(self):
written = sitehandheld.write_launchers(self.tmp / "public", REPO_ROOT)
self.assertEqual([path.name for path in written], list(build_port.ARCHIVES))
for path in written:
with self.subTest(archive=path.name):
self.assertEqual(path.parent, self.tmp / "public" / "get" / "handheld")
built = build_port.build_archive(path.name, self.tmp / "built" / path.name)
self.assertEqual(path.read_bytes(), built.read_bytes())
def test_an_unchanged_archive_is_not_rewritten(self):
first = sitehandheld.write_launchers(self.tmp / "public", REPO_ROOT)
stamps = {path: path.stat().st_mtime_ns for path in first}
sitehandheld.write_launchers(self.tmp / "public", REPO_ROOT)
self.assertEqual({path: path.stat().st_mtime_ns for path in first}, stamps)
def test_a_stale_pin_stops_the_build(self):
"""build_port refuses an install.py the bootstraps do not pin: served,
that launcher would refuse its own installer."""
with mock.patch.dict(sitehandheld._built, clear=True), \
mock.patch.object(build_port, "pinned_installer_sha256", return_value="0" * 64):
with self.assertRaisesRegex(ValueError, "re-pin the bootstraps"):
sitehandheld.write_launchers(self.tmp / "public", REPO_ROOT)
self.assertFalse(list((self.tmp / "public").rglob("RetroBIOS*")))
def test_every_archive_is_on_a_page_with_its_link_and_hash(self):
public = self.tmp / "public"
written = {path.name: path for path in sitehandheld.write_launchers(public, REPO_ROOT)}
pages = _pages()
for name in build_port.ARCHIVES:
with self.subTest(archive=name):
data = written[name].read_bytes()
digest = hashlib.sha256(data).hexdigest()
holding = [page for page, text in pages.items()
if f'href="handheld/{name}"' in text]
self.assertTrue(holding, f"no page links {name}")
for page in holding:
text = pages[page]
# The link resolves to the file written under public/.
target = (Path(page).parent / "handheld" / name).as_posix()
self.assertTrue((public / target).is_file(), target)
block = text.split(f'href="handheld/{name}"', 1)[1].split("\n\n", 2)[1]
self.assertIn(f"SHA-256: `{digest}`", block)
self.assertIn(sitehandheld._size(len(data)), block)
self.assertRegex(block, r"- (Copy it to|Unzip it into) .+\.")
self.assertRegex(block, r"- Start it from .+\.")
class WhereEachSystemFindsItsFile(unittest.TestCase):
def test_each_platform_page_is_a_registered_platform(self):
registered = set(common.list_registered_platforms(PLATFORMS))
self.assertLessEqual(set(sitehandheld.PLATFORM_OF.values()), registered)
self.assertNotIn(sitehandheld.GROUP_PAGE, registered)
def test_the_group_page_sits_with_the_handheld_guide(self):
"""It shares the guide's name, so the sidebar files it with it, and
the guide a muOS or ArkOS owner lands on links it."""
import generate_site # noqa: PLC0415
guide = generate_site.HANDHELD_GUIDE
self.assertEqual(sitehandheld.GROUP_PAGE, guide.page)
with contextlib.chdir(REPO_ROOT):
self.assertIn(f"(../get/{sitehandheld.GROUP_PAGE}.md)",
generate_site.generate_handheld_guide(guide))
self.assertIn(f"]({sitehandheld.GROUP_PAGE}.md)", generate_site.generate_get_index())
def test_every_system_has_its_launchers_on_its_page(self):
pages = _pages()
for system in sitehandheld.systems():
page = sitehandheld.page_of(system)
with self.subTest(system=system):
self.assertIn(page, pages)
for launcher in sitehandheld.launchers_for(system):
self.assertIn(f'href="handheld/{launcher.archive}"', pages[page])
self.assertIn(launcher.folder_for(system), pages[page])
self.assertIn(launcher.start, pages[page])
for system in sitehandheld.group_systems():
self.assertIn(f"\n## {system}\n", pages[f"get/{sitehandheld.GROUP_PAGE}.md"])
def test_the_installer_table_is_written_from_the_same_rows(self):
text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
block = re.search(r"<!-- generated: handheld -->\n(.*?)<!-- /generated: handheld -->",
text, re.S)
self.assertIsNotNone(block)
self.assertEqual(block.group(1).strip(), "\n".join(sitehandheld.table_rows()))
self.assertNotIn("releases/latest", text.split("## On a handheld", 1)[1].split("\n## ")[0])
def test_the_installer_section_opens_on_the_downloads(self):
"""The section was long, with no link to click (owner, 2026-11-10):
it opens on one row per system, each file a link to its copy on the
site, before the detail."""
text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
section = text.split("## On a handheld", 1)[1].split("\n## ")[0]
block = re.search(r"<!-- generated: handheld-downloads -->\n(.*?)"
r"<!-- /generated: handheld-downloads -->", section, re.S)
self.assertIsNotNone(block)
self.assertEqual(block.group(1).strip(), "\n".join(sitehandheld.download_rows()))
self.assertLess(block.start(), section.index("### Where each file goes"))
self.assertLess(len(section[:block.start()].split()), 40)
for system in sitehandheld.systems():
with self.subTest(system=system):
row = next(line for line in block.group(1).splitlines()
if line.startswith(f"| {system} |"))
for launcher in sitehandheld.launchers_for(system):
self.assertIn(f"](../{sitehandheld.SERVED_DIR}/{launcher.archive})", row)
self.assertIn(f"](../{sitehandheld.page_of(system)}#", row)
for name in build_port.ARCHIVES:
self.assertIn(f"](../{sitehandheld.SERVED_DIR}/{name})", block.group(1))
def test_the_generator_writes_them(self):
source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
self.assertIn("sitehandheld.write_launchers(public, _REPO_ROOT)", source)
self.assertIn("sitehandheld.platform_block(platform_name, _REPO_ROOT)", source)
self.assertIn("sitehandheld.group_page(_REPO_ROOT)", source)
if __name__ == "__main__":
unittest.main()
+30 -12
View File
@@ -1501,27 +1501,45 @@ Android as on other systems, carries the same empty `.nomedia` beside
## On a handheld { #on-a-handheld-the-retrobios-port }
RetroBIOS also runs on the handheld itself, in the
[full-screen interface](#the-full-screen-interface), with the controller.
Copy the file for the system to the SD card, then start RetroBIOS from the
menu.
RetroBIOS also runs on the handheld, with the controller. Download the file
for the system, copy it to the SD card, then start RetroBIOS from the menu.
Download the file for your system from the
[latest release](https://github.com/Abdess/retrobios/releases/latest).
<!-- generated: handheld-downloads -->
| System | Download | Steps |
|--------|----------|-------|
| muOS | [RetroBIOS.muxapp](../get/handheld/RetroBIOS.muxapp) | [muOS steps](../get/linux-handhelds.md#muos) |
| ArkOS | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [ArkOS steps](../get/linux-handhelds.md#arkos) |
| dArkOS | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [dArkOS steps](../get/linux-handhelds.md#darkos) |
| ROCKNIX | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [ROCKNIX steps](../get/rocknix.md#on-the-handheld) |
| AmberELEC | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [AmberELEC steps](../get/amberelec.md#on-the-handheld) |
| JELOS | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [JELOS steps](../get/linux-handhelds.md#jelos) |
| KNULLI | [RetroBIOS-batocera.zip](../get/handheld/RetroBIOS-batocera.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [KNULLI steps](../get/knulli.md#on-the-handheld) |
| Batocera | [RetroBIOS-batocera.zip](../get/handheld/RetroBIOS-batocera.zip) | [Batocera steps](../get/batocera.md#on-the-handheld) |
<!-- /generated: handheld-downloads -->
### Where each file goes { #where-each-file-goes }
It runs in the [full-screen interface](#the-full-screen-interface).
<!-- generated: handheld -->
| System | File | Copy it to | Start it from |
|--------|------|------------|---------------|
| muOS | `RetroBIOS.muxapp` | `ARCHIVE` at the root of the card, then install it from the Archive Manager | Applications |
| Any other system with [PortMaster](https://portmaster.games/): ArkOS, dArkOS, ROCKNIX, AmberELEC, JELOS, Knulli | `RetroBIOS.zip` | PortMaster's `autoinstall` folder: `/roms/ports/PortMaster/autoinstall/` on ROCKNIX and AmberELEC, `/roms/tools/PortMaster/autoinstall/` on ArkOS, `/userdata/system/.local/share/PortMaster/autoinstall/` on Knulli | Ports, after opening PortMaster once |
| Batocera, Knulli | `RetroBIOS-batocera.zip`, extracted | `roms/ports` on the SHARE partition | Ports, after updating the game list |
| ArkOS, dArkOS | `RetroBIOS-tools.zip`, extracted | `tools` on the EASYROMS partition | Options, then Tools |
| ROCKNIX, JELOS | `RetroBIOS-tools.zip`, extracted | `/storage/.config/modules`, over the network share or SSH | Tools |
| AmberELEC | `RetroBIOS-tools.zip`, extracted | `/storage/.config/distribution/modules` | Tools |
| [muOS](../get/linux-handhelds.md) | `RetroBIOS.muxapp` | `ARCHIVE` at the root of the card, then install it from the Archive Manager | Applications |
| Any other system with [PortMaster](https://portmaster.games/): [ArkOS](../get/linux-handhelds.md), [dArkOS](../get/linux-handhelds.md), [ROCKNIX](../get/rocknix.md), [AmberELEC](../get/amberelec.md), [JELOS](../get/linux-handhelds.md), [KNULLI](../get/knulli.md) | `RetroBIOS.zip` | PortMaster's `autoinstall` folder: `/roms/ports/PortMaster/autoinstall/` on ROCKNIX and AmberELEC, `/roms/tools/PortMaster/autoinstall/` on ArkOS, `/userdata/system/.local/share/PortMaster/autoinstall/` on KNULLI | Ports, after opening PortMaster once |
| [Batocera](../get/batocera.md), [KNULLI](../get/knulli.md) | `RetroBIOS-batocera.zip`, extracted | `roms/ports` on the SHARE partition | Ports, after updating the game list |
| [ArkOS](../get/linux-handhelds.md), [dArkOS](../get/linux-handhelds.md) | `RetroBIOS-tools.zip`, extracted | `tools` on the EASYROMS partition | Options, then Tools |
| [ROCKNIX](../get/rocknix.md), [JELOS](../get/linux-handhelds.md) | `RetroBIOS-tools.zip`, extracted | `/storage/.config/modules`, over the network share or SSH | Tools |
| [AmberELEC](../get/amberelec.md) | `RetroBIOS-tools.zip`, extracted | `/storage/.config/distribution/modules` | Tools |
<!-- /generated: handheld -->
`RetroBIOS.zip` also installs on muOS, from `MUOS/PortMaster/autoinstall/`
on the first card (`/mnt/mmc` on the device). It then shows the interface in
muxterm, as the muOS app does.
### Buttons on a handheld { #buttons-on-a-handheld }
| Button | Action |
|--------|--------|
| D-pad | Move |
+1 -1
View File
@@ -60,7 +60,7 @@ Every module in `tests/`, by the area it guards.
| Slots and regions | synthetic + profiles | `test_region`, `test_region_agnostic_scan`, `test_region_groups`, `test_region_held`, `test_region_platform_index`, `test_region_required_only`, `test_slot`, `test_slots`, `test_slots_shipped_file` |
| Hardware targets and systems | synthetic + target files | `test_amberelec_targets`, `test_emuelec_targets`, `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` |
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_filesystems`, `test_install_app`, `test_install_from`, `test_install_games`, `test_install_journeys`, `test_install_offline`, `test_install_selection`, `test_install_tui`, `test_pypi_package` |
| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port` |
| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port`, `test_site_handheld` |
| Running cores | parsers on fixed inputs, a libretro core built from C in the test when a compiler exists | `test_core_probe` |
| Measurement records | synthetic record files, the committed `measurements/` against HEAD, the **Proof** line naming the BIOS a core ran, and that no page shows a Tested block, a region outcome or a time per frame | `test_measurements` |
| Real-condition campaign | synthetic profiles, records and content folders, a fake host for load, disk, clock and commands, and whole passes against a libretro core built from C when a compiler and strace exist | `test_campaign` |