From d0802c5360e203c3be65f3e11e9900b751cad8c8 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:04:31 +0200 Subject: [PATCH] feat: place core files per handed system dir A platform can hand each libretro core a system directory of its own (core_system_dirs). platform_layouts then gives one view per directory, so the gap report, the builder, the manifest, slots and cross_reference place a core's files below each folder it is handed, and a core handed none gets no file. Resolution strips the folder again and searches the collection by the core's own path. --- schemas/platform.schema.json | 50 ++++++++++++++ scripts/common.py | 74 ++++++++++++++++++++- scripts/cross_reference.py | 5 +- scripts/packextras.py | 123 +++++++++++++++++++++++------------ scripts/slots.py | 11 +++- scripts/verify.py | 10 ++- 6 files changed, 222 insertions(+), 51 deletions(-) diff --git a/schemas/platform.schema.json b/schemas/platform.schema.json index f45be8a6..b7658db6 100644 --- a/schemas/platform.schema.json +++ b/schemas/platform.schema.json @@ -53,6 +53,56 @@ "uniqueItems": true, "description": "Emulators of standalone_cores the platform also launches as a libretro core: both layouts get their files" }, + "core_system_dirs": { + "type": "object", + "description": "The libretro system directory the platform hands each core, keyed by that directory relative to the pack root. A platform that declares it gives a core no other directory: the core's files are placed below each directory listed for it, and a core listed nowhere gets none (MinUI hands each launch tag its own Bios//)", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "cores", + "source_ref" + ], + "properties": { + "cores": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + }, + "description": "Libretro cores the platform starts with this directory, by the name of their .so" + }, + "devices": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + }, + "description": "Devices whose build ships a launcher for this directory" + }, + "source_ref": { + "type": "string", + "description": "file:line of the launcher that names the core, at the revision `source` pins" + } + } + } + }, + "core_repositories": { + "type": "object", + "description": "Repositories each core of core_system_dirs is built from, read from the platform's build recipes", + "additionalProperties": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + } + }, + "system_directory_ref": { + "type": "string", + "description": "file:line, at the revision `source` pins, of the code that sets the system directory core_system_dirs lists" + }, "homepage": { "type": "string", "format": "uri", diff --git a/scripts/common.py b/scripts/common.py index e67f3fb0..96003159 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -1221,6 +1221,12 @@ def resolve_local_file( """ sha1 = file_entry.get("sha1") name = file_entry.get("name", "") + # A platform that hands each core a system directory of its own (MinUI's + # Bios//) writes that folder in front of the core's path. The tail + # the collection indexes is the core's path alone. + system_dir = file_entry.get("system_dir") + if system_dir and dest_hint.startswith(f"{system_dir}/"): + dest_hint = dest_hint[len(system_dir) + 1:] # A name written with its directory is the destination when no path is # given: gam4980/E.BIN resolved by its basename took another model's OS # in verify --emulator and the emulator pack, while the platform pack, @@ -2106,6 +2112,11 @@ def group_identical_platforms( # One archive cannot serve a filesystem that folds case and one # that does not: Lakka keeps bios.col beside BIOS.col. bool(config.get("case_insensitive_fs")), + # Where each core's files go: the same cores handed other + # folders make another pack. + sorted( + tag for tag in standalone_set(config) if tag.startswith(CORE_DIR_PREFIX) + ), ] fp = hashlib.sha1(f"{fp}|{emulators}".encode()).hexdigest() if target_cores_cache: @@ -2252,6 +2263,55 @@ DUAL_MODE_PREFIX = "dual:" # Marks, in the same set, a whole directory the platform's launcher hands a # standalone emulator (launchers.handed_directories), as JSON. HANDED_PREFIX = "handed:" +# Marks, in the same set, a libretro core and the system directory the +# platform hands it (``core_system_dirs``), as JSON. +CORE_DIR_PREFIX = "coredir:" + + +def core_system_dirs( + emu_name: str, profile: dict, standalone_cores: Iterable[str] +) -> list[str] | None: + """The system directories a platform hands this libretro core, or None. + + None when the platform declares no per-core directory: the core reads + the one system directory every core shares. A platform that declares + them (MinUI hands each launch tag its own Bios//) gives a core it + lists no directory at all, so the list is then empty. + """ + pairs = [ + json.loads(tag[len(CORE_DIR_PREFIX):]) + for tag in standalone_cores if tag.startswith(CORE_DIR_PREFIX) + ] + if not pairs: + return None + names = {emu_name, *(str(c) for c in profile.get("cores") or [])} + return sorted({directory for core, directory in pairs if core in names}) + + +def system_dir_view(profile: dict, directory: str) -> dict: + """The profile as a core reads it from one handed system directory. + + Each file keeps its own path, relative to the system directory, and + carries the directory: destinations put it in front, resolution takes + it off again (resolve_local_file), so the collection is still searched + by the core's own path. A core's archive subfolder moves with it. + """ + view = { + **profile, + "files": [ + {**f, "system_dir": directory} if isinstance(f, dict) else f + for f in profile.get("files") or [] + ], + } + if profile.get("archive_prefix"): + view["archive_prefix"] = f"{directory}/{profile['archive_prefix']}" + return view + + +def system_dir_path(entry: dict, path: str) -> str: + """``path`` below the system directory the entry carries, if any.""" + directory = entry.get("system_dir") + return f"{directory}/{path}" if directory else path def standalone_set(config: dict) -> set[str]: @@ -2263,7 +2323,14 @@ def standalone_set(config: dict) -> set[str]: """ names = {str(c) for c in config.get("standalone_cores") or []} names |= {DUAL_MODE_PREFIX + str(c) for c in config.get("dual_mode_cores") or []} - if names: + # The system directory each core is handed travels the same way, so + # platform_layouts places a core's files below it for every reader. + names |= { + CORE_DIR_PREFIX + json.dumps([str(core), directory]) + for directory, entry in (config.get("core_system_dirs") or {}).items() + for core in entry.get("cores") or [] + } + if names - {n for n in names if n.startswith(CORE_DIR_PREFIX)}: from launchers import handed_directories # noqa: PLC0415 names |= { @@ -2328,7 +2395,10 @@ def platform_layouts( (what the build reads from the system directory, where it reads it). """ if not runs_standalone(emu_name, profile, standalone_cores): - return [(profile, False)] + directories = core_system_dirs(emu_name, profile, standalone_cores) + if directories is None: + return [(profile, False)] + return [(system_dir_view(profile, d), False) for d in directories] standalone = profile root = standalone_root_for(emu_name, profile, standalone_cores) if root: diff --git a/scripts/cross_reference.py b/scripts/cross_reference.py index a235f8d7..090204c3 100644 --- a/scripts/cross_reference.py +++ b/scripts/cross_reference.py @@ -41,6 +41,7 @@ from common import ( require_yaml, resolve_local_file, platform_layouts, + system_dir_path, standalone_set, select_emulator_profiles, ) @@ -652,7 +653,7 @@ def slot_key(f: dict, standalone: bool = False) -> tuple: return ( fname, f.get("archive"), - where or fname, + system_dir_path(f, where or fname), f.get("system"), f.get("variant_group"), f.get("mode", "both"), @@ -727,7 +728,7 @@ def _cross_reference_profile( if is_standalone: effective_path = f.get("standalone_path") or f.get("path") or fname else: - effective_path = f.get("path") or fname + effective_path = system_dir_path(f, f.get("path") or fname) slot = slot_key(f, is_standalone) seen_key = slot if len(layouts) > 1: diff --git a/scripts/packextras.py b/scripts/packextras.py index 34ad6feb..847475ab 100644 --- a/scripts/packextras.py +++ b/scripts/packextras.py @@ -15,7 +15,7 @@ from common import load_emulator_profiles from common import resolution_is_hash_exact from common import resolve_local_file from common import resolve_platform_cores -from common import platform_layouts +from common import core_system_dirs, platform_layouts, system_dir_path from common import standalone_set as platform_standalone_set from common import sanitize_pack_path from common import size_fits @@ -331,7 +331,9 @@ def _extra_dest(u: dict) -> str: the file's name after it ("cafeLibs/" + "snd_user.rpl"). """ archive = u.get("archive") - raw = archive if archive else (u.get("path") or u["name"]) + # An archive goes at the root of the core's system directory, which a + # platform handing each core its own folder writes in the path. + raw = (u.get("path") or archive) if archive else (u.get("path") or u["name"]) return f"{raw}{u['name']}" if raw.endswith("/") else raw @@ -479,6 +481,8 @@ def _agnostic_scan_extras( seed = f.get("path") or "" if standalone: seed = f.get("standalone_path") or seed + else: + seed = system_dir_path(f, seed or fname) folder = seed.rsplit("/", 1)[0] if "/" in seed else "" dest = f"{folder}/{scan_name}" if folder else scan_name full_dest = f"{extras_prefix}/{dest}" if extras_prefix else dest @@ -727,6 +731,7 @@ def _archive_prefix_extras( extras_prefix: str, by_name: dict, taken: dict[str, dict] | None = None, + standalone_set: set[str] | None = None, ) -> list[dict]: """A second copy of an archive under the subdirectory a core reads. @@ -744,45 +749,75 @@ def _archive_prefix_extras( continue if emu_name not in relevant: continue - prefix = profile.get("archive_prefix", "") - if not prefix: + # A platform that hands each core its own system directory moves the + # subfolder with it, once per directory. + views = [profile] + if core_system_dirs(emu_name, profile, standalone_set or set()) is not None: + views = [ + view for view, standalone + in platform_layouts(emu_name, profile, standalone_set or set()) + if not standalone + ] + for view in views: + extras.extend(_archive_prefix_copies( + emu_name, view, covered_names, seen_dests, extras_prefix, + by_name, taken, + )) + return extras + + +def _archive_prefix_copies( + emu_name: str, + profile: dict, + covered_names: set, + seen_dests: set, + extras_prefix: str, + by_name: dict, + taken: dict[str, dict] | None, +) -> list[dict]: + """The prefixed copies of one layout's archives.""" + extras: list[dict] = [] + prefix = profile.get("archive_prefix", "") + if not prefix: + return extras + profile_archives: dict[str, dict] = {} + for f in profile.get("files", []): + archive = f.get("archive", "") + if archive: + profile_archives.setdefault(archive, f) + for archive_name, archive_entry in sorted(profile_archives.items()): + if archive_name not in covered_names: continue - profile_archives: dict[str, dict] = {} - for f in profile.get("files", []): - archive = f.get("archive", "") - if archive: - profile_archives.setdefault(archive, f) - for archive_name, archive_entry in sorted(profile_archives.items()): - if archive_name not in covered_names: - continue - dest = f"{prefix}/{archive_name}" - full_dest = f"{extras_prefix}/{dest}" if extras_prefix else dest - if full_dest in seen_dests and (taken is None or full_dest not in taken): - continue - if not by_name.get(archive_name): - continue - extra = { - "name": archive_name, - "destination": dest, - "required": True, - "hle_fallback": False, - "source_emulator": profile.get("emulator", emu_name), - "source_profile": emu_name, - # Keyed as the gap report keys an archive: name and path alike. - "source_name": archive_name, - "source_path": archive_name, - "category": archive_entry.get("category", "bios"), - "source_system": archive_entry.get("system"), - "source_systems": entry_system_ids(archive_entry, profile), - "region": archive_entry.get("region"), - "variant_group": archive_entry.get("variant_group"), - } - members = archive_members(archive_name, profile.get("files", [])) - if members: - extra["members"] = members - extra["claim_order"] = (3, 0, emu_name) - if _take(extra, full_dest, seen_dests, taken): - extras.append(extra) + dest = f"{prefix}/{archive_name}" + full_dest = f"{extras_prefix}/{dest}" if extras_prefix else dest + if full_dest in seen_dests and (taken is None or full_dest not in taken): + continue + if not by_name.get(archive_name): + continue + extra = { + "name": archive_name, + "destination": dest, + "required": True, + "hle_fallback": False, + "source_emulator": profile.get("emulator", emu_name), + "source_profile": emu_name, + # Keyed as the gap report keys an archive: name and path alike. + "source_name": archive_name, + "source_path": system_dir_path(archive_entry, archive_name), + "category": archive_entry.get("category", "bios"), + "source_system": archive_entry.get("system"), + "source_systems": entry_system_ids(archive_entry, profile), + "region": archive_entry.get("region"), + "variant_group": archive_entry.get("variant_group"), + } + members = archive_members(archive_name, profile.get("files", [])) + if members: + extra["members"] = members + if archive_entry.get("system_dir"): + extra["system_dir"] = archive_entry["system_dir"] + extra["claim_order"] = (3, 0, emu_name) + if _take(extra, full_dest, seen_dests, taken): + extras.append(extra) return extras @@ -1046,7 +1081,7 @@ def _collect_emulator_extras( if is_standalone: raw = f.get("standalone_path") or f.get("path") or fname else: - raw = f.get("path") or fname + raw = system_dir_path(f, f.get("path") or fname) dest = f"{raw}{fname}" if raw.endswith("/") else raw # A bare name is a destination too: the core reads it at the root # of the directory core files go to, which the platform may not @@ -1087,6 +1122,8 @@ def _collect_emulator_extras( extra[identity] = f[identity] if f.get("contents"): extra["contents"] = f["contents"] + if f.get("system_dir"): + extra["system_dir"] = f["system_dir"] extra["claim_order"] = (2, 0, emu_name) if _take(extra, full_dest, seen_dests, taken): extras.append(extra) @@ -1094,7 +1131,7 @@ def _collect_emulator_extras( extras.extend( _archive_prefix_extras( profiles, relevant, covered_names, seen_dests, extras_prefix, - by_name, taken, + by_name, taken, standalone_set, ) ) @@ -1211,6 +1248,8 @@ def _report_extra(u: dict, name: str, dest: str) -> dict: } if u.get("members"): extra["members"] = u["members"] + if u.get("system_dir"): + extra["system_dir"] = u["system_dir"] # The archive layout the core opens ranks same-named copies by what # they hold. Without it the first copy indexed won. if u.get("contents"): diff --git a/scripts/slots.py b/scripts/slots.py index f43aa125..a26a0d14 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -35,6 +35,7 @@ from common import ( resolve_platform_cores, platform_layouts, standalone_set as platform_standalone_set, + system_dir_path, variant_base_name, ) from packresolve import preferred_entries @@ -227,7 +228,7 @@ def profile_claims( dest = ( (entry.get("standalone_path") or entry.get("path")) if is_standalone - else entry.get("path") + else system_dir_path(entry, entry.get("path") or entry.get("name") or "") ) or entry.get("name") or "" if not dest: continue @@ -299,8 +300,12 @@ def _archive_claims( members = entry.get("members") if not members: continue - system = next(f.get("system") for f in files if f.get("archive") == archive) - for dest in (archive, f"{prefix}/{archive}") if prefix else (archive,): + member = next(f for f in files if f.get("archive") == archive) + system = member.get("system") + if member.get("system_dir"): + entry = {**entry, "system_dir": member["system_dir"]} + root = system_dir_path(entry, archive) + for dest in (root, f"{prefix}/{archive}") if prefix else (root,): if placement is None: full = f"{base_dest}/{dest}" if base_dest else dest else: diff --git a/scripts/verify.py b/scripts/verify.py index 887b9666..8481cb4c 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -76,6 +76,7 @@ from common import ( entry_system_ids, sanitize_pack_path, select_emulator_profiles, + system_dir_path, ) yaml = require_yaml() @@ -576,7 +577,7 @@ def find_undeclared_files( if is_standalone: dest = f.get("standalone_path") or f.get("path") or fname else: - dest = f.get("path") or fname + dest = system_dir_path(f, f.get("path") or fname) raw_regions = f.get("region") or [] region_key = tuple( str(value) for value in ( @@ -615,6 +616,7 @@ def find_undeclared_files( if archive: archive_key = ( archive, + f.get("system_dir"), f.get("system"), f.get("variant_group"), region_key, @@ -634,7 +636,7 @@ def find_undeclared_files( "variant_group": f.get("variant_group"), "name": archive, "archive": archive, - "path": archive, + "path": system_dir_path(f, archive), "required": False, "hle_fallback": False, "category": f.get("category", "bios"), @@ -647,6 +649,8 @@ def find_undeclared_files( "archive_required_count": 0, "members": archive_members(archive, profile.get("files", [])), } + if f.get("system_dir"): + archive_entries[archive_key]["system_dir"] = f["system_dir"] entry = archive_entries[archive_key] entry["archive_file_count"] += 1 if f.get("required", False): @@ -738,6 +742,8 @@ def find_undeclared_files( } if f.get("contents"): entry["contents"] = f["contents"] + if f.get("system_dir"): + entry["system_dir"] = f["system_dir"] members = with_archive_members(f, profile.get("files", [])).get("members") if members: entry["members"] = members