diff --git a/emulators/craft.yml b/emulators/craft.yml index de3bce0e..7fd66946 100644 --- a/emulators/craft.yml +++ b/emulators/craft.yml @@ -9,6 +9,12 @@ core_version: "v1" display_name: "Minecraft (Craft)" cores: [craft] systems: [] +exclusion_note: > + The system directory is asked for, and what goes in it is written by the + core: g->db_path and g->db_auth_path, the world database and its auth + table. Nothing is read from it that the user has to supply + (src/main.c:2682-2697). + files: [] notes: > Libretro port of Craft, a simple Minecraft clone by Michael Fogleman. diff --git a/emulators/dice.yml b/emulators/dice.yml index da99528a..e13b9c15 100644 --- a/emulators/dice.yml +++ b/emulators/dice.yml @@ -22,6 +22,11 @@ notes: | dummy launcher files. The system directory is retrieved but never used for file loading (libretro.cpp:60-62). +exclusion_note: > + The system directory is asked for and stored in retro_base_directory, which + is named in no other file of the tree and read nowhere: the answer is never + used (libretro.cpp:28,60-63). + files: [] analysis: diff --git a/emulators/lutro.yml b/emulators/lutro.yml index 7464dcb5..f4316e17 100644 --- a/emulators/lutro.yml +++ b/emulators/lutro.yml @@ -22,4 +22,9 @@ notes: | (filesystem.c:296) but does not load any files from it itself. No BIOS, firmware, or system files required. +exclusion_note: > + The system directory is not read by the core but handed to the Lua game + through fs_getAppdataDirectory, so whatever is loaded from it is the game's + own content, not a file this core names (filesystem.c:291-303). + files: [] diff --git a/scripts/fileless_audit.py b/scripts/fileless_audit.py new file mode 100644 index 00000000..7e6e3d2a --- /dev/null +++ b/scripts/fileless_audit.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Find profiles that declare no files whose source now asks for a directory. + +A profile with an empty `files:` list asserts that the emulator loads nothing +from disk. That assertion ages: a core that embedded everything can grow a +path, and nothing in the repository notices, because there is no file to go +missing and no ref to drift. virtualjaguar carried "No external BIOS files are +required or loaded by this core" while its source had grown eleven filenames +read from the system directory. + +The signal is the request for a directory to read from. A libretro core asks +the frontend with RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY; other shapes ask the +environment or build a path from a home directory. Finding one in a file the +profile itself cites does not prove a file is loaded, and this reports rather +than concludes: it names the profiles worth a reading, so the other hundred and +fifty need none. +""" +from __future__ import annotations + +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +import upstream +from profile_sync import collect_citations, select_views, split_source_ref +from safeparse import yaml_load + +# Ways a program asks for somewhere to read from. +SIGNALS = ( + "RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY", + "GET_SYSTEM_DIRECTORY", + "system_directory", + "get_system_directory", +) + +SOURCE_SUFFIX = (".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".m", ".mm") + + +def cited_paths(document: dict) -> list[str]: + """Source paths the profile points at, in document order, deduplicated.""" + seen: list[str] = [] + for citation in collect_citations(document): + for part in split_source_ref(citation.ref): + path = part.path + if path.endswith(SOURCE_SUFFIX) and path not in seen: + seen.append(path) + return seen + + +def audit(name: str, emulators_dir: str, cache_dir: str, offline: bool): + """Signals found in the sources a fileless profile cites.""" + with open(os.path.join(emulators_dir, f"{name}.yml"), encoding="utf-8") as handle: + document = yaml_load(handle) or {} + if document.get("files"): + return None + if str(document.get("exclusion_note") or "").strip(): + # Someone read this one and wrote down why nothing is declared. The + # check exists to find the profiles nobody has answered yet; an + # answer that goes stale shows up as a drifting ref instead. + return None + if document.get("data_directories"): + # The load is declared, as a directory rather than a file. dinothawr + # reads system_dir/dinothawr/ and says so there; that is coverage, not + # an assertion waiting to age. + return None + views = select_views(document, cache_dir, offline) + if not views: + return [] + hits: list[tuple[str, str]] = [] + for path in cited_paths(document)[:40]: + for view in views: + lines = upstream.fetch_file(view.repo, view.pin, path, cache_dir, offline) + if lines is None: + continue + for signal in SIGNALS: + if any(signal in line for line in lines): + hits.append((path, signal)) + break + break + return hits + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("emulators", nargs="+") + parser.add_argument("--emulators-dir", default="emulators") + parser.add_argument("--cache-dir", default=".cache") + parser.add_argument("--offline", action="store_true") + args = parser.parse_args() + + flagged = 0 + for name in args.emulators: + try: + hits = audit(name, args.emulators_dir, args.cache_dir, args.offline) + except upstream.UpstreamError as exc: + print(f"{name}: unreachable, {exc}", file=sys.stderr) + continue + if hits is None or not hits: + continue + flagged += 1 + print(f"{name}: declares no files, yet its source asks for a directory") + for path, signal in hits: + print(f" {path}: {signal}") + raise SystemExit(1 if flagged else 0) + + +if __name__ == "__main__": + main() diff --git a/tests/test_fileless_audit.py b/tests/test_fileless_audit.py new file mode 100644 index 00000000..a0d4725e --- /dev/null +++ b/tests/test_fileless_audit.py @@ -0,0 +1,131 @@ +"""A profile that declares no files has nothing that can go missing. + +No file to be absent, no ref to drift: an empty `files:` list is the one +assertion in the repository that ages without any existing check noticing. +virtualjaguar carried "No external BIOS files are required or loaded by this +core" while its source had grown eleven filenames read from the system +directory, and only a manual reading found it. +""" +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) + +import fileless_audit + + +ASKS = [ + "static void init(void)", + " if (environ_cb(RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY, &dir) && dir)", + " snprintf(path, sizeof(path), \"%s/bios.rom\", dir);", +] +QUIET = ["static void init(void) { /* nothing */ }"] + +BASE = """emulator: Test +source: "https://github.com/o/n" +source_commit: "pinsha" +files: [] +""" + + +class TestFilelessAudit(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = self.tmp.name + self._orig = ( + fileless_audit.select_views, + fileless_audit.upstream.fetch_file, + fileless_audit.collect_citations, + ) + + class View: + repo = "repo" + pin = "pinsha" + + fileless_audit.select_views = lambda doc, cache, offline: [View()] + fileless_audit.collect_citations = lambda doc: [ + type("C", (), {"ref": "libretro.c:10"})() + ] + self.source = ASKS + fileless_audit.upstream.fetch_file = ( + lambda repo, sha, path, cache, offline=False: self.source + ) + + def tearDown(self): + ( + fileless_audit.select_views, + fileless_audit.upstream.fetch_file, + fileless_audit.collect_citations, + ) = self._orig + self.tmp.cleanup() + + def _write(self, body: str) -> None: + Path(self.dir, "p.yml").write_text(body, encoding="utf-8") + + def _run(self): + return fileless_audit.audit("p", self.dir, ".c", False) + + def test_a_fileless_profile_whose_source_asks_is_flagged(self): + self._write(BASE) + self.assertEqual(self._run(), [("libretro.c", "RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY")]) + + def test_a_source_that_asks_for_nothing_is_quiet(self): + self._write(BASE) + self.source = QUIET + self.assertEqual(self._run(), []) + + def test_a_profile_that_declares_files_is_not_the_subject(self): + self._write("emulator: T\nfiles:\n - name: a.bin\n") + self.assertIsNone(self._run()) + + def test_a_directory_declaration_is_coverage(self): + """dinothawr reads system_dir/dinothawr/ and says so there.""" + self._write(BASE + "data_directories:\n - key: d\n") + self.assertIsNone(self._run()) + + def test_a_written_answer_settles_it(self): + """craft asks for the directory to write its world database in.""" + self._write(BASE + 'exclusion_note: "the directory is written, not read"\n') + self.assertIsNone(self._run()) + + def test_an_empty_answer_does_not_settle_it(self): + self._write(BASE + 'exclusion_note: " "\n') + self.assertEqual(len(self._run()), 1) + + +class TestCorpusIsAnswered(unittest.TestCase): + """Every fileless profile is covered, answered, or flagged. + + Offline, so it reads the fetch cache; skipped where that is cold rather + than turning a network absence into a failure. + """ + + def test_no_fileless_profile_is_left_unexplained(self): + if not (ROOT / ".cache").is_dir(): + self.skipTest("no upstream cache") + from safeparse import yaml_load + + flagged = [] + for path in sorted((ROOT / "emulators").glob("*.yml")): + with path.open(encoding="utf-8") as handle: + document = yaml_load(handle) or {} + if document.get("files"): + continue + try: + hits = fileless_audit.audit( + path.stem, str(ROOT / "emulators"), str(ROOT / ".cache"), True + ) + except Exception: + continue + if hits: + flagged.append(path.stem) + self.assertEqual( + flagged, [], + "these declare no files and their source asks for a directory, " + "with nothing written down about why", + ) diff --git a/wiki/tools.md b/wiki/tools.md index bf111d1a..8f528cf4 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -238,6 +238,33 @@ to the files inside the archive, so the exported DAT lists those entries without a container sha1. Anyone submitting the DAT upstream should mention this. +### fileless_audit.py + +Names the profiles that declare no files whose source asks for a directory to +read from. + +```bash +python scripts/fileless_audit.py craft dice lutro +``` + +An empty `files:` list is the one assertion in the repository that ages +unwatched: there is no file to go missing and no ref to drift, so nothing +notices when a core that embedded everything grows a path. virtualjaguar +carried "No external BIOS files are required or loaded by this core" while its +source had grown eleven filenames read from the system directory. + +The signal is the request itself, `RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY` and +its spellings, looked for in the sources the profile already cites. Finding one +does not prove a file is loaded, which is why this reports rather than +concludes. + +Three answers settle a profile and it is not reported again: it declares files, +it declares `data_directories` (dinothawr reads `system_dir/dinothawr/` and +says so there), or it carries an `exclusion_note` saying what the directory is +for. craft writes its world database in it, dice stores the answer in a +variable no other file names, lutro hands it to the Lua game. What is left is +the set nobody has read yet, which is the only set worth reading. + ### mame_ref_audit.py Checks that each MAME romset ref names the line declaring its own set, at the