feat: place save-dir files in each system folder

Batocera, KNULLI, Recalbox, RetroBat and RetroDECK hand a libretro core <saves>/<system> at each launch. Each copy lands below every system that runs the core. EmuDeck keeps one unsorted saves/retroarch/saves. The unsupported platforms record why.
This commit is contained in:
Abdessamad Derraz committed 2026-10-22 21:16:59 +02:00
1 parent 9a1bf4baa6
commit 2d26385efe
8 files changed
+394 -124

No files matched your search

+13 -20
View File
@@ -12,7 +12,7 @@ and 3-tier storage (embedded/external/user_provided).
from __future__ import annotations
from collections.abc import Callable, Iterable, Iterator
from collections.abc import Callable, Iterator
from artifacts import _build_timestamp, set_plain_mode
import argparse
@@ -1192,6 +1192,13 @@ def generate_pack(
).get("standalone_copies", []),
launch_folders=len(config.get("core_system_dirs") or {}),
save_folder=(save_layout(config).folder if save_count else ""),
save_where=(
save_place(
str(_pack_registry.get(platform_name, {}).get("extract_to") or ""),
save_layout(config).folder, base_dest,
) if save_count else ""
),
save_by_system=bool(save_count and save_layout(config).by_system),
)
_write_generated_member(zf, "README.txt", readme_text)
@@ -3975,24 +3982,6 @@ def _manifest_core_entries(
return total_size
def _check_save_destinations(files: list[dict], omitted: Iterable[dict]) -> None:
"""Refuse a manifest naming one destination below both directories.
The installer keys every entry by its ``dest``: a file read below the
save directory and one of the BIOS tree written alike would be taken
for one file.
"""
roots: dict[str, set[bool]] = {}
for entry in [*files, *omitted]:
roots.setdefault(entry["dest"], set()).add(entry.get("root") == "save")
clashes = sorted(dest for dest, kinds in roots.items() if len(kinds) > 1)
if clashes:
raise ValueError(
"destinations below both the system and save directories: "
+ ", ".join(clashes[:10])
)
def _manifest_dest_of(full_dest: str, base_dest: str) -> str:
"""A pack destination as the manifest writes it, below base_destination."""
if base_dest and full_dest.startswith(f"{base_dest}/"):
@@ -4598,9 +4587,12 @@ def generate_manifest(
result["data_directories"] = data_directories
saved = [entry for entry in manifest_files if entry.get("root") == "save"]
if saved:
_check_save_destinations(manifest_files, omitted_by_destination.values())
layout = save_layout(config)
result["save_directory"] = {"folder": layout.folder, "by_core": layout.by_core}
if layout.by_system:
# The launcher writes the directory at each launch, one folder
# per system: each dest starts with that folder.
result["save_directory"]["by_system"] = True
if case_insensitive and fold_case is None and any(
d.get("os") not in CASE_FOLDING_OS for d in detect
):
@@ -5061,6 +5053,7 @@ from packextras import ( # noqa: E402,F401
from packreadme import ( # noqa: E402,F401
_build_readme,
_build_agnostic_rename_readme,
save_place,
)
from packverify import ( # noqa: E402,F401
_check_member_hash,
+132 -56
View File
@@ -20,7 +20,7 @@ from common import standalone_set as platform_standalone_set
from common import sanitize_pack_path
from common import size_fits
from validation import deliverable, read_from_save_dir, read_from_system_dir
from packpaths import save_destination, save_layout
from packpaths import libretro_systems, save_destination, save_layout
from region import FILE_GROUP
@@ -67,15 +67,16 @@ def save_entry_fields(fe: dict) -> dict:
def save_manifest_dest(fe: dict) -> str:
"""A manifest dest for a file read below the save directory.
The pack path below the platform's save folder when the file is placed,
the core's own folder first where the frontend sorts saves by core.
The pack path below the platform's save folder when the file is placed
(``save_dest``), the system's folder first where the launcher keeps one
per system, the core's own folder first where the frontend sorts saves
by core.
Without a save folder (an omission), the same shape: the core's folder,
then the path the core reads, so two cores reading one name below their
own folders stay two entries.
"""
pack_path = str(fe.get("pack_path") or "")
if pack_path:
return pack_path.split("/", 1)[1]
if fe.get("save_dest"):
return str(fe["save_dest"])
rel = sanitize_pack_path(fe.get("destination") or fe.get("path") or fe.get("name", ""))
folder = fe.get("core_folder")
return f"{folder}/{rel}" if folder else rel
@@ -324,6 +325,7 @@ def hidden_extras(
lower = set(content) if case_insensitive else set()
prefix = _detect_extras_prefix(config, base_dest)
hidden: dict[tuple[str, str, str], str] = {}
packed_keys: set[tuple[str, str, str]] = set()
extras = _collect_emulator_extras(
config, emulators_dir, db, set() if system_ids else set(shipped),
base_dest, profiles, target_cores=target_cores, include_all=include_all,
@@ -356,13 +358,14 @@ def hidden_extras(
held = (sha1_by_path.get(local) or _file_sha1(local)) if local else None
if reason == "case_twin" and held and content.get(full.lower()) == held:
continue
key = (
fe.get("source_emulator", ""), fe.get("source_name", ""),
fe.get("source_path", ""),
)
if reason is not None:
key = (
fe.get("source_emulator", ""), fe.get("source_name", ""),
fe.get("source_path", ""),
)
hidden.setdefault(key, reason)
continue
packed_keys.add(key)
_register_path(full, seen, parents)
content[full.lower()] = held
if case_insensitive:
@@ -373,7 +376,9 @@ def hidden_extras(
placed_kind[full] = fe.get("category", "bios")
if placed_local is not None and local:
placed_local[full] = local
return hidden
# A file read below a save directory kept per system is packed once per
# system: one copy left out does not leave the file out.
return {key: reason for key, reason in hidden.items() if key not in packed_keys}
def _extra_dest(u: dict) -> str:
@@ -407,7 +412,7 @@ def unplaceable_extras(
if not placement.slug_based and u.get("root") != "save":
continue
dest = _extra_dest(u)
if placement.place(u, dest) is None:
if not placement.places(u, dest):
key = (u.get("emulator", ""), u.get("name", ""), u.get("path") or "")
unplaced[key] = placement.unplaced_reason(u, dest)
return unplaced
@@ -893,6 +898,7 @@ class ExtraPlacement:
self.native_systems = native_slug_systems(config)
self.flat = bool(config.get("firmware_folder_flat"))
self.save = save_layout(config)
self.config = config
self.norm_map = (
{_norm_system_id(sid): sid for sid in self.platform_systems}
if self.slug_based
@@ -911,10 +917,8 @@ class ExtraPlacement:
platform keeping saves beside the content does not have.
"""
if entry.get("root") == "save":
if self.save is None:
return None
full = save_destination(dest, entry.get("core_folder"), self.save)
return (dest, full) if full else None
placed = self.save_places(entry, dest)
return placed[0] if placed else None
if self.slug_based:
slug = _slug_for(
entry, self.profiles, self.platform_systems, self.norm_map,
@@ -925,6 +929,43 @@ class ExtraPlacement:
dest = f"{slug}/{dest}"
return dest, (f"{self.prefix}/{dest}" if self.prefix else dest)
def places(self, entry: dict, dest: str) -> list[tuple[str, str]]:
"""Every (pack-relative, full) destination the pack gives an entry.
One for most entries. A file read below the save directory of a
platform that hands each system a save folder of its own goes once
below each system that runs the core.
"""
if entry.get("root") == "save":
return self.save_places(entry, dest)
placed = self.place(entry, dest)
return [placed] if placed is not None else []
def save_places(self, entry: dict, dest: str) -> list[tuple[str, str]]:
"""The destinations of a file read below the save directory."""
if self.save is None:
return []
names = self.core_names(entry)
sorted_by_core = self.save.sorts(names)
systems = libretro_systems(self.config, names) if self.save.by_system else [""]
placed = []
for system in systems:
full = save_destination(
dest, entry.get("core_folder"), self.save, system, sorted_by_core
)
if full:
placed.append((dest, full))
return placed
def core_names(self, entry: dict) -> set[str]:
"""The names a report entry's core goes by: its profile key and the
upstream names its profile lists."""
key = entry.get("profile") or entry.get("emulator", "")
profile = self.profiles.get(key) or next(
(pp for pp in self.profiles.values() if pp.get("emulator") == key), {}
)
return {str(key), *(str(c) for c in profile.get("cores") or [])} - {""}
def nested(self, dest: str) -> bool:
"""True when the file would sit below a folder the frontend lists flat."""
return self.slug_based and self.flat and "/" in dest
@@ -932,7 +973,13 @@ class ExtraPlacement:
def unplaced_reason(self, entry: dict, dest: str) -> str:
"""Why ``place`` returned None for this entry."""
if entry.get("root") == "save":
return "no_save_directory" if self.save is None else "no_core_folder"
if self.save is None:
return "no_save_directory"
if self.save.by_system and not libretro_systems(
self.config, self.core_names(entry)
):
return "no_save_system"
return "no_core_folder"
return "below_platform_folder" if self.nested(dest) else "no_platform_slug"
@@ -1053,49 +1100,51 @@ def _collect_emulator_extras(
name = archive if archive else u["name"]
dest = _extra_dest(u)
# Slug-based platforms: prefix dest with system slug
placed = placement.place(u, dest)
# Slug-based platforms: prefix dest with system slug. A file read
# below a save directory kept per system goes below each system.
placements = placement.places(u, dest)
save_root = u.get("root") == "save"
if not u["in_repo"]:
if placed is not None:
for placed in placements:
missing = _report_extra(u, name, placed[0])
if u.get("root") == "save":
missing["pack_path"] = placed[1]
if save_root:
_save_fields(missing, placed[1], placement)
absent.append(missing)
continue
if placed is None:
if not placements:
if unplaceable is not None:
reason = placement.unplaced_reason(u, dest)
unplaceable.append(
u if reason == "no_platform_slug" else {**u, "unplaceable": reason}
)
continue
dest, full_dest = placed
if claimants is not None:
claimants[(u.get("emulator", ""), u.get("name", ""), u.get("path") or "")] = (
sanitize_pack_path(dest)
sanitize_pack_path(placements[0][0])
)
candidates = [
_report_extra(c, name, dest) for c in (u, *u.get("other_claims", []))
]
if u.get("root") == "save":
for dest, full_dest in placements:
candidates = [
_report_extra(c, name, dest) for c in (u, *u.get("other_claims", []))
]
if save_root:
for item in candidates:
_save_fields(item, full_dest, placement)
# Where the builder meets each claim: the strongest proof first, the
# report's profile order between equals, archives after loose files.
for item in candidates:
item["pack_path"] = full_dest
# Where the builder meets each claim: the strongest proof first, the
# report's profile order between equals, archives after loose files.
for item in candidates:
item["claim_order"] = (
1, 1 if u.get("archive") else 0, -rank.of_extra(item, full_dest),
item["source_profile"],
)
candidates.sort(key=lambda item: item["claim_order"])
extra, claims = candidates[0], candidates[1:]
if not _take(extra, full_dest, seen_dests, taken):
item["claim_order"] = (
1, 1 if u.get("archive") else 0, -rank.of_extra(item, full_dest),
item["source_profile"],
)
candidates.sort(key=lambda item: item["claim_order"])
extra, claims = candidates[0], candidates[1:]
if not _take(extra, full_dest, seen_dests, taken):
for claim in claims:
_another_claim(taken.get(full_dest), claim)
continue
for claim in claims:
_another_claim(taken.get(full_dest), claim)
continue
for claim in claims:
_another_claim(extra, claim)
extras.append(extra)
_another_claim(extra, claim)
extras.append(extra)
# Second pass: find alternative destinations for files already in the pack.
# A file declared by the platform or emitted above may also be needed at a
@@ -1234,20 +1283,29 @@ class _ClaimRanks:
self._db = db
# Two spellings of one path are one file where the filesystem folds case.
self._key = (lambda dest: dest.lower()) if fold else (lambda dest: dest)
self._placed: dict[int, tuple[str, str] | None] = {}
# An entry can take several destinations: one per system folder of a
# save directory kept per system.
self._placed: dict[int, list[tuple[str, str]]] = {}
counts: dict[str, int] = {}
for u in undeclared:
placed = (
placement.place(u, _extra_dest(u))
if u.get("in_repo") and not u.get("bundled") else None
placement.places(u, _extra_dest(u))
if u.get("in_repo") and not u.get("bundled") else []
)
self._placed[id(u)] = placed
if placed is not None:
key = self._key(placed[1])
for _dest, full in placed:
key = self._key(full)
counts[key] = counts.get(key, 0) + 1 + len(u.get("other_claims", []))
self._contested = {dest for dest, n in counts.items() if n > 1}
self._cache: dict[tuple, int] = {}
def _contests(self, u: dict) -> list[tuple[str, str]]:
"""The entry's destinations another claim also asks for."""
return [
placed for placed in self._placed.get(id(u), [])
if self._key(placed[1]) in self._contested
]
def of_extra(self, extra: dict, full_dest: str) -> int:
if self._key(full_dest) not in self._contested:
return 0
@@ -1275,22 +1333,23 @@ class _ClaimRanks:
first: dict[str, int] = {}
keys: list[tuple[int, int, int]] = []
for position, u in enumerate(undeclared):
placed = self._placed.get(id(u))
if placed is None or self._key(placed[1]) not in self._contested:
contests = self._contests(u)
if not contests:
keys.append((position, 0, position))
continue
group = first.setdefault(self._key(placed[1]), position)
group = first.setdefault(self._key(contests[0][1]), position)
keys.append((group, -self.of_entry(u), position))
order = sorted(range(len(undeclared)), key=keys.__getitem__)
return [undeclared[i] for i in order]
def of_entry(self, u: dict) -> int:
placed = self._placed.get(id(u))
if placed is None or self._key(placed[1]) not in self._contested:
contests = self._contests(u)
if not contests:
return 0
name = u.get("archive") or u["name"]
return max(
self.of_extra(_report_extra(c, name, placed[0]), placed[1])
self.of_extra(_report_extra(c, name, dest), full)
for dest, full in contests
for c in (u, *u.get("other_claims", []))
)
@@ -1300,6 +1359,23 @@ def _names_content(entry: dict) -> bool:
return any(entry.get(field) for field in ("sha1", "md5", "sha256", "crc32"))
def _save_fields(extra: dict, pack_path: str, placement: ExtraPlacement) -> None:
"""Record where a file read below the save directory goes in the pack.
``core_folder`` stays only where the frontend sorts saves by core for
every core, the one setting a frontend config can turn off and the
installer then reads (RetroArch's sort_savefiles_enable). A launcher
writing the save directory at each launch fixes the path the pack
already spells out.
"""
extra["pack_path"] = pack_path
if placement.save is not None:
# The path below the save directory, what the manifest writes.
extra["save_dest"] = pack_path[len(placement.save.folder) + 1:]
if not placement.save.by_core:
extra.pop("core_folder", None)
def _report_extra(u: dict, name: str, dest: str) -> dict:
"""The extra a cross-reference entry asks for at its destination."""
extra = {
+62 -15
View File
@@ -6,6 +6,7 @@ on Windows is not a pack."""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from common import sanitize_pack_path
@@ -17,36 +18,58 @@ class SaveLayout:
"""Where a platform's frontend keeps the save directory it hands a core.
``folder`` names it twice: the folder of the pack that holds the files
read below it, and its default place beside the system directory
(RetroArch's own folder holds system/ and saves/). A pack is extracted
into the system directory, so that folder is moved by hand, and the
installer writes to the save directory the frontend's config names.
read below it, and its default place relative to the folder holding the
system directory (RetroArch's own folder holds system/ and saves/,
EmuDeck's Emulation/ holds bios/ and saves/retroarch/saves/). A pack is
extracted into the system directory, so that folder is moved by hand,
and the installer writes to the save directory the frontend's config
names.
``by_core`` is set when the frontend hands each core <save
directory>/<library_name>, RetroArch's sort_savefiles_enable.
``by_system`` is set when the launcher hands a core <save
directory>/<system>, the system it was launched for (Batocera's
/userdata/saves/<system>): a file read there is placed once for each
system that runs the core, the systems the platform file names in
``libretro_systems``. ``by_core_for`` names the cores a launcher
sorts by core where it sorts no other.
"""
folder: str
by_core: bool
by_system: bool = False
by_core_for: frozenset[str] = frozenset()
def sorts(self, names: Iterable[str]) -> bool:
"""Whether the frontend inserts the core's folder for a core known
by any of these names: always where it sorts saves by core, else
only for the cores its launcher sorts (``by_core_for``, RetroBat's
CoreSaveSort)."""
return self.by_core or bool(self.by_core_for & {str(n) for n in names})
def save_layout(config: dict) -> SaveLayout | None:
"""The platform's save directory, or None when it is no fixed place.
Read from the registry's ``save_directory``. A platform that keeps
saves beside the content, or in one folder per launched system, has
none: a file read below it has no single destination.
saves beside the content has none: a file read below it has no single
destination. ``folder`` is a relative path, never one that climbs.
"""
block = config.get("save_directory")
if not isinstance(block, dict):
return None
folder = sanitize_pack_path(str(block.get("folder") or ""))
if not folder or "/" in folder:
raw = str(block.get("folder") or "")
folder = sanitize_pack_path(raw)
if not folder or folder != raw:
return None
return SaveLayout(folder, bool(block.get("by_core")))
return SaveLayout(
folder, bool(block.get("by_core")), bool(block.get("by_system")),
frozenset(str(core) for core in block.get("by_core_for") or []),
)
def save_destination(
path: str, core_folder: str | None, layout: SaveLayout
path: str, core_folder: str | None, layout: SaveLayout, system: str = "",
sorted_by_core: bool | None = None,
) -> str | None:
"""The pack path of a file a core reads below the save directory.
@@ -54,16 +77,40 @@ def save_destination(
is the folder a frontend sorting saves by core inserts: the core's
library_name, empty when it inserts none (an empty library_name, or a
core that reads the same place sorted or not), None when the profile
does not say. None then, where the frontend sorts by core.
does not say. None then, where the frontend sorts by core. ``system``
is the folder a launcher saving by system inserts first, required
where the layout is ``by_system``. ``sorted_by_core`` says whether the
frontend sorts this core's saves, the layout's ``by_core`` by default.
"""
rel = sanitize_pack_path(path)
if not rel:
return None
if layout.by_core and core_folder:
return f"{layout.folder}/{sanitize_pack_path(core_folder)}/{rel}"
if layout.by_core and core_folder is None:
root = layout.folder
if layout.by_system:
folder = sanitize_pack_path(system)
if not folder or "/" in folder:
return None
root = f"{root}/{folder}"
by_core = layout.by_core if sorted_by_core is None else sorted_by_core
if by_core and core_folder:
return f"{root}/{sanitize_pack_path(core_folder)}/{rel}"
if by_core and core_folder is None:
return None
return f"{layout.folder}/{rel}"
return f"{root}/{rel}"
def libretro_systems(config: dict, names: Iterable[str]) -> list[str]:
"""The systems a platform runs any of these libretro core names for.
Read from the platform file's ``libretro_systems`` (core -> the systems
whose launch offers it), which the platform's scraper writes from its
system list. Sorted, each once.
"""
table = config.get("libretro_systems") or {}
found: set[str] = set()
for name in names:
found.update(str(system) for system in table.get(str(name)) or [])
return sorted(found)
def core_save_folder(profile: dict) -> str | None:
+57 -13
View File
@@ -116,22 +116,59 @@ def _generic_manual(bios_paths: list[str]) -> str:
)
def _save_section(display: str, folder: str) -> str:
def save_place(extract_to: str, folder: str, base_dest: str) -> str:
"""Where a platform keeps its save directory by default, as the README
names it: ``folder`` beside the BIOS folder the archive is extracted
into (``base_dest`` names it), or inside the folder the archive is
extracted into when the archive holds the BIOS folder itself
(RetroDECK's ~/retrodeck/). A relative BIOS folder (RetroArch's
system/) gives ``folder`` beside it."""
root = extract_to.rstrip("/")
if not base_dest:
return f"{root}/{folder}/" if root else f"{folder}/"
parent = root.rpartition("/")[0]
if parent:
return f"{parent}/{folder}/"
return f"{folder}/ beside {root or base_dest}/"
def _save_section(
display: str, folder: str, place: str = "", by_system: bool = False,
in_frontend_settings: bool = True, in_place: bool = False,
) -> str:
"""Where the files of the pack's save folder go.
Some cores read their system data below the save directory the frontend
hands them, not below the system directory the archive is extracted
into: Azahar keeps its user folder there.
into: Azahar keeps its user folder there. ``place`` is the save
directory's default place (save_place), saves/ beside system/ when not
given. ``by_system`` says the launcher hands each system a folder of
its own, which the archive's folder already holds.
``in_frontend_settings`` points at RetroArch's setting, for a frontend
whose own config names the directory. ``in_place`` says the archive is
extracted into the folder holding the save directory, which then
needs no move.
"""
return (
" Files read from the save directory\n"
" ----------------------------------\n"
f" Some cores read files from {display}'s save directory, not its\n"
f" system directory. The {folder}/ folder of this archive holds them.\n"
f" Move its contents into the save directory ({folder}/ beside\n"
" system/ by default, see Settings > Directory > Save Files), keeping\n"
" the folders it holds. The automatic install writes them there.\n\n"
)
where = place or f"{folder}/ beside system/"
kept = "the folder of each system" if by_system else "the folders it holds"
lines = [
" Files read from the save directory",
" ----------------------------------",
f" Some cores read files from {display}'s save directory, not its",
f" system directory. The {folder}/ folder of this archive holds them.",
]
if in_place:
lines.append(f" Extracted as above, it lands at {where}, where they are read.")
else:
lines += [
" Move its contents into the save directory, keeping",
f" {kept}. By default it is {where}"
+ ("," if in_frontend_settings else "."),
]
if in_frontend_settings:
lines.append(" see Settings > Directory > Save Files.")
lines.append(" The automatic install writes them there.")
return "\n".join(lines) + "\n\n"
def _build_readme(
@@ -152,13 +189,17 @@ def _build_readme(
standalone_copies: list[dict] | None = None,
launch_folders: int = 0,
save_folder: str = "",
save_where: str = "",
save_by_system: bool = False,
) -> str:
"""Build a personalized step-by-step README for each platform pack.
``launch_folders`` counts the folders a platform that hands each core
its own system directory gives them, where it names no system.
``save_folder`` is the pack folder holding files cores read below the
frontend's save directory, empty when the pack holds none.
frontend's save directory, empty when the pack holds none, and
``save_where`` that directory's default place (save_place).
``save_by_system`` is set when the launcher hands each system a folder.
"""
narrowings = narrowings or []
sep = "=" * 50
@@ -189,7 +230,10 @@ def _build_readme(
)
if save_folder:
guide += _save_section(platform_display, save_folder)
guide += _save_section(
platform_display, save_folder, save_where, save_by_system,
in_frontend_settings=not save_by_system, in_place=not base_dest,
)
if regions:
region_help = (
+35 -7
View File
@@ -395,7 +395,9 @@ def _save_directory_blocks() -> list[str]:
"""Each registry save_directory is null or a folder and a by_core flag.
The folder names the pack folder and the save directory's default place
beside the BIOS directory: one path segment.
beside the BIOS directory: a relative path that never climbs. ``by_system`` (a save
folder per launched system) needs the platform file's libretro_systems,
and ``by_core_for`` names the cores a launcher sorts by core.
"""
registry_path = ROOT / "platforms" / "_registry.yml"
if not registry_path.is_file():
@@ -410,22 +412,46 @@ def _save_directory_blocks() -> list[str]:
if block is None:
continue
folder = block.get("folder") if isinstance(block, dict) else None
sorted_cores = block.get("by_core_for", []) if isinstance(block, dict) else None
if (
not isinstance(block, dict)
or set(block) != {"folder", "by_core"}
or not {"folder", "by_core"} <= set(block) <= {
"folder", "by_core", "by_system", "by_core_for",
}
or not isinstance(folder, str)
or not folder
or re.search(r"[/\\]", folder)
or folder in (".", "..")
or "\\" in folder
or any(part in ("", ".", "..") for part in folder.split("/"))
or not isinstance(block.get("by_core"), bool)
or not isinstance(block.get("by_system", False), bool)
or not isinstance(sorted_cores, list)
or not all(isinstance(core, str) and core for core in sorted_cores)
):
out.append(
f"platforms/_registry.yml: {name}.save_directory must be null or "
"{folder: <one path segment>, by_core: <bool>}"
"{folder: <relative path>, by_core: <bool>, by_system: <bool>, "
"by_core_for: [<core>]}"
)
continue
if block.get("by_system") and not _libretro_systems(name):
out.append(
f"platforms/_registry.yml: {name}.save_directory.by_system and "
f"platforms/{name}.yml lists no libretro_systems"
)
return out
def _libretro_systems(platform: str) -> dict:
"""The platform file's core -> systems table, inheritance included."""
from common import load_platform_config
try:
config = load_platform_config(platform, str(ROOT / "platforms"))
except FileNotFoundError:
return {}
return config.get("libretro_systems") or {}
def _semantic_install_checks(path: Path, manifest: dict) -> list[str]:
out: list[str] = []
files = manifest.get("files", [])
@@ -439,10 +465,12 @@ def _semantic_install_checks(path: Path, manifest: dict) -> list[str]:
f"{path.relative_to(ROOT)}: total_omitted does not equal "
"len(omitted_files)"
)
destinations = [entry.get("dest") for entry in files]
# One dest below the BIOS folder and one below the save directory are
# two places.
destinations = [(entry.get("root"), entry.get("dest")) for entry in files]
if len(destinations) != len(set(destinations)):
out.append(f"{path.relative_to(ROOT)}: duplicate destinations")
omitted_destinations = [entry.get("dest") for entry in omitted]
omitted_destinations = [(entry.get("root"), entry.get("dest")) for entry in omitted]
if len(omitted_destinations) != len(set(omitted_destinations)):
out.append(f"{path.relative_to(ROOT)}: duplicate omitted destinations")
overlap = set(destinations) & set(omitted_destinations)
+1
View File
@@ -876,6 +876,7 @@ _UNPLACED_REASONS = {
"below_platform_folder": "below the folder the platform lists",
"no_save_directory": "read below a save directory the platform keeps in no fixed place",
"no_core_folder": "read below the save directory and the profile names no library_name",
"no_save_system": "read below a save folder per system and no system runs the core there",
}