diff --git a/emulators/azahar.yml b/emulators/azahar.yml index 1bc379f77..5324d187d 100644 --- a/emulators/azahar.yml +++ b/emulators/azahar.yml @@ -19,6 +19,9 @@ cores: systems: - nintendo-3ds +standalone_directories: + - "$XDG_DATA_HOME/azahar-emu" + - "$HOME/.local/share/azahar-emu" data_root: windows: path: "%APPDATA%/Azahar" diff --git a/emulators/eden.yml b/emulators/eden.yml index dda4dfba1..2f8b58c8e 100644 --- a/emulators/eden.yml +++ b/emulators/eden.yml @@ -60,6 +60,9 @@ files: note: "Amiibo decryption keys (two InternalKey structs, 0x50 bytes each)" source_ref: "src/core/hle/service/nfc/common/amiibo_crypto.cpp:281-302" +standalone_directories: + - "$XDG_DATA_HOME/eden" + - "$HOME/.local/share/eden" data_root: windows: path: "%APPDATA%/eden" diff --git a/emulators/lexaloffle.yml b/emulators/lexaloffle.yml index 28f058674..341a1e4a9 100644 --- a/emulators/lexaloffle.yml +++ b/emulators/lexaloffle.yml @@ -24,12 +24,19 @@ cores: 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. + Each 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. + 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). pico-8_0.2.7_raspi.zip holds pico8 (armhf, SDL2 linked in, needs the @@ -67,7 +74,7 @@ notes: | component_manifest.json lists pico8, pico8.dat and pico8_dyn as required. - RetroBat Pico8.Generator.cs runs pico8.exe from the folder its config names pico8 and returns nothing when pico8.exe is absent. That folder is - an emulator install, outside the BIOS tree, so no entry below targets it. + an emulator install, outside the BIOS tree. - ArkOS (dArkOS pico8/pico8.sh) prefers roms/pico-8/pico8_64, then roms/pico-8/pico8_dyn, then extracts pico8_64 and pico8.dat from a *raspi.zip in that folder, and starts Fake08 when the binary or @@ -137,13 +144,13 @@ files: path: pico-8/pico8_dyn system: pico8 required: false - unread_on: [amberelec, emuelec, knulli, recalbox, retrobat, rocknix] + unread_on: [amberelec, emuelec, knulli, recalbox, retrobat] size: 1805928 md5: 724435a9e6f3ef8a55c648fc88651826 sha1: 9b2e573250c70382b4071709a85c8d56c0371d59 description: "PICO-8 0.2.7 x86_64 executable, system libSDL2" - note: "The pico8_dyn of pico-8_0.2.7_amd64.zip, the same program linked against the system SDL2. RetroDECK lists it as required in its manifest, but its launcher executes pico8 only. Batocera never runs it." - source_ref: "pico-8 0.2.7 amd64 pico8_dyn, NEEDED libSDL2-2.0.so.0, RetroDECK pico-8/component_manifest.json:26-32, RetroDECK pico-8/component_launcher.sh:3" + note: "The pico8_dyn of pico-8_0.2.7_amd64.zip, the same program linked against the system SDL2. RetroDECK lists it as required in its manifest, but its launcher executes pico8 only. Batocera never runs it. ROCKNIX runs it from roms/pico-8/ on x86_64, its HW_ARCH other than aarch64." + source_ref: "pico-8 0.2.7 amd64 pico8_dyn, NEEDED libSDL2-2.0.so.0, RetroDECK pico-8/component_manifest.json:26-32, RetroDECK pico-8/component_launcher.sh:3, rocknix projects/ROCKNIX/packages/emulators/standalone/pico-8/scripts/start_pico8.sh:10-17, 48" - name: pico8_dyn path: pico-8/pico8_dyn @@ -173,14 +180,37 @@ files: path: pico-8/pico8.dat system: pico8 required: true - unread_on: [retrobat] size: 11243101 md5: 10b397305093c80109096319a538ba56 sha1: 0f92093752bddb12210c7db6d93af3ba2413cfce description: "PICO-8 0.2.7 data file (boot sequence, themes, demo carts)" - note: "PICO-8 opens it from its own folder and quits with \"couldn't load /pico8.dat\" when it is absent (the amd64 pico8, run without it). Paired with the binary's own version, identical in pico-8_0.2.7_amd64.zip and pico-8_0.2.7_raspi.zip. AmberELEC and ArkOS check for it before starting PICO-8, and KNULLI's installer stops when the archive lacks it." + note: "PICO-8 opens it from its own folder and quits with \"couldn't load /pico8.dat\" when it is absent (the amd64 pico8, run without it). Paired with the binary's own version, identical in pico-8_0.2.7_amd64.zip, pico-8_0.2.7_raspi.zip and pico-8_0.2.7_windows.zip. AmberELEC and ArkOS check for it before starting PICO-8, and KNULLI's installer stops when the archive lacks it." source_ref: "pico-8 0.2.7 amd64 pico8, string pico8.dat at 0x231bc0, pico-8 0.2.7 raspi pico8_64, string pico8.dat at 0x110138, amberelec packages/amberelec/sources/pico-8.sh:51-52, dArkOS pico8/pico8.sh:136-142, knulli board/fsoverlay/usr/bin/knulli-install-pico8:11, 48-51, 69" + - name: pico8.exe + path: pico-8/pico8.exe + system: pico8 + required: true + unread_on: [amberelec, batocera, emuelec, knulli, recalbox, retrodeck, rocknix] + size: 2171788 + md5: 55b1b9db521ffd0cea0995174bd12a2b + sha1: 1d1b504d3283d750869a93ed9befa1e49928d68e + description: "PICO-8 0.2.7 Windows executable" + note: "The pico8.exe of pico-8_0.2.7_windows.zip. RetroBat runs it from its pico8 emulator folder and starts nothing when it is absent." + source_ref: "retrobat emulatorLauncher/Generators/Pico8.Generator.cs:14-18, 56-57" + + - name: SDL2.dll + path: pico-8/SDL2.dll + system: pico8 + required: true + unread_on: [amberelec, batocera, emuelec, knulli, recalbox, retrodeck, rocknix] + size: 1088000 + md5: 098c661f98cd7fd85a82ebc34db5be15 + sha1: 9174251921d675c204e683cdea65a8c906fcba28 + description: "SDL2 library of the PICO-8 0.2.7 Windows build" + note: "pico8.exe imports SDL2.dll, which Windows loads from the executable's folder. Shipped beside it in pico-8_0.2.7_windows.zip." + source_ref: "pico-8 0.2.7 windows pico8.exe, import table" + - name: config.txt unsourceable: "written by the emulator on exit, no canonical file" system: pico8 diff --git a/emulators/ryujinx.yml b/emulators/ryujinx.yml index 50fc96065..74a9b5b43 100644 --- a/emulators/ryujinx.yml +++ b/emulators/ryujinx.yml @@ -16,6 +16,9 @@ systems: [nintendo-switch] analysis_date: "2026-03-26" mode: standalone +standalone_directories: + - "$XDG_CONFIG_HOME/Ryujinx" + - "$HOME/.config/Ryujinx" data_root: windows: path: "%APPDATA%/Ryujinx" diff --git a/emulators/uae4all.yml b/emulators/uae4all.yml index 739c6bd9f..43a4a115c 100644 --- a/emulators/uae4all.yml +++ b/emulators/uae4all.yml @@ -16,6 +16,8 @@ display_name: "Commodore - Amiga (UAE4All2)" cores: [uae4all] systems: [commodore-amiga] verification: existence +standalone_directories: + - "getcwd" notes: | UAE4All2 is Lubomyr's Android and Pandora Amiga emulator. RetroPie builds @@ -26,9 +28,11 @@ notes: | com.cloanto.amigaforever.essentials is therefore compiled out. AndroidAppSettings.cfg:178 names the version 2.3.6.2. - The emulator runs from its install directory and takes it as launchDir - (src/main.cpp:219). Kickstarts are read from launchDir/kickstarts/, which - RetroPie links to the BIOS directory's amiga/ folder + launchDir is the working directory (getcwd, src/main.cpp:219), and the + Kickstarts and rom.key are read from launchDir/kickstarts/ + (src/main.cpp:95-111). Paths below are relative to the working directory. + RetroPie starts the emulator in its install directory and links + kickstarts/ there to the BIOS directory's amiga/ folder (scriptmodules/emulators/uae4all.sh, configure_uae4all). The kickstart choice indexes kickstarts_rom_names and extended_rom_names @@ -54,37 +58,36 @@ notes: | files: - name: "kick13.rom" - path: "amiga/kick13.rom" + path: "kickstarts/kick13.rom" system: commodore-amiga description: "Kickstart 1.3, the default selection" required: false source_ref: "upstream src/memory.cpp:34, source src/main.cpp:1, source src/main.cpp:95-103" - name: "kick12.rom" - path: "amiga/kick12.rom" + path: "kickstarts/kick12.rom" system: commodore-amiga description: "Kickstart 1.2" required: false source_ref: "upstream src/memory.cpp:34" - name: "kick20.rom" - path: "amiga/kick20.rom" + path: "kickstarts/kick20.rom" system: commodore-amiga description: "Kickstart 2.0" required: false source_ref: "upstream src/memory.cpp:34" - name: "kick31.rom" - path: "amiga/kick31.rom" + path: "kickstarts/kick31.rom" system: commodore-amiga description: "Kickstart 3.1" required: false source_ref: "upstream src/memory.cpp:34" - name: "aros-amiga-m68k-rom.bin" - path: "amiga/aros-amiga-m68k-rom.bin" + path: "kickstarts/aros-amiga-m68k-rom.bin" bundled: true - not_bundled_on: [recalbox] system: commodore-amiga description: "AROS Kickstart replacement, main ROM (build of 2014-01-10)" required: false @@ -96,9 +99,8 @@ files: note: "Hashes are of the file in AndroidData/aros20140110.zip in the source tree. RetroPie unpacks that zip into the BIOS folder on install." - name: "aros-amiga-m68k-ext.bin" - path: "amiga/aros-amiga-m68k-ext.bin" + path: "kickstarts/aros-amiga-m68k-ext.bin" bundled: true - not_bundled_on: [recalbox] system: commodore-amiga description: "AROS Kickstart replacement, extended ROM (build of 2014-01-10)" required: false @@ -110,8 +112,23 @@ files: note: "Loaded with the AROS main ROM only. Hashes are of the file in AndroidData/aros20140110.zip in the source tree." - name: "rom.key" - path: "amiga/rom.key" + path: "kickstarts/rom.key" system: commodore-amiga description: "Cloanto key for AMIROMTYPE1 encrypted Kickstarts" required: false source_ref: "upstream src/main.cpp:106-111, source src/memory.cpp:732-776" + + - name: "kick.rom" + path: "kick.rom" + system: commodore-amiga + description: "Custom Kickstart, the file the custom choice opens until the user picks another" + required: false + source_ref: "source src/gp2x/menu/menu_config.cpp:139, source src/gp2x/menu/menu_config.cpp:1243-1246, source src/menu_guichan/menu_guichan.cpp:91-99, source src/menu_guichan/menu_guichan.cpp:1004-1006, source src/main.cpp:100-104" + note: "Read from the working directory. default_prefs also falls back to it when the selected Kickstart is missing from kickstarts/." + + - name: "rom.key" + path: "rom.key" + system: commodore-amiga + description: "Cloanto key, read from the working directory when kickstarts/rom.key is absent" + required: false + source_ref: "source src/main.cpp:106-111, source src/memory.cpp:732-776" diff --git a/emulators/yuzu.yml b/emulators/yuzu.yml index 62228a3ea..77ec2ef35 100644 --- a/emulators/yuzu.yml +++ b/emulators/yuzu.yml @@ -56,6 +56,9 @@ files: note: "Amiibo decryption keys (two InternalKey structs, 0x50 bytes each)" source_ref: "src/core/hle/service/nfc/common/amiibo_crypto.cpp:273-294" +standalone_directories: + - "$XDG_DATA_HOME/yuzu" + - "$HOME/.local/share/yuzu" data_root: windows: path: "%APPDATA%/yuzu" diff --git a/install.ps1 b/install.ps1 index df69b308a..e026762eb 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 = "c2265e2ff8fae9b485f195aa47c4a487f16a287ab6ad2f8f8a133bfede96c983" + $defaultInstallSha256 = "3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 159c0f571..d13291ee6 100644 --- a/install.py +++ b/install.py @@ -141,6 +141,8 @@ OMISSION_REASONS = { "no_save_directory": "no save directory", "no_core_folder": "no core folder", "no_save_system": "no system runs the core", + "outside_bios_folder": "read outside the BIOS folder", + "no_roms_directory": "no roms 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. @@ -3590,6 +3592,8 @@ class SavePlace(NamedTuple): # BIOS folder -> where the files read below its frontend's save directory go, # set once the save directory is known (save_directory_for). _SAVE_DIRS: dict[Path, SavePlace] = {} +# BIOS folder -> the platform's roms folder, set by place_roms_entries. +_ROMS_DIRS: dict[Path, Path] = {} def _entry_place(root: Path, entry: dict) -> tuple[Path, object]: @@ -3600,6 +3604,11 @@ def _entry_place(root: Path, entry: dict) -> tuple[Path, object]: core's own folder where the frontend sorts saves by core. Any other is relative to the BIOS folder. """ + if entry.get("root") == "roms": + roms = _ROMS_DIRS.get(root) + if roms is None: + raise ValueError(f"no roms folder known for {entry.get('dest')}") + return roms, entry.get("dest") if entry.get("root") != "save": return root, entry.get("dest") place = _SAVE_DIRS.get(root) @@ -3625,17 +3634,24 @@ def _entry_path(root: Path, entry: dict) -> Path: def _entry_key(entry: dict) -> str: """What tells one entry of the list from another: its dest, below the - BIOS folder or below the save directory. KNULLI lists its own - fbneo/hiscore.dat in the BIOS tree and FB Alpha 2012's below the save - directory, two files under one dest. NUL never appears in a dest.""" + BIOS folder, below the save directory or in an emulator's own folder. + KNULLI lists its own fbneo/hiscore.dat in the BIOS tree and FB Alpha + 2012's below the save directory, two files under one dest. NUL never + appears in a dest.""" dest = str(entry.get("dest")) - return f"save\0{dest}" if entry.get("root") == "save" else dest - - -def _check_root(entry: dict, dest: str) -> None: - """root is absent or save, and core_folder one folder name of a save entry.""" root = entry.get("root") - if root is not None and root != "save": + 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 + + +def _check_root(entry: dict, dest: str, omitted: bool = False) -> None: + """root is absent or save, emulator only on a file left out (a path in + 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"): raise ValueError(f"invalid root for {dest}") folder = entry.get("core_folder") if folder is None: @@ -3646,6 +3662,25 @@ def _check_root(entry: dict, dest: str) -> None: raise ValueError(f"core_folder must be one folder name for {dest}") +def _check_roms_directory(data: dict) -> None: + """The manifest's roms_directory, which every installable root: roms entry needs.""" + layout = data.get("roms_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") == "roms" for entry in installable): + raise ValueError("entries below the roms folder and no roms_directory") + return + if ( + not isinstance(layout, dict) + or set(layout) != {"bios_folder"} + or len(_safe_relative_path(layout["bios_folder"], "roms_directory.bios_folder").parts) != 1 + ): + raise ValueError("invalid roms_directory") + + def _check_save_directory(data: dict) -> None: """The manifest's save_directory, which every root: save entry needs.""" layout = data.get("save_directory") @@ -3949,6 +3984,7 @@ def _validate_manifest(data: object, plat: str) -> dict: for index, entry in enumerate(data_dirs): _check_data_directory(entry, f"data_directories[{index}]") _check_save_directory(data) + _check_roms_directory(data) declared_total_files = data.get("total_files") if declared_total_files is not None and declared_total_files != len(files): @@ -3974,7 +4010,7 @@ def _validate_manifest(data: object, plat: str) -> dict: raise ValueError(f"duplicate or conflicting omitted destination: {dest}") seen_omitted.add(key) _check_omission(entry, dest) - _check_root(entry, dest) + _check_root(entry, dest, omitted=True) claims = entry.get("alternatives", []) if not isinstance(claims, list) or len(claims) > MAX_SELECTION_TAGS: raise ValueError(f"invalid omitted alternatives for {dest}") @@ -5214,6 +5250,36 @@ def place_save_entries( return kept, unplaced +def place_roms_entries( + files: list[dict], bios_path: Path, manifest: dict, display: str, say: bool = True, +) -> 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. + """ + 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 + return files, [] + if say: + print(f" {_plural(len(below), 'file')} read from {display}'s roms folder left out: " + f"{bios_path} is not its {layout.get('bios_folder') or 'BIOS'} folder.") + kept = [entry for entry in files if entry.get("root") != "roms"] + unplaced = [ + {"dest": entry["dest"], "name": PurePosixPath(entry["dest"]).name, + "system": "", "systems": list(entry.get("systems") or []), "required": False, + "reason": "no_roms_directory", "cores": entry.get("cores"), "root": "roms"} + for entry in below + ] + 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] = {} @@ -15827,6 +15893,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: display = display_name(plat_name, manifest) 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] 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 a74c26223..61a73ba03 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="c2265e2ff8fae9b485f195aa47c4a487f16a287ab6ad2f8f8a133bfede96c983" +DEFAULT_INSTALL_SHA256="3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index d428aa2ec..ee265ccc1 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": 1130265, - "sha256": "c2265e2ff8fae9b485f195aa47c4a487f16a287ab6ad2f8f8a133bfede96c983", + "size": 1133449, + "sha256": "3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1183890, - "sha256": "0d0b07197b3dd080ee41d67fe055881ab7c549f769861ada39e58ed8dda04e5a" + "size": 1187074, + "sha256": "f98b3e8613e5572430097d4a0133d914986d17c47999020459a8555695a9a427" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1130265, - "sha256": "c2265e2ff8fae9b485f195aa47c4a487f16a287ab6ad2f8f8a133bfede96c983", + "size": 1133449, + "sha256": "3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1130265, - "sha256": "c2265e2ff8fae9b485f195aa47c4a487f16a287ab6ad2f8f8a133bfede96c983", + "size": 1133449, + "sha256": "3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0", "executable": false }, { diff --git a/platforms/_registry.yml b/platforms/_registry.yml index 35eaa4b1d..437db5221 100644 --- a/platforms/_registry.yml +++ b/platforms/_registry.yml @@ -843,6 +843,13 @@ 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/platforms/retrobat.yml b/platforms/retrobat.yml index 736923af7..5786bc1a0 100644 --- a/platforms/retrobat.yml +++ b/platforms/retrobat.yml @@ -4956,6 +4956,16 @@ launchers: fails_when_missing: ApplicationException 'No valid PS2 BIOS file found in ''.' depends_on_option: lrps2_bios unset source_ref: "emulatorLauncher/Generators/LibRetro.CoreOptions.cs:3662-3683, 3708-3713" +- emulator: pico8 + build: standalone + kind: fixed_name + names: + - pico8.exe + case_insensitive: true + search_dir: "" + passes_to: exec + fails_when_missing: returns no process when the binary is absent + source_ref: "emulatorLauncher/Generators/Pico8.Generator.cs:14-18, 56-57" - emulator: bizhawk build: standalone kind: names_searched diff --git a/platforms/retropie.yml b/platforms/retropie.yml index 2402aa0dc..cc54b45a2 100644 --- a/platforms/retropie.yml +++ b/platforms/retropie.yml @@ -1181,6 +1181,15 @@ launchers: fails_when_missing: false depends_on_option: "[[ ! -f \"$biosdir/aros-amiga-m68k-ext.bin\" ]]" source_ref: "scriptmodules/emulators/uae4all.sh:85" +- emulator: uae4all + package: uae4all + build: standalone + kind: passes_path + names: [] + search_dir: $md_inst + passes_to: working directory of $romdir/amiga/+Start UAE4All.sh + fails_when_missing: false + source_ref: "scriptmodules/emulators/uae4all.sh:90" - emulator: uae4arm package: uae4arm repo: "git https://github.com/Chips-fr/uae4arm-rpi.git master" diff --git a/platforms/rocknix.yml b/platforms/rocknix.yml index c399951a7..88a0f42fa 100644 --- a/platforms/rocknix.yml +++ b/platforms/rocknix.yml @@ -736,6 +736,19 @@ launchers: source_ref: "projects/ROCKNIX/packages/emulators/standalone/armsx2-sa/config/S922X/ARMSX2/inis/PCSX2.ini:21,\ \ 475, projects/ROCKNIX/packages/emulators/standalone/armsx2-sa/config/inputplumber/ARMSX2/inis/PCSX2.ini:21,\ \ 473, projects/ROCKNIX/packages/emulators/standalone/armsx2-sa/package.mk:117-118" +- emulator: pico-8 + build: standalone + kind: fixed_name + names: + - pico8_64 + - pico8_dyn + search_dir: /storage/roms/pico-8 + passes_to: exec + fails_when_missing: false + depends_on_option: "HW_ARCH aarch64 runs pico8_64, any other pico8_dyn, from /storage/roms/pico-8/${HW_ARCH}\ + \ when that folder exists" + source_ref: "projects/ROCKNIX/packages/emulators/standalone/pico-8/scripts/start_pico8.sh:8-15,\ + \ 35-40, 48" - emulator: flycast build: standalone kind: passes_path diff --git a/schemas/install-manifest.schema.json b/schemas/install-manifest.schema.json index d60fb84b0..160c841d8 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, or below the save directory when root is save." + "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." }, "name": { "type": "string", @@ -204,7 +204,9 @@ "below_platform_folder", "no_save_directory", "no_core_folder", - "no_save_system" + "no_save_system", + "outside_bios_folder", + "no_roms_directory" ], "description": "Why the file is left out, as a code. enumDescriptions gives the meaning of each code, in the order of enum.", "enumDescriptions": [ @@ -216,7 +218,9 @@ "The platform lists each system folder flat (firmware_folder_flat) and the file would sit in a subfolder it does not read.", "The core reads the file below the save directory and the platform has no save directory a pack can fill.", "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 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." ] }, "category": { @@ -361,8 +365,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. Absent for a file of the BIOS tree.", - "const": "save" + "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.", + "enum": [ + "save", + "emulator", + "roms" + ] }, "core_folder": { "description": "The core's library_name, the folder a frontend sorting saves by core (RetroArch's sort_savefiles_enable) inserts between its save directory and dest. Only with root: save. Absent on a save entry when the core reads the same place sorted or not.", @@ -515,6 +523,21 @@ "const": true } } + }, + "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).", + "type": "object", + "additionalProperties": false, + "required": [ + "bios_folder" + ], + "properties": { + "bios_folder": { + "type": "string", + "minLength": 1, + "pattern": "^[^/\\\\]+$" + } + } } }, "$defs": { @@ -533,7 +556,7 @@ "dest": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$", - "description": "Path below base_destination, or below the save directory when root is save." + "description": "Path below base_destination, below the save directory when root is save, or below the roms folder when root is roms." }, "sha1": { "type": "string", @@ -1016,8 +1039,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. Absent for a file of the BIOS tree.", - "const": "save" + "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.", + "enum": [ + "save", + "roms" + ] }, "core_folder": { "description": "The core's library_name, the folder a frontend sorting saves by core (RetroArch's sort_savefiles_enable) inserts between its save directory and dest. Only with root: save. Absent on a save entry when the core reads the same place sorted or not.", diff --git a/scripts/common.py b/scripts/common.py index 3b9e25538..fd188f602 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -1482,6 +1482,29 @@ def resolve_local_file( system_dir = file_entry.get("system_dir") if system_dir and dest_hint.startswith(f"{system_dir}/"): dest_hint = dest_hint[len(system_dir) + 1:] + # A launcher that moves the layout (EmuDeck links azahar's sysdata to + # bios/azahar/keys) changes where the file goes, not the tail the + # collection indexes it under. Both are asked, and the stronger proof + # wins: a content or path match over a name, the destination on a tie. + layout = file_entry.get("layout_path") + if layout and layout != dest_hint: + plain = {k: v for k, v in file_entry.items() if k != "layout_path"} + answers = [ + resolve_local_file( + plain, db, zip_contents, dest_hint=hint, _depth=_depth, + data_dir_registry=data_dir_registry, + ) + for hint in (dest_hint, str(layout)) + ] + for found in answers: + if found[0] is not None and ( + resolution_is_hash_exact(found[1]) or found[1] == "path_exact" + ): + return found + for found in answers: + if found[0] is not None and found[1] not in ("not_found", "hash_mismatch"): + return found + return next((found for found in answers if found[0] is not None), answers[0]) # A name written with its directory is the destination when no path is # given: gam4980/E.BIN resolved by its basename took another model's OS # in verify --emulator and the emulator pack, while the platform pack, @@ -2624,6 +2647,9 @@ HANDED_PREFIX = "handed:" # Marks, in the same set, a libretro core and the system directory the # platform hands it (``core_system_dirs``), as JSON. CORE_DIR_PREFIX = "coredir:" +# Marks, in the same set, a file the platform's launcher executes +# (launchers.executed_files), as JSON. +EXECUTED_PREFIX = "exec:" def core_system_dirs( @@ -2689,14 +2715,19 @@ def standalone_set(config: dict) -> set[str]: for core in entry.get("cores") or [] } if names - {n for n in names if n.startswith(CORE_DIR_PREFIX)}: - from launchers import handed_directories # noqa: PLC0415 + from launchers import executed_files, handed_directories # noqa: PLC0415 names |= { HANDED_PREFIX + json.dumps( - [h.emulator, h.folder, h.directory, sorted(h.handles), h.source_ref] + [h.emulator, h.folder, h.directory, sorted(h.handles), h.source_ref, + h.roms_folder] ) for h in handed_directories(config) } + names |= { + EXECUTED_PREFIX + json.dumps([run.emulator, run.name, run.place, run.source_ref]) + for run in executed_files(config) + } return names @@ -2724,38 +2755,81 @@ def _handed(standalone_cores: Iterable[str]) -> list: for tag in standalone_cores: if not tag.startswith(HANDED_PREFIX): continue - emulator, folder, directory, handles, ref = json.loads(tag[len(HANDED_PREFIX):]) - found.append(HandedDirectory(emulator, folder, directory, frozenset(handles), ref)) + emulator, folder, directory, handles, ref, roms = json.loads(tag[len(HANDED_PREFIX):]) + found.append(HandedDirectory(emulator, folder, directory, frozenset(handles), ref, roms)) return found -def standalone_destination(entry: dict, root: str | None) -> str: - """Where a standalone build reads an entry, relative to the BIOS root. +def _executed(standalone_cores: Iterable[str]) -> list: + """The files a standalone set's launchers execute, as launchers read them.""" + from launchers import ExecutedFile # noqa: PLC0415 - ``standalone_path`` is relative to the directories the build resolves - it against (``standalone_directories``). A platform that hands one of - them a folder of its BIOS tree puts the file under that folder: - RetroPie and ROCKNIX give Flycast bios/dc, EmuDeck bios/flycast/bios. - An entry with no ``standalone_path`` keeps ``path``, the layout both - builds share. + found = [] + for tag in standalone_cores: + if tag.startswith(EXECUTED_PREFIX): + emulator, name, place, ref = json.loads(tag[len(EXECUTED_PREFIX):]) + found.append(ExecutedFile(emulator, name, tuple(place) if place else None, ref)) + return found + + +# 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. +ROMS_ROOT = "roms" + + +def standalone_layout_path(entry: dict, profile: dict) -> str | None: + """The path of an entry relative to the directories its standalone build declares. + + ``standalone_path`` when the entry has one. A profile with no libretro + build has one layout, so its ``path`` is that layout: UAE4All2 reads + kickstarts/kick13.rom below its working directory. """ own = entry.get("standalone_path") - if own and root: - return f"{root}/{own}" - return own or entry.get("path") or entry.get("name", "") + if own: + return own + if "libretro" in str(profile.get("type", "")): + return None + return entry.get("path") or None -def standalone_root_for( +def standalone_destination( + entry: dict, profile: dict, bindings: dict[str, tuple[str, str]] +) -> tuple[str, str] | None: + """Where a platform puts a standalone entry: (tree, path). + + ``bindings`` are the places the platform's launchers give the layout + (launchers.standalone_bindings): RetroPie and ROCKNIX give Flycast bios/dc, + EmuDeck bios/flycast/bios, RetroPie gives UAE4All2's kickstarts/ bios/amiga, + ROCKNIX runs PICO-8 from roms/pico-8. The tree is launchers.BIOS_TREE or + launchers.ROMS_TREE. None when no binding covers the entry. + """ + layout = standalone_layout_path(entry, profile) + if layout is None or not bindings: + return None + from launchers import bound_path # noqa: PLC0415 + + return bound_path(layout, bindings) + + +def standalone_bindings_for( emu_name: str, profile: dict, standalone_cores: Iterable[str] -) -> str | None: - """The folder a platform gives this emulator's standalone layout, or None.""" +) -> dict[str, tuple[str, str]]: + """The places a platform gives this emulator's standalone layout, by subfolder.""" declared = profile.get("standalone_directories") or [] if not declared: - return None - from launchers import standalone_root # noqa: PLC0415 + return {} + from launchers import standalone_bindings # noqa: PLC0415 names = {emu_name, *(str(c) for c in profile.get("cores") or [])} - return standalone_root(names, declared, _handed(standalone_cores)) + layout = [ + path for f in profile.get("files") or [] + if isinstance(f, dict) and (path := standalone_layout_path(f, profile)) + ] + return standalone_bindings( + names, declared, _handed(standalone_cores), _executed(standalone_cores), layout + ) def platform_layouts( @@ -2774,19 +2848,31 @@ def platform_layouts( return [(profile, False)] return [(system_dir_view(profile, d), False) for d in directories] standalone = profile - root = standalone_root_for(emu_name, profile, standalone_cores) - if root: + bindings = standalone_bindings_for(emu_name, profile, standalone_cores) + if bindings: # The standalone view carries the platform's destination, so every # reader of standalone_path (builder, manifest, verify, slots) places # the file in the folder the platform hands the emulator. - standalone = { - **profile, - "files": [ - {**f, "standalone_path": standalone_destination(f, root)} - if isinstance(f, dict) and f.get("standalone_path") else f - for f in profile.get("files") or [] - ], - } + # An entry of the layout no binding covers is read in a folder the + # platform keeps outside the BIOS tree: UAE4All2's kick.rom in + # RetroPie's $md_inst. It is marked, never left at the BIOS root + # where the emulator does not read it. + files = [] + for f in profile.get("files") or []: + if not isinstance(f, dict) or standalone_layout_path(f, profile) is None: + files.append(f) + continue + placed = standalone_destination(f, profile, bindings) + if placed is None or placed[0] == "outside": + files.append({**f, "unplaced": OUTSIDE_BIOS_FOLDER}) + continue + tree, path = placed + 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) + standalone = {**profile, "files": files} dual = {c.removeprefix(DUAL_MODE_PREFIX) for c in standalone_cores if c.startswith(DUAL_MODE_PREFIX)} if "libretro" in str(profile.get("type", "")) and dual & { diff --git a/scripts/cross_reference.py b/scripts/cross_reference.py index af7525a77..d588f671d 100644 --- a/scripts/cross_reference.py +++ b/scripts/cross_reference.py @@ -856,7 +856,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 declaration_settles( + in_platform = not save_root and f.get("root") != "roms" and declaration_settles( f, fname, platform_names, effective_path, index.get("folded_slots", frozenset()) ) @@ -941,6 +941,10 @@ def _cross_reference_profile( } if save_root: entry["root"] = "save" + elif f.get("unplaced"): + entry["unplaced"] = f["unplaced"] + elif f.get("root") == "roms": + entry["root"] = "roms" 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 75bba4923..ddd8b90d3 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -38,6 +38,7 @@ from pathlib import Path, PurePosixPath sys.path.insert(0, os.path.dirname(__file__)) from common import resolve_platform_cores as _platform_cores from common import ( + ROMS_ROOT, installed_data_refs, platform_arg, require_directory, @@ -1302,6 +1303,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. + continue # Core extras: _collect_emulator_extras already adjusted # destinations for slug-based platforms. Apply the effective # prefix (base_dest, or inferred from YAML when base_dest is @@ -4256,7 +4261,8 @@ def _attach_claims( }) continue claim_path, _ = validated_choice( - claim, claim_path, db, validation_index or {}, bios_dir, None, dest, owners, + claim, claim_path, db, validation_index or {}, bios_dir, None, + claim.get("layout_path") or dest, owners, ) copy, _size = _manifest_entry( claim_path, manifest_dest, [claim_emu], db, repo_root, {} @@ -4447,9 +4453,14 @@ 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). - manifest_dest = ( - save_manifest_dest(fe) if save_root else _manifest_dest_of(full_dest, base_dest) - ) + roms_root = fe.get("root") == ROMS_ROOT + if save_root: + manifest_dest = save_manifest_dest(fe) + elif roms_root: + # Relative to the roms directory, which the entry names. + manifest_dest = dest + else: + manifest_dest = _manifest_dest_of(full_dest, base_dest) entry, file_size = _manifest_entry( local_path, manifest_dest, [source_emu] if source_emu else [], @@ -4458,6 +4469,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 if entry is None: if not _claim_platform_omission( omitted_by_destination.get(full_dest), source_emu @@ -4624,10 +4637,18 @@ def _record_unplaceable(unplaceable: list[dict], record_omission: Callable[..., """ for u in unplaceable: 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") == "save": + key = f"save:{save_manifest_dest(u)}" + else: + key = where record_omission( # Keyed apart from the BIOS tree's paths, which a file read below - # the save directory can repeat. - f"save:{save_manifest_dest(u)}" if u.get("root") == "save" else where, + # the save directory or in the emulator's folder can repeat. + key, u, u.get("system") or "", # The profile key, the name a target's core list and the # installer's narrowing match: the display name matched none. @@ -4805,12 +4826,22 @@ def generate_manifest( if cores is None else extra_region_key(file_entry, declared_root(config)) ) save_root = file_entry.get("root") == "save" + # Read in the emulator's own folder, outside the BIOS tree: dest is + # the path the emulator reads, relative to that folder. + outside = bool(file_entry.get("unplaced")) + if outside: + dest_fields: dict = {"dest": sanitize_pack_path(file_entry.get("path") or ""), + "root": "emulator"} + elif file_entry.get("root") == ROMS_ROOT: + dest_fields = {"dest": sanitize_pack_path( + file_entry.get("destination") or file_entry.get("path") or "" + ), "root": ROMS_ROOT} + elif save_root: + dest_fields = {"dest": save_manifest_dest(file_entry), **save_entry_fields(file_entry)} + else: + dest_fields = {"dest": manifest_destination(full_destination)} omitted_by_destination[full_destination] = { - "dest": ( - save_manifest_dest(file_entry) if save_root - else manifest_destination(full_destination) - ), - **(save_entry_fields(file_entry) if save_root else {}), + **dest_fields, "name": str(file_entry.get("name") or ""), "system": system_id, "required": bool(file_entry.get("required", True)), @@ -5164,6 +5195,12 @@ 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 "")} 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 1f9ce1d56..8c7587b3b 100644 --- a/scripts/launchers.py +++ b/scripts/launchers.py @@ -211,6 +211,24 @@ BIOS_ROOT_VARIABLES = ("$biosdir", "$home/RetroPie/BIOS", "${biosPath}", "$bios_ # The kinds that hand an emulator a whole directory. HANDED_KINDS = ("links", "passes_path") +# How a launcher record says the platform starts the emulator in a directory +# (retropie_launchers._working_directory), and the handle a profile gives +# that directory in standalone_directories: the call its code reads it with. +WORKING_DIRECTORY_PASSED = "working directory of " +WORKING_DIRECTORY = "getcwd" +# The handle a profile declares when its program reads its files beside its +# own executable: a launcher record executing one of the profile's files +# places the folder holding that file where the launcher runs it from. +EXECUTABLE_DIRECTORY = "executable directory" + +# The trees a placed folder belongs to: the BIOS tree every destination is +# relative to, and the roms directory of a platform whose registry names it. +BIOS_TREE = "" +ROMS_TREE = "roms" +# A folder of neither tree, which no pack and no installer can fill. +OUTSIDE_TREE = "outside" +Place = tuple[str, str] + _HOME = re.compile(r"^(?:\$\{HOME\}|\$HOME|\$home|~)(?=/|$)") # Flatpak sets XDG_DATA_HOME to ~/.var/app//data inside the sandbox. _FLATPAK_DATA = re.compile(r"^~/\.var/app/[^/]+/data(?=/|$)") @@ -258,6 +276,15 @@ class HandedDirectory: directory: str handles: frozenset[str] source_ref: str + roms_folder: str | 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 def handed_directories(config: dict) -> list[HandedDirectory]: @@ -278,56 +305,190 @@ def handed_directories(config: dict) -> list[HandedDirectory]: if not raw or re.search(r"[\s,:;]", raw): continue directory = normalize_handle(raw) - handles = frozenset( - handle - for token in str(record.get("passes_to") or "").split() - if (handle := normalize_handle(token)) and handle != directory - ) + passes_to = str(record.get("passes_to") or "") + if passes_to.startswith(WORKING_DIRECTORY_PASSED): + # The program runs from the directory: what its code reads + # through getcwd(), or relative to it, is below it. + handles = frozenset({WORKING_DIRECTORY}) + else: + handles = frozenset( + handle + for token in passes_to.split() + if (handle := normalize_handle(token)) and handle != directory + ) + folder = _folder_in_bios(raw, base) for emulator in (e.strip() for e in str(record.get("emulator", "")).split(",")): found.append(HandedDirectory( emulator=emulator, - folder=_folder_in_bios(raw, base), + folder=folder, 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), )) return found -def standalone_root( - names: Iterable[str], declared: Iterable[str], handed: Iterable[HandedDirectory] -) -> str | None: - """The folder of the BIOS tree a platform gives a standalone build's layout. +def folder_in_roms(directory: str, config: dict) -> str | None: + """A directory relative to the platform's roms directory, None outside it. - ``declared`` are the directories the build resolves ``standalone_path`` - against, as its profile names them (``standalone_directories``). A - record handing a folder of the BIOS tree under one of them gives it - directly: RetroPie points Flycast's ~/.local/share/flycast at bios/dc. A - record handing a directory outside the tree under one of them is - followed through a link to the tree: ROCKNIX's patched Flycast uses - /storage/roms/dreamcast/data, which its start script links to bios/dc. + Only a platform whose registry names the roms directory has one + (``roms_directory.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("/") + directory = directory.strip().rstrip("/") + if not roms or not directory.startswith(roms + "/"): + return None + rest = directory[len(roms) + 1:] + return None if re.search(r"[\s<>$:;]", rest) else rest - None when nothing gives a folder, or when two records give two: the - layout is then the profile's, relative to the BIOS root. + +@dataclass(frozen=True) +class ExecutedFile: + """A file a platform's launcher executes, and where it lies. + + ``place`` is (root, path): the BIOS tree or the roms directory, None + when the file lies outside both (RetroBat's emulator folders). + """ + + emulator: str + name: str + place: Place | None + source_ref: str + + +def executed_files(config: dict) -> list[ExecutedFile]: + """Every file a platform's launchers execute, from records passing it to exec.""" + base = str(config.get("base_destination") or "bios") + found: list[ExecutedFile] = [] + for record in config.get("launchers") or []: + if record.get("kind") not in ("fixed_name", "names_searched"): + continue + if str(record.get("passes_to") or "").strip() != "exec": + continue + 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 + # 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 []): + if not name or re.search(r"[<>{}]", name): + continue + if directories: + place: Place | None = (BIOS_TREE, sanitize_pack_path( + 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)) + else: + place = None + for emulator in (e.strip() for e in str(record.get("emulator", "")).split(",")): + found.append(ExecutedFile( + emulator, name.rsplit("/", 1)[-1], place, str(record.get("source_ref", "")) + )) + return found + + +def standalone_bindings( + names: Iterable[str], + declared: Iterable[str], + handed: Iterable[HandedDirectory], + executed: Iterable[ExecutedFile] = (), + layout: Iterable[str] = (), +) -> dict[str, Place]: + """The folders a platform gives a standalone build's layout. + + ``declared`` are the directories the build resolves its paths against, + as its profile names them (``standalone_directories``). The result maps + a subfolder of that layout ("" for the whole of it) to the place, a + folder of the BIOS tree or of the roms directory, the emulator reads it + from. + + A record handing a folder under a declared directory gives it directly: + RetroPie points Flycast's ~/.local/share/flycast at bios/dc. A record + handing a directory outside the trees is followed through the links that + put a folder at it or below it: ROCKNIX's patched Flycast uses + /storage/roms/dreamcast/data, which its start script links to bios/dc, + and RetroPie starts UAE4All2 in $md_inst, whose kickstarts folder it + links to bios/amiga. + + A profile whose program reads its files beside its executable declares + EXECUTABLE_DIRECTORY, and ``layout`` holds its paths: a launcher that + executes one of them gives the folder holding it. Batocera runs + pico-8/pico8 from its BIOS folder, ROCKNIX pico8_64 from + /storage/roms/pico-8. + + A subfolder two records give two places is left out. """ names = set(names) wanted = {normalize_handle(d) for d in declared} if not wanted: - return None + return {} records = [h for h in handed if h.emulator in names] - folders: set[str] = set() + + def below(handle: str, directory: str) -> str | None: + if handle == directory: + return "" + if handle.startswith(directory + "/"): + return handle[len(directory) + 1:] + return None + + found: dict[str, set[Place]] = {} for record in records: - if not record.handles & wanted: + subs = { + sub for handle in record.handles for directory in wanted + if (sub := below(handle, directory)) is not None + } + for sub in subs: + if record.folder is not None: + found.setdefault(sub, set()).add((BIOS_TREE, record.folder)) + continue + # A link into the BIOS tree comes first: the pack fills that. + linked = False + for link in records: + target = link.place() + if target is None: + continue + for handle in link.handles: + rest = below(handle, record.directory) + if rest is not None: + 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 EXECUTABLE_DIRECTORY in wanted: + paths = list(layout) + for run in executed: + if run.emulator not in names: + continue + # A program run from a folder outside both trees (RetroBat's + # emulators/pico8) binds its folder to no place a pack fills. + root, path = run.place or (OUTSIDE_TREE, run.name) + folder = path.rsplit("/", 1)[0] if "/" in path else "" + for entry_path in paths: + if entry_path.rsplit("/", 1)[-1] != run.name: + continue + sub = entry_path.rsplit("/", 1)[0] if "/" in entry_path else "" + found.setdefault(sub, set()).add((root, folder)) + return {sub: next(iter(places)) for sub, places in found.items() if len(places) == 1} + + +def bound_path(path: str, bindings: dict[str, Place]) -> Place | None: + """Where a platform holds a layout path, None when no binding covers it. + + The longest subfolder that contains the path wins: a link of + kickstarts/ places kickstarts/kick13.rom even when the whole layout is + bound elsewhere. + """ + for sub in sorted(bindings, key=len, reverse=True): + if sub and not path.startswith(sub + "/"): continue - if record.folder is not None: - folders.add(record.folder) - continue - folders.update( - link.folder - for link in records - if link.folder is not None and record.directory in link.handles - ) - return next(iter(folders)) if len(folders) == 1 else None + rest = path[len(sub) + 1:] if sub else path + root, folder = bindings[sub] + return (root, f"{folder}/{rest}" if folder else rest) + return None def held_record(report: dict, db: dict) -> dict | None: diff --git a/scripts/packextras.py b/scripts/packextras.py index 29ed24225..f3780431a 100644 --- a/scripts/packextras.py +++ b/scripts/packextras.py @@ -17,7 +17,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 sanitize_pack_path +from common import ROMS_ROOT, 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 @@ -43,11 +43,24 @@ 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", "")))) 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 roms_destination(dest: str) -> str: + """The key of a destination below the roms directory.""" + return f"{ROMS_KEY}{dest}" + + def prefixed_destination(dest: str, prefix: str) -> str: """A core extra's destination from the pack root. @@ -309,9 +322,11 @@ class ExtraResolver: return asset, "release_asset", "" if status in ("not_found", "external", "user_provided") or not local_path: return None, status, "" + # The cores reading a moved layout's file are found under the + # emulator's own path, not the folder the platform put it in. local_path, disagreement = validated_choice( fe, local_path, self.db, self.validation_index, self.bios_dir, None, - dest, self.validation_owners, + fe.get("layout_path") or dest, self.validation_owners, ) # The platform never reads the bytes, the core does: a copy it turns # away by its own size check is not its file, on any platform. @@ -462,9 +477,10 @@ def unplaceable_extras( placement = ExtraPlacement(config, "", profiles) unplaced: dict[tuple[str, str, str], str] = {} for u in undeclared: - if not u.get("in_repo"): + if not u.get("in_repo") and not u.get("unplaced"): continue - if not placement.slug_based and u.get("root") != "save": + if (not placement.slug_based and u.get("root") not in ("save", ROMS_ROOT) + and not u.get("unplaced")): continue dest = _extra_dest(u) if not placement.places(u, dest): @@ -550,7 +566,8 @@ def _agnostic_scan_extras( continue # 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): + if (read_from_save_dir(f, standalone) or f.get("unplaced") + or f.get("root") == ROMS_ROOT): 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 @@ -1001,6 +1018,9 @@ 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")) self.config = config self.norm_map = ( {_norm_system_id(sid): sid for sid in self.platform_systems} @@ -1019,6 +1039,10 @@ class ExtraPlacement: directory and goes below the platform's save folder, which a platform keeping saves beside the content does not have. """ + 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") == "save": placed = self.save_places(entry, dest) return placed[0] if placed else None @@ -1039,6 +1063,8 @@ class ExtraPlacement: platform that hands each system a save folder of its own goes once below each system that runs the core. """ + if entry.get("unplaced"): + return [] if entry.get("root") == "save": return self.save_places(entry, dest) placed = self.place(entry, dest) @@ -1075,6 +1101,10 @@ class ExtraPlacement: def unplaced_reason(self, entry: dict, dest: str) -> str: """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") == "save": if self.save is None: return "no_save_directory" @@ -1196,6 +1226,12 @@ def _collect_emulator_extras( # user's machine. The mode a file is bundled for was settled # upstream, an entry scoped to another build never gets here. continue + if u.get("unplaced"): + # Read in a folder outside the BIOS tree: named, held or not, + # never written where the emulator does not read it. + if unplaceable is not None: + unplaceable.append({**u, "unplaceable": u["unplaced"]}) + continue if not u["in_repo"] and absent is None: continue # For archive entries, use the archive name for resolution @@ -1291,7 +1327,9 @@ def _collect_emulator_extras( # Only duplicate files already covered at another destination. # An unsourceable entry is a file no collection holds: the name # covered elsewhere belongs to another program's file. - if fname not in covered_names or f.get("unsourceable"): + # 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): continue # Installed with the emulator or by the platform's package, # as the first pass already decided for the same entry. @@ -1530,8 +1568,12 @@ 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 if u.get("system_dir"): extra["system_dir"] = u["system_dir"] + if u.get("layout_path"): + extra["layout_path"] = u["layout_path"] # 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/scraper/retrobat_launchers.py b/scripts/scraper/retrobat_launchers.py index a3957f340..6c851eade 100644 --- a/scripts/scraper/retrobat_launchers.py +++ b/scripts/scraper/retrobat_launchers.py @@ -51,6 +51,7 @@ SOURCE_FILES = ( f"{GENERATORS}/OpenMSX.Generator.cs", f"{GENERATORS}/PSXMame.Generator.cs", f"{GENERATORS}/Pcsx2.Generator.cs", + f"{GENERATORS}/Pico8.Generator.cs", f"{GENERATORS}/PortsLauncher.Configuration.cs", f"{GENERATORS}/Project64.Generator.cs", f"{GENERATORS}/Raine.Generator.cs", @@ -2979,6 +2980,44 @@ def xroar(sources: dict[str, Source]) -> list[Record]: return _covered(src, records) +def pico8(sources: dict[str, Source]) -> list[Record]: + """The PICO-8 binary the generator runs, from an emulator folder. + + AppConfig.GetFullPath names a folder of the RetroBat install + (emulatorLauncher.cfg, else emulators/): the binary is never + looked up in the BIOS tree. + """ + src = sources[f"{GENERATORS}/Pico8.Generator.cs"] + folder = src.find(r'string path = AppConfig\.GetFullPath\("(\w+)"\);', "pico8 folder") + exe = src.find( + r'string exe = Path\.Combine\(path, "([^"]+)"\);', "binary path", folder.end() + ) + check = src.find(r"if \(!File\.Exists\(exe\)\)\s*return null;", "binary check", exe.end()) + run = src.find(r"FileName = exe,\s*WorkingDirectory = path,", "binary run", check.end()) + return [ + _record( + "pico8", + "standalone", + "fixed_name", + [exe.group(1)], + source_ref( + ( + src.path, + [ + src.lines(folder.start(), folder.end()), + src.lines(exe.start(), check.end()), + src.lines(run.start(), run.end()), + ], + ) + ), + case_insensitive=True, + search_dir=f"", + passes_to="exec", + fails_when_missing="returns no process when the binary is absent", + ) + ] + + def snes9x(sources: dict[str, Source]) -> list[Record]: src = sources[f"{GENERATORS}/Snes9x.Generator.cs"] entry = src.find(rf'\{{ "([^"]+)", (Path\.Combine\({_BIOS_ROOT}\)) \}}', "bios folder entry") @@ -3247,6 +3286,7 @@ EXTRACTORS: tuple[Callable[[dict[str, Source]], list[Record]], ...] = ( option_paths, pcsx2, pcsx2_libretro, + pico8, psx_cascades, psxmame, raine, diff --git a/scripts/scraper/retropie_launchers.py b/scripts/scraper/retropie_launchers.py index 320ab841a..65c03d28d 100644 --- a/scripts/scraper/retropie_launchers.py +++ b/scripts/scraper/retropie_launchers.py @@ -49,9 +49,10 @@ OUTSIDE_BIOS: dict[str, tuple[str, ...]] = { } # Packages whose emulator reads its system files below the working directory -# the generated launch script enters with pushd, a folder outside $biosdir: -# DraStic opens system/ under it. Each must still pushd. -WORKING_DIRECTORY: tuple[str, ...] = ("drastic",) +# the generated launch script enters with pushd: DraStic opens system/ +# under it, UAE4All2 kickstarts/ (its launchDir is getcwd()). Each must +# still pushd. +WORKING_DIRECTORY: tuple[str, ...] = ("drastic", "uae4all") _FUNCTION = re.compile(r"^function\s+([\w.+-]+)\s*\(\)\s*\{\s*$") _ASSIGN = re.compile(r"^(?:local\s+)?([A-Za-z_]\w*)=(.*)$") diff --git a/scripts/scraper/rocknix_launchers.py b/scripts/scraper/rocknix_launchers.py index f6eae2840..08a4932c2 100644 --- a/scripts/scraper/rocknix_launchers.py +++ b/scripts/scraper/rocknix_launchers.py @@ -575,6 +575,47 @@ def armsx2(tree: RepoTree) -> list[Launcher]: # Start scripts whose ln -s commands put a BIOS path where the emulator reads. +def pico8(tree: RepoTree) -> list[Launcher]: + """The PICO-8 binary start_pico8.sh executes, outside /storage/roms/bios. + + The script picks the binary by HW_ARCH, pico8_64 on aarch64 and + pico8_dyn on any other, and runs it from GAME_DIR, or from + GAME_DIR/${HW_ARCH} when that folder exists. + """ + path = f"{STANDALONE}/pico-8/scripts/start_pico8.sh" + script = Script(path, tree.read(path)) + game_line, game = script.find(r'^\s*GAME_DIR="([^"$]+)"', "GAME_DIR assignment") + case_line, _ = script.find(r"^\s*case \$\{HW_ARCH\} in", "case on HW_ARCH") + arch_line, arch = script.find(r"^\s*(\w+)\)", "HW_ARCH branch", case_line + 1) + first_line, first = script.find(r'STATIC_BIN="([^"$]+)"', "binary of the first branch", arch_line) + other_line, _ = script.find(r"^\s*\*\)", "default HW_ARCH branch", first_line) + rest_line, rest = script.find(r'STATIC_BIN="([^"$]+)"', "binary of the default branch", other_line) + sub_line, _ = script.find( + r'if \[ -d "\$\{GAME_DIR\}/\$\{HW_ARCH\}" \]', "per-architecture launch folder" + ) + end_line, _ = script.find(r"^\s*fi\b", "end of the launch folder test", sub_line) + run_line, _ = script.find(r"^\s*\$\{LAUNCH_DIR\}/\$\{STATIC_BIN\} ", "binary run") + return [ + Launcher( + emulator="pico-8", + build="standalone", + kind="fixed_name", + names=[first.group(1), rest.group(1)], + source_ref=source_ref([(path, { + game_line, case_line, arch_line, first_line, other_line, rest_line, + *range(sub_line, end_line + 1), run_line, + })]), + search_dir=game.group(1).rstrip("/"), + passes_to="exec", + fails_when_missing=False, + depends_on_option=( + f"HW_ARCH {arch.group(1)} runs {first.group(1)}, any other {rest.group(1)}, " + f"from {game.group(1).rstrip('/')}/${{HW_ARCH}} when that folder exists" + ), + ) + ] + + LINK_SCRIPTS = ( ("drastic", f"{STANDALONE}/drastic-sa/scripts/start_drastic.sh"), ("dolphin", f"{STANDALONE}/dolphin-sa/scripts/start_dolphin_gc.sh"), @@ -679,6 +720,7 @@ EXTRACTORS: tuple[Callable[[RepoTree], list[Launcher]], ...] = ( yabasanshiro, duckstation, armsx2, + pico8, patched_directories, links, ) diff --git a/scripts/slots.py b/scripts/slots.py index 0b816e3d2..5297eadb0 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -228,7 +228,7 @@ def profile_claims( # outside the BIOS tree, where no platform declares a file. if outside_gap_scope(entry, is_standalone) or read_from_save_dir( entry, is_standalone - ): + ) or entry.get("unplaced"): continue # A member of a romset is not a file at the BIOS root: the archive # holding it occupies the destination (_archive_claims). Claiming @@ -306,6 +306,7 @@ def _archive_claims( if isinstance(entry, dict) and not outside_gap_scope(entry, is_standalone) and not read_from_save_dir(entry, is_standalone) + and not entry.get("unplaced") ] prefix = profile.get("archive_prefix", "") claims: list[Claim] = [] diff --git a/scripts/truth.py b/scripts/truth.py index d463368ac..70de676b8 100644 --- a/scripts/truth.py +++ b/scripts/truth.py @@ -427,13 +427,15 @@ def _has_exploitable_data(entry: dict) -> bool: def _system_dir_files(profile: dict, standalone: bool) -> list[dict]: - """Entries of the build the platform runs, read from the system directory.""" - return list( - filter( - read_from_system_dir, - filter_files_by_mode(profile.get("files", []), standalone=standalone), - ) - ) + """Entries of the build the platform runs, read from the system directory. + + An entry the platform's launchers leave outside the BIOS tree + (``unplaced``) is not: the platform has no file of its own there. + """ + return [ + f for f in filter_files_by_mode(profile.get("files", []), standalone=standalone) + if read_from_system_dir(f) and not f.get("unplaced") + ] def _archives_not_members(files: list[dict]) -> list[dict]: diff --git a/scripts/verify.py b/scripts/verify.py index 437077555..3f60ad451 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -592,7 +592,10 @@ def find_undeclared_files( # Below the save directory each core is handed a folder # of its own: two cores reading hiscore.dat there read # two files. - ("save", core_save_folder(profile)) if save_root else None, + ("save", core_save_folder(profile)) if save_root + else ("roms",) if f.get("root") == "roms" + # In the emulator's own folder: no other core reads it. + else ("emulator", emu_name) if f.get("unplaced") else None, ) if not fname or unread_on(f, config.get("_key", "")): continue @@ -600,9 +603,11 @@ def find_undeclared_files( duplicate = seen_key in seen_files if duplicate and (first is None or first["profile"] == emu_name): continue + # Nor does it answer for one read below the roms directory. verdict = _candidate_verdict( f, fname, is_standalone, include_all, - {} if save_root else declared_names, dest, folded_slots, + {} if save_root or f.get("root") == "roms" else declared_names, + dest, folded_slots, ) if verdict == "settled": seen_files.add(seen_key) @@ -759,7 +764,14 @@ def find_undeclared_files( members = with_archive_members(f, profile.get("files", [])).get("members") if members: entry["members"] = members - if launcher_layer and not save_root: + if f.get("layout_path"): + 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 launcher_layer and not save_root: launcher_candidates.append((emu_name, is_standalone, f, entry)) if duplicate: first.setdefault("other_claims", []).append(entry) @@ -867,6 +879,8 @@ 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_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", @@ -890,6 +904,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": + # The pack is extracted into the BIOS folder: the installer + # writes this file into the roms folder, the pack cannot. + u["in_pack"] = False + u["omitted"] = "read below the roms folder, which only the installer reaches" + continue if reason: u["in_pack"] = False u["omitted"] = _UNPLACED_REASONS[reason] diff --git a/tests/test_launcher_placement.py b/tests/test_launcher_placement.py new file mode 100644 index 000000000..94466b5ec --- /dev/null +++ b/tests/test_launcher_placement.py @@ -0,0 +1,368 @@ +"""A platform's launcher moves where an emulator's file lands. + +A profile's ``path`` is where the emulator's code reads a file. UAE4All2 +reads kickstarts/ below its working directory, and RetroPie starts it +in its install directory, whose kickstarts/ it links to BIOS/amiga. The +pack, the manifest and verify all put the file in amiga/ on that platform, +and a platform with no such record keeps the profile's layout. +""" + +from __future__ import annotations + +import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache + +import hashlib +import io +import shutil +import sys +import tempfile +import unittest +import zipfile +from contextlib import redirect_stdout +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from common import ( # noqa: E402 + _emulator_profiles_cache, + load_emulator_profiles, + load_platform_config, + platform_layouts, + standalone_set, +) +from generate_pack import generate_manifest, generate_pack # noqa: E402 +from verify import verify_platform # noqa: E402 + +WORKING_DIRECTORY = { + "emulator": "kick", "build": "standalone", "kind": "passes_path", "names": [], + "search_dir": "$md_inst", "passes_to": "working directory of $romdir/amiga/+Start.sh", + "source_ref": "kick.sh:90", +} +LINK = { + "emulator": "kick", "build": "standalone", "kind": "links", "names": [], + "search_dir": "$biosdir/amiga", "passes_to": "$md_inst/kickstarts is a symlink to it", + "source_ref": "kick.sh:79", +} + + +class ALinkMovesTheDestination(unittest.TestCase): + def setUp(self) -> None: + _emulator_profiles_cache.clear() + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + bios = self.root / "bios" / "Commodore" / "Amiga" + bios.mkdir(parents=True) + files, by_md5, by_name = {}, {}, {} + for name, data in (("kick13.rom", b"KICKSTART 1.3"), ("kick.rom", b"CUSTOM KICKSTART")): + (bios / name).write_bytes(data) + sha1 = hashlib.sha1(data).hexdigest() + md5 = hashlib.md5(data).hexdigest() + files[sha1] = {"path": str(bios / name), "name": name, "sha1": sha1, + "md5": md5, "crc32": "", "size": len(data)} + by_md5[md5] = sha1 + by_name[name] = [sha1] + self.db = { + "files": files, + "indexes": {"by_md5": by_md5, "by_name": by_name, "by_crc32": {}, + "by_path_suffix": {}}, + } + kick13 = by_name["kick13.rom"][0] + self.platforms = self.root / "platforms" + self.emulators = self.root / "emulators" + self.platforms.mkdir() + self.emulators.mkdir() + for name, launchers in (("linkplat", [WORKING_DIRECTORY, LINK]), ("plainplat", [])): + (self.platforms / f"{name}.yml").write_text(yaml.safe_dump({ + "platform": name, "version": "1", "verification_mode": "existence", + "base_destination": "BIOS", "cores": ["kick"], "standalone_cores": ["kick"], + "launchers": launchers, + "systems": {"commodore-amiga": {"files": []}}, + })) + (self.platforms / "_registry.yml").write_text(yaml.safe_dump({"platforms": { + "linkplat": {"config": "linkplat.yml"}, "plainplat": {"config": "plainplat.yml"}, + }})) + (self.emulators / "kick.yml").write_text(yaml.safe_dump({ + "emulator": "Kick", "type": "standalone", "cores": ["kick"], + "systems": ["commodore-amiga"], + "standalone_directories": ["getcwd"], + "files": [ + {"name": "kick13.rom", "path": "kickstarts/kick13.rom", + "sha1": kick13, "required": False}, + # Read in the working directory itself, which the platform + # links nowhere in the BIOS tree. + {"name": "kick.rom", "path": "kick.rom", "required": False}, + ], + })) + + def pack_names(self, platform: str, name: str = "kick13.rom") -> list[str]: + with redirect_stdout(io.StringIO()): + zip_path = generate_pack( + platform, str(self.platforms), self.db, str(self.root / "bios"), + str(self.root / "out" / platform), emulators_dir=str(self.emulators), + offline=True, + ) + with zipfile.ZipFile(zip_path) as zf: + return [n for n in zf.namelist() if n.rsplit("/", 1)[-1] == name] + + def manifest(self, platform: str) -> dict: + with redirect_stdout(io.StringIO()): + return generate_manifest( + platform, str(self.platforms), self.db, str(self.root / "bios"), + str(self.platforms / "_registry.yml"), + emulators_dir=str(self.emulators), offline=True, + ) + + def manifest_dests(self, platform: str, name: str = "kick13.rom") -> list[str]: + return [f["dest"] for f in self.manifest(platform)["files"] + if f["dest"].rsplit("/", 1)[-1] == name] + + def verify_entries(self, platform: str, name: str = "kick13.rom") -> list[dict]: + config = load_platform_config(platform, str(self.platforms)) + result = verify_platform(config, self.db, str(self.emulators)) + return [u for u in result["undeclared_files"] if u["name"] == name] + + def verify_paths(self, platform: str) -> list[str]: + return [u["path"] for u in self.verify_entries(platform)] + + def test_the_link_moves_it_in_pack_manifest_and_verify(self) -> None: + self.assertEqual(self.pack_names("linkplat"), ["amiga/kick13.rom"]) + self.assertEqual(self.manifest_dests("linkplat"), ["amiga/kick13.rom"]) + self.assertEqual(self.verify_paths("linkplat"), ["amiga/kick13.rom"]) + + def test_a_platform_without_the_record_is_unchanged(self) -> None: + self.assertEqual(self.pack_names("plainplat"), ["kickstarts/kick13.rom"]) + self.assertEqual(self.manifest_dests("plainplat"), ["kickstarts/kick13.rom"]) + self.assertEqual(self.verify_paths("plainplat"), ["kickstarts/kick13.rom"]) + + def test_a_file_outside_every_binding_is_named_never_placed(self) -> None: + self.assertEqual(self.pack_names("linkplat", "kick.rom"), []) + manifest = self.manifest("linkplat") + self.assertEqual(self.manifest_dests("linkplat", "kick.rom"), []) + omitted = [o for o in manifest["omitted_files"] if o["name"] == "kick.rom"] + self.assertEqual(len(omitted), 1) + self.assertEqual( + (omitted[0]["dest"], omitted[0]["root"], omitted[0]["reason"], omitted[0]["cores"]), + ("kick.rom", "emulator", "outside_bios_folder", ["kick"]), + ) + (entry,) = self.verify_entries("linkplat", "kick.rom") + self.assertFalse(entry["in_pack"]) + self.assertIn("outside the BIOS tree", entry["omitted"]) + + def test_a_root_file_of_another_core_is_not_claimed_by_it(self) -> None: + # Another core reads kick.rom at the BIOS root: kick's own kick.rom, + # in its working directory, is a different place. + for name in ("linkplat", "plainplat"): + path = self.platforms / f"{name}.yml" + data = yaml.safe_load(path.read_text()) + data["cores"] = ["kick", "other"] + path.write_text(yaml.safe_dump(data)) + (self.emulators / "other.yml").write_text(yaml.safe_dump({ + "emulator": "Other", "type": "libretro", "cores": ["other"], + "systems": ["commodore-amiga"], + "files": [{"name": "kick.rom", "path": "kick.rom", "required": False}], + })) + _emulator_profiles_cache.clear() + manifest = self.manifest("linkplat") + (held,) = [f for f in manifest["files"] if f["dest"] == "kick.rom"] + self.assertEqual(held["cores"], ["other"]) + omitted = [o for o in manifest["omitted_files"] if o["name"] == "kick.rom"] + self.assertEqual([(o["root"], o["cores"]) for o in omitted], [("emulator", ["kick"])]) + + def test_the_installer_accepts_the_omission(self) -> None: + import install + + manifest = self.manifest("linkplat") + omitted = next(o for o in manifest["omitted_files"] if o["name"] == "kick.rom") + install._check_omission(omitted, omitted["dest"]) + # Two emulators reading kick.rom in their own folders read two files. + self.assertNotEqual( + install._entry_key(omitted), install._entry_key({**omitted, "cores": ["other"]}) + ) + self.assertNotEqual(install._entry_key(omitted), install._entry_key({"dest": "kick.rom"})) + self.assertEqual(install.OMISSION_REASONS[omitted["reason"]], "read outside the BIOS folder") + with self.assertRaises(ValueError): + install._check_root({"root": "emulator"}, "kick.rom") + install._check_root(omitted, "kick.rom", omitted=True) + + def test_without_relocation_the_root_file_stays_at_the_root(self) -> None: + self.assertEqual(self.pack_names("plainplat", "kick.rom"), ["kick.rom"]) + + def test_the_link_alone_binds_nothing(self) -> None: + # Without the working directory the link's $md_inst is not the + # directory the profile's paths are relative to. + config = {"standalone_cores": ["kick"], "launchers": [LINK]} + profile = load_emulator_profiles(str(self.emulators))["kick"] + (view, standalone), = platform_layouts("kick", profile, standalone_set(config)) + self.assertTrue(standalone) + self.assertNotIn("standalone_path", view["files"][0]) + + +class UAE4AllOnRetroPie(unittest.TestCase): + """The real profile against the real launcher records.""" + + def test_kickstarts_land_in_amiga(self) -> None: + profile = load_emulator_profiles(str(REPO_ROOT / "emulators"))["uae4all"] + config = load_platform_config("retropie", str(REPO_ROOT / "platforms")) + (view, standalone), = platform_layouts("uae4all", profile, standalone_set(config)) + self.assertTrue(standalone) + for entry in view["files"]: + if entry["path"].startswith("kickstarts/"): + self.assertEqual( + entry["standalone_path"], "amiga/" + entry["path"].removeprefix("kickstarts/") + ) + else: + # kick.rom and rom.key in the working directory, $md_inst + self.assertEqual(entry.get("unplaced"), "outside_bios_folder", entry["path"]) + + + +PROGRAM = b"PROGRAM BINARY" +PROGRAM_DATA = b"PROGRAM DATA" + + +class AProgramRunFromItsFolder(unittest.TestCase): + """A program that reads its files beside its executable. + + The profile gives the layout of the program's archive (prog/prog, + prog/prog.dat) and declares the executable directory. A platform that + runs prog/prog from its BIOS folder keeps that layout, one that runs it + from roms/prog places the program folder below its roms folder, and one + that runs it from an emulator folder names its files as not placeable. + """ + + def setUp(self) -> None: + _emulator_profiles_cache.clear() + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + bios = self.root / "bios" / "Maker" / "Prog" + bios.mkdir(parents=True) + files, by_md5, by_name = {}, {}, {} + for name, data in (("prog", PROGRAM), ("prog.dat", PROGRAM_DATA)): + (bios / name).write_bytes(data) + sha1 = hashlib.sha1(data).hexdigest() + md5 = hashlib.md5(data).hexdigest() + files[sha1] = {"path": str(bios / name), "name": name, "sha1": sha1, + "md5": md5, "crc32": "", "size": len(data)} + by_md5[md5] = sha1 + by_name[name] = [sha1] + self.db = {"files": files, "indexes": { + "by_md5": by_md5, "by_name": by_name, "by_crc32": {}, "by_path_suffix": {}}} + self.platforms = self.root / "platforms" + self.emulators = self.root / "emulators" + self.platforms.mkdir() + self.emulators.mkdir() + run = {"emulator": "prog", "build": "standalone", "kind": "fixed_name", + "passes_to": "exec", "source_ref": "start_prog.sh:9"} + launchers = { + "biosplat": [{**run, "names": ["prog/prog"], "search_dir": "/userdata/bios"}], + "romsplat": [{**run, "names": ["prog"], "search_dir": "/storage/roms/prog"}], + "exeplat": [{**run, "names": ["prog"], "search_dir": ""}], + "plainplat": [], + } + for name, records in launchers.items(): + (self.platforms / f"{name}.yml").write_text(yaml.safe_dump({ + "platform": name, "version": "1", "verification_mode": "existence", + "base_destination": "bios", "cores": ["prog"], "standalone_cores": ["prog"], + "launchers": records, + "systems": {"prog-system": {"files": []}}, + })) + (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"}}, + }})) + (self.emulators / "prog.yml").write_text(yaml.safe_dump({ + "emulator": "Prog", "type": "standalone", "cores": ["prog"], + "systems": ["prog-system"], + "standalone_directories": ["executable directory"], + "files": [ + {"name": "prog", "path": "prog/prog", "sha1": by_name["prog"][0], + "required": True}, + {"name": "prog.dat", "path": "prog/prog.dat", + "sha1": by_name["prog.dat"][0], "required": True}, + ], + })) + + def pack(self, platform: str) -> list[str]: + with redirect_stdout(io.StringIO()): + zip_path = generate_pack( + platform, str(self.platforms), self.db, str(self.root / "bios"), + str(self.root / "out" / platform), emulators_dir=str(self.emulators), + offline=True, + ) + with zipfile.ZipFile(zip_path) as zf: + return sorted(n for n in zf.namelist() if n.split("/")[-1].startswith("prog")) + + def manifest(self, platform: str) -> dict: + with redirect_stdout(io.StringIO()): + return generate_manifest( + platform, str(self.platforms), self.db, str(self.root / "bios"), + str(self.platforms / "_registry.yml"), + emulators_dir=str(self.emulators), offline=True, + ) + + def verify(self, platform: str) -> list[dict]: + config = load_platform_config(platform, str(self.platforms)) + result = verify_platform(config, self.db, str(self.emulators)) + return [u for u in result["undeclared_files"] if u["name"].startswith("prog")] + + def test_run_from_the_bios_folder_keeps_the_layout(self) -> None: + for platform in ("biosplat", "plainplat"): + with self.subTest(platform=platform): + self.assertEqual(self.pack(platform), ["prog/prog", "prog/prog.dat"]) + self.assertEqual( + sorted(f["dest"] for f in self.manifest(platform)["files"]), + ["prog/prog", "prog/prog.dat"], + ) + + def test_run_from_the_roms_folder_is_installed_there(self) -> None: + self.assertEqual(self.pack("romsplat"), []) + manifest = self.manifest("romsplat") + self.assertEqual( + 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"}) + 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_run_from_an_emulator_folder_is_named(self) -> None: + self.assertEqual(self.pack("exeplat"), []) + manifest = self.manifest("exeplat") + self.assertEqual([f["dest"] for f in manifest["files"]], []) + self.assertEqual( + sorted((o["dest"], o["root"], o["reason"]) for o in manifest["omitted_files"]), + [("prog/prog", "emulator", "outside_bios_folder"), + ("prog/prog.dat", "emulator", "outside_bios_folder")], + ) + + def test_the_installer_finds_the_roms_folder_above_the_bios_folder(self) -> None: + import install + + manifest = self.manifest("romsplat") + install._check_roms_directory(manifest) + files = list(manifest["files"]) + card = self.root / "card" / "roms" / "bios" + kept, left = install.place_roms_entries(files, card, manifest, "romsplat", say=False) + self.assertEqual((len(kept), left), (2, [])) + self.assertEqual( + install._entry_path(card, kept[0]), + (card.parent / kept[0]["dest"]).resolve() + if install._entry_path(card, kept[0]).is_absolute() else card.parent / kept[0]["dest"], + ) + other = self.root / "elsewhere" + kept, left = install.place_roms_entries(files, other, manifest, "romsplat", say=False) + self.assertEqual(kept, []) + self.assertEqual({o["reason"] for o in left}, {"no_roms_directory"}) + with self.assertRaises(ValueError): + install._check_roms_directory({"files": files}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_launchers_retrobat.py b/tests/test_launchers_retrobat.py index 23029453f..30b523bc3 100644 --- a/tests/test_launchers_retrobat.py +++ b/tests/test_launchers_retrobat.py @@ -1145,6 +1145,36 @@ class FolderArguments(unittest.TestCase): ) +PICO8 = """ + public override System.Diagnostics.ProcessStartInfo Generate(string system, string emulator, string core, string rom, string playersControllers, ScreenResolution resolution) + { + string path = AppConfig.GetFullPath("pico8"); + + string exe = Path.Combine(path, "pico8.exe"); + if (!File.Exists(exe)) + return null; + + return new ProcessStartInfo() + { + FileName = exe, + WorkingDirectory = path, + Arguments = args, + }; + } +""" + + +class ProgramsRunFromEmulatorFolders(unittest.TestCase): + def test_pico8_runs_its_binary_from_its_emulator_folder(self): + (record,) = rl.pico8(_sources(**{f"{G}/Pico8.Generator.cs": PICO8})) + self.assertEqual( + (record["emulator"], record["kind"], record["names"], record["search_dir"], + record["passes_to"], record["source_ref"]), + ("pico8", "fixed_name", ["pico8.exe"], '', "exec", + f"{G}/Pico8.Generator.cs:3-7, 11-12"), + ) + + class EveryBiosReferenceIsRead(unittest.TestCase): def test_unread_reference_stops_the_scrape(self): extra = EKA2L1 + ( diff --git a/tests/test_launchers_retropie.py b/tests/test_launchers_retropie.py index 0e5ef9c66..510aeaae7 100644 --- a/tests/test_launchers_retropie.py +++ b/tests/test_launchers_retropie.py @@ -293,6 +293,28 @@ DRASTIC = module("drastic", """ """, 36) +UAE4ALL = module("uae4all", """ + function configure_uae4all() { + mkRomDir "amiga" + + moveConfigDir "$md_inst/kickstarts" "$biosdir/amiga" + + rm -f "$romdir/amiga/+Start UAE4All.sh" + if [[ "$md_mode" == "install" ]]; then + cat > "$romdir/amiga/+Start UAE4All.sh" << _EOF_ + #!/bin/bash + pushd "$md_inst" + ./uae4all + popd + _EOF_ + fi + + addEmulator 1 "$md_id" "amiga" "bash $romdir/amiga/+Start\\ UAE4All.sh" + addSystem "amiga" + } +""", 60) + + class WorkingDirectory(unittest.TestCase): def test_drastic_reads_below_the_folder_its_script_enters(self): found = records({f"{EMULATORS}/drastic.sh": DRASTIC}, "drastic") @@ -306,6 +328,18 @@ class WorkingDirectory(unittest.TestCase): )], ) + def test_uae4all_runs_from_its_install_directory(self): + found = records({f"{EMULATORS}/uae4all.sh": UAE4ALL}, "uae4all") + entered = [r for r in found if r["kind"] == "passes_path"] + self.assertEqual( + [(r["search_dir"], r["passes_to"], r["source_ref"]) for r in entered], + [( + "$md_inst", + "working directory of $romdir/amiga/+Start UAE4All.sh", + f"{EMULATORS}/uae4all.sh:69", + )], + ) + def test_a_launch_script_that_no_longer_enters_a_folder_raises(self): files = {f"{EMULATORS}/drastic.sh": DRASTIC.replace('pushd "', 'echo "')} with self.assertRaises(rl.LauncherPatternMissing): diff --git a/tests/test_launchers_rocknix.py b/tests/test_launchers_rocknix.py index 22596256b..26fa9192c 100644 --- a/tests/test_launchers_rocknix.py +++ b/tests/test_launchers_rocknix.py @@ -551,6 +551,53 @@ class Links(unittest.TestCase): rl.links(tree) +START_PICO8 = text( + """ + #!/bin/bash + . /etc/profile + + GAME_DIR="/storage/roms/pico-8/" + + case ${HW_ARCH} in + aarch64) + STATIC_BIN="pico8_64" + ;; + *) + STATIC_BIN="pico8_dyn" + ;; + esac + + if [ -d "${GAME_DIR}/${HW_ARCH}" ] + then + LAUNCH_DIR="${GAME_DIR}/${HW_ARCH}" + else + LAUNCH_DIR="${GAME_DIR}" + fi + + ${LAUNCH_DIR}/${STATIC_BIN} -home -root_path ${GAME_DIR} -joystick 0 ${OPTIONS} "${CART}" +""" +) + + +class ProgramsRunOutsideTheBiosFolder(unittest.TestCase): + PATH = f"{SA}/pico-8/scripts/start_pico8.sh" + + def test_pico8_runs_its_binary_from_roms(self): + (record,) = [r.entry() for r in rl.pico8(RepoTree.from_files({self.PATH: START_PICO8}))] + self.assertEqual( + (record["emulator"], record["kind"], record["names"], record["search_dir"], + record["passes_to"]), + ("pico-8", "fixed_name", ["pico8_64", "pico8_dyn"], "/storage/roms/pico-8", "exec"), + ) + self.assertIn("HW_ARCH aarch64 runs pico8_64", record["depends_on_option"]) + self.assertEqual(record["source_ref"], f"{self.PATH}:4-11, 15-22") + + def test_a_script_without_its_binary_choice_raises(self): + broken = START_PICO8.replace('STATIC_BIN="pico8_dyn"', "STATIC_BIN=$OTHER") + with self.assertRaises(LauncherPatternMissing): + rl.pico8(RepoTree.from_files({self.PATH: broken})) + + class Revision(unittest.TestCase): def test_the_revision_is_the_one_the_bios_list_is_read_at(self): url = ( diff --git a/tests/test_standalone_links.py b/tests/test_standalone_links.py index 7ea47c50f..979c2ff83 100644 --- a/tests/test_standalone_links.py +++ b/tests/test_standalone_links.py @@ -26,11 +26,16 @@ from common import ( # noqa: E402 load_emulator_profiles, load_platform_config, platform_layouts, + standalone_bindings_for, standalone_destination, - standalone_root_for, standalone_set, ) -from launchers import handed_directories, normalize_handle, standalone_root # noqa: E402 +from launchers import ( # noqa: E402 + bound_path, + handed_directories, + normalize_handle, + standalone_bindings, +) def link(emulator: str, search_dir: str, passes_to: str, **fields: object) -> dict: @@ -101,7 +106,11 @@ class HandedDirectories(unittest.TestCase): class Root(unittest.TestCase): def root(self, *launchers: dict, base: str = "bios") -> str | None: - return standalone_root( + found = self.bindings(*launchers, base=base).get("") + return found[1] if found else None + + def bindings(self, *launchers: dict, base: str = "bios") -> dict[str, tuple[str, str]]: + return standalone_bindings( {"emu"}, PROFILE["standalone_directories"], handed_directories(config(*launchers, base=base)) ) @@ -130,12 +139,15 @@ class Root(unittest.TestCase): "dc", ) - def test_link_of_another_directory_gives_nothing(self) -> None: + def test_link_of_a_subfolder_binds_that_subfolder_only(self) -> None: # A subfolder of the emulator's tree (Dolphin's Triforce) is not the - # directory standalone_path is relative to. - self.assertIsNone(self.root( + # directory standalone_path is relative to, only the files below it move. + found = self.bindings( link("emu", "$bios_path/Triforce", "$XDG_DATA_HOME/emu/Triforce is a symlink to it"), - )) + ) + self.assertEqual(found, {"Triforce": ("", "Triforce")}) + self.assertEqual(bound_path("Triforce/segaboot.gcm", found), ("", "Triforce/segaboot.gcm")) + self.assertIsNone(bound_path("boot.bin", found)) def test_two_folders_give_nothing(self) -> None: self.assertIsNone(self.root( @@ -186,13 +198,14 @@ class Layout(unittest.TestCase): union = standalone_set(config(self.LINKED, base="BIOS")) | standalone_set(config( link("emu", "${biosPath}/emu/bios", "symlink ${biosPath}/emu/bios -> ${HOME}/.local/share/emu"), )) - self.assertIsNone(standalone_root_for("emu", PROFILE, union)) + self.assertEqual(standalone_bindings_for("emu", PROFILE, union), {}) def test_destination_function(self) -> None: entry = PROFILE["files"][0] - self.assertEqual(standalone_destination(entry, "dc"), "dc/boot.bin") - self.assertEqual(standalone_destination(entry, None), "boot.bin") - self.assertEqual(standalone_destination(PROFILE["files"][2], "dc"), "dc/shared.bin") + self.assertEqual(standalone_destination(entry, PROFILE, {"": ("", "dc")}), ("", "dc/boot.bin")) + self.assertIsNone(standalone_destination(entry, PROFILE, {})) + # The entry both builds share keeps path in a dual-build profile. + self.assertIsNone(standalone_destination(PROFILE["files"][2], PROFILE, {"": ("", "dc")})) class FlycastOnEachPlatform(unittest.TestCase): @@ -215,7 +228,8 @@ class FlycastOnEachPlatform(unittest.TestCase): with self.subTest(platform=platform): cfg = load_platform_config(platform, str(REPO_ROOT / "platforms")) self.assertEqual( - standalone_root_for("flycast", self.profile, standalone_set(cfg)), folder + standalone_bindings_for("flycast", self.profile, standalone_set(cfg)), + {"": ("", folder)}, ) def test_no_standalone_file_lands_at_the_bios_root(self) -> None: diff --git a/wiki/adding-a-platform.md b/wiki/adding-a-platform.md index df26c0de2..e307714b3 100644 --- a/wiki/adding-a-platform.md +++ b/wiki/adding-a-platform.md @@ -210,14 +210,22 @@ resolved. Three strategies exist: - **Omitted**: fallback to system ID intersection, `built_by` profiles excluded. Two lists say how each resolved emulator is laid out. `standalone_cores` names the -ones the platform launches as standalone apps: their files go to `standalone_path`, -under the folder the platform hands the emulator when its `launchers:` say so. A -`links` or `passes_path` record of a whole directory that names one of the profile's -`standalone_directories` gives that folder (`launchers.standalone_root`): RetroPie -links Flycast's `~/.local/share/flycast` to `BIOS/dc`, EmuDeck the Flatpak's data -directory to `bios/flycast/bios`, and ROCKNIX's patched data directory is followed -through the link its start script makes. Two records giving two folders leave the -profile's layout. +ones the platform launches as standalone apps: their files go to `standalone_path` +(`path` for a profile with no libretro build), under the folder the platform hands +the emulator when its `launchers:` say so. A `links` or `passes_path` record of a +directory at or below one of the profile's `standalone_directories` binds that +subfolder of the layout to a folder (`launchers.standalone_bindings`): RetroPie +links Flycast's `~/.local/share/flycast` to `BIOS/dc`, EmuDeck yuzu's `keys` to +`bios/yuzu/keys`, and ROCKNIX's patched data directory is followed through the link +its start script makes. A launch script that enters a folder hands the `getcwd` +directory: RetroPie starts UAE4All2 in `$md_inst`, whose `kickstarts` it links to +`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 +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`). `dual_mode_cores` names those it launches both ways (RetroPie packages `lr-hatari` and `hatari`): both layouts are packed. A profile without a standalone build keeps its libretro layout whatever the platform calls it.