Files
libretro/scripts/cross_reference.py
T
Abdessamad Derraz 17e35d1af9 feat: file cards name the packs that install them
A card named only the platforms whose own list declares the file. A RetroBat user looking up scph5501.bin found every platform but RetroBat, whose pack installs it for six cores. The cards now read the install manifests and add each pack that places the file, with the destination it gives it.
2026-10-20 04:56:23 +02:00

937 lines
36 KiB
Python

#!/usr/bin/env python3
"""Cross-reference emulator profiles against platform configs.
Identifies BIOS files that emulators need but platforms don't declare,
providing gap analysis for extended coverage.
Usage:
python scripts/cross_reference.py
python scripts/cross_reference.py --emulator dolphin
python scripts/cross_reference.py --json
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
import zipfile
import zlib
from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__))
from common import (
platform_arg,
ProfileSelectionError,
_norm_system_id,
directory_members,
get_mame_clone_map,
list_emulator_profiles,
list_registered_platforms,
load_database,
load_emulator_profiles,
load_platform_config,
name_match_size_ok,
parse_md5_list,
require_platform,
require_yaml,
resolve_local_file,
platform_layouts,
standalone_set,
select_emulator_profiles,
)
from hashing import parse_hash_list
from validation import declaration_settles, outside_gap_scope
yaml = require_yaml()
DEFAULT_EMULATORS_DIR = "emulators"
DEFAULT_PLATFORMS_DIR = "platforms"
DEFAULT_DB = "database.json"
def load_platform_files(
platforms_dir: str,
platforms: list[str] | None = None,
) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
"""Collect declared filenames + data_directories per system.
Restricted to *platforms* when given, otherwise every registered platform.
Keyed by the normalized system ID: a profile writes `sega-megacd` where
RetroArch writes `sega-mega-cd`, and an exact match called 924 declared
files undeclared.
"""
declared = {}
platform_data_dirs = {}
for platform_name in platforms or list_registered_platforms(
platforms_dir, include_archived=True
):
config = load_platform_config(platform_name, platforms_dir)
for sys_id, system in config.get("systems", {}).items():
for fe in system.get("files", []):
name = fe.get("name", "")
if name:
declared.setdefault(_norm_system_id(sys_id), set()).add(name)
for dd in system.get("data_directories", []):
ref = dd.get("ref", "")
if ref:
platform_data_dirs.setdefault(_norm_system_id(sys_id), set()).add(ref)
return declared, platform_data_dirs
def declared_names(
platforms_dir: str, platforms: list[str] | None, db: dict
) -> dict[str, list[tuple[int | None, str]]]:
"""Every name the platforms declare, with each declaration's size and slot.
The report judged "undeclared" per normalized system id, which does not
bring bk and elektronika-bk, or nintendo-sgb and nintendo-super-game-boy,
together: 646 entries whose exact name a platform declares were counted
undeclared, while verify and the site read one flat index.
"""
from common import merged_platform_declarations
return merged_platform_declarations(
[
load_platform_config(platform_name, platforms_dir)
for platform_name in platforms or list_registered_platforms(
platforms_dir, include_archived=True
)
],
db,
)
def _build_supplemental_index(
data_root: str = "data", bios_root: str = "bios"
) -> set[str]:
"""Build a set of filenames and directory names in data/ and inside bios/ ZIPs.
A directory is indexed only with its trailing slash: a bare name matched a
file entry of the same name, and BasiliskII's required `ROM` read as held
because cpcemu keeps a directory called ROM.
"""
names: set[str] = set()
root_path = Path(data_root)
if root_path.is_dir():
for fpath in root_path.rglob("*"):
if fpath.name.startswith("."):
continue
if fpath.is_file():
names.add(fpath.name)
names.add(fpath.name.lower())
else:
names.add(fpath.name + "/")
names.add(fpath.name.lower() + "/")
if fpath.is_dir():
# Also index relative path from data/subdir/ for directory entries
parts = fpath.relative_to(root_path).parts
if len(parts) > 1:
rel = "/".join(parts[1:])
names.add(rel + "/")
names.add(rel.lower() + "/")
bios_path = Path(bios_root)
if bios_path.is_dir():
# Index directory names for directory-type entries (e.g., "nestopia/samples/moepro/")
for dpath in bios_path.rglob("*"):
if dpath.is_dir() and not dpath.name.startswith("."):
names.add(dpath.name + "/")
names.add(dpath.name.lower() + "/")
names |= _zip_member_names(bios_path)
return names
def _zip_member_names(bios_path: Path) -> set[str]:
"""Basenames of the files inside every ZIP under bios/, both cases."""
import zipfile
names: set[str] = set()
for zpath in bios_path.rglob("*.zip"):
try:
with zipfile.ZipFile(zpath) as zf:
members = [m for m in zf.namelist() if not m.endswith("/")]
except (zipfile.BadZipFile, OSError) as exc:
print(f" WARNING: unreadable archive {zpath}: {exc}", file=sys.stderr)
continue
for member in members:
basename = member.rsplit("/", 1)[-1]
names.update((basename, basename.lower()))
return names
def _resolve_source(
fname: str,
by_name: dict[str, list],
by_name_lower: dict[str, str],
data_names: set[str] | None = None,
by_path_suffix: dict | None = None,
file_entry: dict | None = None,
db_files: dict | None = None,
bios_dir: str | None = None,
) -> str | None:
"""Return the source category for a file, or None if not found.
Returns ``"bios"`` (in database.json / bios/), ``"data"`` (in data/),
or ``None`` (not available anywhere).
A name hit whose size contradicts a size the emulator verifies is not the
file, so it does not count as available.
"""
def _name_hit(name: str) -> bool:
"""Whether the files indexed under *name* can be this entry."""
if not file_entry or db_files is None:
return True
sha1s = by_name.get(name) or []
if not isinstance(sha1s, list):
sha1s = [sha1s]
return any(
name_match_size_ok(file_entry, db_files.get(sha1, {}).get("size"))
for sha1 in sha1s
)
# bios/ via database.json by_name
if fname in by_name and _name_hit(fname):
return "bios"
stripped = fname.rstrip("/")
basename = stripped.rsplit("/", 1)[-1] if "/" in stripped else None
if basename and basename in by_name and _name_hit(basename):
return "bios"
key = fname.lower()
if key in by_name_lower and _name_hit(by_name_lower[key]):
return "bios"
if basename:
if basename.lower() in by_name_lower and _name_hit(
by_name_lower[basename.lower()]
):
return "bios"
# bios/ via by_path_suffix (regional variants)
if by_path_suffix and fname in by_path_suffix:
return "bios"
# bios/ under the canonical MAME set name, as resolve_local_file does:
# a renamed archive is held once, under the name the dedup kept.
canonical = get_mame_clone_map(bios_dir).get(fname)
if canonical and canonical != fname:
if canonical in by_name and _name_hit(canonical):
return "bios"
if canonical.lower() in by_name_lower and _name_hit(
by_name_lower[canonical.lower()]
):
return "bios"
if data_names and _data_hit(fname, basename, file_entry, data_names):
return "data"
return None
def _data_hit(
fname: str, basename: str | None, file_entry: dict | None, data_names: set[str]
) -> bool:
"""Whether data/ or a ZIP holds the name: a directory entry looks for a
directory, a file entry for a file. A file entry that declares a hash is
held only by a copy carrying it: touchhle's LiberationMono-Regular.ttf
read as held because idtech4a++'s extras.zip has another font of that
name."""
looked_up = [fname, fname.lower()] + ([basename, basename.lower()] if basename else [])
if fname.endswith("/") or (file_entry or {}).get("type") == "directory":
looked_up = [name.rstrip("/") + "/" for name in looked_up]
return any(name in data_names for name in looked_up)
if not any(name in data_names for name in looked_up):
return False
declared = _declared_content(file_entry or {})
if not declared:
return True
return any(
_content_matches(location, declared)
for location in _supplemental_locations().get((basename or fname).lower(), ())
)
_HASH_FIELDS = ("sha1", "md5", "sha256", "crc32")
def _declared_content(file_entry: dict) -> dict[str, set[str]]:
"""The hashes an entry declares, lowercased, by algorithm."""
declared: dict[str, set[str]] = {}
for field in _HASH_FIELDS:
found = set(parse_hash_list(file_entry.get(field)))
if found:
declared[field] = found
return declared
_locations_cache: dict[tuple[str, str], dict[str, list[tuple[str, str]]]] = {}
def _supplemental_locations(
data_root: str = "data", bios_root: str = "bios"
) -> dict[str, list[tuple[str, str]]]:
"""Where each name of the supplemental index sits: a data/ file, or a
member of a ZIP under bios/, keyed by lowercased basename."""
key = (data_root, bios_root)
if key in _locations_cache:
return _locations_cache[key]
found: dict[str, list[tuple[str, str]]] = {}
root = Path(data_root)
if root.is_dir():
for path in root.rglob("*"):
if path.is_file() and not path.name.startswith("."):
found.setdefault(path.name.lower(), []).append((str(path), ""))
for zpath in Path(bios_root).rglob("*.zip") if Path(bios_root).is_dir() else ():
try:
with zipfile.ZipFile(zpath) as zf:
members = [m for m in zf.namelist() if not m.endswith("/")]
except (zipfile.BadZipFile, OSError) as exc:
print(f" WARNING: unreadable archive {zpath}: {exc}", file=sys.stderr)
continue
for member in members:
found.setdefault(member.rsplit("/", 1)[-1].lower(), []).append(
(str(zpath), member)
)
_locations_cache[key] = found
return found
def _content_matches(location: tuple[str, str], declared: dict[str, set[str]]) -> bool:
"""Whether the file or member at *location* carries a declared hash."""
path, member = location
try:
if member:
with zipfile.ZipFile(path) as zf:
if set(declared) == {"crc32"}:
return f"{zf.getinfo(member).CRC:08x}" in declared["crc32"]
data = zf.read(member)
else:
data = Path(path).read_bytes()
except (zipfile.BadZipFile, KeyError, OSError) as exc:
print(f" WARNING: cannot read {path} {member}: {exc}", file=sys.stderr)
return False
actual = {
"sha1": hashlib.sha1(data).hexdigest(),
"md5": hashlib.md5(data).hexdigest(),
"sha256": hashlib.sha256(data).hexdigest(),
"crc32": f"{zlib.crc32(data) & 0xFFFFFFFF:08x}",
}
return any(actual[field] in values for field, values in declared.items())
class FileConsumers:
"""Who reads each collected file, joined through the resolver.
The site joined database files to emulators and platforms by bare name:
each of the four pak0.pk3 of four games was attributed to every engine
reading a pak0.pk3. A consumer here is an entry that resolves to the
file by the evidence the pack uses, so a homonym is nobody's file.
"""
def __init__(self, db: dict, zip_contents: dict | None = None) -> None:
self._db = db
self._zip_contents = zip_contents
self._sha1_by_path = {
path: sha1
for sha1, entry in db.get("files", {}).items()
for path in {entry.get("path", ""), *entry.get("paths", [])}
if path
}
self.platforms: dict[str, set[str]] = {}
# Under which of its systems each platform declares a file, so a
# link can land on that section rather than the page top.
self.platform_systems: dict[str, dict[str, set[str]]] = {}
# Where each platform puts the file in its BIOS directory.
self.platform_destinations: dict[str, dict[str, set[str]]] = {}
self.emulators: dict[str, set[str]] = {}
# Where each platform's pack installs the file, its cores' files
# included: the platform's own list names a fraction of them.
self.installed: dict[str, dict[str, set[str]]] = {}
def sha1_of(self, entry: dict, dest: str = "", owner: str = "") -> str | None:
"""The collected file an entry resolves to, or None.
A profile entry names its owner, as the builder does: without it the
name step chose another tree's copy, and the page credited the file
the pack does not ship.
"""
if not entry.get("name"):
return None
if owner:
entry = {**entry, "source_profile": owner}
path, status = resolve_local_file(
entry, self._db, self._zip_contents, dest_hint=dest
)
if not path or status in ("not_found", "hash_mismatch"):
return None
return self._sha1_by_path.get(path)
def add_platforms(self, configs: dict[str, dict]) -> None:
for name, config in configs.items():
for system_id, system in config.get("systems", {}).items():
for entry in system.get("files", []):
sha1 = self.sha1_of(entry, entry.get("destination", ""))
if sha1:
self.platforms.setdefault(sha1, set()).add(name)
self.platform_systems.setdefault(sha1, {}).setdefault(
name, set()
).add(system_id)
where = str(entry.get("destination") or entry["name"])
# EmuDeck matches by md5 alone: its entries carry a
# system:md5 key, not a place in the folder.
if ":" not in where:
self.platform_destinations.setdefault(sha1, {}).setdefault(
name, set()
).add(where)
def add_manifests(self, install_dir: str | Path) -> None:
"""Read what each install manifest places, alternatives included."""
for path in sorted(Path(install_dir).glob("*.json")):
try:
manifest = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(f"WARNING: {path}: {exc}", file=sys.stderr)
continue
platform = str(manifest.get("platform") or path.stem)
for entry in manifest.get("files") or []:
for sha1 in {entry.get("sha1"), *(
alt.get("sha1") for alt in entry.get("alternatives") or []
)} - {None, ""}:
self.installed.setdefault(sha1, {}).setdefault(
platform, set()).add(str(entry.get("dest", "")))
def path_of(self, sha1: str) -> str:
"""The collection path the database records for a file."""
return str(self._db.get("files", {}).get(sha1, {}).get("path", ""))
def add_emulators(self, profiles: dict[str, dict]) -> None:
for name, profile in profiles.items():
if profile.get("type") == "alias":
continue
for entry in profile.get("files", []):
sha1 = self.sha1_of(entry, entry.get("path") or "", owner=name)
if sha1:
self.emulators.setdefault(sha1, set()).add(name)
def entry_source(f: dict, index: dict) -> str | None:
"""Where the collection holds a profile entry, or None.
By name, path and alias, then by any hash the entry declares. The gap
report and the site pages read this one function, so a file is held or
missing in the same way on both.
"""
by_name, by_name_lower = index["by_name"], index["by_name_lower"]
data_names, by_path_suffix = index.get("data_names"), index["by_path_suffix"]
db_files = index["db_files"]
fname = f.get("name", "")
path_field = f.get("path") or ""
# A directory is held when the collection holds files under it, the rule
# the pack and verify --emulator expand it by: a bare-name lookup missed
# cdogs' .wolf3d/, whose 695 files every pack carries.
if f.get("type") == "directory" and directory_members(
{"indexes": {"by_path_suffix": by_path_suffix}}, path_field or fname
):
return "bios"
for candidate in [fname, *([path_field] if path_field != fname else []),
*(f.get("aliases") or [])]:
if not candidate:
continue
source = _resolve_source(
candidate, by_name, by_name_lower, data_names, by_path_suffix, f, db_files,
index.get("bios_dir"),
)
if source is not None:
return source
def values(field: str) -> list[str]:
return parse_hash_list(f.get(field))
held = (
any(index["by_md5"].get(v) for v in parse_md5_list(f.get("md5")))
or any(v in db_files for v in values("sha1"))
# rust_dos names its SC-55 ROMs by sha256 alone
or any(v in index["by_sha256"] for v in values("sha256"))
or any(index["by_crc32"].get(v) for v in values("crc32"))
)
return "bios" if held else None
def _resolve_archive_source(
archive_name: str,
by_name: dict[str, list],
by_name_lower: dict[str, str],
data_names: set[str] | None = None,
by_path_suffix: dict | None = None,
) -> str:
"""Resolve source for an archive (ZIP) name, returning a source category string."""
result = _resolve_source(
archive_name, by_name, by_name_lower, data_names, by_path_suffix,
)
return result if result is not None else "missing"
def _first_hash(entry: dict) -> str:
"""The first hash an entry declares, strongest first, or ''."""
for field in ("sha1", "sha256", "md5", "crc32"):
value = entry.get(field)
if isinstance(value, list):
value = value[0] if value else ""
if value:
return f"{field}:{str(value).lower()}"
return ""
def slot_key(f: dict, standalone: bool = False) -> tuple:
"""The slot a profile entry fills: its entries on one slot are the
versions its code accepts there, and holding any of them fills it.
The path is the one the running build reads: b2's standalone build
reads MOS 3.20 and 3.50 from two directories, two slots, while the
entries share a name and no `path:`."""
fname = f.get("name", "")
where = (f.get("standalone_path") if standalone else None) or f.get("path")
return (
fname,
f.get("archive"),
where or fname,
f.get("system"),
f.get("variant_group"),
f.get("mode", "both"),
)
def _cross_reference_profile(
emu_name: str,
profile: dict,
declared: dict[str, set[str]],
report: dict,
index: dict,
standalone_cores: set[str] | None = None,
) -> None:
"""Compare one emulator profile against what the platforms declare.
Split out of cross_reference, where it was a 200-line loop body that
carried the whole function's branching: every file is resolved by name,
by path, by hash and by archive membership before it can be called a
gap. *index* carries the database lookups those steps share.
"""
by_name = index["by_name"]
by_name_lower = index["by_name_lower"]
by_path_suffix = index["by_path_suffix"]
data_names = index["data_names"]
all_declared = index["all_declared"]
emu_files = profile.get("files", [])
systems = profile.get("systems", [])
# Skip filename-agnostic profiles (BIOS detected without fixed names)
if profile.get("bios_mode") == "agnostic":
return
if all_declared is not None:
platform_names = all_declared
else:
platform_names = set()
for sys_id in systems:
platform_names.update(declared.get(_norm_system_id(sys_id), set()))
# The build a platform runs decides which entries it reads, as in
# find_undeclared_files: without a platform, the libretro build.
layouts = platform_layouts(emu_name, profile, standalone_cores or set())
# Each file under each layout the platform gives the emulator: both, for
# one it launches as a libretro core and standalone.
layout_files = [(f, standalone) for view, standalone in layouts for f in view.get("files", [])]
gaps = []
covered = []
unsourceable_list: list[dict] = []
archive_gaps: dict[tuple, dict] = {}
seen_files: set[tuple] = set()
# The entries of one profile on one slot are the versions its code
# accepts there (raze's ten DUKE3D.GRP): holding any of them fills the
# slot, as verify --emulator credits it.
versions: dict[tuple, list[dict]] = {}
for f, is_standalone in layout_files:
versions.setdefault(slot_key(f, is_standalone), []).append(f)
for f, is_standalone in layout_files:
fname = f.get("name", "")
# The destination the running build reads, as find_undeclared_files
# takes it: Batocera's standalone Cemu reads keys.txt at
# standalone_path, and the libretro Cemu/keys.txt reported here
# settled the declaration on a slot the platform never fills.
if is_standalone:
effective_path = f.get("standalone_path") or f.get("path") or fname
else:
effective_path = f.get("path") or fname
slot = slot_key(f, is_standalone)
seen_key = slot
if len(layouts) > 1:
# One file read at one path by both builds counts once.
seen_key = (effective_path.casefold(), seen_key)
if not fname or seen_key in seen_files:
continue
# Collect unsourceable files separately (documented, not a gap)
unsourceable_reason = f.get("unsourceable", "")
if unsourceable_reason:
seen_files.add(seen_key)
unsourceable_list.append({
"name": fname,
"required": f.get("required", False),
"reason": unsourceable_reason,
"source_ref": f.get("source_ref", ""),
})
continue
if outside_gap_scope(f, is_standalone):
continue
archive = f.get("archive")
# Check platform declaration: by name, by an alias the core answers
# to (RetroArch lists amiberry's cd32.rom as kick40060.CD32), or by
# archive.
in_platform = declaration_settles(f, fname, platform_names, effective_path)
if in_platform:
seen_files.add(seen_key)
covered.append({
"name": fname,
"path": effective_path,
"required": f.get("required", False),
"in_platform": True,
})
continue
seen_files.add(seen_key)
# Group archived files by archive name
if archive:
archive_key = (
archive,
f.get("system"),
f.get("variant_group"),
f.get("mode", "both"),
)
if archive_key not in archive_gaps:
source = _resolve_archive_source(
archive, by_name, by_name_lower, data_names,
by_path_suffix,
)
archive_gaps[archive_key] = {
"name": archive,
"path": archive,
"required": False,
"note": "",
"source_ref": "",
"in_platform": False,
"in_repo": source != "missing",
"source": source,
"archive": archive,
"archive_file_count": 0,
"archive_required_count": 0,
}
entry = archive_gaps[archive_key]
entry["archive_file_count"] += 1
if f.get("required", False):
entry["archive_required_count"] += 1
entry["required"] = True
if not entry["source_ref"] and f.get("source_ref"):
entry["source_ref"] = f["source_ref"]
continue
# --- resolve source provenance ---
storage = f.get("storage", "")
if storage in ("release", "large_file"):
source = "large_file"
else:
source = next(
(
found for version in versions[slot]
if (found := entry_source(version, index)) is not None
),
"missing",
)
in_repo = source != "missing"
entry = {
"name": fname,
"path": effective_path,
"required": f.get("required", False),
"bundled": bool(f.get("bundled")),
"note": f.get("note", ""),
"source_ref": f.get("source_ref", ""),
"in_platform": False,
"in_repo": in_repo,
"source": source,
# What tells this file from a homonym another emulator wants.
"identity": _first_hash(f),
}
gaps.append(entry)
# Append grouped archive gaps
for ag in sorted(archive_gaps.values(), key=lambda e: e["name"]):
gaps.append(ag)
report[emu_name] = {
"emulator": profile.get("emulator", emu_name),
"systems": systems,
"total_files": len(emu_files),
"platform_covered": len(covered),
"gaps": len(gaps),
"gap_in_repo": sum(1 for g in gaps if g["in_repo"]),
"gap_missing": sum(1 for g in gaps if g["source"] == "missing"),
"gap_bios": sum(1 for g in gaps if g["source"] == "bios"),
"gap_data": sum(1 for g in gaps if g["source"] == "data"),
"gap_large_file": sum(1 for g in gaps if g["source"] == "large_file"),
"gap_details": gaps,
"unsourceable": unsourceable_list,
}
def cross_reference(
profiles: dict[str, dict],
declared: dict[str, set[str]],
db: dict,
platform_data_dirs: dict[str, set[str]] | None = None,
data_names: set[str] | None = None,
all_declared: dict[str, list[tuple[int | None, str]]] | None = None,
standalone_cores: set[str] | None = None,
) -> dict:
"""Compare emulator profiles against platform declarations.
Returns a report with gaps (files emulators need but platforms don't list)
and coverage stats. Each gap entry carries a ``source`` field indicating
where the file is available: ``"bios"`` (bios/ via database.json),
``"data"`` (data/ directory), ``"large_file"`` (GitHub release asset),
or ``"missing"`` (not available anywhere).
The boolean ``in_repo`` is derived: ``source != "missing"``.
When *all_declared* is provided (every filename declared by any platform
for any system, with its sizes and slots), it is used for the ``in_platform`` check
instead of the per-system lookup. This is appropriate for the global
gap analysis page where "undeclared" means "no platform declares it at all".
"""
platform_data_dirs = platform_data_dirs or {}
by_name = db.get("indexes", {}).get("by_name", {})
by_name_lower = {k.lower(): k for k in by_name}
by_md5 = db.get("indexes", {}).get("by_md5", {})
by_crc32 = db.get("indexes", {}).get("by_crc32", {})
by_sha256 = db.get("indexes", {}).get("by_sha256", {})
by_path_suffix = db.get("indexes", {}).get("by_path_suffix", {})
db_files = db.get("files", {})
report = {}
index = {
"by_name": by_name,
"by_name_lower": by_name_lower,
"by_md5": by_md5,
"by_crc32": by_crc32,
"by_sha256": by_sha256,
"by_path_suffix": by_path_suffix,
"db_files": db_files,
"data_names": data_names,
"all_declared": all_declared,
"bios_dir": db.get("bios_dir"),
}
for emu_name, profile in profiles.items():
_cross_reference_profile(
emu_name, profile, declared, report, index, standalone_cores
)
return report
def print_report(
report: dict, scope: str = "all emulators", show_clean: bool = False
) -> None:
"""Print a human-readable gap analysis report.
`scope` names what the totals cover: under --platform, the emulators
that platform runs, not all of them. `show_clean` lists an emulator with
nothing undeclared too: asked about by name, it was the whole answer and
the report used to leave it out.
"""
print("Emulator vs Platform Gap Analysis")
print("=" * 60)
print("* marks a file the emulator requires")
total_gaps = 0
totals: dict[str, int] = {"bios": 0, "data": 0, "large_file": 0, "missing": 0}
# A runtime component bundled with its emulator is left out of the
# collection by design, as verify and the gaps page say: not a file to find.
bundled = 0
for emu_name, data in sorted(report.items()):
gaps = data["gaps"]
if gaps == 0 and not show_clean:
continue
parts = []
for key in ("bios", "data", "large_file", "missing"):
count = data.get(f"gap_{key}", 0)
if count:
parts.append(f"{count} {key}")
status = ", ".join(parts) if parts else "OK"
systems = ", ".join(data["systems"])
print(f"\n{data['emulator']}" + (f" ({systems})" if systems else ""))
print(
f" {data['total_files']} files in profile, "
f"{data['platform_covered']} declared by platforms, "
f"{gaps} undeclared"
)
print(f" Gaps: {status}")
for g in data["gap_details"]:
req = "*" if g["required"] else " "
src = g.get("source", "missing").upper()
if g.get("bundled") and g.get("source") == "missing":
src = "BUNDLED"
# One line per file: a note spanning lines fell to column 0
# between entries and read as an emulator heading.
note = f": {' '.join(str(g['note']).split())}" if g["note"] else ""
archive_info = ""
if g.get("archive"):
fc = g.get("archive_file_count", 0)
rc = g.get("archive_required_count", 0)
archive_info = f" ({fc} files, {rc} required)"
print(f" {req} {g['name']} [{src}]{archive_info}{note}")
total_gaps += gaps
for key in totals:
totals[key] += data.get(f"gap_{key}", 0)
emulator_bundled = sum(
1 for g in data["gap_details"]
if g.get("bundled") and g.get("source") == "missing"
)
bundled += emulator_bundled
totals["missing"] -= emulator_bundled
print(f"\n{'=' * 60}")
print(f"Total: {total_gaps} undeclared files across {scope}")
available = totals["bios"] + totals["data"] + totals["large_file"]
print(f" {available} available (bios: {totals['bios']}, data: {totals['data']}, "
f"large_file: {totals['large_file']})")
print(f" {totals['missing']} missing (need to be sourced)")
if bundled:
print(f" {bundled} bundled with their emulator, not collected")
def main() -> None:
parser = argparse.ArgumentParser(allow_abbrev=False, description="Emulator vs platform gap analysis")
parser.add_argument(
"--emulators-dir", default=DEFAULT_EMULATORS_DIR,
help="emulator profile directory (default: %(default)s)",
)
parser.add_argument(
"--platforms-dir", default=DEFAULT_PLATFORMS_DIR,
help="platform YAML directory (default: %(default)s)",
)
parser.add_argument(
"--db", default=DEFAULT_DB,
help="database file to read (default: %(default)s)",
)
parser.add_argument("--emulator", "-e", help="Analyze single emulator")
parser.add_argument(
"--platform", "-p", type=platform_arg, help="Restrict analysis to one platform's cores"
)
parser.add_argument("--target", "-t", help="Hardware target (e.g., switch, rpi4)")
parser.add_argument("--json", action="store_true", help="JSON output")
parser.add_argument(
"--list-emulators", action="store_true", help="List available emulators"
)
args = parser.parse_args()
if args.list_emulators:
ignored = [flag for flag, on in (
("--emulator", args.emulator), ("--platform", args.platform),
("--target", args.target), ("--json", args.json),
) if on]
if ignored:
parser.error(f"--list-emulators does not apply {', '.join(ignored)}")
list_emulator_profiles(args.emulators_dir)
return
if args.platform:
require_platform(args.platform, args.platforms_dir)
profiles = load_emulator_profiles(args.emulators_dir)
if args.emulator:
# A typo answered "No emulator profiles found" with exit 0.
try:
select_emulator_profiles(
[args.emulator],
load_emulator_profiles(args.emulators_dir, skip_aliases=False),
)
except ProfileSelectionError as exc:
raise SystemExit(f"ERROR: {exc}") from None
profiles = {k: v for k, v in profiles.items() if k == args.emulator}
if args.target and not args.platform:
parser.error("--target requires --platform")
standalone_cores: set[str] = set()
if args.platform:
from common import load_target_config, resolve_platform_cores
target_cores = (
load_target_config(args.platform, args.target, args.platforms_dir)
if args.target
else None
)
config = load_platform_config(args.platform, args.platforms_dir)
relevant = resolve_platform_cores(config, profiles, target_cores=target_cores)
run_anywhere = (
set(resolve_platform_cores(config, profiles)) if args.target else relevant
)
profiles = {k: v for k, v in profiles.items() if k in relevant}
standalone_cores = standalone_set(config)
else:
# The tree as every platform runs it: a profile some platform runs
# standalone keeps its standalone entries. Laid out as libretro only,
# eka2l1, dolphin and xenia-edge lost 133 files no platform declares.
from common import list_registered_platforms
standalone_cores = set().union(*(
standalone_set(load_platform_config(name, args.platforms_dir))
for name in list_registered_platforms(args.platforms_dir, include_archived=True)
))
if not profiles:
if args.emulator and args.target and set(args.emulator.split(",")) & run_anywhere:
raise SystemExit(
f"ERROR: {args.emulator} is not built for {args.platform}'s "
f"{args.target} target; {args.platform} runs it elsewhere "
f"(drop --target to see its files)"
)
if args.emulator:
raise SystemExit(f"ERROR: {args.emulator} is not run by {args.platform}")
print(f"{args.platform or 'The tree'} runs no profiled emulator.", file=sys.stderr)
return
selected = [args.platform] if args.platform else None
declared, plat_data_dirs = load_platform_files(args.platforms_dir, selected)
db = load_database(args.db)
data_names = _build_supplemental_index()
report = cross_reference(
profiles, declared, db, plat_data_dirs, data_names,
all_declared=declared_names(args.platforms_dir, selected, db),
standalone_cores=standalone_cores,
)
if args.json:
print(json.dumps(report, indent=2))
else:
if args.emulator:
scope = args.emulator
elif args.platform:
scope = f"the emulators {args.platform} runs"
else:
scope = "all emulators"
print_report(report, scope, show_clean=bool(args.emulator))
if __name__ == "__main__":
from common import run_cli
run_cli(main)