mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
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:
1 parent
9a1bf4baa6
commit
2d26385efe
8 files changed
+394
-124
No files matched your search
+87
-11
@@ -493,12 +493,17 @@ platforms:
|
||||
source_url: https://raw.githubusercontent.com/batocera-linux/batocera.linux/master/package/batocera/core/batocera-scripts/scripts/batocera-systems
|
||||
source_format: python_dict
|
||||
hash_type: md5
|
||||
# The libretro launcher hands each core /userdata/saves/<system> with
|
||||
# sort_savefiles_enable off (python-src/batocera-launch-libretro/
|
||||
# batocera_launch_libretro/emulator.py:754-757 at 8bbc0d6e, saves_dir in
|
||||
# python-src/batocera-launch/batocera_launch/emulator.py:251-252 at
|
||||
# d30617e5): one folder per launched system, no single place.
|
||||
save_directory: null
|
||||
# configgen hands each libretro core /userdata/saves/<system> at every
|
||||
# launch, with sort_savefiles_enable off (package/batocera/core/
|
||||
# batocera-configgen/configgen/configgen/generators/libretro/
|
||||
# libretroConfig.py:225-228 and batoceraPaths.py:22 at batocera-43.1).
|
||||
# <system> is the ES system name (Emulator.py:119, args.system), so a
|
||||
# file is placed once below each system es_systems.yml offers the core
|
||||
# for (libretro_systems in batocera.yml).
|
||||
save_directory:
|
||||
folder: saves
|
||||
by_core: false
|
||||
by_system: true
|
||||
schedule: weekly
|
||||
target_scraper: batocera_targets
|
||||
target_source: https://github.com/batocera-linux/batocera.linux
|
||||
@@ -524,6 +529,16 @@ platforms:
|
||||
hash_type: md5
|
||||
# Bios.cpp:64-68 hashes a .zip with Zip::Md5Composite, never its bytes
|
||||
zip_digest: composite
|
||||
# configgen hands each libretro core /recalbox/share/saves/<system> at
|
||||
# every launch, with sort_savefiles_enable off (projects/configgen/
|
||||
# configgen/generators/libretro/libretroRetroarch.py:338, 347-348 and
|
||||
# 419-420, recalboxFiles.py:84 at 10.1.1). <system> is the name the
|
||||
# system.ini gives, so a file is placed once below each system offering
|
||||
# the core (libretro_systems in recalbox.yml).
|
||||
save_directory:
|
||||
folder: saves
|
||||
by_core: false
|
||||
by_system: true
|
||||
schedule: monthly
|
||||
target_scraper: recalbox_targets
|
||||
target_source: https://gitlab.com/recalbox/recalbox/-/tree/master/configs
|
||||
@@ -552,11 +567,20 @@ platforms:
|
||||
hash_type: md5
|
||||
# Windows: names differing only by case land on one file.
|
||||
case_insensitive_fs: true
|
||||
# emulatorLauncher hands a libretro core saves\<system> (saves\<system>\
|
||||
# LR-amiberry for amiberry, saves\ for mame), sorted by core for dolphin
|
||||
# only (emulatorLauncher/Generators/LibRetro.Generator.cs:1128-1131 and
|
||||
# 1305-1315 at 7f8dc28a): one folder per launched system, no single place.
|
||||
save_directory: null
|
||||
# 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)
|
||||
# (emulatorLauncher/Generators/LibRetro.Generator.cs:1128-1132, 1304-1315
|
||||
# and 2707 at 48cdc616). <system> is the ES system name, so a file is
|
||||
# placed once below each system es_systems.cfg offers the core for
|
||||
# (libretro_systems in retrobat.yml). mame (saves\) and amiberry
|
||||
# (saves\<system>\LR-amiberry) differ, and neither profile has a file
|
||||
# read below the save directory.
|
||||
save_directory:
|
||||
folder: saves
|
||||
by_core: false
|
||||
by_system: true
|
||||
by_core_for: [dolphin]
|
||||
schedule: weekly
|
||||
target_scraper: null
|
||||
target_source: null
|
||||
@@ -591,6 +615,16 @@ platforms:
|
||||
hash_type: md5
|
||||
# Windows: names differing only by case land on one file.
|
||||
case_insensitive_fs: true
|
||||
# RetroArch_setupSaves points savefile_directory at
|
||||
# $savesPath/retroarch/saves (functions/EmuScripts/emuDeckRetroArch.sh:236
|
||||
# at dddb3022), savesPath ~/Emulation/saves beside biosPath
|
||||
# ~/Emulation/bios (functions/helperFunctions.sh:347-348). The shipped
|
||||
# retroarch.cfg turns savefiles_in_content_dir and both sort options off
|
||||
# (configs/org.libretro.RetroArch/config/retroarch/retroarch.cfg:3135,
|
||||
# 3176-3177): one folder for every core.
|
||||
save_directory:
|
||||
folder: saves/retroarch/saves
|
||||
by_core: false
|
||||
schedule: weekly
|
||||
target_scraper: emudeck_targets
|
||||
target_source: https://github.com/dragoonDorise/EmuDeck
|
||||
@@ -669,6 +703,20 @@ platforms:
|
||||
source_url: https://github.com/RetroDECK/components
|
||||
source_format: github_component_manifests
|
||||
hash_type: md5
|
||||
# savefile_directory is $saves_path, ~/retrodeck/saves beside
|
||||
# ~/retrodeck/bios (retroarch/component_prepare.sh:27 and 44 at
|
||||
# components fcd0d729), and RetroArch sorts saves by content, not by
|
||||
# core (retroarch/rd_assets/rd_config/retroarch.cfg:3233-3234 and
|
||||
# 3275-3276). RetroArch 1.22.2 appends the name of the folder holding
|
||||
# the ROM (runloop.c:8205-8209 and 8249-8258 at 69a4f0ea): saves/<ROM
|
||||
# folder> for a ROM at the top of its system folder, the folder of each
|
||||
# system whose ES-DE command loads the core (libretro_systems in
|
||||
# retrodeck.yml, from RetroDECK/ES-DE resources/systems/linux/
|
||||
# es_systems.xml at 15e1b236, the release RetroDECK takes as latest).
|
||||
save_directory:
|
||||
folder: saves
|
||||
by_core: false
|
||||
by_system: true
|
||||
schedule: monthly
|
||||
contributed_by:
|
||||
- username: monster-penguin
|
||||
@@ -705,6 +753,11 @@ platforms:
|
||||
source_url: https://raw.githubusercontent.com/rommapp/romm/master/backend/models/fixtures/known_bios_files.json
|
||||
source_format: json
|
||||
hash_type: sha1
|
||||
# Cores run in the browser through EmulatorJS: the save file lives in
|
||||
# the Emscripten filesystem and goes through RomM's save API
|
||||
# (frontend/src/views/Player/EmulatorJS/utils.ts:409-426 at 5.4.0).
|
||||
# Nothing a user installs reaches it.
|
||||
save_directory: null
|
||||
schedule: monthly
|
||||
contributed_by:
|
||||
- username: PixNyb
|
||||
@@ -765,6 +818,11 @@ platforms:
|
||||
extract_to: /storage/roms/bios/
|
||||
logo: https://avatars.githubusercontent.com/u/163176886
|
||||
scraper: rocknix
|
||||
# Every device's retroarch.cfg sets savefiles_in_content_dir (for
|
||||
# instance projects/ROCKNIX/packages/emulators/libretro/retroarch/
|
||||
# sources/RK3566/retroarch.cfg:664-665 at 20261001) and setsettings.sh
|
||||
# leaves it: saves sit beside each game, no single place.
|
||||
save_directory: null
|
||||
schedule: weekly
|
||||
target_scraper: rocknix_targets
|
||||
target_source: https://github.com/ROCKNIX/distribution/blob/next/projects/ROCKNIX/packages/virtual/emulators/package.mk
|
||||
@@ -793,6 +851,10 @@ platforms:
|
||||
source_url: https://github.com/AmberELEC/AmberELEC/blob/dev/packages/amberelec/sources/scripts/batocera-systems
|
||||
source_format: python_dict
|
||||
hash_type: md5
|
||||
# retroarch.cfg sets savefiles_in_content_dir (packages/games/tools/
|
||||
# retroarch/sources/retroarch.cfg:567-568 at a21975bf) and
|
||||
# setsettings.py leaves it: saves sit beside each game, no single place.
|
||||
save_directory: null
|
||||
schedule: monthly
|
||||
target_scraper: null
|
||||
target_source: null
|
||||
@@ -830,6 +892,10 @@ platforms:
|
||||
source_url: https://github.com/EmuELEC/EmuELEC/blob/master/packages/sx05re/emuelec/bin/batocera/batocera-systems
|
||||
source_format: python_dict
|
||||
hash_type: md5
|
||||
# savefile_directory stays commented out (packages/sx05re/libretro_base/
|
||||
# retroarch/package.mk:115 at v4.8) and setsettings.sh never sets it, so
|
||||
# RetroArch saves beside each game: no single place.
|
||||
save_directory: null
|
||||
schedule: monthly
|
||||
target_scraper: null
|
||||
target_source: null
|
||||
@@ -858,6 +924,16 @@ platforms:
|
||||
source_url: https://github.com/knulli-cfw/knulli-linux/blob/knulli-main/package/system/knulli-scripts/scripts/knulli-systems
|
||||
source_format: python_dict
|
||||
hash_type: md5
|
||||
# configgen hands each libretro core /userdata/saves/<system> at every
|
||||
# launch, with sort_savefiles_enable off (package/system/knulli-configgen/
|
||||
# configgen/configgen/generators/libretro/libretroConfig.py:240-243 and
|
||||
# batoceraPaths.py:22 at 20260511), as Batocera's does: a file is placed
|
||||
# once below each system es_systems.yml offers the core for
|
||||
# (libretro_systems in knulli.yml).
|
||||
save_directory:
|
||||
folder: saves
|
||||
by_core: false
|
||||
by_system: true
|
||||
schedule: monthly
|
||||
target_scraper: null
|
||||
target_source: null
|
||||
|
||||
@@ -177,7 +177,8 @@
|
||||
"no_platform_slug",
|
||||
"below_platform_folder",
|
||||
"no_save_directory",
|
||||
"no_core_folder"
|
||||
"no_core_folder",
|
||||
"no_save_system"
|
||||
]
|
||||
},
|
||||
"category": {
|
||||
@@ -431,10 +432,14 @@
|
||||
"properties": {
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"pattern": "^[^/\\\\.][^/\\\\]*$"
|
||||
"pattern": "^[^/\\\\.][^/\\\\]*(/[^/\\\\.][^/\\\\]*)*$"
|
||||
},
|
||||
"by_core": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"by_system": {
|
||||
"description": "true when the platform's launcher hands each core <save directory>/<system> at each launch (Batocera, RetroBat). Each dest then starts with that system's folder, and no frontend config read at install time changes it.",
|
||||
"const": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-20
@@ -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
@@ -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
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user