From 3db2819150ab221b0c3fc8f68e8ae2e1c16b2a03 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:03:49 +0100 Subject: [PATCH] refactor: read the roms root from romfolders platforms/romfolders/.yml records the roms root from the OS's own source (roms_root path and from_bios), the same fact the registry's roms_directory restated for ROCKNIX alone. The manifest's roms_directory now carries from_bios, which the installer follows from a BIOS folder of the platform's name, and the registry key is gone. A list that installs above its BIOS folder (AmberELEC) holds its roms root in the install tree: a file a launcher runs from there is a plain dest. emulators_directory stays in the registry: no rom folders file names emulator folders. --- install.ps1 | 2 +- install.py | 20 +++++++---- install.sh | 2 +- install/launchers/index.json | 16 ++++----- platforms/_registry.yml | 7 ---- schemas/install-manifest.schema.json | 9 +++-- scripts/common.py | 52 ++++++++++++++++++++++++++-- scripts/generate_pack.py | 10 +++--- scripts/launchers.py | 41 ++++++++++++++++------ scripts/packextras.py | 20 +++++++---- scripts/verify.py | 2 ++ tests/test_launcher_placement.py | 51 +++++++++++++++++++++++++-- 12 files changed, 180 insertions(+), 52 deletions(-) diff --git a/install.ps1 b/install.ps1 index de798289b..a7b1581f4 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,7 +16,7 @@ # and Invoke-WebRequest throws on a cut transfer before the hash is read: # the site's build rewrites this line there (scripts/sitebootstrap.py). $mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." - $defaultInstallSha256 = "b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b" + $defaultInstallSha256 = "67c84f81ad5a452a73ae6b6f36bbdf593a48728754dbb0f77296c381012f2fd2" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 42f24af07..6c765d906 100644 --- a/install.py +++ b/install.py @@ -3687,10 +3687,17 @@ def _check_roms_directory(data: dict) -> None: return if ( not isinstance(layout, dict) - or set(layout) != {"bios_folder"} + or set(layout) != {"bios_folder", "from_bios"} or len(_safe_relative_path(layout["bios_folder"], "roms_directory.bios_folder").parts) != 1 + or not isinstance(layout["from_bios"], str) ): raise ValueError("invalid roms_directory") + # The roms root relative to the BIOS folder: up some levels, then down + # into named folders. Nothing climbs again once it went down. + parts = PurePosixPath(layout["from_bios"]).parts + ups = next((i for i, part in enumerate(parts) if part != ".."), len(parts)) + if not ups or any(part in ("..", ".", "/") or _CONTROLS.search(part) for part in parts[ups:]): + raise ValueError("invalid roms_directory.from_bios") def _check_emulators_directory(data: dict) -> None: @@ -5294,17 +5301,18 @@ def place_roms_entries( ) -> tuple[list[dict], list[dict]]: """Find the roms folder for the entries a launcher reads below it. - The platform keeps its BIOS folder in its roms folder under the name the - list gives (ROCKNIX's /storage/roms/bios): a BIOS folder of that name - puts the roms folder above it. Any other is a folder this run cannot - relate to the roms, and the entries are left out with the reason. + The list gives the roms root relative to the BIOS folder, as the OS's + own source places them (ROCKNIX's /storage/roms holds bios/): a BIOS + folder of the platform's name puts the roms root there. Any other is a + folder this run cannot relate to the roms, and the entries are left out + with the reason. """ below = [entry for entry in files if entry.get("root") == "roms"] if not below: return files, [] layout = manifest.get("roms_directory") or {} if layout and bios_path.name == layout.get("bios_folder"): - _ROMS_DIRS[bios_path] = bios_path.parent + _ROMS_DIRS[bios_path] = Path(os.path.normpath(bios_path / layout["from_bios"])) return files, [] if say: print(f" {_plural(len(below), 'file')} read from {display}'s roms folder left out: " diff --git a/install.sh b/install.sh index 3d4d8ddd0..8087b7e89 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins # curl or wget stop on a cut transfer before the hash is read: the site's # build rewrites this line there (scripts/sitebootstrap.py). MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." -DEFAULT_INSTALL_SHA256="b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b" +DEFAULT_INSTALL_SHA256="67c84f81ad5a452a73ae6b6f36bbdf593a48728754dbb0f77296c381012f2fd2" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index c72f65bf8..db1837125 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": 1138424, - "sha256": "b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b", + "size": 1138987, + "sha256": "67c84f81ad5a452a73ae6b6f36bbdf593a48728754dbb0f77296c381012f2fd2", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1192049, - "sha256": "9da63b607a6ae2a734f4437c224b484229c514b08bbab6341b529ce102de464f" + "size": 1192612, + "sha256": "90e6c3ec8b3eae2d9f8f1b07874c083e4d555fb325590999ef4690fb67125426" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1138424, - "sha256": "b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b", + "size": 1138987, + "sha256": "67c84f81ad5a452a73ae6b6f36bbdf593a48728754dbb0f77296c381012f2fd2", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1138424, - "sha256": "b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b", + "size": 1138987, + "sha256": "67c84f81ad5a452a73ae6b6f36bbdf593a48728754dbb0f77296c381012f2fd2", "executable": false }, { diff --git a/platforms/_registry.yml b/platforms/_registry.yml index bb6e08118..42bf41f68 100644 --- a/platforms/_registry.yml +++ b/platforms/_registry.yml @@ -860,13 +860,6 @@ platforms: # sources/RK3566/retroarch.cfg:664-665 at 20261001) and setsettings.sh # leaves it: saves sit beside each game, no single place. save_directory: null - # The BIOS folder sits in the roms directory (/storage/roms/bios, the - # detect entry below), where start_pico8.sh also runs PICO-8 from - # /storage/roms/pico-8. A launcher path below path is placed there, and - # the installer finds that directory as the folder holding bios_folder. - roms_directory: - path: /storage/roms - bios_folder: bios schedule: weekly target_scraper: rocknix_targets target_source: https://github.com/ROCKNIX/distribution/blob/next/projects/ROCKNIX/packages/virtual/emulators/package.mk diff --git a/schemas/install-manifest.schema.json b/schemas/install-manifest.schema.json index c820b6c4f..c34a87586 100644 --- a/schemas/install-manifest.schema.json +++ b/schemas/install-manifest.schema.json @@ -528,17 +528,22 @@ } }, "roms_directory": { - "description": "Where the platform keeps its roms folder, present when an entry carries root: roms. bios_folder is the name of the BIOS folder inside it: a BIOS folder of that name has the roms folder as its parent, any other leaves root: roms entries out (no_roms_directory).", + "description": "Where the platform keeps its roms root, present when an entry carries root: roms. from_bios is that root relative to the BIOS folder, as platforms/romfolders/.yml records it from the OS's own source (roms_root.from_bios). It is followed only from a BIOS folder named bios_folder (the platform's base_destination); any other leaves root: roms entries out (no_roms_directory).", "type": "object", "additionalProperties": false, "required": [ - "bios_folder" + "bios_folder", + "from_bios" ], "properties": { "bios_folder": { "type": "string", "minLength": 1, "pattern": "^[^/\\\\]+$" + }, + "from_bios": { + "type": "string", + "pattern": "^\\.\\.(/\\.\\.)*(/[^/.][^/]*)*$" } } }, diff --git a/scripts/common.py b/scripts/common.py index 25923b849..9522a8618 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -509,6 +509,14 @@ def load_platform_config(platform_name: str, platforms_dir: str = "platforms") - # The registry key, which per-platform profile fields (installed_on, # not_bundled_on) name: the display name differs (MiSTer FPGA). config["_key"] = platform_name + # Where the platform's games live, as its rom folders file records it + # from the OS's own source (platforms/romfolders/.yml, roms_root). + romfolders = os.path.join(platforms_dir, "romfolders", f"{platform_name}.yml") + if os.path.exists(romfolders): + with open(romfolders) as f: + roms_root = (source_yaml(f) or {}).get("roms_root") + if isinstance(roms_root, dict): + config["_roms_root"] = roms_root _platform_config_cache[cache_key] = config return config @@ -2755,7 +2763,7 @@ def standalone_set(config: dict) -> set[str]: names |= { HANDED_PREFIX + json.dumps( [h.emulator, h.folder, h.directory, sorted(h.handles), h.source_ref, - h.roms_folder] + h.roms_place] ) for h in handed_directories(config) } @@ -2791,7 +2799,9 @@ def _handed(standalone_cores: Iterable[str]) -> list: if not tag.startswith(HANDED_PREFIX): continue emulator, folder, directory, handles, ref, roms = json.loads(tag[len(HANDED_PREFIX):]) - found.append(HandedDirectory(emulator, folder, directory, frozenset(handles), ref, roms)) + found.append(HandedDirectory( + emulator, folder, directory, frozenset(handles), ref, tuple(roms) if roms else None + )) return found @@ -2818,6 +2828,39 @@ EMULATORS_ROOT = "emulators" OUTER_ROOTS = (ROMS_ROOT, EMULATORS_ROOT) +def outer_layout(config: dict, root: str) -> dict | None: + """Where a platform keeps the folder an outer root names, or None. + + roms: the roms root its rom folders file records (``path``, the OS's + spelling, and ``from_bios``, relative to the BIOS folder), with the + BIOS folder's own name, base_destination, that a run's folder must + carry before ``from_bios`` is followed from it. emulators: the + registry's ``emulators_directory``, the folders a launcher searches + for an emulator by key, which no rom folders file describes. + """ + if root == ROMS_ROOT: + found = config.get("_roms_root") or {} + base = str(config.get("base_destination") or "") + path, relation = found.get("path"), found.get("from_bios") + if not (path and relation) or "<" in str(path): + return None + if base: + return {"path": str(path), "from_bios": str(relation), "bios_folder": base} + # A list whose dests start above the BIOS folder (AmberELEC's + # bios/...) installs into the folder holding it: a roms root there + # or below is part of the install tree, written as a plain dest. + declared = declared_root(config) + if not declared: + return None + inside = os.path.normpath(f"{declared}/{relation}") + if inside == ".." or inside.startswith("../"): + return None + return {"path": str(path), "install_prefix": "" if inside == "." else inside} + if root == EMULATORS_ROOT: + return config.get("emulators_directory") or None + return None + + def standalone_layout_path(entry: dict, profile: dict) -> str | None: """The path of an entry relative to the directories its standalone build declares. @@ -2908,6 +2951,11 @@ def platform_layouts( tree, path = placed moved = {**f, "standalone_path": path, "layout_path": standalone_layout_path(f, profile)} + if tree == "install": + # Below the roms root inside the install tree (AmberELEC's + # /storage/roms): a plain dest the platform prefix skips. + files.append({**moved, "unprefixed": True}) + continue # A file the launcher runs from the roms directory (ROCKNIX's # roms/pico-8) or an emulator folder (RetroBat's emulators/pico8) # is relative to it, and says so. diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 4ed76e77a..1f8516755 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -40,6 +40,7 @@ from alternatives import core_file_spares, settle_sets, slot_set_keys from common import resolve_platform_cores as _platform_cores from common import ( OUTER_ROOTS, + outer_layout, installed_data_refs, platform_arg, require_directory, @@ -4686,11 +4687,12 @@ def _record_absent( ) -# What a manifest's roms_directory and emulators_directory carry from the -# registry: the BIOS folder's name, and for emulator folders the places the +# What a manifest's roms_directory and emulators_directory carry: the BIOS +# folder's name, for the roms root its place relative to the BIOS folder +# (romfolders roms_root.from_bios), and for emulator folders the places the # launcher searches, the one a missing folder is created at, and the config # file whose key moves a folder. -MANIFEST_OUTER_FIELDS = ("bios_folder", "searched", "default", "config") +MANIFEST_OUTER_FIELDS = ("bios_folder", "from_bios", "searched", "default", "config") def _record_unplaceable(unplaceable: list[dict], record_omission: Callable[..., None]) -> None: @@ -5259,7 +5261,7 @@ def generate_manifest( if data_directories: result["data_directories"] = data_directories for root in OUTER_ROOTS: - layout = config.get(f"{root}_directory") or {} + layout = outer_layout(config, root) or {} if layout and any(entry.get("root") == root for entry in [*manifest_files, *result["omitted_files"]]): # Where the installer finds the folder: relative to the folder diff --git a/scripts/launchers.py b/scripts/launchers.py index 75a25186d..37ac6a3b7 100644 --- a/scripts/launchers.py +++ b/scripts/launchers.py @@ -331,6 +331,9 @@ BIOS_TREE = "" ROMS_TREE = "roms" # The emulator folders of a platform whose registry describes them. EMULATORS_TREE = "emulators" +# The tree a platform's list installs into when it starts above the BIOS +# folder: a plain dest, with no prefix. +INSTALL_TREE = "install" # A folder of neither tree, which no pack and no installer can fill. OUTSIDE_TREE = "outside" Place = tuple[str, str] @@ -382,15 +385,13 @@ class HandedDirectory: directory: str handles: frozenset[str] source_ref: str - roms_folder: str | None = None + roms_place: Place | None = None def place(self) -> Place | None: """The folder as a place, None when it lies outside both roots.""" if self.folder is not None: return (BIOS_TREE, self.folder) - if self.roms_folder is not None: - return (ROMS_TREE, self.roms_folder) - return None + return self.roms_place def handed_directories(config: dict) -> list[HandedDirectory]: @@ -430,19 +431,37 @@ def handed_directories(config: dict) -> list[HandedDirectory]: directory=directory, handles=handles, source_ref=str(record.get("source_ref", "")), - roms_folder=None if folder is not None else folder_in_roms(raw, config), + roms_place=None if folder is not None else _roms_place( + config, folder_in_roms(raw, config) + ), )) return found +def _roms_place(config: dict, path: str | None) -> Place | None: + """A path below the roms root as a place: in the install tree when the + platform installs above its BIOS folder (AmberELEC), else the roms tree.""" + if path is None: + return None + from common import ROMS_ROOT, outer_layout # noqa: PLC0415 + + layout = outer_layout(config, ROMS_ROOT) or {} + if "install_prefix" in layout: + prefix = layout["install_prefix"] + return (INSTALL_TREE, sanitize_pack_path(f"{prefix}/{path}" if prefix else path)) + return (ROMS_TREE, path) + + def folder_in_roms(directory: str, config: dict) -> str | None: """A directory relative to the platform's roms directory, None outside it. - Only a platform whose registry names the roms directory has one - (``roms_directory.path``): ROCKNIX's /storage/roms, which holds its BIOS + Only a platform whose rom folders file records its roms root has one + (``roms_root.path``): ROCKNIX's /storage/roms, which holds its BIOS folder and the folders its launchers run programs from. """ - roms = str((config.get("roms_directory") or {}).get("path") or "").rstrip("/") + from common import ROMS_ROOT, outer_layout # noqa: PLC0415 + + roms = str((outer_layout(config, ROMS_ROOT) or {}).get("path") or "").rstrip("/") directory = directory.strip().rstrip("/") if not roms or not directory.startswith(roms + "/"): return None @@ -503,7 +522,7 @@ def executed_files(config: dict) -> list[ExecutedFile]: f"{directories[0]}/{name}" if directories[0] else name )) elif roms is not None: - place = (ROMS_TREE, sanitize_pack_path(f"{roms}/{name}" if roms else name)) + place = _roms_place(config, sanitize_pack_path(f"{roms}/{name}" if roms else name)) elif emulator_folder is not None: place = (EMULATORS_TREE, sanitize_pack_path(f"{emulator_folder}/{name}")) else: @@ -581,8 +600,8 @@ def standalone_bindings( key = "/".join(p for p in (sub, rest) if p) found.setdefault(key, set()).add(target) linked = True - if not linked and record.roms_folder is not None: - found.setdefault(sub, set()).add((ROMS_TREE, record.roms_folder)) + if not linked and record.roms_place is not None: + found.setdefault(sub, set()).add(record.roms_place) if EXECUTABLE_DIRECTORY in wanted: paths = list(layout) for run in executed: diff --git a/scripts/packextras.py b/scripts/packextras.py index d665a5e4f..16a5d64b3 100644 --- a/scripts/packextras.py +++ b/scripts/packextras.py @@ -18,7 +18,7 @@ from common import resolve_local_file from common import resolve_platform_cores from common import core_system_dirs, platform_layouts, system_dir_path from common import standalone_set as platform_standalone_set -from common import OUTER_ROOTS, sanitize_pack_path +from common import OUTER_ROOTS, outer_layout, sanitize_pack_path from common import size_fits from validation import deliverable, read_from_save_dir, read_from_system_dir from packpaths import libretro_systems, save_destination, save_layout @@ -44,6 +44,10 @@ def extra_full_destination(fe: dict, prefix: str) -> str: """ if fe.get("root") == "save": return str(fe.get("pack_path") or "") + if fe.get("unprefixed"): + # A path of the install tree outside the BIOS folder (AmberELEC's + # pico-8/ beside bios/): no platform prefix in front. + return sanitize_pack_path(fe.get("destination", fe.get("name", ""))) if fe.get("root") in OUTER_ROOTS: return outer_destination( fe["root"], sanitize_pack_path(fe.get("destination", fe.get("name", ""))) @@ -573,7 +577,7 @@ def _agnostic_scan_extras( # The scan walks the BIOS tree: a file read below the save # directory is placed by the gap report's pass. if (read_from_save_dir(f, standalone) or f.get("unplaced") - or f.get("root") in OUTER_ROOTS): + or f.get("root") in OUTER_ROOTS or f.get("unprefixed")): continue # An agnostic BIOS mode says the BIOS filename is free, not that # every file the emulator loads is. A free filename still has a @@ -1024,12 +1028,10 @@ class ExtraPlacement: self.native_systems = native_slug_systems(config) self.flat = bool(config.get("firmware_folder_flat")) self.save = save_layout(config) - # The roms directory and emulator folders the registry describes, + # The roms root and emulator folders the platform's files describe, # for a file a launcher runs from them (ROCKNIX's roms/pico-8, # RetroBat's emulators/pico8). - self.outer = { - root for root in OUTER_ROOTS if config.get(f"{root}_directory") - } + self.outer = {root for root in OUTER_ROOTS if outer_layout(config, root)} self.config = config self.norm_map = ( {_norm_system_id(sid): sid for sid in self.platform_systems} @@ -1050,6 +1052,8 @@ class ExtraPlacement: """ if entry.get("unplaced"): return None + if entry.get("unprefixed"): + return dest, dest if entry.get("root") in OUTER_ROOTS: root = entry["root"] return (dest, outer_destination(root, dest)) if root in self.outer else None @@ -1339,7 +1343,7 @@ def _collect_emulator_extras( # covered elsewhere belongs to another program's file. # Nor is one placed outside the BIOS tree by the layout. if (fname not in covered_names or f.get("unsourceable") or f.get("unplaced") - or f.get("root") in OUTER_ROOTS): + or f.get("root") in OUTER_ROOTS or f.get("unprefixed")): continue # Installed with the emulator or by the platform's package, # as the first pass already decided for the same entry. @@ -1586,6 +1590,8 @@ def _report_extra(u: dict, name: str, dest: str) -> dict: extra["system_dir"] = u["system_dir"] if u.get("layout_path"): extra["layout_path"] = u["layout_path"] + if u.get("unprefixed"): + extra["unprefixed"] = True # The archive layout the core opens ranks same-named copies by what # they hold. Without it the first copy indexed won. if u.get("contents"): diff --git a/scripts/verify.py b/scripts/verify.py index 9d703a646..7a05a1be9 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -794,6 +794,8 @@ def find_undeclared_files( entry["members"] = members if f.get("layout_path"): entry["layout_path"] = f["layout_path"] + if f.get("unprefixed"): + entry["unprefixed"] = True if f.get("unplaced"): entry["unplaced"] = f["unplaced"] elif f.get("root") in OUTER_ROOTS: diff --git a/tests/test_launcher_placement.py b/tests/test_launcher_placement.py index 50cafe33c..2e23f5c75 100644 --- a/tests/test_launcher_placement.py +++ b/tests/test_launcher_placement.py @@ -274,13 +274,36 @@ class AProgramRunFromItsFolder(unittest.TestCase): })) (self.platforms / "_registry.yml").write_text(yaml.safe_dump({"platforms": { **{name: {"config": f"{name}.yml"} for name in launchers}, - "romsplat": {"config": "romsplat.yml", - "roms_directory": {"path": "/storage/roms", "bios_folder": "bios"}}, "emuplat": {"config": "emuplat.yml", "emulators_directory": { "launcher_spelling": '', "bios_folder": "bios", "searched": ["frontend/{key}", "emulators/{key}"], "default": "emulators/{key}", "config": "frontend/launcher.cfg"}}, }})) + # The roms root as the OS's own source records it (romfolders). + (self.platforms / "romfolders").mkdir() + (self.platforms / "romfolders" / "romsplat.yml").write_text(yaml.safe_dump({ + "setup": "romsplat", "platform": "romsplat", + "roms_root": {"path": "/storage/roms", "from_bios": "..", "source_ref": "os x.mk:1"}, + "folders": [], + })) + # A list that installs into the roms root itself, its BIOS folder + # one of the folders there (AmberELEC's /storage/roms). + (self.platforms / "rootplat.yml").write_text(yaml.safe_dump({ + "platform": "rootplat", "version": "1", "verification_mode": "existence", + "base_destination": "", "cores": ["prog"], "standalone_cores": ["prog"], + "launchers": [{**run, "names": ["prog"], "search_dir": "/storage/roms/prog"}], + "systems": {"prog-system": {"files": [ + {"name": "own.bin", "destination": "bios/own.bin", "required": False}, + ]}}, + })) + registry = yaml.safe_load((self.platforms / "_registry.yml").read_text()) + registry["platforms"]["rootplat"] = {"config": "rootplat.yml"} + (self.platforms / "_registry.yml").write_text(yaml.safe_dump(registry)) + (self.platforms / "romfolders" / "rootplat.yml").write_text(yaml.safe_dump({ + "setup": "rootplat", "platform": "rootplat", + "roms_root": {"path": "/storage/roms", "from_bios": "..", "source_ref": "os x.mk:1"}, + "folders": [], + })) (self.emulators / "prog.yml").write_text(yaml.safe_dump({ "emulator": "Prog", "type": "standalone", "cores": ["prog"], "systems": ["prog-system"], @@ -332,11 +355,20 @@ class AProgramRunFromItsFolder(unittest.TestCase): sorted((f["dest"], f.get("root")) for f in manifest["files"]), [("prog/prog", "roms"), ("prog/prog.dat", "roms")], ) - self.assertEqual(manifest["roms_directory"], {"bios_folder": "bios"}) + self.assertEqual(manifest["roms_directory"], {"bios_folder": "bios", "from_bios": ".."}) entries = self.verify("romsplat") self.assertEqual(sorted(u["path"] for u in entries), ["prog/prog", "prog/prog.dat"]) self.assertTrue(all(u["in_pack"] is False for u in entries)) + def test_a_roms_root_inside_the_install_tree_is_a_plain_dest(self) -> None: + self.assertEqual(self.pack("rootplat"), ["prog/prog", "prog/prog.dat"]) + manifest = self.manifest("rootplat") + self.assertEqual( + sorted((f["dest"], f.get("root")) for f in manifest["files"] if "prog" in f["dest"]), + [("prog/prog", None), ("prog/prog.dat", None)], + ) + self.assertNotIn("roms_directory", manifest) + def test_run_from_an_emulator_folder_is_named(self) -> None: self.assertEqual(self.pack("exeplat"), []) manifest = self.manifest("exeplat") @@ -367,6 +399,19 @@ class AProgramRunFromItsFolder(unittest.TestCase): self.assertEqual({o["reason"] for o in left}, {"no_roms_directory"}) with self.assertRaises(ValueError): install._check_roms_directory({"files": files}) + # A roms root beside the BIOS folder (Batocera's ../roms). + beside = {**manifest, "roms_directory": {"bios_folder": "bios", "from_bios": "../roms"}} + install._check_roms_directory(beside) + disk = self.root / "userdata" / "bios" + install.place_roms_entries(files, disk, beside, "romsplat", say=False) + self.assertEqual(install._entry_place(disk, kept_entry := files[0])[0], + self.root / "userdata" / "roms") + self.assertEqual(kept_entry["root"], "roms") + for climbing in ("roms", "../roms/..", "/storage/roms"): + with self.subTest(from_bios=climbing), self.assertRaises(ValueError): + install._check_roms_directory( + {**manifest, "roms_directory": {"bios_folder": "bios", "from_bios": climbing}} + ) def test_run_from_an_emulator_folder_the_registry_describes(self) -> None: self.assertEqual(self.pack("emuplat"), [])