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

+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: