feat: keep one bios per slot on declared order

This commit is contained in:
Abdessamad Derraz committed 2026-08-11 13:34:15 +02:00
1 parent ecbe69760d
commit a372f2abf3
6 files changed
+428

No files matched your search

+81
View File
@@ -53,6 +53,7 @@ from common import (
yaml_load,
)
import region as region_mod
import slot as slot_mod
from deterministic_zip import _FIXED_DATE_TIME, rebuild_zip_deterministic
from nativemode import hash_mismatch_excludes_file
from validation import (
@@ -251,6 +252,45 @@ def _find_candidate_satisfying_both(
return None
def _pack_member_groups(
config: dict,
pack_systems: dict,
emulators_dir: str,
db: dict,
base_dest: str,
emu_profiles: dict | None,
target_cores: set[str] | None,
source: str,
) -> dict[str, list[tuple[str, str]]]:
"""Group every candidate of a pack by system, as (destination, name).
Covers the platform baseline and the core extras, because a narrowing pass
that saw only one of the two would compare an incomplete set.
"""
groups: dict[str, list[tuple[str, str]]] = {}
for sys_id, system in pack_systems.items():
members = groups.setdefault(sys_id, [])
for fe in system.get("files", []):
dest = _sanitize_path(fe.get("destination", fe.get("name", "")))
if dest:
members.append((dest, fe.get("name", "")))
if source == "platform":
return groups
emu_systems = {
n: list(p.get("systems", [])) for n, p in (emu_profiles or {}).items()
}
for fe in _collect_emulator_extras(
config, emulators_dir, db, set(), base_dest, emu_profiles,
target_cores=target_cores, include_all=(source == "truth"),
):
dest = _sanitize_path(fe.get("destination", fe.get("name", "")))
if not dest:
continue
for sys_id in emu_systems.get(fe.get("source_emulator", ""), ["_extras"]):
groups.setdefault(sys_id, []).append((dest, fe.get("name", "")))
return groups
def _target_tag(target_name: str) -> str:
"""Filename tag for a hardware target.
@@ -1161,6 +1201,7 @@ def generate_pack(
flatten: bool = True,
regions: list[str] | None = None,
target_name: str | None = None,
one_per_slot: bool = False,
offline: bool | None = None,
) -> str | None:
"""Generate a ZIP pack for a platform.
@@ -1350,6 +1391,30 @@ def generate_pack(
region_groups, region_index, regions
)
slot_undecidable: list[str] = []
if one_per_slot:
idx = region_mod.build_region_index(emu_profiles or {})
members_by_system = _pack_member_groups(
config, pack_systems, emulators_dir, db, base_dest,
emu_profiles, target_cores, source,
)
slot_groups: dict[str, list[tuple[str, str]]] = {}
for sys_id, members in members_by_system.items():
for dest, name in members:
if dest in region_drops:
continue
tier = ",".join(sorted(region_mod.lookup_regions(idx, dest, name)))
slot_groups.setdefault(f"{sys_id}|{tier}", []).append((dest, name))
slot_drops, slot_undecidable = slot_mod.resolve_slot_drops(
slot_groups, slot_mod.build_slot_index(emu_profiles or {})
)
region_drops = region_drops | slot_drops
if slot_undecidable:
print(
f" {len(slot_undecidable)} slot(s) with no declared order: "
f"every candidate kept"
)
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
if source != "truth":
for sys_id, system in sorted(pack_systems.items()):
@@ -2371,6 +2436,7 @@ def generate_split_packs(
source: str = "full",
regions: list[str] | None = None,
target_name: str | None = None,
one_per_slot: bool = False,
offline: bool | None = None,
) -> list[str]:
"""Generate split packs (one ZIP per system or manufacturer)."""
@@ -2438,6 +2504,7 @@ def generate_split_packs(
source=source,
regions=regions,
target_name=target_name,
one_per_slot=one_per_slot,
offline=offline,
)
if zip_path:
@@ -2701,6 +2768,13 @@ def _validate_args(args, parser):
parser.error("--manifest is incompatible with --split")
if getattr(args, "region", None) and has_from_md5:
parser.error("--region and --from-md5 are mutually exclusive")
if getattr(args, "one_per_slot", False):
if has_from_md5:
parser.error("--one-per-slot and --from-md5 are mutually exclusive")
if args.manifest:
parser.error("--one-per-slot is incompatible with --manifest")
if has_emulator or has_system:
parser.error("--one-per-slot requires --platform or --all")
def _write_manifest_if_changed(path: str, manifest: dict) -> None:
@@ -2941,6 +3015,7 @@ def _run_platform_packs(
source=source,
regions=getattr(args, "regions", None),
target_name=args.target,
one_per_slot=args.one_per_slot,
offline=args.offline,
)
print(f" Split into {len(zip_paths)} packs")
@@ -2962,6 +3037,7 @@ def _run_platform_packs(
source=source,
regions=getattr(args, "regions", None),
target_name=args.target,
one_per_slot=args.one_per_slot,
offline=args.offline,
)
if not args.split and zip_path and aliases:
@@ -3090,6 +3166,11 @@ def main():
"--region",
help="Region priority list, best first (e.g. us,eu,jp)",
)
parser.add_argument(
"--one-per-slot",
action="store_true",
help="Keep one BIOS per system and region where the core declares an order",
)
parser.add_argument(
"--list-targets",
action="store_true",
+116
View File
@@ -0,0 +1,116 @@
"""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; 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 an ordered search list the core's code
actually walks, recorded as `search_rank:` on the file entry: rank 1 is tried
first, and the first file found wins. PicoDrive's biosfiles_us/eu/jp arrays are
the shape this describes.
`priority:` is deliberately not read. Its meaning is disputed: the field
reference calls it a tie-breaker where higher wins, while DuckStation's own
selection compares the numbers the other way, and its values rank PS2 images
above the plain PlayStation BIOS. Selecting on it dropped scph5501, the very
file most US setups load.
Where no ordered list is declared, the group is reported undecidable and every
candidate is kept: picking one would be the arbitrary selection this exists to
remove, and it could discard the file the core would have loaded.
"""
from __future__ import annotations
BIOS_CATEGORY = "bios"
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 ""
for key in {path, name} - {""}:
entry = index.setdefault(
key, {"rank": None, "emulators": []}
)
rank = f.get("search_rank")
if rank is not None:
current = entry["rank"]
entry["rank"] = (
rank if current is None else min(current, rank)
)
if emu_name not in entry["emulators"]:
entry["emulators"].append(emu_name)
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],
) -> 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 search rank and exactly one holds the first rank; anything else
keeps every candidate.
"""
keep: set[str] = set()
drop: set[str] = set()
undecidable: list[str] = []
for group_id, members in groups.items():
candidates: list[tuple[str, int | None]] = []
for destination, name in members:
entry = lookup_slot(index, destination, name)
if entry is None:
keep.add(destination)
continue
candidates.append((destination, entry["rank"]))
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(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(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)