mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 00:27:20 -05:00
chore: merge true platform list versions
# Conflicts: # install.ps1 # install.py # install.sh # install/launchers/index.json
This commit is contained in:
commit
7ec4d00375
45 files changed
+559
-64
No files matched your search
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
$defaultInstallSha256 = "aba3edfbad94300545c951a5b7fd78ce7ce56c1640930c9291be69cbc5e34e02"
|
||||
$defaultInstallSha256 = "825b55c17403287319a0c7d6e118e34d1bbf21cf0e6e66029c6c7eb767d2e0cb"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
|
||||
+150
-1
@@ -641,6 +641,12 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = {
|
||||
# up to MAX_SCANNED_FILES, truncated past them), and the folders below
|
||||
# it on another drive, not entered.
|
||||
"scanned": ("folder", "files", "truncated", "other_drives"),
|
||||
# Before choices, once a list is read: the system here runs another
|
||||
# version than the one the list was built for (list_version, from the
|
||||
# manifest, installed_version, from the system's own version file).
|
||||
# message says it and what it means. Not written when either version
|
||||
# is unknown, and never a refusal.
|
||||
"version": ("platform", "list_version", "installed_version", "message"),
|
||||
# Before the file events of a check: the systems the run's list lets
|
||||
# one choose among, --system aside, in id order (other for the files
|
||||
# no system names). What Choose systems lists, and what the plan counts
|
||||
@@ -1661,6 +1667,127 @@ _CARD_SYSTEMS = {"MinUI": "/.system/res"}
|
||||
_DETECTED_OS: dict[tuple[str, Path], str] = {}
|
||||
|
||||
|
||||
# A version as a list or a system writes it: v1.22.2, 43.1, 8.3.1.1.
|
||||
_VERSION_TEXT = re.compile(r"[0-9A-Za-z][0-9A-Za-z._+-]{0,39}")
|
||||
# What generate_pack.py wrote as every list's version until it recorded the
|
||||
# platform's own: not a version, so a list holding it names none.
|
||||
_NO_LIST_VERSION = "1.0"
|
||||
_MD5_CHECK = "{name}'s own BIOS check may report some files as wrong though they work."
|
||||
|
||||
|
||||
def _version_line(path: Path) -> str:
|
||||
"""The first line of a small version file, "" when it cannot be read."""
|
||||
if not _is_file(path):
|
||||
return ""
|
||||
try:
|
||||
with path.open("rb") as fh:
|
||||
head = fh.read(4096)
|
||||
except OSError:
|
||||
return ""
|
||||
lines = head.decode("utf-8", errors="replace").strip().splitlines()
|
||||
return lines[0].strip() if lines else ""
|
||||
|
||||
|
||||
def _batocera_version(_bios_path: Path) -> str:
|
||||
# batocera-system.mk:9,100-102 (batocera-43.1): the version, then the
|
||||
# date and time of the build.
|
||||
words = _version_line(_on_device("/usr/share/batocera/batocera.version")).split()
|
||||
return words[0] if words else ""
|
||||
|
||||
|
||||
def _recalbox_version(_bios_path: Path) -> str:
|
||||
# Dockerfile:31 (recalbox 10.1.1) writes RECALBOX_VERSION, which
|
||||
# .gitlab-ci.yml:723 sets to the release tag, or for a build of a branch
|
||||
# to its date and name. A build from a working tree says "development"
|
||||
# (projects/configgen/configgen/challenge/RuleScript.py:64). Only a
|
||||
# release is a version a list is built for.
|
||||
text = _version_line(_on_device("/recalbox/recalbox.version"))
|
||||
return text if re.fullmatch(r"\d+(?:\.\d+)+", text) else ""
|
||||
|
||||
|
||||
def _retrobat_version(bios_path: Path) -> str:
|
||||
# <RetroBat>/system/version.info, which es-update writes and es-checkversion
|
||||
# reads (emulatorlauncher 48cdc616, es-update/RetrobatVersion.cs:66-92).
|
||||
# The 8.3.1 installer holds "8.3.1.1-stable-win64".
|
||||
text = _version_line(bios_path.parent / "system" / "version.info")
|
||||
head = text.split("-", 1)[0]
|
||||
return head if re.fullmatch(r"\d+(?:\.\d+)+", head) else ""
|
||||
|
||||
|
||||
def _os_release_version(key: str, value: str) -> Callable[[Path], str]:
|
||||
"""VERSION_ID from os-release, on the system that names itself so."""
|
||||
def read(_bios_path: Path) -> str:
|
||||
release = _parse_os_release()
|
||||
if release.get(key, "").lower() != value:
|
||||
return ""
|
||||
return release.get("VERSION_ID", "")
|
||||
return read
|
||||
|
||||
|
||||
def _same_release(installed: str, listed: str) -> bool:
|
||||
return installed == listed
|
||||
|
||||
|
||||
def _retrobat_release(installed: str, listed: str) -> bool:
|
||||
# The release tagged 8.3.1 ships 8.3.1.1: a fourth number is a build of
|
||||
# the tagged version.
|
||||
return installed == listed or installed.startswith(listed + ".")
|
||||
|
||||
|
||||
class _VersionSource(NamedTuple):
|
||||
"""Where a system states the version it runs, and what a different one means."""
|
||||
|
||||
read: Callable[[Path], str]
|
||||
meaning: str
|
||||
same: Callable[[str, str], bool] = _same_release
|
||||
# Written by the list (v6.1) and not by the system (6.1).
|
||||
prefix: str = ""
|
||||
|
||||
|
||||
# The systems whose version a file states, read without running anything.
|
||||
# RetroArch writes its version in no file: its lists come from core info
|
||||
# files the online updater replaces, so its version is not compared. KNULLI
|
||||
# writes its codename (knulli-system.mk:9,100-102, "scarab") and ROCKNIX the
|
||||
# date of the build (scripts/image:82), not the release their lists name.
|
||||
# AmberELEC's list names a prerelease tag, and how that tag maps to the
|
||||
# VERSION its image writes is not established.
|
||||
_VERSION_SOURCES: dict[str, _VersionSource] = {
|
||||
"batocera": _VersionSource(_batocera_version, _MD5_CHECK),
|
||||
"recalbox": _VersionSource(_recalbox_version, _MD5_CHECK),
|
||||
"retrobat": _VersionSource(_retrobat_version, _MD5_CHECK, _retrobat_release),
|
||||
# scripts/image:159-164 (Lakka-LibreELEC v6.1) writes VERSION_ID from
|
||||
# OS_VERSION, "6.1" in distributions/Lakka/version, and ID=lakka.
|
||||
"lakka": _VersionSource(
|
||||
_os_release_version("ID", "lakka"),
|
||||
"Lakka checks only that a file is there, so this matters only where a core "
|
||||
"of the version it runs reads a file this list does not name.", prefix="v"),
|
||||
# scripts/image:172-177 (EmuELEC v4.8): VERSION_ID from OS_VERSION, 4.8 in
|
||||
# distributions/EmuELEC/version, and NAME=EmuELEC.
|
||||
"emuelec": _VersionSource(_os_release_version("NAME", "emuelec"), _MD5_CHECK),
|
||||
}
|
||||
|
||||
|
||||
def version_note(plat: str, bios_path: Path, manifest: dict) -> tuple[str, str, str] | None:
|
||||
"""(list version, installed version, sentence) when the system here runs
|
||||
another version than the one the list was built for, else None: an
|
||||
unknown version on either side says nothing."""
|
||||
source = _VERSION_SOURCES.get(plat)
|
||||
listed = manifest.get("version")
|
||||
if (source is None or not isinstance(listed, str) or listed == _NO_LIST_VERSION
|
||||
or not _VERSION_TEXT.fullmatch(listed)):
|
||||
return None
|
||||
installed = source.read(bios_path)
|
||||
if not _VERSION_TEXT.fullmatch(installed):
|
||||
return None
|
||||
compared = listed[len(source.prefix):] if listed.startswith(source.prefix) else listed
|
||||
if source.same(installed, compared):
|
||||
return None
|
||||
name = display_name(plat, manifest)
|
||||
return listed, installed, (
|
||||
f"This list was built for {name} {listed}, this machine runs {installed}. "
|
||||
+ source.meaning.format(name=name))
|
||||
|
||||
|
||||
def _rule_matches(rule: _OsRule, release: dict[str, str]) -> bool:
|
||||
if rule.method == "os_release":
|
||||
# Any of the KEY=value pairs, the value compared without case.
|
||||
@@ -5778,10 +5905,12 @@ def _report_outcome(
|
||||
kinds: tuple[Tally, int] | None = None,
|
||||
same_choice: str = "",
|
||||
term: Terminal | None = None,
|
||||
notes: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Close the run on what happened, and leave with a status that says so.
|
||||
kinds is the count of the files installed (tally) and of the data
|
||||
tree files, given for a list marking its game data, said apart.
|
||||
tree files, given for a list marking its game data, said apart. notes
|
||||
are lines every ending repeats (a list built for another version).
|
||||
|
||||
A run that installed nothing used to end on the word Done and a zero exit,
|
||||
which reads as success to a person and to a script alike. offline counts
|
||||
@@ -5819,6 +5948,8 @@ def _report_outcome(
|
||||
if declined and not downloaded and not errors and not interrupted and not offline:
|
||||
say(f"\nNothing installed: {_plural(declined, 'file')} not downloaded, as answered.")
|
||||
say(f"{_plural(up_to_date, 'file')} already up to date at {_locations(paths)}.")
|
||||
for note in notes or []:
|
||||
say(note)
|
||||
return
|
||||
# Left out for an unknown save directory, said with that reason in
|
||||
# every ending: "the collection does not hold it" blamed the collection.
|
||||
@@ -5828,6 +5959,7 @@ def _report_outcome(
|
||||
f"{_plural(len(unplaced), 'file')} read from "
|
||||
f"{unplaced[0].get('frontend') or 'the frontend'}'s save directory left out: "
|
||||
"its place is not known. Name it with --save-dir next time."] if unplaced else [])
|
||||
kept += list(notes or [])
|
||||
if interrupted:
|
||||
say(f"\nStopped. {_plural(downloaded, 'file')} installed.")
|
||||
for line in kept:
|
||||
@@ -8183,6 +8315,9 @@ class PackSource:
|
||||
# regions), None for a pack that does not say: the remedy for an
|
||||
# option the pack cannot apply names that list only when it can.
|
||||
self.list_records: frozenset | None = None
|
||||
# The version of the platform's list the pack was built from, "" for
|
||||
# a pack that does not record it.
|
||||
self.list_version = ""
|
||||
try:
|
||||
for path in self.files:
|
||||
archive = zipfile.ZipFile(path)
|
||||
@@ -8273,6 +8408,9 @@ class PackSource:
|
||||
records = data.get("list_records")
|
||||
if isinstance(records, list) and all(isinstance(axis, str) for axis in records):
|
||||
self.list_records = frozenset(records) & {"systems", "cores", "regions"}
|
||||
version = data.get("list_version")
|
||||
if isinstance(version, str) and _VERSION_TEXT.fullmatch(version):
|
||||
self.list_version = version
|
||||
for key in ("system_titles", "system_names"):
|
||||
held = data.get(key)
|
||||
if isinstance(held, dict):
|
||||
@@ -8315,6 +8453,7 @@ class PackSource:
|
||||
return {
|
||||
"manifest_version": 2,
|
||||
"platform": plat,
|
||||
**({"version": self.list_version} if self.list_version else {}),
|
||||
**{key: value for key, value in self.names.items() if value},
|
||||
"files": [
|
||||
{"dest": f["path"], "sha1": f["sha1"], "size": f["size"],
|
||||
@@ -11930,6 +12069,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
total_omitted = 0
|
||||
omitted_seen: list[dict] = []
|
||||
left_out_seen: list[dict] = []
|
||||
version_notes: list[str] = []
|
||||
total_interrupted = False
|
||||
total_bytes = 0
|
||||
total_seconds = 0.0
|
||||
@@ -12137,6 +12277,14 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
args.emulator, manifest, plat_name, files, omitted_files
|
||||
)
|
||||
|
||||
# Said before the install and again with the result.
|
||||
note = None if listing else version_note(plat_name, bios_path, manifest)
|
||||
if note is not None:
|
||||
print(f" {note[2]}")
|
||||
version_notes.append(note[2])
|
||||
EVENTS.emit("version", platform=plat_name, list_version=note[0],
|
||||
installed_version=note[1], message=note[2])
|
||||
|
||||
# Before the menu: it offered 23 files, then 22 were checked, the
|
||||
# one read from an unknown save directory left out. Said once the
|
||||
# choice is made, over the files it keeps (say_saves).
|
||||
@@ -12620,6 +12768,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
total_errors + total_offline),
|
||||
offline=total_offline,
|
||||
term=term,
|
||||
notes=version_notes,
|
||||
)
|
||||
finish_app(args, platforms, app_cards, interactive and not total_declined)
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ esac
|
||||
TEMP_INSTALLER=""
|
||||
TEMP_DIRECTORY=""
|
||||
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
DEFAULT_INSTALL_SHA256="aba3edfbad94300545c951a5b7fd78ce7ce56c1640930c9291be69cbc5e34e02"
|
||||
DEFAULT_INSTALL_SHA256="825b55c17403287319a0c7d6e118e34d1bbf21cf0e6e66029c6c7eb767d2e0cb"
|
||||
MAX_INSTALLER_BYTES=2097152
|
||||
|
||||
cleanup() {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"categorized": true,
|
||||
"platform": "amberelec",
|
||||
"display_name": "AmberELEC",
|
||||
"version": "1.0",
|
||||
"generated": "2026-10-26T22:11:28Z",
|
||||
"version": "prerelease-20261019_1835",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "",
|
||||
"detect": [
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"categorized": true,
|
||||
"platform": "batocera",
|
||||
"display_name": "Batocera",
|
||||
"version": "1.0",
|
||||
"version": "43.1",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "bios",
|
||||
"detect": [
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"categorized": true,
|
||||
"platform": "bizhawk",
|
||||
"display_name": "BizHawk",
|
||||
"version": "1.0",
|
||||
"generated": "2026-10-26T22:11:28Z",
|
||||
"version": "2.11.1",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "Firmware",
|
||||
"detect": [
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"categorized": true,
|
||||
"platform": "emudeck",
|
||||
"display_name": "EmuDeck",
|
||||
"version": "1.0",
|
||||
"version": "2.7EA",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "bios",
|
||||
"detect": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"categorized": true,
|
||||
"platform": "emuelec",
|
||||
"display_name": "EmuELEC",
|
||||
"version": "1.0",
|
||||
"version": "4.8",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "",
|
||||
"detect": [
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"categorized": true,
|
||||
"platform": "knulli",
|
||||
"display_name": "KNULLI",
|
||||
"version": "1.0",
|
||||
"version": "20260511",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "bios",
|
||||
"detect": [
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"categorized": true,
|
||||
"platform": "lakka",
|
||||
"display_name": "Lakka",
|
||||
"version": "1.0",
|
||||
"version": "v6.1",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "system",
|
||||
"detect": [
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
{
|
||||
"dest": "roms/ports/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 833938,
|
||||
"sha256": "aba3edfbad94300545c951a5b7fd78ce7ce56c1640930c9291be69cbc5e34e02",
|
||||
"size": 840737,
|
||||
"sha256": "825b55c17403287319a0c7d6e118e34d1bbf21cf0e6e66029c6c7eb767d2e0cb",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -71,8 +71,8 @@
|
||||
"muos": {
|
||||
"archive": {
|
||||
"dest": "ARCHIVE/RetroBIOS.muxapp",
|
||||
"size": 886487,
|
||||
"sha256": "2c0bfadad268416608e9ada4e5b77934ac997208bca9f6546a4827c165a131b8"
|
||||
"size": 893286,
|
||||
"sha256": "d463dc4f1203733a0b4932d90a659c03b91701f27a6da7b01becb69f05dab83c"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
@@ -92,8 +92,8 @@
|
||||
{
|
||||
"name": "RetroBIOS/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 833938,
|
||||
"sha256": "aba3edfbad94300545c951a5b7fd78ce7ce56c1640930c9291be69cbc5e34e02",
|
||||
"size": 840737,
|
||||
"sha256": "825b55c17403287319a0c7d6e118e34d1bbf21cf0e6e66029c6c7eb767d2e0cb",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -166,8 +166,8 @@
|
||||
{
|
||||
"dest": ".config/modules/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 833938,
|
||||
"sha256": "aba3edfbad94300545c951a5b7fd78ce7ce56c1640930c9291be69cbc5e34e02",
|
||||
"size": 840737,
|
||||
"sha256": "825b55c17403287319a0c7d6e118e34d1bbf21cf0e6e66029c6c7eb767d2e0cb",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@
|
||||
"categorized": true,
|
||||
"platform": "minui",
|
||||
"display_name": "MinUI",
|
||||
"version": "1.0",
|
||||
"generated": "2026-10-26T22:11:28Z",
|
||||
"version": "2025-11-27",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "",
|
||||
"detect": [
|
||||
{
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"categorized": true,
|
||||
"platform": "misterfpga",
|
||||
"display_name": "MiSTer FPGA",
|
||||
"version": "1.0",
|
||||
"generated": "2026-10-26T22:11:28Z",
|
||||
"version": "2026-09-29",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "games",
|
||||
"detect": [
|
||||
{
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"categorized": true,
|
||||
"platform": "myminui",
|
||||
"display_name": "MyMinUI",
|
||||
"version": "1.0",
|
||||
"generated": "2026-10-26T22:11:28Z",
|
||||
"version": "2026-08-09",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "",
|
||||
"detect": [
|
||||
{
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@
|
||||
"categorized": true,
|
||||
"platform": "nextui",
|
||||
"display_name": "NextUI",
|
||||
"version": "1.0",
|
||||
"generated": "2026-10-26T22:11:28Z",
|
||||
"version": "2026-10-20",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "",
|
||||
"detect": [
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"categorized": true,
|
||||
"platform": "recalbox",
|
||||
"display_name": "Recalbox",
|
||||
"version": "1.0",
|
||||
"version": "10.1.1",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "bios",
|
||||
"detect": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"categorized": true,
|
||||
"platform": "retroarch",
|
||||
"display_name": "RetroArch",
|
||||
"version": "1.0",
|
||||
"version": "v1.22.2",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "system",
|
||||
"detect": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"categorized": true,
|
||||
"platform": "retrobat",
|
||||
"display_name": "RetroBat",
|
||||
"version": "1.0",
|
||||
"version": "8.3.1",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "bios",
|
||||
"detect": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"categorized": true,
|
||||
"platform": "retrodeck",
|
||||
"display_name": "RetroDECK",
|
||||
"version": "1.0",
|
||||
"version": "0.10.10b",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "",
|
||||
"detect": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"categorized": true,
|
||||
"platform": "retropie",
|
||||
"display_name": "RetroPie",
|
||||
"version": "1.0",
|
||||
"version": "4.8.12",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "BIOS",
|
||||
"detect": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"categorized": true,
|
||||
"platform": "rocknix",
|
||||
"display_name": "ROCKNIX",
|
||||
"version": "1.0",
|
||||
"version": "20261001",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "bios",
|
||||
"detect": [
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@
|
||||
"categorized": true,
|
||||
"platform": "romm",
|
||||
"display_name": "RomM",
|
||||
"version": "1.0",
|
||||
"generated": "2026-10-26T22:11:28Z",
|
||||
"version": "5.4.0",
|
||||
"generated": "2026-10-27T10:27:58Z",
|
||||
"base_destination": "bios",
|
||||
"detect": [
|
||||
{
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
inherits: retroarch
|
||||
platform: Lakka
|
||||
homepage: "https://www.lakka.tv"
|
||||
# Version is inherited from retroarch.yml (fetched dynamically by scraper)
|
||||
# Lakka-LibreELEC's release tag, the revision platforms/targets/lakka.yml
|
||||
# reads its cores at. A version is never inherited from retroarch.yml.
|
||||
version: v6.1
|
||||
# Lakka uses the same BIOS requirements as RetroArch
|
||||
@@ -15,6 +15,7 @@
|
||||
"detect",
|
||||
"error",
|
||||
"scanned",
|
||||
"version",
|
||||
"choices",
|
||||
"file",
|
||||
"system",
|
||||
@@ -51,6 +52,9 @@
|
||||
{
|
||||
"$ref": "#/$defs/scanned"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/version"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/choices"
|
||||
},
|
||||
@@ -244,6 +248,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"event",
|
||||
"platform",
|
||||
"list_version",
|
||||
"installed_version",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"event": {
|
||||
"const": "version"
|
||||
},
|
||||
"platform": {
|
||||
"type": "string"
|
||||
},
|
||||
"list_version": {
|
||||
"type": "string"
|
||||
},
|
||||
"installed_version": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -67,7 +67,8 @@
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Version of the platform's own list this manifest was built from."
|
||||
"minLength": 1,
|
||||
"description": "Version of the platform's own list this manifest was built from, as the platform YAML records it (inherits: resolved): the release tag the scraper read (Batocera 43.1, Recalbox 10.1.1). A list kept on a branch with no release records what the scraper pinned instead: MinUI, MyMinUI and NextUI the date of the pinned commit, MiSTer the timestamp of the BiosDB it read. RetroArch and Lakka name the RetroArch release current when System.dat was read at its pinned commit, RetroPie the __version of RetroPie-Setup at its pinned commit."
|
||||
},
|
||||
"generated": {
|
||||
"type": "string",
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "array", "items": { "type": "string" } }
|
||||
},
|
||||
"list_version": {
|
||||
"description": "Version of the platform's list the pack was built from, the version of its install manifest. The installer compares it with the version the machine runs when it installs from the pack alone. The version key above numbers this file's format.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"list_records": {
|
||||
"description": "What the platform's install list narrows by: the installer names that list as the remedy for --core or --region given with the pack alone only when it records them.",
|
||||
"type": "array",
|
||||
|
||||
@@ -235,6 +235,10 @@ def _inherited_yaml(platform_name: str, platforms_dir: str) -> tuple[dict, list[
|
||||
return config, [platform_name]
|
||||
|
||||
merged, chain = _inherited_yaml(config["inherits"], platforms_dir)
|
||||
# A version names one platform's release, never its parent's: Lakka
|
||||
# read as RetroArch v1.22.2 in its manifest, pack name and site page.
|
||||
# dat_version stays, it is the version of the inherited list itself.
|
||||
merged.pop("version", None)
|
||||
merged.update(
|
||||
{k: v for k, v in config.items() if k not in ("inherits", "overrides")}
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ KEY_TYPES: dict[str, dict] = {
|
||||
**dict.fromkeys((
|
||||
"installer", "os", "os_name", "stage", "reason", "platform", "message",
|
||||
"folder", "dest", "system", "name", "bios_dir", "path", "member",
|
||||
"list_version", "installed_version",
|
||||
), _TEXT),
|
||||
"mode": {"enum": ["install", "check", "dry-run", "list"]},
|
||||
"state": {"enum": ["present", "wrong_hash", "missing"]},
|
||||
|
||||
@@ -351,6 +351,23 @@ def _platform_pack_stem(
|
||||
return "_".join(_name_part(n) for n in names) + version_tag
|
||||
|
||||
|
||||
def platform_list_version(config: dict, platform: str) -> str:
|
||||
"""The version of the list a platform's YAML transcribes: the release
|
||||
tag the scraper read, or for a list kept on a branch with no release,
|
||||
the date of the commit it pinned. load_platform_config never passes a
|
||||
parent's version to a child, Lakka is not RetroArch v1.22.2.
|
||||
|
||||
The installer compares it with the version a machine runs, so a missing
|
||||
value is refused rather than written as a stand-in.
|
||||
"""
|
||||
version = str(config.get("version") or "").strip()
|
||||
if not version:
|
||||
raise ValueError(
|
||||
f"{platform}: the platform YAML records no version of its own"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
# What the builder writes into a platform pack beside the files it carries.
|
||||
PACK_DOCUMENTS = ("README.txt", "manifest.json")
|
||||
|
||||
@@ -2568,6 +2585,9 @@ def _run_manifest_mode(
|
||||
alias_manifest["display_name"] = alias_cfg.get(
|
||||
"platform", alias_plat
|
||||
)
|
||||
alias_manifest["version"] = platform_list_version(
|
||||
alias_cfg, alias_plat
|
||||
)
|
||||
alias_manifest["base_destination"] = alias_cfg.get(
|
||||
"base_destination", ""
|
||||
)
|
||||
@@ -4600,7 +4620,7 @@ def generate_manifest(
|
||||
**({"no_game_data": True} if no_game_data else {}),
|
||||
"platform": platform_name,
|
||||
"display_name": platform_display,
|
||||
"version": "1.0",
|
||||
"version": platform_list_version(config, platform_name),
|
||||
"generated": now,
|
||||
"base_destination": base_dest,
|
||||
"detect": detect,
|
||||
@@ -4772,13 +4792,18 @@ def pack_systems(install_manifest: dict) -> dict:
|
||||
records.add("cores")
|
||||
if records or any("regions" in entry for entry in entries):
|
||||
records.add("regions")
|
||||
return {
|
||||
systems = {
|
||||
"files": {entry["dest"]: list(entry.get("systems") or [])
|
||||
for entry in files if "systems" in entry},
|
||||
"system_titles": dict(install_manifest.get("system_titles") or {}),
|
||||
"system_names": dict(install_manifest.get("system_names") or {}),
|
||||
"list_records": sorted(records),
|
||||
}
|
||||
# The version of the platform's list, which the installer compares with
|
||||
# the one the machine runs when it has the pack alone.
|
||||
if install_manifest.get("version"):
|
||||
systems["list_version"] = install_manifest["version"]
|
||||
return systems
|
||||
|
||||
|
||||
def annotate_pack_systems(manifest: dict, systems: dict | None) -> None:
|
||||
@@ -4793,8 +4818,9 @@ def annotate_pack_systems(manifest: dict, systems: dict | None) -> None:
|
||||
for key in ("system_titles", "system_names"):
|
||||
if systems.get(key):
|
||||
manifest[key] = systems[key]
|
||||
if "list_records" in systems:
|
||||
manifest["list_records"] = systems["list_records"]
|
||||
for key in ("list_records", "list_version"):
|
||||
if key in systems:
|
||||
manifest[key] = systems[key]
|
||||
|
||||
|
||||
def inject_manifest(zip_path: str, manifest: dict) -> None:
|
||||
|
||||
@@ -215,12 +215,12 @@ class Scraper(BaseScraper):
|
||||
if not entry["docs"]:
|
||||
del entry["docs"]
|
||||
|
||||
# The BiosDB has no release: the date it was built is the only
|
||||
# version the list carries, and the manifest needs one.
|
||||
timestamp = data.get("timestamp")
|
||||
released = (
|
||||
datetime.fromtimestamp(timestamp, timezone.utc).strftime("%Y-%m-%d")
|
||||
if isinstance(timestamp, (int, float))
|
||||
else ""
|
||||
)
|
||||
if not isinstance(timestamp, (int, float)):
|
||||
raise RuntimeError(f"{self.url}: the BiosDB records no timestamp")
|
||||
released = datetime.fromtimestamp(timestamp, timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
return {
|
||||
"platform": "MiSTer FPGA",
|
||||
|
||||
@@ -2080,6 +2080,59 @@ def _git_paths(*args: str, stdin: list[str] | None = None) -> set[str] | None:
|
||||
return {p for p in result.stdout.split("\0") if p}
|
||||
|
||||
|
||||
class ManifestVersionIsThePlatformsOwn(unittest.TestCase):
|
||||
"""Every install manifest named its list "1.0", and an inheriting YAML
|
||||
took its parent's version: Lakka was published as RetroArch v1.22.2.
|
||||
The installer compares the list's version with the one a machine runs,
|
||||
so each manifest states the version its own platform YAML records."""
|
||||
|
||||
def setUp(self):
|
||||
from scripts.common import _platform_config_cache
|
||||
_platform_config_cache.clear()
|
||||
|
||||
def test_committed_manifests_state_their_platform_version(self):
|
||||
from scripts.common import load_platform_config
|
||||
manifests = sorted((ROOT / "install").glob("*.json"))
|
||||
self.assertTrue(manifests)
|
||||
for path in manifests:
|
||||
with self.subTest(manifest=path.name):
|
||||
data = json.loads(path.read_text())
|
||||
config = load_platform_config(data["platform"], str(ROOT / "platforms"))
|
||||
self.assertTrue(config.get("version"))
|
||||
self.assertEqual(data["version"], str(config["version"]))
|
||||
|
||||
def test_no_child_shows_its_parents_version(self):
|
||||
from scripts.common import load_platform_config
|
||||
platforms = ROOT / "platforms"
|
||||
children = {}
|
||||
for path in sorted(platforms.glob("*.yml")):
|
||||
raw = yaml.safe_load(path.read_text()) or {}
|
||||
if isinstance(raw, dict) and raw.get("inherits"):
|
||||
children[path.stem] = raw
|
||||
self.assertIn("lakka", children)
|
||||
for name, raw in children.items():
|
||||
with self.subTest(platform=name):
|
||||
parent = yaml.safe_load(
|
||||
(platforms / f"{raw['inherits']}.yml").read_text())
|
||||
version = load_platform_config(name, str(platforms)).get("version")
|
||||
self.assertEqual(version, raw.get("version"))
|
||||
self.assertNotEqual(version, parent.get("version"))
|
||||
|
||||
def test_an_unversioned_child_has_no_version(self):
|
||||
from scripts.common import load_platform_config
|
||||
with tempfile.TemporaryDirectory(dir=TMP_ROOT) as tmp:
|
||||
Path(tmp, "parent.yml").write_text("platform: P\nversion: '2.0'\nsystems: {}\n")
|
||||
Path(tmp, "child.yml").write_text("inherits: parent\nplatform: C\n")
|
||||
self.assertNotIn("version", load_platform_config("child", tmp))
|
||||
|
||||
def test_lakka_names_the_release_its_cores_are_read_at(self):
|
||||
platforms = ROOT / "platforms"
|
||||
lakka = yaml.safe_load((platforms / "lakka.yml").read_text())
|
||||
targets = yaml.safe_load((platforms / "targets" / "lakka.yml").read_text())
|
||||
tag = re.search(r"/blob/([^/]+)/", targets["source"]).group(1)
|
||||
self.assertEqual(lakka["version"], tag)
|
||||
|
||||
|
||||
class EveryManifestEntryIsFetchable(unittest.TestCase):
|
||||
"""An install manifest may not list a file the installer cannot get.
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ class ACoreFileFillsAFreeDeclaration(unittest.TestCase):
|
||||
self.emulators.mkdir()
|
||||
(self.platforms / "fakeplat.yml").write_text(yaml.safe_dump({
|
||||
"platform": "FakePlat",
|
||||
"version": "2.1",
|
||||
"verification_mode": "existence",
|
||||
"base_destination": "system",
|
||||
"cores": ["fakecore"],
|
||||
@@ -118,7 +119,7 @@ class AFileDeclaredUnderAnotherName(unittest.TestCase):
|
||||
(root / "platforms").mkdir()
|
||||
(root / "emulators").mkdir()
|
||||
(root / "platforms" / "fakeplat.yml").write_text(yaml.safe_dump({
|
||||
"platform": "FakePlat", "verification_mode": "existence",
|
||||
"platform": "FakePlat", "version": "2.1", "verification_mode": "existence",
|
||||
"base_destination": "system", "cores": ["gs"],
|
||||
"systems": {"apple-iigs": {"files": [
|
||||
{"name": "ROM1", "destination": "ROM1", "md5": md5},
|
||||
@@ -156,7 +157,7 @@ class AMissingDataDirectoryFailsTheRun(unittest.TestCase):
|
||||
(root / "emulators").mkdir()
|
||||
(root / "bios").mkdir()
|
||||
(root / "platforms" / "fakeplat.yml").write_text(yaml.safe_dump({
|
||||
"platform": "FakePlat", "verification_mode": "existence",
|
||||
"platform": "FakePlat", "version": "2.1", "verification_mode": "existence",
|
||||
"base_destination": "system", "cores": [],
|
||||
"systems": {"console-a": {"files": [], "data_directories": [
|
||||
{"ref": "assets", "destination": "assets"},
|
||||
@@ -197,7 +198,7 @@ class AFileNobodyHoldsIsAnOmission(unittest.TestCase):
|
||||
for folder in ("platforms", "emulators", "bios"):
|
||||
(root / folder).mkdir()
|
||||
(root / "platforms" / "fakeplat.yml").write_text(yaml.safe_dump({
|
||||
"platform": "FakePlat", "verification_mode": "existence",
|
||||
"platform": "FakePlat", "version": "2.1", "verification_mode": "existence",
|
||||
"base_destination": "system", "cores": ["devil"],
|
||||
"systems": {"diablo": {"files": []}},
|
||||
}))
|
||||
@@ -238,7 +239,7 @@ class AMemberNameIsNotALooseFile(unittest.TestCase):
|
||||
(root / "platforms").mkdir()
|
||||
(root / "emulators").mkdir()
|
||||
(root / "platforms" / "fakeplat.yml").write_text(yaml.safe_dump({
|
||||
"platform": "FakePlat", "verification_mode": "existence",
|
||||
"platform": "FakePlat", "version": "2.1", "verification_mode": "existence",
|
||||
"base_destination": "system", "cores": ["naomi2core"],
|
||||
"systems": {"sega-naomi2": {"files": []}},
|
||||
}))
|
||||
@@ -283,7 +284,7 @@ class TwoDeclarationsOfOnePath(unittest.TestCase):
|
||||
for folder in ("platforms", "emulators"):
|
||||
(root / folder).mkdir()
|
||||
(root / "platforms" / "fakeplat.yml").write_text(yaml.safe_dump({
|
||||
"platform": "FakePlat", "verification_mode": "existence",
|
||||
"platform": "FakePlat", "version": "2.1", "verification_mode": "existence",
|
||||
"base_destination": "system", "cores": [],
|
||||
"systems": {
|
||||
"a-system": {"files": [{"name": first, "destination": "shared.bin"}]},
|
||||
@@ -335,7 +336,7 @@ class ANamelessWhitelistEntry(unittest.TestCase):
|
||||
(root / folder).mkdir()
|
||||
values = [md5.upper(), f"{'0' * 32},{md5}", "f" * 32]
|
||||
(root / "platforms" / "fakeplat.yml").write_text(yaml.safe_dump({
|
||||
"platform": "FakePlat", "verification_mode": "md5",
|
||||
"platform": "FakePlat", "version": "2.1", "verification_mode": "md5",
|
||||
"base_destination": "bios", "cores": [],
|
||||
"systems": {"sony-playstation": {"files": [
|
||||
{"name": f"sony-playstation:{v}", "destination": "", "md5": v}
|
||||
|
||||
@@ -76,7 +76,7 @@ class _Collection(unittest.TestCase):
|
||||
}}
|
||||
self.db = {"files": files, "indexes": generate_db.build_indexes(files, {})}
|
||||
self.config = {
|
||||
"platform": "Exist", "verification_mode": "existence",
|
||||
"platform": "Exist", "version": "2.1", "verification_mode": "existence",
|
||||
"cores": ["sized", "unsized"],
|
||||
"systems": {"mac": {"files": []}, "iigs": {"files": []}},
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ files:
|
||||
|
||||
PLATFORM = {
|
||||
"platform": "Demo",
|
||||
"version": "2.1",
|
||||
"verification_mode": "existence",
|
||||
"base_destination": "",
|
||||
"cores": ["demo"],
|
||||
|
||||
@@ -224,6 +224,7 @@ class TestE2E(unittest.TestCase):
|
||||
def _create_existence_platform(self):
|
||||
config = {
|
||||
"platform": "TestExistence",
|
||||
"version": "9.1",
|
||||
"verification_mode": "existence",
|
||||
"base_destination": "system",
|
||||
"systems": {
|
||||
@@ -3676,6 +3677,51 @@ class TestE2E(unittest.TestCase):
|
||||
self.assertIsInstance(f["size"], int)
|
||||
self.assertGreater(len(f["sha1"]), 0)
|
||||
|
||||
def test_91_manifest_version_is_the_platform_list_version(self):
|
||||
"""The manifest names the version of the list it was built from.
|
||||
|
||||
It was the constant "1.0" for every platform, so the installer could
|
||||
not tell which Batocera or Recalbox release a list described. Two
|
||||
platforms of different versions show the value comes from each YAML.
|
||||
A YAML without one, an inheriting child included, is refused rather
|
||||
than written with a stand-in or its parent's version.
|
||||
"""
|
||||
from common import _platform_config_cache
|
||||
from generate_pack import generate_manifest
|
||||
|
||||
registry_path = os.path.join(self.platforms_dir, "_test_registry_version.yml")
|
||||
with open(registry_path, "w") as fh:
|
||||
yaml.dump({"platforms": {}}, fh)
|
||||
with open(os.path.join(self.platforms_dir, "test_md5.yml")) as fh:
|
||||
config = yaml.safe_load(fh)
|
||||
config["version"] = "41"
|
||||
with open(os.path.join(self.platforms_dir, "versioned_md5.yml"), "w") as fh:
|
||||
yaml.dump(config, fh)
|
||||
del config["version"]
|
||||
with open(os.path.join(self.platforms_dir, "unversioned.yml"), "w") as fh:
|
||||
yaml.dump(config, fh)
|
||||
_platform_config_cache.clear()
|
||||
|
||||
def version_of(name: str) -> str:
|
||||
return generate_manifest(
|
||||
name, self.platforms_dir, self.db, self.bios_dir, registry_path,
|
||||
emulators_dir=self.emulators_dir, offline=True,
|
||||
)["version"]
|
||||
|
||||
self.assertEqual(version_of("test_existence"), "9.1")
|
||||
self.assertEqual(version_of("versioned_md5"), "41")
|
||||
for name in ("unversioned", "test_inherited"):
|
||||
with self.subTest(platform=name), \
|
||||
self.assertRaisesRegex(ValueError, "records no version"):
|
||||
version_of(name)
|
||||
with open(os.path.join(self.platforms_dir, "test_inherited.yml")) as fh:
|
||||
child = yaml.safe_load(fh)
|
||||
child["version"] = "2"
|
||||
with open(os.path.join(self.platforms_dir, "test_inherited.yml"), "w") as fh:
|
||||
yaml.dump(child, fh)
|
||||
_platform_config_cache.clear()
|
||||
self.assertEqual(version_of("test_inherited"), "2")
|
||||
|
||||
def test_91b_manifest_hash_points_at_a_fetchable_file(self):
|
||||
"""A declared hash no local file carries must not blank repo_path.
|
||||
|
||||
@@ -5726,6 +5772,7 @@ struct BurnDriver BurnDrvneogeo = {
|
||||
self.db = self._build_db()
|
||||
config = {
|
||||
"platform": "RegionPlat",
|
||||
"version": "3.0",
|
||||
"verification_mode": "existence",
|
||||
"systems": {
|
||||
"region-console": {
|
||||
|
||||
@@ -47,6 +47,7 @@ class FrontendDigestDecides(unittest.TestCase):
|
||||
)
|
||||
(self.platforms / "digestplat.yml").write_text(yaml.safe_dump({
|
||||
"platform": "DigestPlat",
|
||||
"version": "2.1",
|
||||
"verification_mode": "md5",
|
||||
"base_destination": "bios",
|
||||
"systems": {"psx": {"files": [{
|
||||
|
||||
@@ -23,6 +23,7 @@ import unittest
|
||||
from types import SimpleNamespace
|
||||
import unittest.mock
|
||||
import urllib.error
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -5852,5 +5853,157 @@ class KeptFilesEndToEnd(unittest.TestCase, _SyntheticTree):
|
||||
self.assertEqual(len(list((self.home / install.BACKUP_DIR).iterdir())), 2)
|
||||
|
||||
|
||||
|
||||
class TheListVersionIsComparedWithTheSystems(unittest.TestCase):
|
||||
"""The list says which version of the platform it was built for, and the
|
||||
installer reads the version the system runs from the file the system
|
||||
writes. A difference is said, never refused."""
|
||||
|
||||
def note(self, plat: str, listed: str, bios: Path = Path("/userdata/bios")):
|
||||
return install.version_note(plat, bios, {"platform": plat, "version": listed})
|
||||
|
||||
def test_batocera_reads_the_first_word_of_its_version_file(self):
|
||||
_device(self, {"/usr/share/batocera/batocera.version": "41 2025/01/17 10:12\n"})
|
||||
listed, installed, text = self.note("batocera", "43.1")
|
||||
self.assertEqual((listed, installed), ("43.1", "41"))
|
||||
self.assertEqual(
|
||||
text, "This list was built for Batocera 43.1, this machine runs 41. "
|
||||
"Batocera's own BIOS check may report some files as wrong though they work.")
|
||||
self.assertIsNone(self.note("batocera", "41"))
|
||||
|
||||
def test_recalbox_reads_only_a_release(self):
|
||||
_device(self, {"/recalbox/recalbox.version": "10.0\n"})
|
||||
self.assertEqual(self.note("recalbox", "10.1.1")[:2], ("10.1.1", "10.0"))
|
||||
self.assertIsNone(self.note("recalbox", "10.0"))
|
||||
for built in ("development", "2026/10/01 main (4242)"):
|
||||
with self.subTest(built=built):
|
||||
_device(self, {"/recalbox/recalbox.version": built})
|
||||
self.assertIsNone(self.note("recalbox", "10.1.1"))
|
||||
|
||||
def test_retrobat_reads_the_folder_beside_its_bios_folder(self):
|
||||
root = Path(_tempdir(self))
|
||||
version = root / "system" / "version.info"
|
||||
version.parent.mkdir()
|
||||
version.write_text("8.3.1.1-stable-win64")
|
||||
# The release tagged 8.3.1 ships 8.3.1.1.
|
||||
self.assertIsNone(self.note("retrobat", "8.3.1", root / "bios"))
|
||||
version.write_text("8.2.0-stable-win64")
|
||||
self.assertEqual(self.note("retrobat", "8.3.1", root / "bios")[:2],
|
||||
("8.3.1", "8.2.0"))
|
||||
|
||||
def test_lakka_and_emuelec_read_os_release(self):
|
||||
_device(self, {}, release='NAME="Lakka"\nID="lakka"\nVERSION_ID="6.0"\n')
|
||||
listed, installed, text = self.note("lakka", "v6.1")
|
||||
self.assertEqual((listed, installed), ("v6.1", "6.0"))
|
||||
self.assertIn("Lakka checks only that a file is there", text)
|
||||
_device(self, {}, release='NAME="Lakka"\nID="lakka"\nVERSION_ID="6.1"\n')
|
||||
self.assertIsNone(self.note("lakka", "v6.1"))
|
||||
_device(self, {}, release='NAME="EmuELEC"\nVERSION_ID="4.7"\n')
|
||||
self.assertEqual(self.note("emuelec", "4.8")[:2], ("4.8", "4.7"))
|
||||
# The file of another system says nothing about this one.
|
||||
self.assertIsNone(self.note("lakka", "v6.1"))
|
||||
|
||||
def test_an_unknown_version_says_nothing(self):
|
||||
_device(self, {"/usr/share/batocera/batocera.version": "41 2025/01/17 10:12"})
|
||||
self.assertIsNone(self.note("batocera", "1.0"))
|
||||
self.assertIsNone(install.version_note("batocera", Path("/userdata/bios"),
|
||||
{"platform": "batocera"}))
|
||||
self.assertIsNone(self.note("retroarch", "v1.22.2"))
|
||||
_device(self, {"/usr/share/knulli/knulli.version": "scarab 2026/05/11 10:00"})
|
||||
self.assertIsNone(self.note("knulli", "20260511"))
|
||||
_device(self, {})
|
||||
self.assertIsNone(self.note("batocera", "43.1"))
|
||||
_device(self, {"/usr/share/batocera/batocera.version": "\x1b[2J 2025/01/17"})
|
||||
self.assertIsNone(self.note("batocera", "43.1"))
|
||||
|
||||
def test_a_pack_alone_gives_the_version_of_its_list(self):
|
||||
"""A handheld without network installs from the pack: its manifest.json
|
||||
records the list's version, which generate_pack takes from the
|
||||
install list built with the same choices."""
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
import generate_pack
|
||||
data = b"bios"
|
||||
install_list = {"version": "43.1", "files": [
|
||||
{"dest": "a.bin", "sha1": hashlib.sha1(data).hexdigest(), "size": 4}]}
|
||||
pack_list = {"files": [{"path": "a.bin", "sha1": hashlib.sha1(data).hexdigest(),
|
||||
"size": 4}]}
|
||||
generate_pack.annotate_pack_systems(pack_list, generate_pack.pack_systems(install_list))
|
||||
self.assertEqual(pack_list["list_version"], "43.1")
|
||||
pack = Path(_tempdir(self)) / "Batocera_43.1_BIOS_Pack.zip"
|
||||
with zipfile.ZipFile(pack, "w") as archive:
|
||||
archive.writestr("manifest.json", json.dumps(pack_list))
|
||||
archive.writestr("a.bin", data)
|
||||
source = install.PackSource([pack])
|
||||
self.addCleanup(source.close)
|
||||
self.assertEqual(source.manifest("batocera")["version"], "43.1")
|
||||
|
||||
def test_the_result_repeats_it_in_every_ending(self):
|
||||
for kw in ({"downloaded": 3}, {"errors": 2}, {"downloaded": 1, "errors": 1}):
|
||||
with self.subTest(**kw):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf), contextlib.suppress(SystemExit):
|
||||
install._report_outcome([Path("/userdata/bios")], omitted=0,
|
||||
interrupted=False, notes=["Built for 43.1."],
|
||||
**{"downloaded": 0, "up_to_date": 0,
|
||||
"errors": 0, **kw})
|
||||
self.assertIn("Built for 43.1.", buf.getvalue())
|
||||
|
||||
|
||||
class TheListVersionIsSaidBeforeAndAfter(unittest.TestCase):
|
||||
"""A RetroBat folder holding 8.2.0 and a list built for 8.3.1, through
|
||||
the installer as a person and as the screen run it."""
|
||||
|
||||
DATA = b"retrobat bios" * 8
|
||||
|
||||
def setUp(self):
|
||||
self.root = Path(_tempdir(self))
|
||||
(self.root / "system").mkdir()
|
||||
(self.root / "system" / "version.info").write_text("8.2.0-stable-win64")
|
||||
self.bios = self.root / "bios"
|
||||
self.bios.mkdir()
|
||||
(self.bios / "a.bin").write_bytes(self.DATA)
|
||||
self.list = self.root / "retrobat.json"
|
||||
self.list.write_text(json.dumps({
|
||||
"manifest_version": 2, "platform": "retrobat", "version": "8.3.1",
|
||||
"files": [{"dest": "a.bin", "sha1": hashlib.sha1(self.DATA).hexdigest(),
|
||||
"size": len(self.DATA), "repo_path": "bios/a.bin"}],
|
||||
}))
|
||||
|
||||
def run_installer(self, *options: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retrobat",
|
||||
"--dest", str(self.bios), "--manifest", str(self.list), "--offline",
|
||||
"--yes", *options],
|
||||
capture_output=True, text=True, timeout=120, check=False,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
SAID = ("This list was built for RetroBat 8.3.1, this machine runs 8.2.0. "
|
||||
"RetroBat's own BIOS check may report some files as wrong though they work.")
|
||||
|
||||
def test_line_mode_says_it_before_the_install_and_with_the_result(self):
|
||||
proc = self.run_installer()
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertEqual(proc.stdout.count(self.SAID), 2, proc.stdout)
|
||||
self.assertLess(proc.stdout.index(self.SAID), proc.stdout.index("Checking existing"))
|
||||
self.assertGreater(proc.stdout.rindex(self.SAID), proc.stdout.index("Done."))
|
||||
|
||||
def test_the_screen_reads_an_event(self):
|
||||
proc = self.run_installer("--check", "--events")
|
||||
events = [json.loads(line) for line in proc.stdout.splitlines()]
|
||||
said = [event for event in events if event["event"] == "version"]
|
||||
self.assertEqual(said, [{"event": "version", "platform": "retrobat",
|
||||
"list_version": "8.3.1", "installed_version": "8.2.0",
|
||||
"message": self.SAID}])
|
||||
kinds = [event["event"] for event in events]
|
||||
self.assertLess(kinds.index("version"), kinds.index("check"))
|
||||
|
||||
def test_the_same_version_says_nothing(self):
|
||||
(self.root / "system" / "version.info").write_text("8.3.1.1-stable-win64")
|
||||
proc = self.run_installer()
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertNotIn("This list was built for", proc.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1021,7 +1021,7 @@ class AHeldFileTheInstallerCannotFetch(unittest.TestCase):
|
||||
self.db = {"files": files, "indexes": generate_db.build_indexes(files, {})}
|
||||
Path("platforms").mkdir()
|
||||
Path("platforms/p.yml").write_text(json.dumps({
|
||||
"platform": "P", "verification_mode": "existence", "cores": ["core"],
|
||||
"platform": "P", "version": "2.1", "verification_mode": "existence", "cores": ["core"],
|
||||
"systems": {"sys": {"files": []}},
|
||||
}))
|
||||
Path("platforms/_registry.yml").write_text(
|
||||
@@ -1096,7 +1096,7 @@ class AStandaloneLayoutBelowTheBiosFolder(unittest.TestCase):
|
||||
self.db = {"files": files, "indexes": generate_db.build_indexes(files, {})}
|
||||
Path("platforms").mkdir()
|
||||
Path("platforms/p.yml").write_text(json.dumps({
|
||||
"platform": "P", "verification_mode": "existence",
|
||||
"platform": "P", "version": "2.1", "verification_mode": "existence",
|
||||
"base_destination": "bios", "cores": ["dsa", "other"],
|
||||
"standalone_cores": ["dsa"], "systems": {"sys": {"files": []}},
|
||||
}))
|
||||
|
||||
@@ -193,6 +193,7 @@ class PackPlacesCoreFilesUnderTheTag(unittest.TestCase):
|
||||
|
||||
platform = {
|
||||
"platform": "MinUI Test",
|
||||
"version": "2025-11-27",
|
||||
"base_destination": "",
|
||||
"verification_mode": "existence",
|
||||
"cores": ["gambatte", "arcadecore", "unlisted"],
|
||||
|
||||
@@ -92,7 +92,7 @@ class _Fixture(unittest.TestCase):
|
||||
for key, profile in PROFILES.items():
|
||||
(self.emulators / f"{key}.yml").write_text(yaml.dump(profile))
|
||||
platform = {
|
||||
"platform": "Handheld", "verification_mode": "existence",
|
||||
"platform": "Handheld", "version": "2.1", "verification_mode": "existence",
|
||||
"cores": ["emu", "doomcore"],
|
||||
"systems": {
|
||||
"console": {"files": [{"name": "firmware.rom", "destination": "firmware.rom"}]},
|
||||
|
||||
@@ -52,6 +52,7 @@ files:
|
||||
|
||||
PLATFORM = {
|
||||
"platform": "Demo",
|
||||
"version": "2.1",
|
||||
"verification_mode": "existence",
|
||||
"base_destination": "",
|
||||
"cores": ["demo"],
|
||||
@@ -494,7 +495,7 @@ class RefusedFilesAreNotPresent(unittest.TestCase):
|
||||
}
|
||||
db = {"files": files, "indexes": generate_db.build_indexes(files, {})}
|
||||
config = {
|
||||
"platform": "Hashy", "verification_mode": "md5", "base_destination": "bios",
|
||||
"platform": "Hashy", "version": "2.1", "verification_mode": "md5", "base_destination": "bios",
|
||||
"systems": {"sys": {"files": [
|
||||
{"name": "good.bin", "destination": "good.bin",
|
||||
"md5": hashlib.md5(b"good.bin").hexdigest()},
|
||||
|
||||
@@ -51,6 +51,7 @@ class RequiredTwins(unittest.TestCase):
|
||||
self.db = {"files": files, "indexes": generate_db.build_indexes(files, {})}
|
||||
platform = {
|
||||
"platform": "Twins",
|
||||
"version": "2.1",
|
||||
"verification_mode": "md5",
|
||||
"base_destination": "bios",
|
||||
"cores": [],
|
||||
|
||||
@@ -243,7 +243,7 @@ class _Fixture(unittest.TestCase):
|
||||
"required": False}]}
|
||||
for name in ("testra", "testnosave"):
|
||||
(self.platforms / f"{name}.yml").write_text(yaml.safe_dump({
|
||||
"platform": name, "verification_mode": "existence",
|
||||
"platform": name, "version": "2.1", "verification_mode": "existence",
|
||||
"base_destination": "system", "cores": ["corex"],
|
||||
"systems": {"test-console": system},
|
||||
}))
|
||||
@@ -399,7 +399,7 @@ class AFolderPerSystem(_Fixture):
|
||||
"required": False}]}
|
||||
for name in ("testbato", "testrb"):
|
||||
(self.platforms / f"{name}.yml").write_text(yaml.safe_dump({
|
||||
"platform": name, "verification_mode": "existence",
|
||||
"platform": name, "version": "2.1", "verification_mode": "existence",
|
||||
"base_destination": "bios", "cores": ["corex", "corey"],
|
||||
"systems": {"test-console": system},
|
||||
# Two systems run CoreX, none runs CoreY.
|
||||
|
||||
@@ -58,6 +58,7 @@ class SlugPlatform(unittest.TestCase):
|
||||
self.db = {"files": files, "indexes": generate_db.build_indexes(files, {})}
|
||||
self.config = {
|
||||
"platform": "Slugs",
|
||||
"version": "2.1",
|
||||
"verification_mode": "md5",
|
||||
"base_destination": "bios",
|
||||
"cores": ["dosbox"],
|
||||
@@ -139,7 +140,7 @@ class RegionKeysForEveryClaimant(unittest.TestCase):
|
||||
)
|
||||
common._emulator_profiles_cache.clear()
|
||||
profiles = common.load_emulator_profiles("emulators")
|
||||
config = {"platform": "P", "verification_mode": "existence", "cores": ["one", "two"],
|
||||
config = {"platform": "P", "version": "2.1", "verification_mode": "existence", "cores": ["one", "two"],
|
||||
"systems": {"sys": {"files": []}}}
|
||||
_groups, extra_dests = platform_region_groups(
|
||||
config, config["systems"], "emulators", db, "", profiles
|
||||
@@ -178,7 +179,7 @@ class FlatFirmwareFolder(unittest.TestCase):
|
||||
}
|
||||
db = {"files": files, "indexes": generate_db.build_indexes(files, {})}
|
||||
config = {
|
||||
"platform": "Flat", "verification_mode": "md5",
|
||||
"platform": "Flat", "version": "2.1", "verification_mode": "md5",
|
||||
"base_destination": "bios", "cores": ["psx"],
|
||||
"systems": {
|
||||
"psx": {"files": [{"name": "scph5501.bin",
|
||||
@@ -300,7 +301,7 @@ class OnePathCountsOnce(unittest.TestCase):
|
||||
common._emulator_profiles_cache.clear()
|
||||
self.addCleanup(common._emulator_profiles_cache.clear)
|
||||
profiles = common.load_emulator_profiles("emulators")
|
||||
config = {"platform": "P", "verification_mode": "existence", "cores": ["one", "two"],
|
||||
config = {"platform": "P", "version": "2.1", "verification_mode": "existence", "cores": ["one", "two"],
|
||||
"systems": {"amiga": {"files": []}}}
|
||||
report = verify_platform(config, db, "emulators", profiles)
|
||||
claims = [u for u in report["undeclared_files"] if u["name"] == "rom.key"]
|
||||
|
||||
+16
-1
@@ -279,6 +279,20 @@ file list is downloaded once and handed to each run.
|
||||
[Files you already had](#files-you-already-had).
|
||||
8. Copies into standalone emulator directories, only with `--standalone-copies`.
|
||||
|
||||
The file list names the version of the platform it was built for (Batocera
|
||||
43.1, RetroBat 8.3.1). Where the system writes its own version in a file, the
|
||||
installer reads it, and when the two differ it says so before the install and
|
||||
again with the result: "This list was built for Batocera 43.1, this machine
|
||||
runs 41. Batocera's own BIOS check may report some files as wrong though they
|
||||
work." The install goes on. The files read are
|
||||
`/usr/share/batocera/batocera.version` on Batocera, `/recalbox/recalbox.version`
|
||||
on Recalbox (a release, not a branch build), `system\version.info` beside the
|
||||
`bios` folder of RetroBat, and `VERSION_ID` in `/etc/os-release` on Lakka and
|
||||
EmuELEC. Nothing is run to find it, and an unknown version says nothing:
|
||||
RetroArch writes its version in no file, KNULLI writes a codename, ROCKNIX the
|
||||
date of the build, and AmberELEC's prerelease tags are not matched to the version
|
||||
its image writes.
|
||||
|
||||
Some cores read their system data below the frontend's save directory, not
|
||||
below the BIOS folder: Azahar's keys, shared font and NAND files, Citra's
|
||||
`sysdata`, Dolphin's `User` tree. The file list marks them `root: save`, and
|
||||
@@ -1551,6 +1565,7 @@ other schemas under `api/v1/schemas/`.
|
||||
| `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found |
|
||||
| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart |
|
||||
| `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered |
|
||||
| `version` | `platform`, `list_version`, `installed_version`, `message` | Before `choices`, when the system here runs another version than the one the list was built for: `list_version` is the list's, `installed_version` the one the system's version file states, and `message` says it and what it means. Not written when either version is unknown. The run goes on |
|
||||
| `choices` | `platform`, `systems` | Before `checking`: the systems the run's list lets one choose among, sorted by id, `other` last for the files no system names. The run's other options apply (`--core`, `--region`, `--no-game-data`, the files left out for an unknown save directory), `--system` does not: these are the systems a screen lists to choose from, and the count a `--system` is out of |
|
||||
| `file` | `platform`, `dest`, `systems`, `size`, `state` | Each file of the list after the check. `state` is `present` (hash verified), `wrong_hash` or `missing` |
|
||||
| `system` | `platform`, `system`, `name`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed` | Each system, sorted by id, and with `--system` only the systems named. Files with no system count under `other`. `name` is the platform's own name for it |
|
||||
@@ -1571,7 +1586,7 @@ other schemas under `api/v1/schemas/`.
|
||||
|
||||
<!-- /generated: events -->
|
||||
|
||||
Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees), never a key of an
|
||||
Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees, `version` with the list's version), never a key of an
|
||||
existing kind: a reader skips a kind it does not know. An option refused
|
||||
before the run starts (`install.py: error:`, code 2) writes
|
||||
no event at all. A program reads a missing `end` as that case, or as a
|
||||
|
||||
Reference in new issue
Block a user