diff --git a/install.ps1 b/install.ps1 index 6d870ac14..651b4714d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951" + $defaultInstallSha256 = "8e6be38d489e9b481c8e82ba964b54054524b7a103a7527eddce20e52b261f0e" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 325e7efa1..f2198e3cf 100644 --- a/install.py +++ b/install.py @@ -546,6 +546,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 @@ -1530,6 +1536,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: + # /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. @@ -5635,9 +5762,11 @@ def _report_outcome( offline: int = 0, kinds: list[int] | None = None, same_choice: str = "", + notes: list[str] | None = None, ) -> None: """Close the run on what happened, and leave with a status that says so. - kinds is [BIOS files, game data files] installed, said apart. + kinds is [BIOS files, game data files] installed, 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 @@ -5674,6 +5803,8 @@ def _report_outcome( if declined and not downloaded and not errors and not interrupted and not offline: print(f"\nNothing installed: {_plural(declined, 'file')} not downloaded, as answered.") print(f"{_plural(up_to_date, 'file')} already up to date at {_locations(paths)}.") + for note in notes or []: + print(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. @@ -5683,6 +5814,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: print(f"\nStopped. {_plural(downloaded, 'file')} installed.") for line in kept: @@ -8015,6 +8147,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) @@ -8105,6 +8240,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): @@ -8147,6 +8285,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"], @@ -11762,6 +11901,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 @@ -11965,6 +12105,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). @@ -12440,6 +12588,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: sources=_run_sources(bios_indexes, total_downloaded, total_errors + total_offline), offline=total_offline, + notes=version_notes, ) finish_app(args, platforms, app_cards, interactive and not total_declined) diff --git a/install.sh b/install.sh index 34c845946..03f877ea9 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951" +DEFAULT_INSTALL_SHA256="8e6be38d489e9b481c8e82ba964b54054524b7a103a7527eddce20e52b261f0e" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 77a7edb56..75d68aba3 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 818600, - "sha256": "749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951", + "size": 825401, + "sha256": "8e6be38d489e9b481c8e82ba964b54054524b7a103a7527eddce20e52b261f0e", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 871149, - "sha256": "cbca9dd0e3d7de656965dbff79512991856a03853c0e771da97e5e95d7d31f49" + "size": 877950, + "sha256": "d0989778736c47d3e4fe8241d04b4ebb085814e259efe579dd92b2caad2cfdca" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 818600, - "sha256": "749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951", + "size": 825401, + "sha256": "8e6be38d489e9b481c8e82ba964b54054524b7a103a7527eddce20e52b261f0e", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 818600, - "sha256": "749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951", + "size": 825401, + "sha256": "8e6be38d489e9b481c8e82ba964b54054524b7a103a7527eddce20e52b261f0e", "executable": false }, { diff --git a/schemas/install-events.schema.json b/schemas/install-events.schema.json index 2ffaefdfe..62db8c06b 100644 --- a/schemas/install-events.schema.json +++ b/schemas/install-events.schema.json @@ -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, diff --git a/scripts/generate_event_schema.py b/scripts/generate_event_schema.py index 7fb77bbd1..468e33bac 100644 --- a/scripts/generate_event_schema.py +++ b/scripts/generate_event_schema.py @@ -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"]}, diff --git a/tests/test_install.py b/tests/test_install.py index ce3389aa5..42fb10a39 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -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() diff --git a/wiki/installer.md b/wiki/installer.md index f5ac09f70..71f3975f5 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -270,6 +270,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 @@ -1534,6 +1548,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 | @@ -1554,7 +1569,7 @@ other schemas under `api/v1/schemas/`. -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