Files
libretro/scripts/slot.py
T
Abdessamad Derraz 315dab82b7 feat: give each set its requirement and one pick
Three changes to the search-list model, one commit because the readers
and the profiles must move together for the schema to hold.

Set requirement in a groups map. pom2 falls back on apple2.rom for the
][ and the ][+, and on apple2e.rom for the //e it boots and for the
unenhanced //e: one required on the file could not say both. A profile
now declares groups: {name: {required}}, a member writes no required
of its own (schema and validate_schemas refuse one) and load_emulator_
profiles derives it, true when one of its sets is. group takes a name,
a list, or a map from set to rank where the rank differs per set. pom2
is grouped from SystemProfile.cpp and MainWindow.cpp:246-296 at its pin.

One member per required set under --required-only. The pick is the
first the code reads among the held members the region filter leaves,
by rank then declaration order; a set the platform declares a member
of is carried by that declaration. The emulator pack, verify
--emulator, the platform pack, its manifest and verify --platform read
alternatives.required_only_spares or core_file_spares.

variant_group is now selection_slot: files a core chooses between by
a rule other than first found (disc territory for PicoDrive and
Pizza Boy SC, the o2em_bios option, a WebAssembly probe in SWF
Player). Where the code takes any one, the field became a group:
ARMSX1 (ArmSxBackend.java:149-157, name order), ePSXe (load_bios
0x433540-0x433621 of the 2.0.5 Linux x64 binary, one configured path,
HLE when BiosHLE is set), FPse and FPse-NG, DXX-Rebirth's HOGs
(inferno.cpp:472-512). odin and zesarux carried it with no effect.
slot.py compares ranks only between candidates that share a set.
2026-11-09 18:19:32 +01:00

201 lines
8.7 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.
bucketed: dict[tuple[str, tuple[str, ...]], list[tuple[str, int | None, frozenset]]] = {}
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
tier = tuple(sorted(entry["regions"]))
rank = None if entry.get("conflict") else entry["rank"]
bucketed.setdefault((group_id, tier), []).append(
(destination, rank, frozenset(entry.get("sets") or ()))
)
# 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. Candidates are
# compared when they share a set, and those in no set among themselves.
tiers: dict[tuple[str, tuple[str, ...], int], list[tuple[str, int | None]]] = {}
for (group_id, tier), found in bucketed.items():
for part, component in enumerate(_by_shared_set(found)):
tiers[(group_id, tier, part)] = component
for (group_id, tier, _part), 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)
def _by_shared_set(
found: list[tuple[str, int | None, frozenset]],
) -> list[list[tuple[str, int | None]]]:
"""The candidates of one tier split where no set links them."""
parent = list(range(len(found)))
def root(i: int) -> int:
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
first_of: dict[object, int] = {}
for i, (_dest, _rank, sets) in enumerate(found):
for key in sets or {None}:
if key in first_of:
parent[root(i)] = root(first_of[key])
else:
first_of[key] = i
parts: dict[int, list[tuple[str, int | None]]] = {}
for i, (dest, rank, _sets) in enumerate(found):
parts.setdefault(root(i), []).append((dest, rank))
return list(parts.values())