chore: merge maker folder name matches

This commit is contained in:
Abdessamad Derraz committed 2026-11-06 00:58:46 +01:00
commit 1008b4b0f5
14 files changed
+2908 -15185

No files matched your search

+104
View File
@@ -90,6 +90,71 @@ def _casefold_name_index(by_name: dict) -> dict[str, list[str]]:
return folded
# bios/Other/<emulator> and bios/Arcade/<emulator or board> file a copy by
# who loads it, not by machine.
BUCKET_FOLDERS = frozenset({"Other", "Arcade"})
def names_manufacturer(sys_id: str, manufacturer: str) -> bool:
"""Whether a system id names a maker folder: nec-pc-88 names NEC."""
slug = manufacturer.lower().replace(" ", "-")
return sys_id == slug or sys_id.startswith(f"{slug}-")
def _maker_folder(path: str, bios_dir: str) -> str | None:
"""The bios/<maker>/ folder a collection path is filed under."""
prefix = f"{bios_dir.rstrip('/')}/"
if not path.startswith(prefix):
return None
parts = path[len(prefix):].split("/")
return parts[0] if len(parts) >= 2 else None
# One slot, keyed like the casefold view: the files dict, its size, the folders.
_maker_folders_cache: list[tuple[dict, int, frozenset[str]]] = []
def _maker_folders(db: dict) -> frozenset[str]:
"""Every maker folder the collection files a copy under."""
files_db = db.get("files", {})
if _maker_folders_cache:
source, size, folders = _maker_folders_cache[0]
if source is files_db and size == len(files_db):
return folders
bios_dir = str(db.get("bios_dir") or "bios")
folders = frozenset(
folder
for entry in files_db.values()
if (folder := _maker_folder(str(entry.get("path", "")), bios_dir))
and folder not in BUCKET_FOLDERS
)
_maker_folders_cache[:] = [(files_db, len(files_db), folders)]
return folders
def system_makers(file_entry: dict, db: dict) -> frozenset[str]:
"""The maker folders a platform entry's system names.
Only a platform's own entry is asked: one that names an owning emulator
is placed by that emulator's tree. Empty when the system names no maker
(pc88, arcade), so nothing is narrowed there.
"""
sys_id = str(file_entry.get("platform_system") or "")
if not sys_id or _owner_tokens(file_entry):
return frozenset()
return frozenset(
folder for folder in _maker_folders(db) if names_manufacturer(sys_id, folder)
)
def other_maker(path: str, makers: frozenset[str], db: dict) -> bool:
"""Whether a path is filed under a maker the entry's system does not name."""
if not makers:
return False
folder = _maker_folder(path, str(db.get("bios_dir") or "bios"))
return folder is not None and folder not in BUCKET_FOLDERS and folder not in makers
def name_match_size_ok(file_entry: dict, candidate_size: int | None) -> bool:
"""Whether a candidate found by name may be the file the entry describes.
@@ -357,6 +422,19 @@ def load_platform_config(platform_name: str, platforms_dir: str = "platforms") -
for sys_id, system in config.get("systems", {}).items()
}
# Each entry carries the system that declares it: a hashless name match is
# narrowed to the maker folder that system names (resolve_local_file).
# Fresh dicts, since a YAML anchor can share one entry between systems.
config["systems"] = {
sys_id: {
**system,
"files": [{**f, "platform_system": sys_id} for f in system["files"]],
}
if isinstance(system, dict) and system.get("files")
else system
for sys_id, system in config.get("systems", {}).items()
}
# The registry key, which per-platform profile fields (installed_on,
# not_bundled_on) name: the display name differs (MiSTer FPGA).
config["_key"] = platform_name
@@ -1573,6 +1651,10 @@ def resolve_local_file(
# hash and path steps above run first, so collecting the real bytes makes
# the entry resolve.
unsourceable = bool(file_entry.get("unsourceable")) or own_file_absent
# A platform's own hashless entry whose system names a maker takes no copy
# filed under another maker: AmberELEC's nec-pc-88 DISK.ROM, which QUASI88
# reads as the 2 KB PC-88 disk ROM, got the 16 KB MSX one by name.
makers = frozenset() if has_strong_hash else system_makers(file_entry, db)
if not has_strong_hash and not unsourceable:
candidates = []
@@ -1637,6 +1719,26 @@ def resolve_local_file(
continue
if os.path.exists(path) and path not in candidates:
candidates.append(path)
if makers and candidates:
candidates = [p for p in candidates if not other_maker(p, makers, db)]
if not candidates:
# The name belongs to another maker's file here. An emulator
# of this system may still read it as an alias of one of its
# own files, an alias by_name leaves out because a primary
# already carries the name: QUASI88 reads DISK.ROM as
# n88sub.rom.
shadowed = db.get("indexes", {}).get("by_shadowed_alias", {})
for try_name in own_names:
for match_sha1 in shadowed.get(try_name, []):
path = files_db.get(match_sha1, {}).get("path", "")
if (
_maker_folder(path, str(db.get("bios_dir") or "bios"))
in makers
and _size_ok(match_sha1)
and os.path.exists(path)
and path not in candidates
):
candidates.append(path)
if candidates:
if zipped_file:
candidates = [p for p in candidates if ".zip" in os.path.basename(p)]
@@ -1701,6 +1803,8 @@ def resolve_local_file(
if zipped_file:
candidates = [c for c in candidates if ".zip" in os.path.basename(c[0])]
# Another maker's file is no mismatch of this one, it is another file.
candidates = [c for c in candidates if not other_maker(c[0], makers, db)]
# A size the emulator itself verifies settles a name collision, so a
# candidate that fits is preferred over one that does not: "ROM" answers
# both an Apple IIgs image and a Macintosh one. It ranks rather than
+39 -2
View File
@@ -294,6 +294,28 @@ def build_indexes(files: dict, aliases: dict) -> dict:
}
def shadowed_alias_index(
shadowed: list[tuple[str, set[str], list[str]]], by_name: dict
) -> dict[str, list[str]]:
"""Profile aliases that a primary's name keeps out of by_name.
QUASI88 reads DISK.ROM as n88sub.rom, but DISK.ROM is the MSX disk ROM's
own name, so by_name never points it at the PC-88 file. The resolver reads
this index only where the system names a maker and no copy filed under
that maker answers the name. An entry with no hash designates the content
its own name holds alone in by_name.
"""
index: dict[str, set[str]] = {}
for entry_name, matched, taken in shadowed:
held = by_name.get(entry_name, [])
designated = matched or (set(held) if len(held) == 1 else set())
for alias in taken:
fresh = designated - set(by_name.get(alias, []))
if fresh:
index.setdefault(alias, set()).update(fresh)
return {alias: sorted(sha1s) for alias, sha1s in sorted(index.items())}
def load_cache(cache_path: str) -> dict:
"""Load cache file if it exists."""
try:
@@ -429,8 +451,9 @@ def main() -> int:
if preserved:
print(f" Preserved {preserved} large file entries from existing database")
shadowed: list[tuple[str, set[str], list[str]]] = []
try:
platform_aliases = _collect_all_aliases(files, aliases)
platform_aliases = _collect_all_aliases(files, aliases, shadowed)
except UnreadableSource as exc:
for source in exc.sources:
print(f"Error: cannot read {source}", file=sys.stderr)
@@ -446,6 +469,7 @@ def main() -> int:
provenance_counts = annotate_provenance(files, snapshots)
indexes = build_indexes(files, aliases)
indexes["by_shadowed_alias"] = shadowed_alias_index(shadowed, indexes["by_name"])
total_size = sum(entry["size"] for entry in files.values())
database = {
@@ -508,11 +532,17 @@ class UnreadableSource(Exception):
self.sources = sources
def _collect_all_aliases(files: dict, copies: dict | None = None) -> dict:
def _collect_all_aliases(
files: dict,
copies: dict | None = None,
shadowed: list[tuple[str, set[str], list[str]]] | None = None,
) -> dict:
"""Collect alternate filenames from platform YAMLs, core-info, and known aliases.
Registers alternate names so generate_pack can resolve files stored under different names.
``copies`` maps a sha1 to the other files the scan found with that content.
``shadowed`` receives, per profile entry, its name, the contents its hashes
designate and the aliases a primary's name already carries.
"""
md5_to_sha1 = {}
name_to_sha1 = {}
@@ -635,6 +665,13 @@ def _collect_all_aliases(files: dict, copies: dict | None = None) -> dict:
entry_aliases.insert(0, entry_name)
if not entry_aliases:
continue
if shadowed is not None:
taken = [
alias for alias in entry_aliases
if alias != entry_name and alias in named_primaries
]
if taken:
shadowed.append((entry_name, set(matched), taken))
if not matched and len(holders.get(entry_name, ())) == 1:
# A name carried by several contents names none of them:
# quasi88's disk.rom aliases went to whichever
+19 -14
View File
@@ -40,6 +40,8 @@ from typing import TypeVar
sys.path.insert(0, os.path.dirname(__file__))
from common import (
BUCKET_FOLDERS,
names_manufacturer,
upstream_profile_index,
build_zip_contents_index,
GAME_DATA_TOPS,
@@ -219,21 +221,10 @@ def _copy_places(db: dict) -> dict[str, set[tuple[str, str, str]]]:
return places
# bios/Other/<emulator> and bios/Arcade/<emulator or board> file a copy by
# who loads it, not by machine. A copy also filed under a machine's maker
# places the system there.
_BUCKET_FOLDERS = frozenset({"Other", "Arcade"})
def _slug(manufacturer: str) -> str:
return manufacturer.lower().replace(" ", "-")
def _names_manufacturer(sys_id: str, manufacturer: str) -> bool:
slug = _slug(manufacturer)
return sys_id == slug or sys_id.startswith(f"{slug}-")
def _copies_by_path(db: dict) -> dict[str, set[tuple[str, str, str]]]:
"""The copy places of each content, keyed by the path the database keeps."""
files = db.get("files", {})
@@ -276,13 +267,13 @@ def _system_folder(
declared = str(fe.get("name", "")).casefold()
named = {
(m, c) for m, c, base in held
if base.casefold() == declared or _names_manufacturer(sys_id, m)
if base.casefold() == declared or names_manufacturer(sys_id, m)
}
for place in named or {(m, c) for m, c, _ in held}:
votes[place] = votes.get(place, 0) + 1
if votes:
own = [place for place in votes if _names_manufacturer(sys_id, place[0])]
makers = [place for place in votes if place[0] not in _BUCKET_FOLDERS]
own = [place for place in votes if names_manufacturer(sys_id, place[0])]
makers = [place for place in votes if place[0] not in BUCKET_FOLDERS]
candidates = own or makers or list(votes)
return max(sorted(candidates), key=lambda place: votes[place])
for fe in files:
@@ -1044,6 +1035,20 @@ def _split_contract(config: dict) -> tuple[dict, dict]:
"""
allowed = _contract_keys()
contract = {key: value for key, value in config.items() if key in allowed}
# platform_system is the resolver's copy of the key each entry sits under.
if isinstance(contract.get("systems"), dict):
contract["systems"] = {
sys_id: {
**system,
"files": [
{k: v for k, v in f.items() if k != "platform_system"}
for f in system["files"]
],
}
if isinstance(system, dict) and system.get("files")
else system
for sys_id, system in contract["systems"].items()
}
registry = {key: value for key, value in config.items()
if key not in allowed and key != "_key"}
# The merged config gives a child its parent's release_pack: Lakka and