chore: merge launcher placement

This commit is contained in:
Abdessamad Derraz committed 2026-11-09 18:23:46 +01:00
commit 56ed37c3f0
32 files changed
+1289 -160

No files matched your search

+117 -31
View File
@@ -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 & {
+5 -1
View File
@@ -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 = {
+48 -11
View File
@@ -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)
+192 -31
View File
@@ -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/<id>/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:
+48 -6
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 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"):
+40
View File
@@ -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/<key>): 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"<AppConfig.GetFullPath(\"{folder.group(1)}\")>",
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,
+4 -3
View File
@@ -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/<name> under it. Each must still pushd.
WORKING_DIRECTORY: tuple[str, ...] = ("drastic",)
# the generated launch script enters with pushd: DraStic opens system/<name>
# under it, UAE4All2 kickstarts/<name> (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*)=(.*)$")
+42
View File
@@ -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,
)
+2 -1
View File
@@ -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] = []
+9 -7
View File
@@ -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]:
+23 -3
View File
@@ -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]