fix: place slot claims where the pack puts extras

This commit is contained in:
Abdessamad Derraz committed 2026-10-10 12:07:00 +02:00
1 parent 2c2f284414
commit 3d0170264a
4 files changed
+89 -23

No files matched your search

+8 -4
View File
@@ -1733,7 +1733,7 @@ def platform_declarations(
declaring a file under a different name than the emulator profile declaring a file under a different name than the emulator profile
(e.g. Batocera ROM1 vs gsplus ROM). (e.g. Batocera ROM1 vs gsplus ROM).
""" """
prefix = "" if config.get("base_destination") else declared_root(config).lower() prefix = declared_root(config).lower()
declared: dict[str, list[tuple[int | None, str]]] = {} declared: dict[str, list[tuple[int | None, str]]] = {}
by_md5 = db.get("indexes", {}).get("by_md5", {}) by_md5 = db.get("indexes", {}).get("by_md5", {})
files_db = db.get("files", {}) files_db = db.get("files", {})
@@ -1767,11 +1767,15 @@ def platform_declarations(
def declared_root(config: dict) -> str: def declared_root(config: dict) -> str:
"""The directory nearly every declared destination starts with, if any. """The BIOS directory a platform writes into every destination, if any.
A platform without base_destination writes it into each destination; Only a platform whose base_destination is explicitly empty does that
core files go under the same directory. (RetroDECK writes bios/ in front of each file), and core files go under
the same directory. A config that does not state a base says nothing,
and its common folder is a system's, not the BIOS root.
""" """
if config.get("base_destination", None) != "":
return ""
roots = [ roots = [
d.split("/", 1)[0] d.split("/", 1)[0]
for system in config.get("systems", {}).values() for system in config.get("systems", {}).values()
+49 -17
View File
@@ -436,6 +436,45 @@ def _archive_prefix_extras(
return extras return extras
class ExtraPlacement:
"""Where a pack puts a core's file, decided once for every reader.
The builder, and slots.py judging what a destination holds, both read
this: placed apart, slot arbitration compared RetroDECK's bios/ claims
with core claims at the root and never saw them meet.
"""
def __init__(self, config: dict, base_dest: str, profiles: dict) -> None:
from common import _norm_system_id
self.prefix = _detect_extras_prefix(config, base_dest)
self.slug_based, self.sys_to_slug = _detect_slug_structure(config)
self.platform_systems = set(config.get("systems", {}).keys())
self.norm_map = (
{_norm_system_id(sid): sid for sid in self.platform_systems}
if self.slug_based
else {}
)
self.profiles = profiles
def place(self, entry: dict, dest: str) -> tuple[str, str] | None:
"""(pack-relative destination, full destination), or None.
``entry`` names its profile and may name its own system. A
slug-based platform cannot place a file of a system it has no
folder for.
"""
if self.slug_based:
slug = _slug_for(
entry, self.profiles, self.platform_systems, self.norm_map,
self.sys_to_slug,
)
if not slug:
return None
dest = f"{slug}/{dest}"
return dest, (f"{self.prefix}/{dest}" if self.prefix else dest)
def _collect_emulator_extras( def _collect_emulator_extras(
config: dict, config: dict,
emulators_dir: str, emulators_dir: str,
@@ -469,7 +508,7 @@ def _collect_emulator_extras(
Works for ANY platform (RetroArch, Batocera, Recalbox, etc.) Works for ANY platform (RetroArch, Batocera, Recalbox, etc.)
""" """
from common import _norm_system_id, resolve_platform_cores from common import resolve_platform_cores
from verify import find_undeclared_files from verify import find_undeclared_files
profiles = ( profiles = (
@@ -479,13 +518,9 @@ def _collect_emulator_extras(
) )
# Detect destination conventions for core extras # Detect destination conventions for core extras
extras_prefix = _detect_extras_prefix(config, base_dest) placement = ExtraPlacement(config, base_dest, profiles)
is_slug_based, sys_to_slug = _detect_slug_structure(config) extras_prefix = placement.prefix
platform_systems = set(config.get("systems", {}).keys()) is_slug_based = placement.slug_based
norm_map: dict[str, str] = {}
if is_slug_based:
for sid in platform_systems:
norm_map[_norm_system_id(sid)] = sid
# Use strict YAML names (no DB alias enrichment) so that files known # Use strict YAML names (no DB alias enrichment) so that files known
# under an alias still get packed at the emulator's expected path. # under an alias still get packed at the emulator's expected path.
@@ -508,15 +543,12 @@ def _collect_emulator_extras(
dest = f"{raw_dest}{u['name']}" if raw_dest.endswith("/") else raw_dest dest = f"{raw_dest}{u['name']}" if raw_dest.endswith("/") else raw_dest
# Slug-based platforms: prefix dest with system slug # Slug-based platforms: prefix dest with system slug
if is_slug_based: placed = placement.place(u, dest)
slug = _slug_for(u, profiles, platform_systems, norm_map, sys_to_slug) if placed is None:
if not slug: if unplaceable is not None:
if unplaceable is not None: unplaceable.append(u)
unplaceable.append(u) continue
continue dest, full_dest = placed
dest = f"{slug}/{dest}"
full_dest = f"{extras_prefix}/{dest}" if extras_prefix else dest
if claimants is not None: if claimants is not None:
claimants[(u.get("emulator", ""), u.get("name", ""), u.get("path") or "")] = ( claimants[(u.get("emulator", ""), u.get("name", ""), u.get("path") or "")] = (
sanitize_pack_path(dest) sanitize_pack_path(dest)
+21 -2
View File
@@ -136,9 +136,14 @@ def profile_claims(
standalone_cores: set[str] | None = None, standalone_cores: set[str] | None = None,
zip_contents: dict | None = None, zip_contents: dict | None = None,
data_dir_registry: dict | None = None, data_dir_registry: dict | None = None,
config: dict | None = None,
) -> list[Claim]: ) -> list[Claim]:
"""What each emulator profile says belongs at each destination it names. """What each emulator profile says belongs at each destination it names.
With the platform's config, a claim sits where the pack puts the core's
file (ExtraPlacement): under RetroDECK's bios/, under RomM's system
folder. A file the platform cannot place claims nothing.
A file can exist in one build of an emulator and not the other, and the A file can exist in one build of an emulator and not the other, and the
two builds read from different directories. The mode is decided per two builds read from different directories. The mode is decided per
emulator, not per pack: a platform runs some of its emulators as libretro emulator, not per pack: a platform runs some of its emulators as libretro
@@ -148,6 +153,11 @@ def profile_claims(
``standalone_path``. This is the gate verify already applies. ``standalone_path``. This is the gate verify already applies.
""" """
standalone_cores = standalone_cores or set() standalone_cores = standalone_cores or set()
placement = None
if config is not None:
from packextras import ExtraPlacement # noqa: PLC0415
placement = ExtraPlacement(config, base_dest, profiles)
claims: list[Claim] = [] claims: list[Claim] = []
for emu_name, profile in sorted(profiles.items()): for emu_name, profile in sorted(profiles.items()):
if profile.get("type") in ("launcher", "alias"): if profile.get("type") in ("launcher", "alias"):
@@ -174,7 +184,15 @@ def profile_claims(
) or entry.get("name") or "" ) or entry.get("name") or ""
if not dest: if not dest:
continue continue
full = f"{base_dest}/{dest}" if base_dest else dest if placement is None:
full = f"{base_dest}/{dest}" if base_dest else dest
else:
placed = placement.place(
{"profile": emu_name, "system": entry.get("system")}, dest
)
if placed is None:
continue
full = placed[1]
# The owner's own copy is the one the builder ships. # The owner's own copy is the one the builder ships.
local, status = resolve_local_file( local, status = resolve_local_file(
{**entry, "source_profile": emu_name}, {**entry, "source_profile": emu_name},
@@ -236,7 +254,8 @@ def find_conflicts(
# claim at all matches what ships is a disagreement. # claim at all matches what ships is a disagreement.
by_slot: dict[str, list[Claim]] = {} by_slot: dict[str, list[Claim]] = {}
for claim in profile_claims( for claim in profile_claims(
profiles, db, base_dest, standalone_cores, zip_contents, data_dir_registry profiles, db, base_dest, standalone_cores, zip_contents, data_dir_registry,
config=config,
): ):
key = _normalize(claim.destination) key = _normalize(claim.destination)
platform = by_dest.get(key) platform = by_dest.get(key)
+11
View File
@@ -125,6 +125,17 @@ class TestConflicts(unittest.TestCase):
} }
} }
def test_a_core_claim_sits_where_the_pack_puts_it(self):
"""RetroDECK writes bios/ into every destination and leaves its base
empty; core files go under bios/ too. Claimed at the root, they never
met the platform's and four contradictions went unreported."""
config = {"base_destination": "", "systems": {"console": {"files": [
{"name": "IPL.bin", "destination": "bios/GC/JAP/IPL.bin", "md5": "m" * 32},
{"name": "other.bin", "destination": "bios/other.bin"},
]}}}
conflicts = slots.find_conflicts(config, self._profile("GC/JAP/IPL.bin"), REGIONS_DB)
self.assertEqual([c.destination for c in conflicts], ["bios/GC/JAP/IPL.bin"])
def test_wrong_region_under_a_region_slot_is_reported(self): def test_wrong_region_under_a_region_slot_is_reported(self):
conflicts = slots.find_conflicts( conflicts = slots.find_conflicts(
self._config("m" * 32), self._profile("GC/JAP/IPL.bin"), REGIONS_DB self._config("m" * 32), self._profile("GC/JAP/IPL.bin"), REGIONS_DB