diff --git a/platforms/_registry.yml b/platforms/_registry.yml index e3ffbad6..e9809a0c 100644 --- a/platforms/_registry.yml +++ b/platforms/_registry.yml @@ -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/ 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/ 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). + # 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/ 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). 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\ (saves\\ - # 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\ 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). 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\\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/ 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/ 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 diff --git a/schemas/install-manifest.schema.json b/schemas/install-manifest.schema.json index 41882b9b..dee48ee1 100644 --- a/schemas/install-manifest.schema.json +++ b/schemas/install-manifest.schema.json @@ -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 / 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 } } } diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index f10c97b2..4c47638d 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -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, diff --git a/scripts/packextras.py b/scripts/packextras.py index 1dcd773d..cef99ea4 100644 --- a/scripts/packextras.py +++ b/scripts/packextras.py @@ -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 = { diff --git a/scripts/packpaths.py b/scripts/packpaths.py index 54fb0eb5..67dcc7d5 100644 --- a/scripts/packpaths.py +++ b/scripts/packpaths.py @@ -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 /, RetroArch's sort_savefiles_enable. + ``by_system`` is set when the launcher hands a core /, the system it was launched for (Batocera's + /userdata/saves/): 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: diff --git a/scripts/packreadme.py b/scripts/packreadme.py index 11fffe12..2cdeb8ed 100644 --- a/scripts/packreadme.py +++ b/scripts/packreadme.py @@ -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 = ( diff --git a/scripts/validate_schemas.py b/scripts/validate_schemas.py index d04e5827..7c1c89f2 100644 --- a/scripts/validate_schemas.py +++ b/scripts/validate_schemas.py @@ -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: , by_core: }" + "{folder: , by_core: , by_system: , " + "by_core_for: []}" + ) + 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) diff --git a/scripts/verify.py b/scripts/verify.py index 1800f56c..8e509567 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -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", }