fix: let target installs match their target pack

This commit is contained in:
Abdessamad Derraz committed 2026-10-17 00:39:57 +02:00
1 parent 0a5e15789f
commit e659228777
12 files changed
+1326 -70

No files matched your search

+153 -36
View File
@@ -3160,6 +3160,66 @@ def _attach_claims(
entry.pop("alternatives", None)
def _record_slot_choices(
entry: dict,
choices: list[tuple[str, str | None]],
declarations: list[tuple[list[str], dict, str]],
settle,
dest: str,
db: dict,
repo_root: str,
) -> None:
"""Record what a pack built for fewer cores serves at a platform destination.
`slot_choices` lists, in the order slots decides, the cores whose claim
settles the slot and the copy it serves, a choice without a copy leaving
the platform's answer. `declared_choices` is the platform's answer by the
declaring systems that remain, in the order the builder takes them.
Nothing is recorded where every narrowing gets the entry's own copy.
"""
def download(file_entry: dict, sys_id: str, override: str | None) -> dict | None:
local_path, _status = settle(file_entry, sys_id, dest, override)
if local_path is None:
return None
copy, _size = _manifest_entry(local_path, dest, None, db, repo_root, {})
if copy is None:
return None
return {k: v for k, v in copy.items() if k not in ("dest", "cores")}
declared: list[dict] = []
for systems, file_entry, sys_id in declarations:
copy = download(file_entry, sys_id, None)
if copy is None:
# An omission is not a copy the installer can switch to.
return
if declared and declared[-1]["sha1"] == copy["sha1"]:
declared[-1]["systems"].extend(systems)
else:
declared.append({**copy, "systems": list(systems)})
_systems, first, first_sys = declarations[0]
recorded: list[dict] = []
seen: set[str] = set()
for emulator, path in choices:
if emulator in seen:
continue
seen.add(emulator)
copy = {} if path is None else download(first, first_sys, path)
if copy is None:
return
if recorded and recorded[-1].get("sha1") == copy.get("sha1"):
recorded[-1]["cores"].append(emulator)
else:
recorded.append({**copy, "cores": [emulator]})
while recorded and "sha1" not in recorded[-1]:
recorded.pop()
if not recorded and len(declared) == 1:
return
if recorded:
entry["slot_choices"] = recorded
entry["declared_choices"] = declared
def _manifest_core_entries(
core_files: list,
config: dict,
@@ -3441,6 +3501,13 @@ def generate_manifest(
config, emu_profiles, db, zip_contents, data_registry,
target_cores=target_cores, source=source,
)
# The full manifest records every core's answer at a contested slot, so a
# target narrowed from it gets what that target's own pack serves.
slot_choices = (
slots.slot_choices(config, emu_profiles, db, zip_contents, data_registry)
if target_cores is None and source != "platform" and emu_profiles
else {}
)
def manifest_destination(full_destination: str) -> str:
if base_dest and full_destination.startswith(f"{base_dest}/"):
@@ -3489,6 +3556,80 @@ def generate_manifest(
pack_systems, db, bios_dir, base_dest, required_only,
zip_contents, offline,
)
declarations: dict[str, dict[str, list[dict]]] = {}
for sys_id, system in sorted(pack_systems.items()):
for file_entry in system.get("files", []):
if required_only and file_entry.get("required") is False:
continue
dest = sanitize_pack_path(file_entry.get("destination", file_entry["name"]))
if dest and not (region_drops and dest in region_drops):
full_dest = f"{base_dest}/{dest}" if base_dest else dest
declarations.setdefault(full_dest, {}).setdefault(sys_id, []).append(
file_entry
)
def declaration_order(full_dest: str) -> list[tuple[list[str], dict, str]]:
"""The declarations answering a destination as its systems drop.
A pack built for a target loses the systems no available core
reads, and the next declaration takes the destination by the
same rule: RetroArch's bare nintendo-ds dsi_bios9.bin once the
pinned nintendo-dsi one is gone.
"""
remaining = dict(declarations.get(full_dest, {}))
order: list[tuple[list[str], dict, str]] = []
while remaining:
winner = _preferred_entries(
{sid: {"files": fes} for sid, fes in remaining.items()},
db, bios_dir, base_dest, required_only, zip_contents, offline,
).get(full_dest)
first_sys, first = next(
(sid, fe) for sid, fes in sorted(remaining.items()) for fe in fes
if winner is None or id(fe) == winner
)
systems = sorted(
sid for sid, fes in remaining.items()
if any(fe is first for fe in fes)
)
order.append((systems, first, first_sys))
for sid in systems:
del remaining[sid]
return order
def settle(file_entry: dict, sys_id: str, dest: str, override: str | None):
"""The file served once the slot is settled, or None and why not."""
local_path, status = slots.apply_override(override, *resolve_file(
file_entry,
db,
bios_dir,
zip_contents,
data_dir_registry=data_registry,
offline=offline,
))
if status == "not_found" and not reads_file_contents(verification_mode):
found = agnostic_substitute(file_entry, sys_id, db, manifest_profiles)
if found:
local_path, status = found[0], "agnostic_fallback"
if os.path.basename(local_path) != file_entry.get("name", ""):
# The pack explains the rename in a note of its own.
rename_notes.add(file_entry.get("name", ""))
# The builder's verdict: a hash platform omits what its frontend
# would reject, a zipped_file archive included when the member is
# missing whatever the outer resolution said. An existence
# platform never reads the bytes.
status, refusal = baseline_verdict(
file_entry, local_path, status, verification_mode
)
if refusal or status in ("not_found", "external"):
return None, "hash_mismatch" if refusal else status
# The variant the pack ships when an emulator rejects the
# resolved file: the installer must place the same bytes.
local_path, _disagreement = validated_choice(
file_entry, local_path, db, manifest_validation, bios_dir,
digest_algorithm(verification_mode), dest, manifest_owners,
)
return local_path, status
for sys_id, system in sorted(pack_systems.items()):
for file_entry in system.get("files", []):
if required_only and file_entry.get("required") is False:
@@ -3524,48 +3665,24 @@ def generate_manifest(
)
continue
local_path, status = resolve_file(
file_entry,
db,
bios_dir,
zip_contents,
data_dir_registry=data_registry,
offline=offline,
local_path, status = settle(
file_entry, sys_id, dest, slot_overrides.get(full_dest)
)
local_path, status = slots.apply_override(
slot_overrides.get(full_dest), local_path, status
)
if status == "not_found" and not reads_file_contents(verification_mode):
found = agnostic_substitute(file_entry, sys_id, db, manifest_profiles)
if found:
local_path, status = found[0], "agnostic_fallback"
if os.path.basename(local_path) != file_entry.get("name", ""):
# The pack explains the rename in a note of its own.
rename_notes.add(file_entry.get("name", ""))
# The builder's verdict: a hash platform omits what its
# frontend would reject, a zipped_file archive included when
# the member is missing whatever the outer resolution said.
# An existence platform never reads the bytes.
status, refusal = baseline_verdict(
file_entry, local_path, status, verification_mode
)
if refusal or status in ("not_found", "external"):
record_omission(
full_dest, file_entry, sys_id,
"hash_mismatch" if refusal else status, None,
)
if local_path is None:
record_omission(full_dest, file_entry, sys_id, status, None)
continue
# The variant the pack ships when an emulator rejects the
# resolved file: the installer must place the same bytes.
local_path, _disagreement = validated_choice(
file_entry, local_path, db, manifest_validation, bios_dir,
digest_algorithm(verification_mode), dest, manifest_owners,
)
entry, file_size = _manifest_entry(
local_path, dest, None, db, repo_root,
selection.fields(dest, file_entry["name"]),
)
if entry is not None and target_cores is None:
order = declaration_order(full_dest)
if full_dest in slot_choices or len(order) > 1:
_record_slot_choices(
entry, slot_choices.get(full_dest, []), order,
settle, dest, db, repo_root,
)
if entry is None:
record_omission(full_dest, file_entry, sys_id, "not_found", None)
if file_size and full_dest not in seen_destinations:
@@ -3706,7 +3823,7 @@ def _manifest_core_index(
}
named = {
c for entry in [*files, *omitted]
for copy in [entry, *entry.get("alternatives", [])]
for copy in [entry, *entry.get("alternatives", []), *entry.get("slot_choices", [])]
for c in copy.get("cores") or []
}
named |= {c for cores in system_cores.values() for c in cores}
+79 -24
View File
@@ -28,6 +28,7 @@ from common import (
parse_md5_list,
resolution_is_hash_exact,
resolve_local_file,
resolve_platform_cores,
runs_standalone,
)
from packresolve import preferred_entries
@@ -302,20 +303,16 @@ def _claim_rank(claim: Claim) -> int:
return 1 if claim.is_proven else 0
def find_conflicts(
def _slot_claims(
config: dict,
profiles: dict,
db: dict,
base_dest: str = "",
standalone_cores: set[str] | None = None,
zip_contents: dict | None = None,
data_dir_registry: dict | None = None,
) -> list[Conflict]:
"""Destinations where a proven profile claim contradicts what ships.
Only proven claims are compared. A claim resolved by filename alone
asserts nothing about content and cannot contradict anything.
"""
base_dest: str,
standalone_cores: set[str] | None,
zip_contents: dict | None,
data_dir_registry: dict | None,
) -> tuple[dict[str, Claim], dict[str, list[Claim]]]:
"""The platform claim and the proven profile claims at each destination."""
fold = bool(config.get("case_insensitive_fs", False))
# The builder's own choice among declarations sharing a destination,
# read from the same function: the most accepting archive for zipped_file
@@ -369,6 +366,27 @@ def find_conflicts(
continue
by_slot.setdefault(key, []).append(claim)
return by_dest, by_slot
def find_conflicts(
config: dict,
profiles: dict,
db: dict,
base_dest: str = "",
standalone_cores: set[str] | None = None,
zip_contents: dict | None = None,
data_dir_registry: dict | None = None,
) -> list[Conflict]:
"""Destinations where a proven profile claim contradicts what ships.
Only proven claims are compared. A claim resolved by filename alone
asserts nothing about content and cannot contradict anything.
"""
by_dest, by_slot = _slot_claims(
config, profiles, db, base_dest, standalone_cores, zip_contents,
data_dir_registry,
)
# A declaration that lists several accepted hashes is satisfied by any of
# them: Recalbox names both scph1001.bin revisions, and mesence names
# four BS-X dumps among which System.dat pins one. The resolver stops on
@@ -428,20 +446,45 @@ def pack_overrides(
verify all read this: two of them deciding alone gave the one-line
installer different bytes than the ZIP.
"""
from common import resolve_platform_cores
# A baseline-only pack serves what the platform declares, and a target's
# pack answers to the cores that hardware builds: DuckStation does not
# exist on any RetroArch target and still replaced its scph5000.bin.
if not profiles or source == "platform":
return {}
platform_profiles = {
name: profiles[name]
for name in resolve_platform_cores(config, profiles, target_cores=target_cores)
}
mode = config.get("verification_mode", "existence")
cores = set(resolve_platform_cores(config, profiles, target_cores=target_cores))
overrides: dict[str, str] = {}
for conflict in find_conflicts(
for destination, choices in slot_choices(
config, profiles, db, zip_contents, data_dir_registry
).items():
path = next((path for emulator, path in choices if emulator in cores), None)
if path:
overrides[destination] = path
return overrides
def slot_choices(
config: dict,
profiles: dict,
db: dict,
zip_contents: dict | None = None,
data_dir_registry: dict | None = None,
) -> dict[str, list[tuple[str, str | None]]]:
"""Destination -> (core, the file it has served there) in decision order.
A pack built for a set of cores serves the first choice whose core is in
it, None standing for the platform's own answer; no core in it leaves
the platform's answer too. The order is arbitrate's: a core agreeing with
the platform settles the slot whatever else is present, then the
strongest proof decides. Read for one core set by pack_overrides and
recorded whole in the install manifest, so a target narrowed from the
full manifest gets the bytes the target's own pack carries.
"""
if nativemode.reads_file_contents(config.get("verification_mode", "existence")):
return {}
platform_profiles = {
name: profiles[name] for name in resolve_platform_cores(config, profiles)
}
by_dest, by_slot = _slot_claims(
config,
platform_profiles,
db,
@@ -449,11 +492,23 @@ def pack_overrides(
{str(c) for c in config.get("standalone_cores", [])},
zip_contents,
data_dir_registry,
):
decision = arbitrate(conflict, mode)
if decision.serves_both and decision.winner.local_path:
overrides[conflict.destination] = decision.winner.local_path
return overrides
)
choices: dict[str, list[tuple[str, str | None]]] = {}
for key, claims in sorted(by_slot.items()):
platform = by_dest[key]
agree = [_satisfies(c, platform, db) for c in claims]
agreeing = [c for c, ok in zip(claims, agree) if ok]
others = sorted(
(c for c, ok in zip(claims, agree) if not ok),
key=lambda claim: (-_claim_rank(claim), claims.index(claim)),
)
if not others:
continue
choices[platform.destination] = [
*((c.emulator, None) for c in agreeing),
*((c.emulator, c.local_path) for c in others),
]
return choices
def apply_override(