feat: install files into retrobat emulator folders

RetroBat's Pico8 generator runs pico8.exe from AppConfig.GetFullPath:
the emulatorLauncher.cfg key, else the first existing of four folders of
the RetroBat install. The registry describes that search
(emulators_directory) and the manifest writes such a file as
root: emulators. The installer places it only when the BIOS folder sits
beside RetroBat's launcher config and the config does not move the
folder, and names it no_emulators_directory otherwise. Packs skip it as
they skip root: roms.

The PICO-8 0.2.7 Windows build (pico8.exe, SDL2.dll) is collected from
the archive.org copy of pico-8_0.2.7_windows.zip, its pico8.dat being
the 0.2.7 one already held.
This commit is contained in:
Abdessamad Derraz committed 2026-11-09 18:55:49 +01:00
1 parent f47c8b8e5a
commit c8fe8d914d
16 files changed
+329 -79

No files matched your search

Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -16,7 +16,7 @@
# and Invoke-WebRequest throws on a cut transfer before the hash is read:
# the site's build rewrites this line there (scripts/sitebootstrap.py).
$mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
$defaultInstallSha256 = "3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0"
$defaultInstallSha256 = "b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+105 -3
View File
@@ -143,6 +143,7 @@ OMISSION_REASONS = {
"no_save_system": "no system runs the core",
"outside_bios_folder": "read outside the BIOS folder",
"no_roms_directory": "no roms folder",
"no_emulators_directory": "no emulator folder",
}
# What a file is when it is not firmware, as the manifest's category field
# says, and as the installer words it. Firmware carries no category.
@@ -3594,6 +3595,9 @@ class SavePlace(NamedTuple):
_SAVE_DIRS: dict[Path, SavePlace] = {}
# BIOS folder -> the platform's roms folder, set by place_roms_entries.
_ROMS_DIRS: dict[Path, Path] = {}
# (BIOS folder, emulator key) -> the emulator folder, set by
# place_emulator_entries.
_EMULATOR_DIRS: dict[tuple[Path, str], Path] = {}
def _entry_place(root: Path, entry: dict) -> tuple[Path, object]:
@@ -3609,6 +3613,12 @@ def _entry_place(root: Path, entry: dict) -> tuple[Path, object]:
if roms is None:
raise ValueError(f"no roms folder known for {entry.get('dest')}")
return roms, entry.get("dest")
if entry.get("root") == "emulators":
key, _, rest = str(entry.get("dest")).partition("/")
folder = _EMULATOR_DIRS.get((root, key))
if folder is None:
raise ValueError(f"no emulator folder known for {entry.get('dest')}")
return folder, rest
if entry.get("root") != "save":
return root, entry.get("dest")
place = _SAVE_DIRS.get(root)
@@ -3643,7 +3653,7 @@ def _entry_key(entry: dict) -> str:
if root == "emulator":
# A path in one emulator's own folder: two emulators read two files.
return f"emulator\0{','.join(entry.get('cores') or [])}\0{dest}"
return f"{root}\0{dest}" if root in ("save", "roms") else dest
return f"{root}\0{dest}" if root in ("save", "roms", "emulators") else dest
def _check_root(entry: dict, dest: str, omitted: bool = False) -> None:
@@ -3651,8 +3661,10 @@ def _check_root(entry: dict, dest: str, omitted: bool = False) -> None:
the emulator's own folder, outside the BIOS tree), and core_folder one
folder name of a save entry."""
root = entry.get("root")
if root not in (None, "save", "roms") and not (omitted and root == "emulator"):
if root not in (None, "save", "roms", "emulators") and not (omitted and root == "emulator"):
raise ValueError(f"invalid root for {dest}")
if root == "emulators" and len(PurePosixPath(dest).parts) < 2:
raise ValueError(f"an emulators entry names its folder first: {dest}")
folder = entry.get("core_folder")
if folder is None:
return
@@ -3681,6 +3693,32 @@ def _check_roms_directory(data: dict) -> None:
raise ValueError("invalid roms_directory")
def _check_emulators_directory(data: dict) -> None:
"""The manifest's emulators_directory, which every installable root: emulators entry needs."""
layout = data.get("emulators_directory")
installable = [
entry for field in ("files", "case_variants")
for entry in data.get(field) or [] if isinstance(entry, dict)
]
if layout is None:
if any(entry.get("root") == "emulators" for entry in installable):
raise ValueError("entries in emulator folders and no emulators_directory")
return
if not isinstance(layout, dict) or set(layout) != {"bios_folder", "searched", "default", "config"}:
raise ValueError("invalid emulators_directory")
searched = layout["searched"]
if (
not isinstance(searched, list) or not searched or layout["default"] not in searched
or len(_safe_relative_path(layout["bios_folder"], "emulators_directory.bios_folder").parts) != 1
):
raise ValueError("invalid emulators_directory")
for template in searched:
if not isinstance(template, str) or template.count("{key}") != 1:
raise ValueError("invalid emulators_directory.searched")
_safe_relative_path(template.replace("{key}", "key"), "emulators_directory.searched")
_safe_relative_path(layout["config"], "emulators_directory.config")
def _check_save_directory(data: dict) -> None:
"""The manifest's save_directory, which every root: save entry needs."""
layout = data.get("save_directory")
@@ -3985,6 +4023,7 @@ def _validate_manifest(data: object, plat: str) -> dict:
_check_data_directory(entry, f"data_directories[{index}]")
_check_save_directory(data)
_check_roms_directory(data)
_check_emulators_directory(data)
declared_total_files = data.get("total_files")
if declared_total_files is not None and declared_total_files != len(files):
@@ -5280,6 +5319,67 @@ def place_roms_entries(
return kept, unplaced
def _config_keys(path: Path) -> set[str]:
"""The keys an emulatorLauncher-style key=value file sets, lowercased."""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return set()
return {
line.split("=", 1)[0].strip().lower()
for line in text.splitlines() if "=" in line and line.split("=", 1)[1].strip()
}
def place_emulator_entries(
files: list[dict], bios_path: Path, manifest: dict, display: str, say: bool = True,
) -> tuple[list[dict], list[dict]]:
"""Find the emulator folder each entry a launcher runs from one goes to.
The platform keeps its BIOS folder beside its own folder holding the
launcher config (RetroBat's bios beside emulationstation): a BIOS
folder of that name whose parent holds the config is the platform's.
A key the config sets moves the folder somewhere this run does not
follow, so its files are left out. Otherwise the folder is the first of
the searched places that exists, the default one when none does, as the
launcher searches them.
"""
inside = [entry for entry in files if entry.get("root") == "emulators"]
if not inside:
return files, []
layout = manifest.get("emulators_directory") or {}
home = bios_path.parent
config = home / layout["config"] if layout else None
matches = bool(layout) and bios_path.name == layout.get("bios_folder") and config.is_file()
moved = _config_keys(config) if matches else set()
kept: list[dict] = []
unplaced: list[dict] = []
for entry in files:
if entry.get("root") != "emulators":
kept.append(entry)
continue
key = str(entry["dest"]).split("/", 1)[0]
if matches and key.lower() not in moved:
places = [home / t.replace("{key}", key) for t in layout["searched"]]
found = next((place for place in places if place.is_dir()), None)
_EMULATOR_DIRS[(bios_path, key)] = found or home / layout["default"].replace("{key}", key)
kept.append(entry)
continue
unplaced.append({
"dest": entry["dest"], "name": PurePosixPath(entry["dest"]).name,
"system": "", "systems": list(entry.get("systems") or []), "required": False,
"reason": "no_emulators_directory", "cores": entry.get("cores"),
"root": "emulators",
})
if unplaced and say:
why = (f"{config} sets their folder" if matches
else f"{bios_path} is not the {layout.get('bios_folder') or 'BIOS'} folder "
f"beside {layout.get('config') or 'its config'}")
print(f" {_plural(len(unplaced), 'file')} read from {display}'s emulator folders "
f"left out: {why}.")
return kept, unplaced
# Per BIOS folder, where its save files go in words, or why that is not
# known: read by the lines that count them once a choice is made.
_SAVE_WHERE: dict[Path, str] = {}
@@ -15894,7 +15994,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
files, unplaced = place_save_entries(
files, bios_path, manifest, args.save_dir, display, say=False)
files, beyond = place_roms_entries(files, bios_path, manifest, display, say=not listing)
unplaced = [*unplaced, *beyond]
files, elsewhere = place_emulator_entries(
files, bios_path, manifest, display, say=not listing)
unplaced = [*unplaced, *beyond, *elsewhere]
if not manifest.get("save_directory"):
# Its own list leaves these files out: naming the folder with
# --save-dir installs none of them, and was advised in a loop.
+1 -1
View File
@@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins
# curl or wget stop on a cut transfer before the hash is read: the site's
# build rewrites this line there (scripts/sitebootstrap.py).
MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
DEFAULT_INSTALL_SHA256="3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0"
DEFAULT_INSTALL_SHA256="b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+8 -8
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 1133449,
"sha256": "3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0",
"size": 1138424,
"sha256": "b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 1187074,
"sha256": "f98b3e8613e5572430097d4a0133d914986d17c47999020459a8555695a9a427"
"size": 1192049,
"sha256": "9da63b607a6ae2a734f4437c224b484229c514b08bbab6341b529ce102de464f"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 1133449,
"sha256": "3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0",
"size": 1138424,
"sha256": "b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 1133449,
"sha256": "3f44e2c6757db61c78a1df6781a0bbb78294a66cda5752617fc2d3b501847bf0",
"size": 1138424,
"sha256": "b9586fece4058b670a4fe83b8c2825b7882024eba816a36353b07a7496af7f1b",
"executable": false
},
{
+17
View File
@@ -590,6 +590,23 @@ platforms:
hash_type: md5
# Windows: names differing only by case land on one file.
case_insensitive_fs: true
# A generator takes an emulator folder from AppConfig.GetFullPath(key):
# the key in emulatorLauncher.cfg, else the first existing of
# emulationstation/<key>, emulators/<key>, system/emulators/<key> and
# system/<key> below the RetroBat folder (EmulatorLauncher.Common/
# FileFormats/ConfigFile.cs:126-180 at ca486005). emulatorLauncher runs
# from emulationstation/, its LocalPath (emulatorLauncher/Program.cs:536-537),
# beside bios/.
emulators_directory:
launcher_spelling: '<AppConfig.GetFullPath("{key}")>'
bios_folder: bios
searched:
- "emulationstation/{key}"
- "emulators/{key}"
- "system/emulators/{key}"
- "system/{key}"
default: "emulators/{key}"
config: emulationstation/emulatorLauncher.cfg
# emulatorLauncher hands each libretro core saves\<system> at every
# launch, saves\ beside bios\ (emulatorLauncher.cfg in retrobat 8.3.1),
# and sorts saves by core for dolphin alone (CoreSaveSort)
+46 -8
View File
@@ -179,7 +179,7 @@
"dest": {
"type": "string",
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$",
"description": "Where the file would go, below base_destination, below the save directory when root is save, below the roms folder when root is roms, or in the emulator's own folder when root is emulator."
"description": "Where the file would go, below base_destination, below the save directory when root is save, below the roms folder when root is roms, in an emulator folder when root is emulators (its key first), or in the emulator's own folder when root is emulator."
},
"name": {
"type": "string",
@@ -206,7 +206,8 @@
"no_core_folder",
"no_save_system",
"outside_bios_folder",
"no_roms_directory"
"no_roms_directory",
"no_emulators_directory"
],
"description": "Why the file is left out, as a code. enumDescriptions gives the meaning of each code, in the order of enum.",
"enumDescriptions": [
@@ -220,7 +221,8 @@
"The frontend sorts saves by core and the core's profile does not name its folder (core_folder).",
"The launcher gives each system its own save folder and no system of the platform runs the core.",
"The platform runs the emulator from a folder outside the BIOS tree and links none of the BIOS tree where the emulator reads this file (root: emulator).",
"The platform's launcher runs the file from its roms folder (root: roms) and the run cannot tell where that folder is: the BIOS folder is not the one the platform keeps in it."
"The platform's launcher runs the file from its roms folder (root: roms) and the run cannot tell where that folder is: the BIOS folder is not the one the platform keeps in it.",
"The platform's launcher runs the file from one of its emulator folders (root: emulators) and the run cannot place it there: the BIOS folder is not the one beside the platform's launcher config, or that config sets the folder."
]
},
"category": {
@@ -365,11 +367,12 @@
}
},
"root": {
"description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. emulator: dest is the path the emulator reads in its own folder, which the platform keeps outside the BIOS tree (reason outside_bios_folder). roms: dest is relative to the platform's roms folder (roms_directory). Absent for a file of the BIOS tree.",
"description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. emulator: dest is the path the emulator reads in its own folder, which the platform keeps outside the BIOS tree (reason outside_bios_folder). roms: dest is relative to the platform's roms folder (roms_directory). emulators: dest is the emulator folder's key then the path in it (emulators_directory). Absent for a file of the BIOS tree.",
"enum": [
"save",
"emulator",
"roms"
"roms",
"emulators"
]
},
"core_folder": {
@@ -538,6 +541,40 @@
"pattern": "^[^/\\\\]+$"
}
}
},
"emulators_directory": {
"description": "Where the platform keeps its emulator folders, present when an entry carries root: emulators. Paths are relative to the folder holding the BIOS folder, which is named bios_folder. searched are the places the launcher tries for a key, in order, default the one the installer creates when none exists, and config the launcher's key=value file: a key it sets moves the folder, and its files are left out (no_emulators_directory).",
"type": "object",
"additionalProperties": false,
"required": [
"bios_folder",
"searched",
"default",
"config"
],
"properties": {
"bios_folder": {
"type": "string",
"minLength": 1,
"pattern": "^[^/\\\\]+$"
},
"searched": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*\\{key\\}.*$"
}
},
"default": {
"type": "string",
"pattern": "\\{key\\}"
},
"config": {
"type": "string",
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"
}
}
}
},
"$defs": {
@@ -556,7 +593,7 @@
"dest": {
"type": "string",
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$",
"description": "Path below base_destination, below the save directory when root is save, or below the roms folder when root is roms."
"description": "Path below base_destination, below the save directory when root is save, below the roms folder when root is roms, or in an emulator folder when root is emulators (its key first)."
},
"sha1": {
"type": "string",
@@ -1039,10 +1076,11 @@
}
},
"root": {
"description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. roms: dest is relative to the platform's roms folder, which roms_directory locates (a file its launcher runs from there, ROCKNIX's roms/pico-8). Absent for a file of the BIOS tree.",
"description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. roms: dest is relative to the platform's roms folder, which roms_directory locates (a file its launcher runs from there, ROCKNIX's roms/pico-8). emulators: dest is the key of an emulator folder of the platform, then the path in it, located by emulators_directory (RetroBat's emulators/pico8). Absent for a file of the BIOS tree.",
"enum": [
"save",
"roms"
"roms",
"emulators"
]
},
"core_folder": {
+8 -3
View File
@@ -2775,8 +2775,12 @@ def _executed(standalone_cores: Iterable[str]) -> list:
# Why a standalone entry has no destination on a platform that relocates its
# layout: the manifest's omission reason, named as the installer words it.
OUTSIDE_BIOS_FOLDER = "outside_bios_folder"
# The entry root of a file read below the platform's roms directory.
# The entry root of a file read below the platform's roms directory, and of
# one read in an emulator's folder outside the BIOS tree that the registry
# describes. Each is located by the registry entry <root>_directory.
ROMS_ROOT = "roms"
EMULATORS_ROOT = "emulators"
OUTER_ROOTS = (ROMS_ROOT, EMULATORS_ROOT)
def standalone_layout_path(entry: dict, profile: dict) -> str | None:
@@ -2870,8 +2874,9 @@ def platform_layouts(
moved = {**f, "standalone_path": path,
"layout_path": standalone_layout_path(f, profile)}
# A file the launcher runs from the roms directory (ROCKNIX's
# roms/pico-8) is relative to it, and says so.
files.append({**moved, "root": ROMS_ROOT} if tree else moved)
# roms/pico-8) or an emulator folder (RetroBat's emulators/pico8)
# is relative to it, and says so.
files.append({**moved, "root": tree} if tree else moved)
standalone = {**profile, "files": files}
dual = {c.removeprefix(DUAL_MODE_PREFIX) for c in standalone_cores
if c.startswith(DUAL_MODE_PREFIX)}
+4 -3
View File
@@ -24,6 +24,7 @@ from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__))
from common import (
OUTER_ROOTS,
platform_arg,
ProfileSelectionError,
_foreign_tree,
@@ -867,7 +868,7 @@ def _cross_reference_profile(
# archive.
# A platform declares files of its BIOS tree: none answers for one
# the core reads below the save directory.
in_platform = not save_root and f.get("root") != "roms" and declaration_settles(
in_platform = not save_root and f.get("root") not in OUTER_ROOTS and declaration_settles(
f, fname, platform_names, effective_path, index.get("folded_slots", frozenset()),
index.get("declared_contents"),
)
@@ -955,8 +956,8 @@ def _cross_reference_profile(
entry["root"] = "save"
elif f.get("unplaced"):
entry["unplaced"] = f["unplaced"]
elif f.get("root") == "roms":
entry["root"] = "roms"
elif f.get("root") in OUTER_ROOTS:
entry["root"] = f["root"]
elif index.get("launcher_candidates") is not None:
# What verify hands the launcher layer for the same entry.
probe = {
+30 -19
View File
@@ -38,7 +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,
OUTER_ROOTS,
installed_data_refs,
platform_arg,
require_directory,
@@ -1303,9 +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.
if fe.get("root") in OUTER_ROOTS:
# Read below the roms directory or in an emulator folder: the
# pack is extracted into the BIOS folder and cannot reach
# them, the installer places these files.
continue
# Core extras: _collect_emulator_extras already adjusted
# destinations for slug-based platforms. Apply the effective
@@ -4453,11 +4454,11 @@ def _manifest_core_entries(
# extras prefix when it is an internal layout dir (RetroDECK bios/).
# A file read below the save directory is written relative to the
# directory the core is handed, and says so (save_entry_fields).
roms_root = fe.get("root") == ROMS_ROOT
outer_root = fe.get("root") if fe.get("root") in OUTER_ROOTS else None
if save_root:
manifest_dest = save_manifest_dest(fe)
elif roms_root:
# Relative to the roms directory, which the entry names.
elif outer_root:
# Relative to the roms or emulators folder the entry names.
manifest_dest = dest
else:
manifest_dest = _manifest_dest_of(full_dest, base_dest)
@@ -4469,8 +4470,8 @@ def _manifest_core_entries(
)
if entry is not None and save_root:
entry.update(save_entry_fields(fe))
elif entry is not None and roms_root:
entry["root"] = ROMS_ROOT
elif entry is not None and outer_root:
entry["root"] = outer_root
if entry is None:
if not _claim_platform_omission(
omitted_by_destination.get(full_dest), source_emu
@@ -4630,6 +4631,13 @@ def _record_absent(
)
# What a manifest's roms_directory and emulators_directory carry from the
# registry: the BIOS folder's name, and for emulator folders the places the
# launcher searches, the one a missing folder is created at, and the config
# file whose key moves a folder.
MANIFEST_OUTER_FIELDS = ("bios_folder", "searched", "default", "config")
def _record_unplaceable(unplaceable: list[dict], record_omission: Callable[..., None]) -> None:
"""Held by the collection, nowhere to put it on this platform.
@@ -4639,8 +4647,8 @@ def _record_unplaceable(unplaceable: list[dict], record_omission: Callable[...,
where = u.get("path") or u.get("name", "")
if u.get("unplaced"):
key = f"emulator:{u.get('profile') or u.get('emulator', '')}:{where}"
elif u.get("root") == ROMS_ROOT:
key = f"roms:{where}"
elif u.get("root") in OUTER_ROOTS:
key = f"{u['root']}:{where}"
elif u.get("root") == "save":
key = f"save:{save_manifest_dest(u)}"
else:
@@ -4832,10 +4840,10 @@ def generate_manifest(
if outside:
dest_fields: dict = {"dest": sanitize_pack_path(file_entry.get("path") or ""),
"root": "emulator"}
elif file_entry.get("root") == ROMS_ROOT:
elif file_entry.get("root") in OUTER_ROOTS:
dest_fields = {"dest": sanitize_pack_path(
file_entry.get("destination") or file_entry.get("path") or ""
), "root": ROMS_ROOT}
), "root": file_entry["root"]}
elif save_root:
dest_fields = {"dest": save_manifest_dest(file_entry), **save_entry_fields(file_entry)}
else:
@@ -5195,12 +5203,15 @@ def generate_manifest(
}
if data_directories:
result["data_directories"] = data_directories
roms = config.get("roms_directory") or {}
if any(entry.get("root") == ROMS_ROOT
for entry in [*manifest_files, *result["omitted_files"]]) and roms:
# Where the installer finds the roms directory: the folder holding
# the BIOS folder, which the platform names bios_folder.
result["roms_directory"] = {"bios_folder": str(roms.get("bios_folder") or "")}
for root in OUTER_ROOTS:
layout = config.get(f"{root}_directory") or {}
if layout and any(entry.get("root") == root
for entry in [*manifest_files, *result["omitted_files"]]):
# Where the installer finds the folder: relative to the folder
# holding the BIOS folder, which the platform names bios_folder.
result[f"{root}_directory"] = {
key: layout[key] for key in MANIFEST_OUTER_FIELDS if key in layout
}
saved = [entry for entry in manifest_files if entry.get("root") == "save"]
if saved:
layout = save_layout(config)
+21
View File
@@ -329,6 +329,8 @@ EXECUTABLE_DIRECTORY = "executable directory"
# relative to, and the roms directory of a platform whose registry names it.
BIOS_TREE = ""
ROMS_TREE = "roms"
# The emulator folders of a platform whose registry describes them.
EMULATORS_TREE = "emulators"
# A folder of neither tree, which no pack and no installer can fill.
OUTSIDE_TREE = "outside"
Place = tuple[str, str]
@@ -448,6 +450,22 @@ def folder_in_roms(directory: str, config: dict) -> str | None:
return None if re.search(r"[\s<>$:;]", rest) else rest
def folder_in_emulators(directory: str, config: dict) -> str | None:
"""The emulator folder a launcher names by its key, None for any other.
Only a platform whose registry describes its emulator folders has one
(``emulators_directory.launcher_spelling``): RetroBat's generators take
each from AppConfig.GetFullPath("<key>"), a folder of the RetroBat
install named after the key.
"""
spelling = str((config.get("emulators_directory") or {}).get("launcher_spelling") or "")
if "{key}" not in spelling:
return None
pattern = re.escape(spelling).replace(re.escape("{key}"), r"([\w.-]+)")
found = re.fullmatch(pattern, directory.strip())
return found.group(1) if found else None
@dataclass(frozen=True)
class ExecutedFile:
"""A file a platform's launcher executes, and where it lies.
@@ -474,6 +492,7 @@ def executed_files(config: dict) -> list[ExecutedFile]:
raw = str(record.get("search_dir") or "").strip()
directories = _directories(record, base)
roms = folder_in_roms(raw, config) if raw and not directories else None
emulator_folder = folder_in_emulators(raw, config) if raw and not directories else None
# A program file needs no extension (pico8, vox): the names are
# taken as written, placeholders left out.
for name in (str(n).strip() for n in record.get("names") or []):
@@ -485,6 +504,8 @@ def executed_files(config: dict) -> list[ExecutedFile]:
))
elif roms is not None:
place = (ROMS_TREE, sanitize_pack_path(f"{roms}/{name}" if roms else name))
elif emulator_folder is not None:
place = (EMULATORS_TREE, sanitize_pack_path(f"{emulator_folder}/{name}"))
else:
place = None
for emulator in (e.strip() for e in str(record.get("emulator", "")).split(",")):
+28 -23
View File
@@ -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 ROMS_ROOT, sanitize_pack_path
from common import OUTER_ROOTS, sanitize_pack_path
from common import size_fits
from validation import deliverable, read_from_save_dir, read_from_system_dir
from packpaths import libretro_systems, save_destination, save_layout
@@ -43,22 +43,23 @@ def extra_full_destination(fe: dict, prefix: str) -> str:
"""
if fe.get("root") == "save":
return str(fe.get("pack_path") or "")
if fe.get("root") == ROMS_ROOT:
return roms_destination(sanitize_pack_path(fe.get("destination", fe.get("name", ""))))
if fe.get("root") in OUTER_ROOTS:
return outer_destination(
fe["root"], sanitize_pack_path(fe.get("destination", fe.get("name", "")))
)
return prefixed_destination(
sanitize_pack_path(fe.get("destination", fe.get("name", ""))), prefix
)
# Keys a destination below the roms directory apart from the BIOS tree's
# paths. Never written into a pack: the pack is extracted into the BIOS
# folder, and the installer places these files (``root: roms``).
ROMS_KEY = "roms:"
def outer_destination(root: str, dest: str) -> str:
"""The key of a destination below the roms or an emulator folder.
def roms_destination(dest: str) -> str:
"""The key of a destination below the roms directory."""
return f"{ROMS_KEY}{dest}"
Kept apart from the BIOS tree's paths and never written into a pack:
the pack is extracted into the BIOS folder, and the installer places
these files (``root: roms``, ``root: emulators``).
"""
return f"{root}:{dest}"
def prefixed_destination(dest: str, prefix: str) -> str:
@@ -479,7 +480,7 @@ def unplaceable_extras(
for u in undeclared:
if not u.get("in_repo") and not u.get("unplaced"):
continue
if (not placement.slug_based and u.get("root") not in ("save", ROMS_ROOT)
if (not placement.slug_based and u.get("root") not in ("save", *OUTER_ROOTS)
and not u.get("unplaced")):
continue
dest = _extra_dest(u)
@@ -567,7 +568,7 @@ def _agnostic_scan_extras(
# The scan walks the BIOS tree: a file read below the save
# directory is placed by the gap report's pass.
if (read_from_save_dir(f, standalone) or f.get("unplaced")
or f.get("root") == ROMS_ROOT):
or f.get("root") in OUTER_ROOTS):
continue
# An agnostic BIOS mode says the BIOS filename is free, not that
# every file the emulator loads is. A free filename still has a
@@ -1018,9 +1019,12 @@ class ExtraPlacement:
self.native_systems = native_slug_systems(config)
self.flat = bool(config.get("firmware_folder_flat"))
self.save = save_layout(config)
# The roms directory the registry names, for a file a launcher runs
# from it (ROCKNIX's roms/pico-8).
self.roms = bool((config.get("roms_directory") or {}).get("path"))
# The roms directory and emulator folders the registry describes,
# for a file a launcher runs from them (ROCKNIX's roms/pico-8,
# RetroBat's emulators/pico8).
self.outer = {
root for root in OUTER_ROOTS if config.get(f"{root}_directory")
}
self.config = config
self.norm_map = (
{_norm_system_id(sid): sid for sid in self.platform_systems}
@@ -1041,8 +1045,9 @@ class ExtraPlacement:
"""
if entry.get("unplaced"):
return None
if entry.get("root") == ROMS_ROOT:
return (dest, roms_destination(dest)) if self.roms else None
if entry.get("root") in OUTER_ROOTS:
root = entry["root"]
return (dest, outer_destination(root, dest)) if root in self.outer else None
if entry.get("root") == "save":
placed = self.save_places(entry, dest)
return placed[0] if placed else None
@@ -1103,8 +1108,8 @@ class ExtraPlacement:
"""Why ``place`` returned None for this entry."""
if entry.get("unplaced"):
return str(entry["unplaced"])
if entry.get("root") == ROMS_ROOT:
return "no_roms_directory"
if entry.get("root") in OUTER_ROOTS:
return f"no_{entry['root']}_directory"
if entry.get("root") == "save":
if self.save is None:
return "no_save_directory"
@@ -1329,7 +1334,7 @@ def _collect_emulator_extras(
# covered elsewhere belongs to another program's file.
# Nor is one placed outside the BIOS tree by the layout.
if (fname not in covered_names or f.get("unsourceable") or f.get("unplaced")
or f.get("root") == ROMS_ROOT):
or f.get("root") in OUTER_ROOTS):
continue
# Installed with the emulator or by the platform's package,
# as the first pass already decided for the same entry.
@@ -1568,8 +1573,8 @@ def _report_extra(u: dict, name: str, dest: str) -> dict:
extra["root"] = "save"
if u.get("core_folder") is not None:
extra["core_folder"] = u["core_folder"]
elif u.get("root") == ROMS_ROOT:
extra["root"] = ROMS_ROOT
elif u.get("root") in OUTER_ROOTS:
extra["root"] = u["root"]
if u.get("system_dir"):
extra["system_dir"] = u["system_dir"]
if u.get("layout_path"):
+13 -9
View File
@@ -38,6 +38,7 @@ import slots
sys.path.insert(0, os.path.dirname(__file__))
from hashing import parse_md5_list
from common import (
OUTER_ROOTS,
_foreign_tree,
_owner_tokens,
platform_arg,
@@ -596,7 +597,7 @@ def find_undeclared_files(
# of its own: two cores reading hiscore.dat there read
# two files.
("save", core_save_folder(profile)) if save_root
else ("roms",) if f.get("root") == "roms"
else (f["root"],) if f.get("root") in OUTER_ROOTS
# In the emulator's own folder: no other core reads it.
else ("emulator", emu_name) if f.get("unplaced") else None,
)
@@ -609,9 +610,9 @@ def find_undeclared_files(
# Nor does it answer for one read below the roms directory.
verdict = _candidate_verdict(
f, fname, is_standalone, include_all,
{} if save_root or f.get("root") == "roms" else declared_names,
{} if save_root or f.get("root") in OUTER_ROOTS else declared_names,
dest, folded_slots,
None if save_root or f.get("root") == "roms" else declared_contents,
None if save_root or f.get("root") in OUTER_ROOTS else declared_contents,
)
if verdict == "settled":
seen_files.add(seen_key)
@@ -772,9 +773,10 @@ def find_undeclared_files(
entry["layout_path"] = f["layout_path"]
if f.get("unplaced"):
entry["unplaced"] = f["unplaced"]
elif f.get("root") == "roms":
# Read below the roms directory (ROCKNIX's roms/pico-8).
entry["root"] = "roms"
elif f.get("root") in OUTER_ROOTS:
# Read below the roms directory (ROCKNIX's roms/pico-8)
# or in an emulator folder (RetroBat's emulators/pico8).
entry["root"] = f["root"]
elif launcher_layer and not save_root:
launcher_candidates.append((emu_name, is_standalone, f, entry))
if duplicate:
@@ -885,6 +887,7 @@ def find_exclusion_notes(
_UNPLACED_REASONS = {
"outside_bios_folder": "read in a folder the platform keeps outside the BIOS tree",
"no_roms_directory": "read below a roms folder the platform's registry does not name",
"no_emulators_directory": "read in an emulator folder the platform's registry does not describe",
"no_platform_slug": "no platform slug for its system",
"below_platform_folder": "below the folder the platform lists",
"no_save_directory": "read below a save directory the platform keeps in no fixed place",
@@ -908,11 +911,12 @@ def _mark_unplaced(config: dict, undeclared: list[dict], profiles: dict) -> None
reason = unplaced.get(
(u.get("emulator", ""), u.get("name", ""), u.get("path") or "")
)
if not reason and u.get("root") == "roms":
if not reason and u.get("root") in OUTER_ROOTS:
# The pack is extracted into the BIOS folder: the installer
# writes this file into the roms folder, the pack cannot.
# writes this file into the roms or emulator folder, the pack
# cannot.
u["in_pack"] = False
u["omitted"] = "read below the roms folder, which only the installer reaches"
u["omitted"] = f"read below the {u['root']} folder, which only the installer reaches"
continue
if reason:
u["in_pack"] = False
+42
View File
@@ -262,6 +262,7 @@ class AProgramRunFromItsFolder(unittest.TestCase):
"biosplat": [{**run, "names": ["prog/prog"], "search_dir": "/userdata/bios"}],
"romsplat": [{**run, "names": ["prog"], "search_dir": "/storage/roms/prog"}],
"exeplat": [{**run, "names": ["prog"], "search_dir": "<emulator folder>"}],
"emuplat": [{**run, "names": ["prog"], "search_dir": '<GetPath("prog")>'}],
"plainplat": [],
}
for name, records in launchers.items():
@@ -275,6 +276,10 @@ class AProgramRunFromItsFolder(unittest.TestCase):
**{name: {"config": f"{name}.yml"} for name in launchers},
"romsplat": {"config": "romsplat.yml",
"roms_directory": {"path": "/storage/roms", "bios_folder": "bios"}},
"emuplat": {"config": "emuplat.yml", "emulators_directory": {
"launcher_spelling": '<GetPath("{key}")>', "bios_folder": "bios",
"searched": ["frontend/{key}", "emulators/{key}"],
"default": "emulators/{key}", "config": "frontend/launcher.cfg"}},
}}))
(self.emulators / "prog.yml").write_text(yaml.safe_dump({
"emulator": "Prog", "type": "standalone", "cores": ["prog"],
@@ -363,6 +368,43 @@ class AProgramRunFromItsFolder(unittest.TestCase):
with self.assertRaises(ValueError):
install._check_roms_directory({"files": files})
def test_run_from_an_emulator_folder_the_registry_describes(self) -> None:
self.assertEqual(self.pack("emuplat"), [])
manifest = self.manifest("emuplat")
self.assertEqual(
sorted((f["dest"], f.get("root")) for f in manifest["files"]),
[("prog/prog", "emulators"), ("prog/prog.dat", "emulators")],
)
self.assertEqual(manifest["emulators_directory"]["default"], "emulators/{key}")
self.assertTrue(all(u["in_pack"] is False for u in self.verify("emuplat")))
def test_the_installer_follows_the_launcher_search(self) -> None:
import install
manifest = self.manifest("emuplat")
install._check_emulators_directory(manifest)
files = list(manifest["files"])
home = self.root / "Frontend"
bios = home / "bios"
bios.mkdir(parents=True)
# Not the platform's folder: no launcher config beside it.
kept, left = install.place_emulator_entries(files, bios, manifest, "emuplat", say=False)
self.assertEqual((kept, {o["reason"] for o in left}), ([], {"no_emulators_directory"}))
(home / "frontend").mkdir()
(home / "frontend" / "launcher.cfg").write_text("other=x\n")
kept, left = install.place_emulator_entries(files, bios, manifest, "emuplat", say=False)
self.assertEqual((len(kept), left), (2, []))
entry = next(f for f in kept if f["dest"] == "prog/prog")
self.assertEqual(install._entry_place(bios, entry), (home / "emulators" / "prog", "prog"))
# The first searched place that exists wins.
(home / "frontend" / "prog").mkdir()
install.place_emulator_entries(files, bios, manifest, "emuplat", say=False)
self.assertEqual(install._entry_place(bios, entry)[0], home / "frontend" / "prog")
# A key the launcher config sets moves the folder: left out.
(home / "frontend" / "launcher.cfg").write_text("prog=D:\\Prog\n")
kept, left = install.place_emulator_entries(files, bios, manifest, "emuplat", say=False)
self.assertEqual((kept, len(left)), ([], 2))
class EachProgramItsOwnProfile(unittest.TestCase):
"""PICO-8 and Voxatron are two programs: a platform running one gets its files only."""
+5 -1
View File
@@ -222,7 +222,11 @@ directory: RetroPie starts UAE4All2 in `$md_inst`, whose `kickstarts` it links t
`BIOS/amiga`. A profile declaring `executable directory` is bound where a record
executes one of its files: Batocera runs `pico-8/pico8` from `bios`, ROCKNIX
`pico8_64` from `/storage/roms/pico-8`, below the `roms_directory` its registry entry
names, which the manifest writes as `root: roms`. Two records giving two folders
names, which the manifest writes as `root: roms`. RetroBat runs `pico8.exe` from an
emulator folder its generators name by key: the registry's `emulators_directory`
spells that key and lists the places the launcher searches, and the manifest writes
`root: emulators`, which the installer places only beside RetroBat's own launcher
config. Two records giving two folders
leave the profile's layout. Once a platform binds part of a layout, an entry no
binding covers is read in a folder outside the BIOS tree: it is never packed, and
the manifest names it (`root: emulator`, `outside_bios_folder`).