diff --git a/bios/Lexaloffle/PICO-8/SDL2.dll b/bios/Lexaloffle/PICO-8/SDL2.dll new file mode 100644 index 000000000..701779044 Binary files /dev/null and b/bios/Lexaloffle/PICO-8/SDL2.dll differ diff --git a/bios/Lexaloffle/PICO-8/pico8.exe b/bios/Lexaloffle/PICO-8/pico8.exe new file mode 100644 index 000000000..6ee0b9802 Binary files /dev/null and b/bios/Lexaloffle/PICO-8/pico8.exe differ diff --git a/emulators/lexaloffle.yml b/emulators/lexaloffle.yml index 341a1e4a9..1b28f72d4 100644 --- a/emulators/lexaloffle.yml +++ b/emulators/lexaloffle.yml @@ -12,30 +12,30 @@ version_basis: version_source: url: "https://www.lexaloffle.com/dl/docs/pico-8_changelog.txt" pattern: '^\s*v(0\.\d+\.\d+[a-z]?)\s*$' -display_name: "Lexaloffle - PICO-8 / Voxatron" +display_name: "Lexaloffle - PICO-8" cores: - lexaloffle - "pico-8" - - vox_official - "pico8" - "Pico-8" - "pico_standalone" - "float-scaled" systems: - pico8 - - voxatron standalone_directories: - "executable directory" notes: | - Commercial fantasy consoles by Lexaloffle Games LLP, sold as ZIP archives - per platform. Nothing in either program checks a hash of its own files: - the hashes below are those of the files in the official 0.2.7 archives. + Commercial fantasy console by Lexaloffle Games LLP, sold as ZIP archives + per platform. Nothing in the program checks a hash of its own files: the + hashes below are those of the files in the official 0.2.7 archives. + Voxatron, the same publisher's other program, is profiled apart + (voxatron.yml). - Each program finds its own folder through /proc/self/exe and reads its + The program finds its own folder through /proc/self/exe and reads its .dat beside itself. The paths below are the layout of the archives, whose - top folders are pico-8/ and voxatron/: a launcher that runs one of these - files from a folder places the program folder there. + top folder is pico-8/: a launcher that runs one of these files from a + folder places the program folder there. PICO-8 0.2.7 ships five archives. pico-8_0.2.7_amd64.zip holds pico8 (x86_64, SDL2 linked in) and pico8_dyn (x86_64, system libSDL2). @@ -83,11 +83,6 @@ notes: | and three other home folders on linuxarm, pico8 on linux and unix, and pico8.exe on Windows. - Voxatron: Batocera's generator runs bios/voxatron/vox with - LD_LIBRARY_PATH set to bios/voxatron. x86_64 only. vox_dyn is the - variant linked against the system SDL2. libHoloPlayCore.so is the Looking - Glass SDK the binary links against. - files: # ------------------------------------------------------- # PICO-8 0.2.7 @@ -234,79 +229,3 @@ files: description: "SPLORE favourites list" source_ref: "pico-8 0.2.7 amd64 pico8, string favourites.txt at 0x242346" note: "Read from the -home folder. The bbs/.pip.png images beside it are downloaded from the BBS." - - # ------------------------------------------------------- - # Voxatron - # ------------------------------------------------------- - - name: vox - path: voxatron/vox - system: voxatron - required: true - description: "Voxatron main executable" - source_ref: "batocera lexaloffleGenerator.py:20, 42, 48-52" - note: "The Voxatron program vox, an x86_64 ELF with SDL2 linked in. Batocera's generator runs bios/voxatron/vox and raises when it is missing or not executable." - - - name: vox_dyn - path: voxatron/vox_dyn - system: voxatron - required: false - description: "Voxatron dynamically linked executable" - source_ref: "voxatron vox_dyn, NEEDED libSDL2-2.0.so.0, batocera lexaloffleGenerator.py:20, 42" - note: "The same program linked against the system SDL2 (its only extra NEEDED library). Batocera's generator never runs it." - - - name: vox.dat - path: voxatron/vox.dat - system: voxatron - required: true - description: "Voxatron data file" - source_ref: "voxatron vox, string vox.dat at 0x2a58f, voxatron vox_dyn, string vox.dat at 0x1f1af" - note: "Opened from the folder of the binary that runs. Absent, vox stops with \"FATAL ERROR: could not load vox.dat\"." - - - name: libHoloPlayCore.so - path: voxatron/libHoloPlayCore.so - system: voxatron - required: false - description: "Looking Glass holographic display support library" - source_ref: "voxatron vox, voxatron vox_dyn, batocera lexaloffleGenerator.py:41, 87-90" - note: "Neither binary links it or names it, and neither opens it in a run (strace, with and without the file). Batocera's generator sets LD_LIBRARY_PATH to bios/voxatron, where it keeps the library." - - - name: config.txt - unsourceable: "written by the emulator on exit, no canonical file" - system: voxatron - required: false - description: "Voxatron settings" - source_ref: "voxatron vox, string config.txt at 0x27bea0" - note: "Read beside the binary, then from ~/.lexaloffle/Voxatron, and written there when absent." - - - name: sdl_controllers.txt - unsourceable: "user supplied, no canonical file" - system: voxatron - required: false - description: "Custom SDL controller mappings" - source_ref: "voxatron vox, string sdl_controllers.txt at 0x2847f7" - note: "Read beside the binary, then from ~/.lexaloffle/Voxatron, where Voxatron writes it when absent." - - - name: favourites.txt - unsourceable: "written by the emulator, no canonical file" - system: voxatron - required: false - description: "Cart browser favourites list" - source_ref: "voxatron vox, string favourites.txt at 0x27a39e" - note: "Read from ~/.lexaloffle/Voxatron." - - - name: internal_carts.txt - unsourceable: "written by the emulator, no canonical file" - system: voxatron - required: false - description: "Voxatron internal cart list" - source_ref: "voxatron vox, string internal_carts.txt at 0x27a446" - note: "Read from ~/.lexaloffle/Voxatron and written there at startup." - - - name: player_1.vpp - path: player/player_1.vpp - unsourceable: "written by the emulator, no canonical file" - system: voxatron - required: false - description: "Voxatron player profile" - source_ref: "voxatron vox, string player/player_%d.vpp at 0x273f90" - note: "Read from ~/.lexaloffle/Voxatron/player, and written there when absent." diff --git a/emulators/voxatron.yml b/emulators/voxatron.yml new file mode 100644 index 000000000..bc3350742 --- /dev/null +++ b/emulators/voxatron.yml @@ -0,0 +1,104 @@ +emulator: Voxatron +type: standalone +core_classification: other +upstream: "https://www.lexaloffle.com/voxatron.php" +profiled_date: "2026-11-09" +core_version: "0.3.5b" +version_basis: + kind: unverified + why: no_artifact +display_name: "Lexaloffle - Voxatron" +cores: + - vox_official +systems: + - voxatron +standalone_directories: + - "executable directory" + +notes: | + Commercial voxel fantasy console by Lexaloffle Games LLP, sold as ZIP + archives. The held binaries report voxatron 0.3.5b. Nothing in the + program checks a hash of its own files. + + The program finds its own folder through /proc/self/exe and reads vox.dat + beside itself. The paths below put the program in voxatron/: a launcher + that runs vox from a folder places that folder there. + + Batocera's generator runs bios/voxatron/vox with LD_LIBRARY_PATH set to + bios/voxatron. x86_64 only. vox_dyn is the variant linked against the + system SDL2. libHoloPlayCore.so is the Looking Glass SDK the binary links + against. + +files: + - name: vox + path: voxatron/vox + system: voxatron + required: true + description: "Voxatron main executable" + source_ref: "batocera lexaloffleGenerator.py:20, 42, 48-52" + note: "The Voxatron program vox, an x86_64 ELF with SDL2 linked in. Batocera's generator runs bios/voxatron/vox and raises when it is missing or not executable." + + - name: vox_dyn + path: voxatron/vox_dyn + system: voxatron + required: false + description: "Voxatron dynamically linked executable" + source_ref: "voxatron vox_dyn, NEEDED libSDL2-2.0.so.0, batocera lexaloffleGenerator.py:20, 42" + note: "The same program linked against the system SDL2 (its only extra NEEDED library). Batocera's generator never runs it." + + - name: vox.dat + path: voxatron/vox.dat + system: voxatron + required: true + description: "Voxatron data file" + source_ref: "voxatron vox, string vox.dat at 0x2a58f, voxatron vox_dyn, string vox.dat at 0x1f1af" + note: "Opened from the folder of the binary that runs. Absent, vox stops with \"FATAL ERROR: could not load vox.dat\"." + + - name: libHoloPlayCore.so + path: voxatron/libHoloPlayCore.so + system: voxatron + required: false + description: "Looking Glass holographic display support library" + source_ref: "voxatron vox, voxatron vox_dyn, batocera lexaloffleGenerator.py:41, 87-90" + note: "Neither binary links it or names it, and neither opens it in a run (strace, with and without the file). Batocera's generator sets LD_LIBRARY_PATH to bios/voxatron, where it keeps the library." + + - name: config.txt + unsourceable: "written by the emulator on exit, no canonical file" + system: voxatron + required: false + description: "Voxatron settings" + source_ref: "voxatron vox, string config.txt at 0x27bea0" + note: "Read beside the binary, then from ~/.lexaloffle/Voxatron, and written there when absent." + + - name: sdl_controllers.txt + unsourceable: "user supplied, no canonical file" + system: voxatron + required: false + description: "Custom SDL controller mappings" + source_ref: "voxatron vox, string sdl_controllers.txt at 0x2847f7" + note: "Read beside the binary, then from ~/.lexaloffle/Voxatron, where Voxatron writes it when absent." + + - name: favourites.txt + unsourceable: "written by the emulator, no canonical file" + system: voxatron + required: false + description: "Cart browser favourites list" + source_ref: "voxatron vox, string favourites.txt at 0x27a39e" + note: "Read from ~/.lexaloffle/Voxatron." + + - name: internal_carts.txt + unsourceable: "written by the emulator, no canonical file" + system: voxatron + required: false + description: "Voxatron internal cart list" + source_ref: "voxatron vox, string internal_carts.txt at 0x27a446" + note: "Read from ~/.lexaloffle/Voxatron and written there at startup." + + - name: player_1.vpp + path: player/player_1.vpp + unsourceable: "written by the emulator, no canonical file" + system: voxatron + required: false + description: "Voxatron player profile" + source_ref: "voxatron vox, string player/player_%d.vpp at 0x273f90" + note: "Read from ~/.lexaloffle/Voxatron/player, and written there when absent." diff --git a/install.ps1 b/install.ps1 index e026762eb..de798289b 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 = "3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0" + $defaultInstallSha256 = "b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index d13291ee6..42f24af07 100644 --- a/install.py +++ b/install.py @@ -143,6 +143,7 @@ OMISSION_REASONS = { "no_save_system": "no system runs the core", "outside_bios_folder": "read outside the BIOS folder", "no_roms_directory": "no roms folder", + "no_emulators_directory": "no emulator folder", } # What a file is when it is not firmware, as the manifest's category field # says, and as the installer words it. Firmware carries no category. @@ -3594,6 +3595,9 @@ class SavePlace(NamedTuple): _SAVE_DIRS: dict[Path, SavePlace] = {} # BIOS folder -> the platform's roms folder, set by place_roms_entries. _ROMS_DIRS: dict[Path, Path] = {} +# (BIOS folder, emulator key) -> the emulator folder, set by +# place_emulator_entries. +_EMULATOR_DIRS: dict[tuple[Path, str], Path] = {} def _entry_place(root: Path, entry: dict) -> tuple[Path, object]: @@ -3609,6 +3613,12 @@ def _entry_place(root: Path, entry: dict) -> tuple[Path, object]: if roms is None: raise ValueError(f"no roms folder known for {entry.get('dest')}") return roms, entry.get("dest") + if entry.get("root") == "emulators": + key, _, rest = str(entry.get("dest")).partition("/") + folder = _EMULATOR_DIRS.get((root, key)) + if folder is None: + raise ValueError(f"no emulator folder known for {entry.get('dest')}") + return folder, rest if entry.get("root") != "save": return root, entry.get("dest") place = _SAVE_DIRS.get(root) @@ -3643,7 +3653,7 @@ def _entry_key(entry: dict) -> str: if root == "emulator": # A path in one emulator's own folder: two emulators read two files. return f"emulator\0{','.join(entry.get('cores') or [])}\0{dest}" - return f"{root}\0{dest}" if root in ("save", "roms") else dest + return f"{root}\0{dest}" if root in ("save", "roms", "emulators") else dest def _check_root(entry: dict, dest: str, omitted: bool = False) -> None: @@ -3651,8 +3661,10 @@ def _check_root(entry: dict, dest: str, omitted: bool = False) -> None: the emulator's own folder, outside the BIOS tree), and core_folder one folder name of a save entry.""" root = entry.get("root") - if root not in (None, "save", "roms") and not (omitted and root == "emulator"): + if root not in (None, "save", "roms", "emulators") and not (omitted and root == "emulator"): raise ValueError(f"invalid root for {dest}") + if root == "emulators" and len(PurePosixPath(dest).parts) < 2: + raise ValueError(f"an emulators entry names its folder first: {dest}") folder = entry.get("core_folder") if folder is None: return @@ -3681,6 +3693,32 @@ def _check_roms_directory(data: dict) -> None: raise ValueError("invalid roms_directory") +def _check_emulators_directory(data: dict) -> None: + """The manifest's emulators_directory, which every installable root: emulators entry needs.""" + layout = data.get("emulators_directory") + installable = [ + entry for field in ("files", "case_variants") + for entry in data.get(field) or [] if isinstance(entry, dict) + ] + if layout is None: + if any(entry.get("root") == "emulators" for entry in installable): + raise ValueError("entries in emulator folders and no emulators_directory") + return + if not isinstance(layout, dict) or set(layout) != {"bios_folder", "searched", "default", "config"}: + raise ValueError("invalid emulators_directory") + searched = layout["searched"] + if ( + not isinstance(searched, list) or not searched or layout["default"] not in searched + or len(_safe_relative_path(layout["bios_folder"], "emulators_directory.bios_folder").parts) != 1 + ): + raise ValueError("invalid emulators_directory") + for template in searched: + if not isinstance(template, str) or template.count("{key}") != 1: + raise ValueError("invalid emulators_directory.searched") + _safe_relative_path(template.replace("{key}", "key"), "emulators_directory.searched") + _safe_relative_path(layout["config"], "emulators_directory.config") + + def _check_save_directory(data: dict) -> None: """The manifest's save_directory, which every root: save entry needs.""" layout = data.get("save_directory") @@ -3985,6 +4023,7 @@ def _validate_manifest(data: object, plat: str) -> dict: _check_data_directory(entry, f"data_directories[{index}]") _check_save_directory(data) _check_roms_directory(data) + _check_emulators_directory(data) declared_total_files = data.get("total_files") if declared_total_files is not None and declared_total_files != len(files): @@ -5280,6 +5319,67 @@ def place_roms_entries( return kept, unplaced +def _config_keys(path: Path) -> set[str]: + """The keys an emulatorLauncher-style key=value file sets, lowercased.""" + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return set() + return { + line.split("=", 1)[0].strip().lower() + for line in text.splitlines() if "=" in line and line.split("=", 1)[1].strip() + } + + +def place_emulator_entries( + files: list[dict], bios_path: Path, manifest: dict, display: str, say: bool = True, +) -> tuple[list[dict], list[dict]]: + """Find the emulator folder each entry a launcher runs from one goes to. + + The platform keeps its BIOS folder beside its own folder holding the + launcher config (RetroBat's bios beside emulationstation): a BIOS + folder of that name whose parent holds the config is the platform's. + A key the config sets moves the folder somewhere this run does not + follow, so its files are left out. Otherwise the folder is the first of + the searched places that exists, the default one when none does, as the + launcher searches them. + """ + inside = [entry for entry in files if entry.get("root") == "emulators"] + if not inside: + return files, [] + layout = manifest.get("emulators_directory") or {} + home = bios_path.parent + config = home / layout["config"] if layout else None + matches = bool(layout) and bios_path.name == layout.get("bios_folder") and config.is_file() + moved = _config_keys(config) if matches else set() + kept: list[dict] = [] + unplaced: list[dict] = [] + for entry in files: + if entry.get("root") != "emulators": + kept.append(entry) + continue + key = str(entry["dest"]).split("/", 1)[0] + if matches and key.lower() not in moved: + places = [home / t.replace("{key}", key) for t in layout["searched"]] + found = next((place for place in places if place.is_dir()), None) + _EMULATOR_DIRS[(bios_path, key)] = found or home / layout["default"].replace("{key}", key) + kept.append(entry) + continue + unplaced.append({ + "dest": entry["dest"], "name": PurePosixPath(entry["dest"]).name, + "system": "", "systems": list(entry.get("systems") or []), "required": False, + "reason": "no_emulators_directory", "cores": entry.get("cores"), + "root": "emulators", + }) + if unplaced and say: + why = (f"{config} sets their folder" if matches + else f"{bios_path} is not the {layout.get('bios_folder') or 'BIOS'} folder " + f"beside {layout.get('config') or 'its config'}") + print(f" {_plural(len(unplaced), 'file')} read from {display}'s emulator folders " + f"left out: {why}.") + return kept, unplaced + + # Per BIOS folder, where its save files go in words, or why that is not # known: read by the lines that count them once a choice is made. _SAVE_WHERE: dict[Path, str] = {} @@ -15894,7 +15994,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: files, unplaced = place_save_entries( files, bios_path, manifest, args.save_dir, display, say=False) files, beyond = place_roms_entries(files, bios_path, manifest, display, say=not listing) - unplaced = [*unplaced, *beyond] + files, elsewhere = place_emulator_entries( + files, bios_path, manifest, display, say=not listing) + unplaced = [*unplaced, *beyond, *elsewhere] if not manifest.get("save_directory"): # Its own list leaves these files out: naming the folder with # --save-dir installs none of them, and was advised in a loop. diff --git a/install.sh b/install.sh index 61a73ba03..3d4d8ddd0 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="3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0" +DEFAULT_INSTALL_SHA256="b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index ee265ccc1..c72f65bf8 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": 1133449, - "sha256": "3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0", + "size": 1138424, + "sha256": "b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1187074, - "sha256": "f98b3e8613e5572430097d4a0133d914986d17c47999020459a8555695a9a427" + "size": 1192049, + "sha256": "9da63b607a6ae2a734f4437c224b484229c514b08bbab6341b529ce102de464f" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1133449, - "sha256": "3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0", + "size": 1138424, + "sha256": "b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1133449, - "sha256": "3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0", + "size": 1138424, + "sha256": "b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b", "executable": false }, { diff --git a/platforms/_registry.yml b/platforms/_registry.yml index 437db5221..bb6e08118 100644 --- a/platforms/_registry.yml +++ b/platforms/_registry.yml @@ -590,6 +590,23 @@ platforms: hash_type: md5 # Windows: names differing only by case land on one file. case_insensitive_fs: true + # A generator takes an emulator folder from AppConfig.GetFullPath(key): + # the key in emulatorLauncher.cfg, else the first existing of + # emulationstation/, emulators/, system/emulators/ and + # system/ below the RetroBat folder (EmulatorLauncher.Common/ + # FileFormats/ConfigFile.cs:126-180 at ca486005). emulatorLauncher runs + # from emulationstation/, its LocalPath (emulatorLauncher/Program.cs:536-537), + # beside bios/. + emulators_directory: + launcher_spelling: '' + bios_folder: bios + searched: + - "emulationstation/{key}" + - "emulators/{key}" + - "system/emulators/{key}" + - "system/{key}" + default: "emulators/{key}" + config: emulationstation/emulatorLauncher.cfg # emulatorLauncher hands each libretro core saves\ at every # launch, saves\ beside bios\ (emulatorLauncher.cfg in retrobat 8.3.1), # and sorts saves by core for dolphin alone (CoreSaveSort) diff --git a/schemas/install-manifest.schema.json b/schemas/install-manifest.schema.json index 160c841d8..c820b6c4f 100644 --- a/schemas/install-manifest.schema.json +++ b/schemas/install-manifest.schema.json @@ -179,7 +179,7 @@ "dest": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$", - "description": "Where the file would go, below base_destination, below the save directory when root is save, below the roms folder when root is roms, or in the emulator's own folder when root is emulator." + "description": "Where the file would go, below base_destination, below the save directory when root is save, below the roms folder when root is roms, in an emulator folder when root is emulators (its key first), or in the emulator's own folder when root is emulator." }, "name": { "type": "string", @@ -206,7 +206,8 @@ "no_core_folder", "no_save_system", "outside_bios_folder", - "no_roms_directory" + "no_roms_directory", + "no_emulators_directory" ], "description": "Why the file is left out, as a code. enumDescriptions gives the meaning of each code, in the order of enum.", "enumDescriptions": [ @@ -220,7 +221,8 @@ "The frontend sorts saves by core and the core's profile does not name its folder (core_folder).", "The launcher gives each system its own save folder and no system of the platform runs the core.", "The platform runs the emulator from a folder outside the BIOS tree and links none of the BIOS tree where the emulator reads this file (root: emulator).", - "The platform's launcher runs the file from its roms folder (root: roms) and the run cannot tell where that folder is: the BIOS folder is not the one the platform keeps in it." + "The platform's launcher runs the file from its roms folder (root: roms) and the run cannot tell where that folder is: the BIOS folder is not the one the platform keeps in it.", + "The platform's launcher runs the file from one of its emulator folders (root: emulators) and the run cannot place it there: the BIOS folder is not the one beside the platform's launcher config, or that config sets the folder." ] }, "category": { @@ -365,11 +367,12 @@ } }, "root": { - "description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. emulator: dest is the path the emulator reads in its own folder, which the platform keeps outside the BIOS tree (reason outside_bios_folder). roms: dest is relative to the platform's roms folder (roms_directory). Absent for a file of the BIOS tree.", + "description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. emulator: dest is the path the emulator reads in its own folder, which the platform keeps outside the BIOS tree (reason outside_bios_folder). roms: dest is relative to the platform's roms folder (roms_directory). emulators: dest is the emulator folder's key then the path in it (emulators_directory). Absent for a file of the BIOS tree.", "enum": [ "save", "emulator", - "roms" + "roms", + "emulators" ] }, "core_folder": { @@ -538,6 +541,40 @@ "pattern": "^[^/\\\\]+$" } } + }, + "emulators_directory": { + "description": "Where the platform keeps its emulator folders, present when an entry carries root: emulators. Paths are relative to the folder holding the BIOS folder, which is named bios_folder. searched are the places the launcher tries for a key, in order, default the one the installer creates when none exists, and config the launcher's key=value file: a key it sets moves the folder, and its files are left out (no_emulators_directory).", + "type": "object", + "additionalProperties": false, + "required": [ + "bios_folder", + "searched", + "default", + "config" + ], + "properties": { + "bios_folder": { + "type": "string", + "minLength": 1, + "pattern": "^[^/\\\\]+$" + }, + "searched": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*\\{key\\}.*$" + } + }, + "default": { + "type": "string", + "pattern": "\\{key\\}" + }, + "config": { + "type": "string", + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" + } + } } }, "$defs": { @@ -556,7 +593,7 @@ "dest": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$", - "description": "Path below base_destination, below the save directory when root is save, or below the roms folder when root is roms." + "description": "Path below base_destination, below the save directory when root is save, below the roms folder when root is roms, or in an emulator folder when root is emulators (its key first)." }, "sha1": { "type": "string", @@ -1039,10 +1076,11 @@ } }, "root": { - "description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. roms: dest is relative to the platform's roms folder, which roms_directory locates (a file its launcher runs from there, ROCKNIX's roms/pico-8). Absent for a file of the BIOS tree.", + "description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. roms: dest is relative to the platform's roms folder, which roms_directory locates (a file its launcher runs from there, ROCKNIX's roms/pico-8). emulators: dest is the key of an emulator folder of the platform, then the path in it, located by emulators_directory (RetroBat's emulators/pico8). Absent for a file of the BIOS tree.", "enum": [ "save", - "roms" + "roms", + "emulators" ] }, "core_folder": { diff --git a/scripts/common.py b/scripts/common.py index eb0033553..25923b849 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -2810,8 +2810,12 @@ def _executed(standalone_cores: Iterable[str]) -> list: # Why a standalone entry has no destination on a platform that relocates its # layout: the manifest's omission reason, named as the installer words it. OUTSIDE_BIOS_FOLDER = "outside_bios_folder" -# The entry root of a file read below the platform's roms directory. +# The entry root of a file read below the platform's roms directory, and of +# one read in an emulator's folder outside the BIOS tree that the registry +# describes. Each is located by the registry entry _directory. ROMS_ROOT = "roms" +EMULATORS_ROOT = "emulators" +OUTER_ROOTS = (ROMS_ROOT, EMULATORS_ROOT) def standalone_layout_path(entry: dict, profile: dict) -> str | None: @@ -2905,8 +2909,9 @@ def platform_layouts( moved = {**f, "standalone_path": path, "layout_path": standalone_layout_path(f, profile)} # A file the launcher runs from the roms directory (ROCKNIX's - # roms/pico-8) is relative to it, and says so. - files.append({**moved, "root": ROMS_ROOT} if tree else moved) + # roms/pico-8) or an emulator folder (RetroBat's emulators/pico8) + # is relative to it, and says so. + files.append({**moved, "root": tree} if tree else moved) standalone = {**profile, "files": files} dual = {c.removeprefix(DUAL_MODE_PREFIX) for c in standalone_cores if c.startswith(DUAL_MODE_PREFIX)} @@ -3032,7 +3037,12 @@ def resolve_platform_cores( if cores_config == "all_libretro": result = every_libretro() elif isinstance(cores_config, list): - core_set = {str(c) for c in cores_config} + # An emulator the platform launches standalone runs there even when + # its own list names only the cores: Batocera's es_systems.yml files + # lexaloffle and gsplus as standalone emulators and nowhere else. + core_set = {str(c) for c in cores_config} | { + str(c) for c in config.get("standalone_cores") or [] + } index = upstream_profile_index(profiles) platform = str(config.get("_key") or "") diff --git a/scripts/cross_reference.py b/scripts/cross_reference.py index b1c5c1c50..fcbb0de1c 100644 --- a/scripts/cross_reference.py +++ b/scripts/cross_reference.py @@ -25,6 +25,7 @@ from pathlib import Path sys.path.insert(0, os.path.dirname(__file__)) from alternatives import set_keys, set_requirements, settle_sets from common import ( + OUTER_ROOTS, platform_arg, ProfileSelectionError, _foreign_tree, @@ -875,7 +876,7 @@ def _cross_reference_profile( # archive. # A platform declares files of its BIOS tree: none answers for one # the core reads below the save directory. - in_platform = not save_root and f.get("root") != "roms" and declaration_settles( + in_platform = not save_root and f.get("root") not in OUTER_ROOTS and declaration_settles( f, fname, platform_names, effective_path, index.get("folded_slots", frozenset()), index.get("declared_contents"), ) @@ -973,8 +974,8 @@ def _cross_reference_profile( entry["root"] = "save" elif f.get("unplaced"): entry["unplaced"] = f["unplaced"] - elif f.get("root") == "roms": - entry["root"] = "roms" + elif f.get("root") in OUTER_ROOTS: + entry["root"] = f["root"] elif index.get("launcher_candidates") is not None: # What verify hands the launcher layer for the same entry. probe = { diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 83abfa07b..4ed76e77a 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -39,7 +39,7 @@ sys.path.insert(0, os.path.dirname(__file__)) from alternatives import core_file_spares, settle_sets, slot_set_keys from common import resolve_platform_cores as _platform_cores from common import ( - ROMS_ROOT, + OUTER_ROOTS, installed_data_refs, platform_arg, require_directory, @@ -1309,9 +1309,10 @@ def generate_pack( continue if not dest: continue - if fe.get("root") == ROMS_ROOT: - # Read below the roms directory: the pack is extracted into - # the BIOS folder and cannot reach it, the installer places it. + if fe.get("root") in OUTER_ROOTS: + # Read below the roms directory or in an emulator folder: the + # pack is extracted into the BIOS folder and cannot reach + # them, the installer places these files. continue # Core extras: _collect_emulator_extras already adjusted # destinations for slug-based platforms. Apply the effective @@ -4508,11 +4509,11 @@ def _manifest_core_entries( # extras prefix when it is an internal layout dir (RetroDECK bios/). # A file read below the save directory is written relative to the # directory the core is handed, and says so (save_entry_fields). - roms_root = fe.get("root") == ROMS_ROOT + outer_root = fe.get("root") if fe.get("root") in OUTER_ROOTS else None if save_root: manifest_dest = save_manifest_dest(fe) - elif roms_root: - # Relative to the roms directory, which the entry names. + elif outer_root: + # Relative to the roms or emulators folder the entry names. manifest_dest = dest else: manifest_dest = _manifest_dest_of(full_dest, base_dest) @@ -4524,8 +4525,8 @@ def _manifest_core_entries( ) if entry is not None and save_root: entry.update(save_entry_fields(fe)) - elif entry is not None and roms_root: - entry["root"] = ROMS_ROOT + elif entry is not None and outer_root: + entry["root"] = outer_root if entry is None: if not _claim_platform_omission( omitted_by_destination.get(full_dest), source_emu @@ -4685,6 +4686,13 @@ 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 +# 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") + + def _record_unplaceable(unplaceable: list[dict], record_omission: Callable[..., None]) -> None: """Held by the collection, nowhere to put it on this platform. @@ -4694,8 +4702,8 @@ def _record_unplaceable(unplaceable: list[dict], record_omission: Callable[..., where = u.get("path") or u.get("name", "") if u.get("unplaced"): key = f"emulator:{u.get('profile') or u.get('emulator', '')}:{where}" - elif u.get("root") == ROMS_ROOT: - key = f"roms:{where}" + elif u.get("root") in OUTER_ROOTS: + key = f"{u['root']}:{where}" elif u.get("root") == "save": key = f"save:{save_manifest_dest(u)}" else: @@ -4887,10 +4895,10 @@ def generate_manifest( if outside: dest_fields: dict = {"dest": sanitize_pack_path(file_entry.get("path") or ""), "root": "emulator"} - elif file_entry.get("root") == ROMS_ROOT: + elif file_entry.get("root") in OUTER_ROOTS: dest_fields = {"dest": sanitize_pack_path( file_entry.get("destination") or file_entry.get("path") or "" - ), "root": ROMS_ROOT} + ), "root": file_entry["root"]} elif save_root: dest_fields = {"dest": save_manifest_dest(file_entry), **save_entry_fields(file_entry)} else: @@ -5250,12 +5258,15 @@ def generate_manifest( } if data_directories: result["data_directories"] = data_directories - roms = config.get("roms_directory") or {} - if any(entry.get("root") == ROMS_ROOT - for entry in [*manifest_files, *result["omitted_files"]]) and roms: - # Where the installer finds the roms directory: the folder holding - # the BIOS folder, which the platform names bios_folder. - result["roms_directory"] = {"bios_folder": str(roms.get("bios_folder") or "")} + for root in OUTER_ROOTS: + layout = config.get(f"{root}_directory") 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 + # holding the BIOS folder, which the platform names bios_folder. + result[f"{root}_directory"] = { + key: layout[key] for key in MANIFEST_OUTER_FIELDS if key in layout + } saved = [entry for entry in manifest_files if entry.get("root") == "save"] if saved: layout = save_layout(config) diff --git a/scripts/launchers.py b/scripts/launchers.py index ae879375d..75a25186d 100644 --- a/scripts/launchers.py +++ b/scripts/launchers.py @@ -329,6 +329,8 @@ EXECUTABLE_DIRECTORY = "executable directory" # relative to, and the roms directory of a platform whose registry names it. BIOS_TREE = "" ROMS_TREE = "roms" +# The emulator folders of a platform whose registry describes them. +EMULATORS_TREE = "emulators" # A folder of neither tree, which no pack and no installer can fill. OUTSIDE_TREE = "outside" Place = tuple[str, str] @@ -448,6 +450,22 @@ def folder_in_roms(directory: str, config: dict) -> str | None: return None if re.search(r"[\s<>$:;]", rest) else rest +def folder_in_emulators(directory: str, config: dict) -> str | None: + """The emulator folder a launcher names by its key, None for any other. + + Only a platform whose registry describes its emulator folders has one + (``emulators_directory.launcher_spelling``): RetroBat's generators take + each from AppConfig.GetFullPath(""), a folder of the RetroBat + install named after the key. + """ + spelling = str((config.get("emulators_directory") or {}).get("launcher_spelling") or "") + if "{key}" not in spelling: + return None + pattern = re.escape(spelling).replace(re.escape("{key}"), r"([\w.-]+)") + found = re.fullmatch(pattern, directory.strip()) + return found.group(1) if found else None + + @dataclass(frozen=True) class ExecutedFile: """A file a platform's launcher executes, and where it lies. @@ -474,6 +492,7 @@ def executed_files(config: dict) -> list[ExecutedFile]: raw = str(record.get("search_dir") or "").strip() directories = _directories(record, base) roms = folder_in_roms(raw, config) if raw and not directories else None + emulator_folder = folder_in_emulators(raw, config) if raw and not directories else None # A program file needs no extension (pico8, vox): the names are # taken as written, placeholders left out. for name in (str(n).strip() for n in record.get("names") or []): @@ -485,6 +504,8 @@ def executed_files(config: dict) -> list[ExecutedFile]: )) elif roms is not None: place = (ROMS_TREE, 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: place = None for emulator in (e.strip() for e in str(record.get("emulator", "")).split(",")): diff --git a/scripts/packextras.py b/scripts/packextras.py index f7da5e964..d665a5e4f 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 ROMS_ROOT, sanitize_pack_path +from common import OUTER_ROOTS, 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,22 +44,23 @@ def extra_full_destination(fe: dict, prefix: str) -> str: """ if fe.get("root") == "save": return str(fe.get("pack_path") or "") - if fe.get("root") == ROMS_ROOT: - return roms_destination(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", ""))) + ) return prefixed_destination( sanitize_pack_path(fe.get("destination", fe.get("name", ""))), prefix ) -# Keys a destination below the roms directory apart from the BIOS tree's -# paths. Never written into a pack: the pack is extracted into the BIOS -# folder, and the installer places these files (``root: roms``). -ROMS_KEY = "roms:" +def outer_destination(root: str, dest: str) -> str: + """The key of a destination below the roms or an emulator folder. - -def roms_destination(dest: str) -> str: - """The key of a destination below the roms directory.""" - return f"{ROMS_KEY}{dest}" + Kept apart from the BIOS tree's paths and never written into a pack: + the pack is extracted into the BIOS folder, and the installer places + these files (``root: roms``, ``root: emulators``). + """ + return f"{root}:{dest}" def prefixed_destination(dest: str, prefix: str) -> str: @@ -484,7 +485,7 @@ def unplaceable_extras( for u in undeclared: if not u.get("in_repo") and not u.get("unplaced"): continue - if (not placement.slug_based and u.get("root") not in ("save", ROMS_ROOT) + if (not placement.slug_based and u.get("root") not in ("save", *OUTER_ROOTS) and not u.get("unplaced")): continue dest = _extra_dest(u) @@ -572,7 +573,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") == ROMS_ROOT): + or f.get("root") in OUTER_ROOTS): 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 @@ -1023,9 +1024,12 @@ 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 the registry names, for a file a launcher runs - # from it (ROCKNIX's roms/pico-8). - self.roms = bool((config.get("roms_directory") or {}).get("path")) + # The roms directory and emulator folders the registry describes, + # 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.config = config self.norm_map = ( {_norm_system_id(sid): sid for sid in self.platform_systems} @@ -1046,8 +1050,9 @@ class ExtraPlacement: """ if entry.get("unplaced"): return None - if entry.get("root") == ROMS_ROOT: - return (dest, roms_destination(dest)) if self.roms else None + if entry.get("root") in OUTER_ROOTS: + root = entry["root"] + return (dest, outer_destination(root, dest)) if root in self.outer else None if entry.get("root") == "save": placed = self.save_places(entry, dest) return placed[0] if placed else None @@ -1108,8 +1113,8 @@ class ExtraPlacement: """Why ``place`` returned None for this entry.""" if entry.get("unplaced"): return str(entry["unplaced"]) - if entry.get("root") == ROMS_ROOT: - return "no_roms_directory" + if entry.get("root") in OUTER_ROOTS: + return f"no_{entry['root']}_directory" if entry.get("root") == "save": if self.save is None: return "no_save_directory" @@ -1334,7 +1339,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") == ROMS_ROOT): + or f.get("root") in OUTER_ROOTS): continue # Installed with the emulator or by the platform's package, # as the first pass already decided for the same entry. @@ -1575,8 +1580,8 @@ def _report_extra(u: dict, name: str, dest: str) -> dict: extra["root"] = "save" if u.get("core_folder") is not None: extra["core_folder"] = u["core_folder"] - elif u.get("root") == ROMS_ROOT: - extra["root"] = ROMS_ROOT + elif u.get("root") in OUTER_ROOTS: + extra["root"] = u["root"] if u.get("system_dir"): extra["system_dir"] = u["system_dir"] if u.get("layout_path"): diff --git a/scripts/verify.py b/scripts/verify.py index ac765c89a..9d703a646 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -46,6 +46,7 @@ from alternatives import ( slot_set_keys, ) from common import ( + OUTER_ROOTS, _foreign_tree, _owner_tokens, platform_arg, @@ -607,7 +608,7 @@ def find_undeclared_files( # of its own: two cores reading hiscore.dat there read # two files. ("save", core_save_folder(profile)) if save_root - else ("roms",) if f.get("root") == "roms" + else (f["root"],) if f.get("root") in OUTER_ROOTS # In the emulator's own folder: no other core reads it. else ("emulator", emu_name) if f.get("unplaced") else None, ) @@ -620,9 +621,9 @@ def find_undeclared_files( # Nor does it answer for one read below the roms directory. verdict = _candidate_verdict( f, fname, is_standalone, include_all, - {} if save_root or f.get("root") == "roms" else declared_names, + {} if save_root or f.get("root") in OUTER_ROOTS else declared_names, dest, folded_slots, - None if save_root or f.get("root") == "roms" else declared_contents, + None if save_root or f.get("root") in OUTER_ROOTS else declared_contents, ) if verdict == "settled": seen_files.add(seen_key) @@ -795,9 +796,10 @@ def find_undeclared_files( entry["layout_path"] = f["layout_path"] if f.get("unplaced"): entry["unplaced"] = f["unplaced"] - elif f.get("root") == "roms": - # Read below the roms directory (ROCKNIX's roms/pico-8). - entry["root"] = "roms" + elif f.get("root") in OUTER_ROOTS: + # Read below the roms directory (ROCKNIX's roms/pico-8) + # or in an emulator folder (RetroBat's emulators/pico8). + entry["root"] = f["root"] elif launcher_layer and not save_root: launcher_candidates.append((emu_name, is_standalone, f, entry)) keys = set_keys((emu_name, is_standalone), f) @@ -975,6 +977,7 @@ def find_exclusion_notes( _UNPLACED_REASONS = { "outside_bios_folder": "read in a folder the platform keeps outside the BIOS tree", "no_roms_directory": "read below a roms folder the platform's registry does not name", + "no_emulators_directory": "read in an emulator folder the platform's registry does not describe", "no_platform_slug": "no platform slug for its system", "below_platform_folder": "below the folder the platform lists", "no_save_directory": "read below a save directory the platform keeps in no fixed place", @@ -998,11 +1001,12 @@ def _mark_unplaced(config: dict, undeclared: list[dict], profiles: dict) -> None reason = unplaced.get( (u.get("emulator", ""), u.get("name", ""), u.get("path") or "") ) - if not reason and u.get("root") == "roms": + if not reason and u.get("root") in OUTER_ROOTS: # The pack is extracted into the BIOS folder: the installer - # writes this file into the roms folder, the pack cannot. + # writes this file into the roms or emulator folder, the pack + # cannot. u["in_pack"] = False - u["omitted"] = "read below the roms folder, which only the installer reaches" + u["omitted"] = f"read below the {u['root']} folder, which only the installer reaches" continue if reason: u["in_pack"] = False diff --git a/tests/test_launcher_aliases.py b/tests/test_launcher_aliases.py index b5a3ee7e9..76d1456ff 100644 --- a/tests/test_launcher_aliases.py +++ b/tests/test_launcher_aliases.py @@ -57,7 +57,8 @@ NAMED_FOR_A_LAUNCHER = { # Profiles whose entries still rest on a launcher, with the fact that blocks # reading the emulator itself. LAUNCHER_SOURCED = { - "lexaloffle": "PICO-8 and Voxatron are sold binaries, and no copy of either is held to read", + "lexaloffle": "PICO-8 is a sold binary, and no copy is held to read", + "voxatron": "Voxatron is a sold binary, and no copy is held to read", } _SENTENCE = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9(\"'])") diff --git a/tests/test_launcher_placement.py b/tests/test_launcher_placement.py index 94466b5ec..50cafe33c 100644 --- a/tests/test_launcher_placement.py +++ b/tests/test_launcher_placement.py @@ -262,6 +262,7 @@ class AProgramRunFromItsFolder(unittest.TestCase): "biosplat": [{**run, "names": ["prog/prog"], "search_dir": "/userdata/bios"}], "romsplat": [{**run, "names": ["prog"], "search_dir": "/storage/roms/prog"}], "exeplat": [{**run, "names": ["prog"], "search_dir": ""}], + "emuplat": [{**run, "names": ["prog"], "search_dir": ''}], "plainplat": [], } for name, records in launchers.items(): @@ -275,6 +276,10 @@ class AProgramRunFromItsFolder(unittest.TestCase): **{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"}}, }})) (self.emulators / "prog.yml").write_text(yaml.safe_dump({ "emulator": "Prog", "type": "standalone", "cores": ["prog"], @@ -363,6 +368,71 @@ class AProgramRunFromItsFolder(unittest.TestCase): with self.assertRaises(ValueError): install._check_roms_directory({"files": files}) + def test_run_from_an_emulator_folder_the_registry_describes(self) -> None: + self.assertEqual(self.pack("emuplat"), []) + manifest = self.manifest("emuplat") + self.assertEqual( + sorted((f["dest"], f.get("root")) for f in manifest["files"]), + [("prog/prog", "emulators"), ("prog/prog.dat", "emulators")], + ) + self.assertEqual(manifest["emulators_directory"]["default"], "emulators/{key}") + self.assertTrue(all(u["in_pack"] is False for u in self.verify("emuplat"))) + + def test_the_installer_follows_the_launcher_search(self) -> None: + import install + + manifest = self.manifest("emuplat") + install._check_emulators_directory(manifest) + files = list(manifest["files"]) + home = self.root / "Frontend" + bios = home / "bios" + bios.mkdir(parents=True) + # Not the platform's folder: no launcher config beside it. + kept, left = install.place_emulator_entries(files, bios, manifest, "emuplat", say=False) + self.assertEqual((kept, {o["reason"] for o in left}), ([], {"no_emulators_directory"})) + (home / "frontend").mkdir() + (home / "frontend" / "launcher.cfg").write_text("other=x\n") + kept, left = install.place_emulator_entries(files, bios, manifest, "emuplat", say=False) + self.assertEqual((len(kept), left), (2, [])) + entry = next(f for f in kept if f["dest"] == "prog/prog") + self.assertEqual(install._entry_place(bios, entry), (home / "emulators" / "prog", "prog")) + # The first searched place that exists wins. + (home / "frontend" / "prog").mkdir() + install.place_emulator_entries(files, bios, manifest, "emuplat", say=False) + self.assertEqual(install._entry_place(bios, entry)[0], home / "frontend" / "prog") + # A key the launcher config sets moves the folder: left out. + (home / "frontend" / "launcher.cfg").write_text("prog=D:\\Prog\n") + kept, left = install.place_emulator_entries(files, bios, manifest, "emuplat", say=False) + self.assertEqual((kept, len(left)), ([], 2)) + + +class EachProgramItsOwnProfile(unittest.TestCase): + """PICO-8 and Voxatron are two programs: a platform running one gets its files only.""" + + def test_only_the_platforms_that_run_voxatron_get_it(self) -> None: + from common import list_registered_platforms, resolve_platform_cores + + profiles = load_emulator_profiles(str(REPO_ROOT / "emulators")) + runs = { + name: {k for k in ("lexaloffle", "voxatron") + if k in resolve_platform_cores( + load_platform_config(name, str(REPO_ROOT / "platforms")), profiles)} + for name in list_registered_platforms(str(REPO_ROOT / "platforms")) + } + self.assertEqual( + {name for name, found in runs.items() if "voxatron" in found}, {"batocera", "knulli"} + ) + self.assertTrue(all("lexaloffle" in runs[name] for name in ( + "batocera", "knulli", "rocknix", "retrodeck", "retrobat", "recalbox"))) + + def test_a_standalone_emulator_is_run(self) -> None: + from common import resolve_platform_cores + + profiles = {"emu": {"emulator": "Emu", "type": "standalone", "cores": ["emu"], + "systems": ["sys"], "files": []}} + config = {"cores": ["other"], "standalone_cores": ["emu"], "systems": {}} + self.assertEqual(resolve_platform_cores(config, profiles), {"emu"}) + if __name__ == "__main__": unittest.main() diff --git a/wiki/adding-a-platform.md b/wiki/adding-a-platform.md index e307714b3..ac987bcfd 100644 --- a/wiki/adding-a-platform.md +++ b/wiki/adding-a-platform.md @@ -222,7 +222,11 @@ directory: RetroPie starts UAE4All2 in `$md_inst`, whose `kickstarts` it links t `BIOS/amiga`. A profile declaring `executable directory` is bound where a record executes one of its files: Batocera runs `pico-8/pico8` from `bios`, ROCKNIX `pico8_64` from `/storage/roms/pico-8`, below the `roms_directory` its registry entry -names, which the manifest writes as `root: roms`. Two records giving two folders +names, which the manifest writes as `root: roms`. RetroBat runs `pico8.exe` from an +emulator folder its generators name by key: the registry's `emulators_directory` +spells that key and lists the places the launcher searches, and the manifest writes +`root: emulators`, which the installer places only beside RetroBat's own launcher +config. Two records giving two folders leave the profile's layout. Once a platform binds part of a layout, an entry no binding covers is read in a folder outside the BIOS tree: it is never packed, and the manifest names it (`root: emulator`, `outside_bios_folder`).