Files
libretro/scripts/packpaths.py
T
Abdessamad Derraz 1cd6c9ee4d feat: place save-dir files in packs and lists
load_from: save_dir entries of a libretro build were left out of every
pack. They now go below the platform's save folder (registry
save_directory: RetroArch saves/, Lakka savefiles/, by core), the
manifest marks them root: save, and verify replays the same placement.
Platforms keeping saves per system or per game list them as omitted.
2026-10-22 19:58:26 +02:00

180 lines
6.7 KiB
Python

"""Where a file lands inside a pack.
Two files may not claim the same destination, and a destination may
not sit inside another file's path: a pack that cannot be extracted
on Windows is not a pack."""
from __future__ import annotations
from dataclasses import dataclass
from common import sanitize_pack_path
from validation import read_from_save_dir
@dataclass(frozen=True)
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.
``by_core`` is set when the frontend hands each core <save
directory>/<library_name>, RetroArch's sort_savefiles_enable.
"""
folder: str
by_core: bool
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.
"""
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:
return None
return SaveLayout(folder, bool(block.get("by_core")))
def save_destination(
path: str, core_folder: str | None, layout: SaveLayout
) -> str | None:
"""The pack path of a file a core reads below the save directory.
``path`` is relative to the directory the core is handed. ``core_folder``
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.
"""
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:
return None
return f"{layout.folder}/{rel}"
def core_save_folder(profile: dict) -> str | None:
"""The folder a frontend sorting saves by core inserts for this core.
RetroArch appends a non-empty library_name only (runloop.c
runloop_path_set_redirect). A core declaring ``save_folder_once`` adds
its own folder to the save directory unless it is already named so
(Cemu's in_named_dir): its paths start with that folder and land in the
same place sorted or not. None when the profile names no library_name.
"""
if "library_name" not in profile:
return None
if profile.get("save_folder_once"):
return ""
return str(profile.get("library_name") or "")
# Top folder of an emulator pack holding the files a libretro core reads
# below the frontend's save directory, apart from those of its system
# directory at the root.
EMULATOR_PACK_SAVE_FOLDER = "saves"
def _path_parents(dest: str) -> list[str]:
"""Return all parent directory segments of a path."""
parts = dest.split("/")
return ["/".join(parts[:i]) for i in range(1, len(parts))]
def _has_path_conflict(dest: str, seen_files: set[str], seen_parents: set[str]) -> bool:
"""Check if dest conflicts with existing paths (file vs directory).
Returns True if adding dest would create an impossible extraction:
- A parent of dest is already a file (e.g., adding X/Y when X is a file)
- dest itself is already used as a directory (e.g., adding X when X/Y exists)
"""
for parent in _path_parents(dest):
if parent in seen_files:
return True
if dest in seen_parents:
return True
return False
def placement_block(
dest: str,
seen_files: set[str],
seen_lower: set[str],
seen_parents: set[str],
case_insensitive: bool,
) -> str | None:
"""Why a destination cannot take a file in this pack, or None.
The one rule the builder applies to a core extra and the report replays:
counted apart, the report called a skipped file packed.
"""
if dest in seen_files:
return "taken"
if case_insensitive and dest.lower() in seen_lower:
return "case_twin"
if _has_path_conflict(dest, seen_files, seen_parents):
return "path_conflict"
return None
def _register_path(dest: str, seen_files: set[str], seen_parents: set[str]) -> None:
"""Track a file path and its parent directories."""
seen_files.add(dest)
for parent in _path_parents(dest):
seen_parents.add(parent)
def _flat(arcname: str, prefix: str, flatten: bool) -> str:
"""Strip base_destination prefix from ZIP arcname when flattening."""
if flatten and prefix and arcname.startswith(prefix + "/"):
return arcname[len(prefix) + 1:]
return arcname
def _resolve_destination(file_entry: dict, standalone: bool) -> str:
"""Resolve the ZIP destination path for a file entry.
``path:`` is where the libretro build reads the file from the system
directory and ``standalone_path:`` where the standalone app reads it, so
a file's destination is written whole. ``pack_structure`` places a
profile's data directories and archives: prefixing files with it gave
PPSSPP/PPSSPP/ppge_atlas.zim, and the platform packs, which never read
it, put libretro Dolphin's IPL at the root of system/.
"""
# 1. standalone_path override
if standalone and file_entry.get("standalone_path"):
rel = file_entry["standalone_path"]
# 2. path field
elif file_entry.get("path"):
rel = file_entry["path"]
# 3. name fallback
else:
rel = file_entry.get("name", "")
dest = sanitize_pack_path(rel)
# Read below the frontend's save directory: kept apart from the system
# directory's files at the root, in the folder the pack names for it.
if dest and read_from_save_dir(file_entry, standalone):
return f"{EMULATOR_PACK_SAVE_FOLDER}/{dest}"
return dest
def _archive_destination(
archive_name: str, archive_prefix: str, pack_structure: dict | None, standalone: bool
) -> str:
"""Where an emulator pack places one of the profile's archives."""
archive_dest = sanitize_pack_path(archive_name)
if archive_prefix:
archive_dest = f"{archive_prefix}/{archive_dest}"
prefix = (pack_structure or {}).get("standalone" if standalone else "libretro", "")
return f"{prefix}/{archive_dest}" if prefix else archive_dest