Files
libretro/scripts/slot.py
T
Abdessamad Derraz 2c76cc7df9 feat: read a search list as one need
A core that loads the first of several names (yabause: saturn_bios.bin,
sega_101.bin, mpr-17933.bin) needs one of them. Profiles wrote this as
required on the first name and optional on the others, so verify and
the gap report called saturn_bios.bin a missing required file while
sega_101.bin was held, and a set held only through a name marked
optional read as a need met by an optional file.

Model: an explicit group on the file entry, mirroring the platform
field. priority was not reused: it is a rank, read by slot.py, region.py
and truth.py inside a (system, region) slot, and that slot is not the
set. px68k-sdl ranks its IPL list and its font list each from 1 in one
system, pom2 ranks one list per machine model, DuckStation ranks 105
images across every region and takes any of them. A rank cannot say
where one list ends. priority stays the order inside a group.

Readers, through alternatives.settle_sets:
- verify --emulator and the emulator pack: a held member fills the
  set, the others leave the report, a set nobody holds is one gap
  under its first member naming the others.
- find_undeclared_files (verify platform, core complement of packs and
  manifests): same rule, a platform declaration or launcher counts as
  answering the set.
- cross_reference: excused members are set aside as other versions.
- slot.py: ranks compare members of one set only.
- archive members: a required set inside an archive is met by one.
- truth: a member gives no requirement of its own, one_of and
  one_of_required carry the set, and the exporters and the diff leave
  such a line's required as the platform wrote it.
- validate_schemas: members of a group share required.
2026-11-09 17:39:46 +01:00

170 lines
7.5 KiB
Python

"""One file per slot: keep the BIOS a core would actually load.
A slot is a (system, region) pair holding several interchangeable BIOS. Region
filtering narrows a pack to one territory, but a system can still ship three US
PlayStation BIOS that differ only by revision, which is what leaves the choice
open in the frontend.
The winner is only ever taken from the preference order the core's own code
applies, recorded as `priority:` on the file entry, lowest first. DuckStation
keeps the image whose priority is lower (bios.cpp, FindBIOSImageInDirectory)
and its table de-prioritizes by raising the number: the buggy launch console
sits at 50 and PS2 images at 100, while scph5501 sits at 5. A core that walks
an ordered search list, as PicoDrive does with biosfiles_us/eu/jp, maps onto
the same field as 1, 2, 3.
Every candidate of a group must carry one. A set where some members are
unranked cannot be ordered, and dropping the unranked ones would discard
exactly the file the core may load. Where the order is not declared the group
is reported undecidable and every candidate is kept: picking one would be the
arbitrary selection this exists to remove.
The scale is only meaningful inside one emulator's candidate set. When two
profiles rank the same file differently (pcsx1 walks scph1001 first while
DuckStation prefers scph5501), the pack serves both cores, so neither order
may win: the file is marked conflicting and its group stays undecidable.
The same holds when one profile ranks a file and another declares it with no
rank: the second core reads it by its own rule, which no rank expresses.
"""
from __future__ import annotations
from alternatives import set_keys
BIOS_CATEGORY = "bios"
def platform_slot_index(
config: dict, profiles: dict, target_cores: set[str] | None = None
) -> dict[str, dict]:
"""The slot index of the emulators a platform runs, nothing else.
Built over every profile, DuckStation, ePSXe and Rustation ranked the
PlayStation BIOS of RomM, whose cores rank none of them, and
`--one-per-slot` dropped four images RomM declares.
"""
from common import resolve_platform_cores # noqa: PLC0415
cores = resolve_platform_cores(config, profiles, target_cores=target_cores)
return build_slot_index({n: profiles[n] for n in cores if n in profiles})
def build_slot_index(profiles: dict) -> dict[str, dict]:
"""Map a file key to its slot metadata, keyed by path then name."""
index: dict[str, dict] = {}
for emu_name, profile in sorted(profiles.items()):
if profile.get("type") in ("launcher", "alias"):
continue
for f in profile.get("files") or []:
if not isinstance(f, dict):
continue
if f.get("category", BIOS_CATEGORY) != BIOS_CATEGORY:
continue
name = f.get("name", "")
path = f.get("path") or ""
# Keyed by the standalone layout too, as build_region_index is.
for key in {path, f.get("standalone_path") or "", name} - {""}:
entry = index.setdefault(
key,
{"rank": None, "conflict": False, "regions": set(),
"emulators": [], "ranked_by": set(), "unranked_by": set(),
"sets": set()},
)
entry["regions"] |= {str(r) for r in (f.get("region") or [])}
entry["sets"] |= set(set_keys(emu_name, f))
rank = f.get("priority")
entry["unranked_by" if rank is None else "ranked_by"].add(emu_name)
if rank is not None:
current = entry["rank"]
if current is not None and current != rank:
entry["conflict"] = True
entry["rank"] = (
rank if current is None else min(current, rank)
)
if emu_name not in entry["emulators"]:
entry["emulators"].append(emu_name)
# A core that declares the file without a rank loads it by a rule of its
# own: Genesis Plus GX opens bios_CD_U.bin by name for a US disc, and
# PicoDrive's rank 4 for it would let its rank-3 file replace it.
for entry in index.values():
if entry["ranked_by"] and entry.pop("unranked_by") - entry["ranked_by"]:
entry["conflict"] = True
entry.pop("unranked_by", None)
entry.pop("ranked_by")
return index
def lookup_slot(index: dict[str, dict], destination: str, name: str) -> dict | None:
"""Look a candidate up, trying the destination then its suffixes then name."""
if destination:
entry = index.get(destination)
if entry:
return entry
parts = destination.split("/")
for i in range(1, len(parts)):
entry = index.get("/".join(parts[i:]))
if entry:
return entry
return index.get(name)
def resolve_slot_drops(
groups: dict[str, list[tuple[str, str]]],
index: dict[str, dict],
held: set[str] | None = None,
) -> tuple[set[str], list[str]]:
"""Destinations to skip, and the groups no declared order can decide.
Returns (drops, undecidable). A group is decided only when every candidate
declares a priority and exactly one holds the lowest. Anything else keeps
every candidate. ``held`` names the destinations the collection holds: a
candidate it lacks ships nothing, so it neither wins nor displaces one.
"""
keep: set[str] = set()
drop: set[str] = set()
undecidable: list[str] = []
# A slot is a system AND a declared region: the Japanese and American
# PlayStation BIOS are not alternatives to each other, and comparing their
# ranks across regions would pick one territory's file for another's.
tiers: dict[tuple[str, tuple[str, ...], tuple], list[tuple[str, int | None]]] = {}
for group_id, members in groups.items():
for destination, name in members:
if held is not None and destination not in held:
continue
entry = lookup_slot(index, destination, name)
if entry is None:
keep.add(destination)
continue
# A rank orders the members of one set: px68k-sdl ranks its IPL
# and its font ROM lists each from 1, and the two never compete.
tier = tuple(sorted(entry["regions"]))
sets = tuple(sorted(entry.get("sets") or ()))
rank = None if entry.get("conflict") else entry["rank"]
tiers.setdefault((group_id, tier, sets), []).append((destination, rank))
for (group_id, tier, _sets), candidates in tiers.items():
if len(candidates) < 2:
keep.update(dest for dest, _p in candidates)
continue
# Every candidate must carry a rank: a set where some members are
# unranked cannot be ordered, and dropping the unranked ones would
# discard exactly the file the core may load.
if any(p is None for _d, p in candidates):
undecidable.append(f"{group_id}|{','.join(tier)}" if tier else group_id)
keep.update(dest for dest, _p in candidates)
continue
best = min(p for _d, p in candidates)
winners = [dest for dest, p in candidates if p == best]
if len(winners) != 1:
undecidable.append(f"{group_id}|{','.join(tier)}" if tier else group_id)
keep.update(dest for dest, _p in candidates)
continue
keep.add(winners[0])
drop.update(dest for dest, _p in candidates if dest != winners[0])
return drop - keep, sorted(undecidable)