"""Game data laid out in a directory is not served by another game's file. Engines read generic names from their own tree: `pak0.pak` is Quake II's under `baseq2/` and Half-Life's under `valve/`. The name step of the resolver ignores directories, so once one of them is collected every other entry of that name resolves to it and a pack ships the wrong game's bytes under the right path. The collection stores such a file under the tail the engine reads (`.../baseq2/pak0.pak`), which lets its own entry resolve by path; an entry that only reaches the same file by its bare name is a homonym, and carries `unsourceable:` until its own bytes are held. """ from __future__ import annotations import os import re import sys import unittest from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT / "scripts")) from common import ( # noqa: E402 build_zip_contents_index, load_data_dir_registry, load_database, load_emulator_profiles, resolution_is_hash_exact, resolve_local_file, ) def homonyms(claims: list[tuple[str, str, str, str]]) -> list[tuple[str, str, str]]: """(profile, destination, file) for name-only claims on a file that another entry reaches by its path. claims: (profile, destination, status, local file). """ # Reached by its path or by its content: a hash is at least as strong a # proof, and an owner that gained a sha1 stopped protecting its file. by_path = { local for _, _, status, local in claims if status == "path_exact" or resolution_is_hash_exact(status) } found = [] for profile, dest, status, local in claims: if status != "name_exact" or local not in by_path: continue if local.casefold().endswith("/" + dest.casefold()): continue if local.rsplit("/", 1)[-1].casefold() != dest.rsplit("/", 1)[-1].casefold(): # Reached through an alias: the profile says the two are one file. continue found.append((profile, dest, local)) return sorted(found) class HomonymRule(unittest.TestCase): def test_a_name_claim_on_a_file_another_entry_reaches_by_path_is_named(self): claims = [ ("quake2", "baseq2/pak0.pak", "path_exact", "bios/Q2/baseq2/pak0.pak"), ("halflife", "valve/pak0.pak", "name_exact", "bios/Q2/baseq2/pak0.pak"), ] self.assertEqual( homonyms(claims), [("halflife", "valve/pak0.pak", "bios/Q2/baseq2/pak0.pak")] ) def test_an_owner_proven_by_hash_still_protects_its_file(self): claims = [ ("quake2", "baseq2/pak0.pak", "sha1_exact", "bios/Q2/baseq2/pak0.pak"), ("halflife", "valve/pak0.pak", "name_exact", "bios/Q2/baseq2/pak0.pak"), ] self.assertEqual( homonyms(claims), [("halflife", "valve/pak0.pak", "bios/Q2/baseq2/pak0.pak")] ) def test_a_file_only_ever_found_by_name_is_not_judged(self): claims = [ ("mame2003", "mame2003/cheat.dat", "name_exact", "bios/Arcade/cheat.dat"), ("mame2010", "mame2010/cheat.dat", "name_exact", "bios/Arcade/cheat.dat"), ] self.assertEqual(homonyms(claims), []) def test_a_declared_alias_is_the_profile_speaking(self): claims = [ ("m", "crosshair/cross7.png", "path_exact", "bios/m/crosshair/cross7.png"), ("m", "crosshair/cross8.png", "name_exact", "bios/m/crosshair/cross7.png"), ] self.assertEqual(homonyms(claims), []) def test_two_entries_sharing_a_path_agree(self): claims = [ ("a", "etmain/pak1.pk3", "path_exact", "bios/ET/etmain/pak1.pk3"), ("b", "etmain/pak1.pk3", "path_exact", "bios/ET/etmain/pak1.pk3"), ] self.assertEqual(homonyms(claims), []) class AbsentFileIsNotReplacedByAHomonym(unittest.TestCase): """The database names the file a destination designates. When that file is a release asset the checkout does not hold, the name step answered with whatever else carried the name: Enemy Territory's `etmain/pak0.pk3` resolved to the Quake III demo pak on a clone without the large files.""" def setUp(self): import hashlib import tempfile import generate_db self._tmp = tempfile.TemporaryDirectory() root = Path(self._tmp.name) files = {} for relative, payload in ( ("bios/ET/etmain/pak0.pk3", b"enemy territory"), ("bios/Q3/demoq3/pak0.pk3", b"quake iii demo"), ): target = root / relative target.parent.mkdir(parents=True) target.write_bytes(payload) sha1 = hashlib.sha1(payload).hexdigest() files[sha1] = { "path": str(target), "name": "pak0.pk3", "size": len(payload), "sha1": sha1, "md5": hashlib.md5(payload).hexdigest(), "sha256": hashlib.sha256(payload).hexdigest(), "crc32": "0", } indexes = generate_db.build_indexes( {s: {**r, "path": r["path"][len(str(root)) + 1:]} for s, r in files.items()}, {}, ) self.db = {"files": files, "indexes": indexes} self.own = root / "bios/ET/etmain/pak0.pk3" self.entry = {"name": "pak0.pk3", "path": "etmain/pak0.pk3"} def tearDown(self): self._tmp.cleanup() def _resolve(self): return resolve_local_file(self.entry, self.db, {}, dest_hint="etmain/pak0.pk3") def test_present_it_resolves_by_its_path(self): self.assertEqual(self._resolve(), (str(self.own), "path_exact")) def test_absent_it_is_not_found_rather_than_another_game(self): self.own.unlink() self.assertEqual(self._resolve(), (None, "not_found")) class SecondPassKeepsIdentity(unittest.TestCase): """A copy of a covered file at another core's path keeps the entry's proof. The second extras pass copied a name the baseline already covers to the profile's own path, without its hashes or its `unsourceable:` flag, so the copy resolved on the name: RetroBat's pack carried Quake III's baseq3/pak1.pk3 as MOHAA's mainta/pak1.pk3. An unsourceable entry is settled, as the first pass treats it, and gets no copy. """ def test_flag_and_hashes_reach_the_alternative_destination(self): from packextras import _collect_emulator_extras sha = "a" * 40 md5 = "b" * 32 db = { "files": {sha: {"path": "bios/Q3/baseq3/pak1.pk3", "name": "pak1.pk3", "sha1": sha, "md5": md5, "size": 10}}, "indexes": {"by_name": {"pak1.pk3": [sha]}, "by_md5": {md5: sha}, "by_path_suffix": {"baseq3/pak1.pk3": [sha]}, "by_crc32": {}}, } config = { "platform": "P", "cores": ["mohaa"], "standalone_cores": ["mohaa"], "systems": {"q3": {"files": [ {"name": "pak1.pk3", "destination": "baseq3/pak1.pk3", "md5": md5}]}}, } profiles = {"mohaa": { "emulator": "MOHAA", "type": "standalone", "cores": ["mohaa"], "systems": ["mohaa"], "files": [ {"name": "pak1.pk3", "path": "mainta/pak1.pk3", "unsourceable": "retail"}, {"name": "pak1.pk3", "path": "maintt/pak1.pk3", "sha1": "c" * 40}, ], }} extras = {e["destination"]: e for e in _collect_emulator_extras( config, "emulators", db, set(), "", profiles)} # Unsourceable is settled, as in the first pass: no copy at all. self.assertNotIn("mainta/pak1.pk3", extras) self.assertEqual(extras["maintt/pak1.pk3"].get("sha1"), "c" * 40) class ReleaseAssetIsNotANameMatch(unittest.TestCase): """The release fallback keeps the flag the local resolver honours. ioquake3's baseq3/pak0.pk3 is flagged unsourceable; the local resolver returned not_found, then the release fallback looked the asset up by its bare name and handed Return to Castle Wolfenstein's demo pak to three platform manifests. """ def test_an_unsourceable_entry_without_hash_never_reaches_the_asset_store(self): from unittest import mock import packresolve entry = {"name": "pak0.pk3", "unsourceable": "retail"} db = {"files": {}, "indexes": {"by_name": {}, "by_md5": {}, "by_path_suffix": {}}} with mock.patch.object(packresolve, "fetch_large_file", return_value="/x/pak0.pk3") as fetch: self.assertEqual( packresolve.resolve_file(entry, db, "bios", {}, offline=True), (None, "not_found"), ) fetch.assert_not_called() class NameStepPrefersTheOwnersCopy(unittest.TestCase): """Same-named files are ordered by where the asking profile keeps its own. The name step took the first path in index order: Syobon Action's coin.ogg came from TheXTech's sound folder while its own copy sat in bios/Other/syobonaction/. """ def test_the_copy_stored_with_the_profile_wins(self): import tempfile from common import resolve_local_file with tempfile.TemporaryDirectory() as tmp: previous = os.getcwd() os.chdir(tmp) self.addCleanup(os.chdir, previous) other = Path("bios/Game Engines/TheXTech/sound/coin.ogg") own = Path("bios/Other/syobonaction/coin.ogg") for path, data in ((other, b"a" * 10), (own, b"b" * 12)): path.parent.mkdir(parents=True) path.write_bytes(data) files = { "1" * 40: {"path": str(other), "name": "coin.ogg", "size": 10}, "2" * 40: {"path": str(own), "name": "coin.ogg", "size": 12}, } db = {"files": files, "indexes": { "by_name": {"coin.ogg": ["1" * 40, "2" * 40]}, "by_md5": {}, "by_crc32": {}, "by_path_suffix": {}}} entry = {"name": "coin.ogg", "path": "SyobonAction/SE/coin.ogg"} path, status = resolve_local_file(entry, db, dest_hint=entry["path"]) self.assertEqual((path, status), (str(own), "name_exact")) entry = {"name": "coin.ogg", "path": "SE/coin.ogg", "source_profile": "syobonaction"} path, _status = resolve_local_file(entry, db, dest_hint=entry["path"]) self.assertEqual(path, str(own)) class CollectionCarriesNoGameDataHomonym(unittest.TestCase): def test_the_profiles_resolve_no_game_data_to_another_game(self): database = REPO_ROOT / "database.json" if not database.is_file(): self.skipTest("no database.json") previous = os.getcwd() os.chdir(REPO_ROOT) try: db = load_database(str(database)) profiles = load_emulator_profiles("emulators", skip_aliases=False) zip_index = build_zip_contents_index(db) registry = load_data_dir_registry("platforms") claims = [] for name, profile in sorted(profiles.items()): for entry in profile.get("files") or []: if entry.get("category") != "game_data" or entry.get("unsourceable"): continue dest = str(entry.get("path") or entry.get("name") or "") if "/" not in dest.strip("/"): continue local, status = resolve_local_file( entry, db, zip_index, dest_hint=dest, data_dir_registry=registry ) if local: claims.append((name, dest, status, local)) finally: os.chdir(previous) self.assertEqual(homonyms(claims), []) class ControllerDatabasesAreTheirOwn(unittest.TestCase): """Solarus and EKA2L1 were handed the 26 KB C-Dogs copy by name. Each ships its own gamecontrollerdb.txt: Solarus its assets/ copy, EKA2L1 the SDL_GameControllerDB submodule it pins. """ def test_each_resolves_by_content(self): db_path = REPO_ROOT / "database.json" if not db_path.exists(): self.skipTest("no database.json") db = load_database(str(db_path)) profiles = load_emulator_profiles(str(REPO_ROOT / "emulators")) for key in ("solarus", "eka2l1"): entry = next( e for e in profiles[key]["files"] if e["name"] == "gamecontrollerdb.txt" ) with self.subTest(profile=key): path, status = resolve_local_file(entry, db) self.assertIn(status, ("sha1_exact", "md5_exact")) self.assertNotIn("C-Dogs", path or "") class SwitchKeysAreTheNewestSet(unittest.TestCase): """A harvested key set under Arcade/ won the name tie: master_key_10 shipped where the collection held master_key_12.""" def test_every_switch_profile_gets_the_newest_master_key(self): db_path = REPO_ROOT / "database.json" if not db_path.exists(): self.skipTest("no database.json") db = load_database(str(db_path)) profiles = load_emulator_profiles(str(REPO_ROOT / "emulators")) held = [ Path(entry["path"]) for entry in db["files"].values() if entry["name"] == "prod.keys" and (REPO_ROOT / entry["path"]).exists() ] def newest(path: Path) -> int: generations = re.findall( r"^master_key_([0-9a-f]{2})\s*=", path.read_text(errors="replace"), re.MULTILINE ) return max((int(g, 16) for g in generations), default=-1) best = max(newest(REPO_ROOT / p) for p in held) for key in ("yuzu", "citron", "eden", "suyu", "ryujinx", "kenji-nx", "skyline"): entry = next(e for e in profiles[key]["files"] if e["name"] == "prod.keys") with self.subTest(profile=key): path, _status = resolve_local_file(entry, db, dest_hint=entry.get("path") or "") self.assertEqual(newest(REPO_ROOT / path), best) class Pc98ScsiBiosIsNotTheAppleCard(unittest.TestCase): """np2kai declared an exact 16 KB its loader never requires; the Apple II SCSI card ROM of that size won the name tie over the PC-98 BIOS.""" def test_np2kai_resolves_the_pc98_rom(self): db_path = REPO_ROOT / "database.json" if not db_path.exists(): self.skipTest("no database.json") db = load_database(str(db_path)) profile = load_emulator_profiles(str(REPO_ROOT / "emulators"))["np2kai"] entry = next(e for e in profile["files"] if e["name"] == "scsi.rom") path, _status = resolve_local_file(entry, db, dest_hint=entry["path"]) self.assertIn("PC-98", path or "") class BundledFilesComeFromTheirOwnTree(unittest.TestCase): """EmuCoreX was served NetherSX2's shaders and Supermodel-Dojo super3's Games.xml: the collection lacked their copies and the ties were broken by index order.""" CASES = ( ("emucorex", "tfx.glsl"), ("emucorex", "fxaa.fx"), ("emucorex", "GameIndex.yaml"), ("supermodel-dojo", "Games.xml"), ) def test_each_resolves_to_its_owner(self): db_path = REPO_ROOT / "database.json" if not db_path.exists(): self.skipTest("no database.json") db = load_database(str(db_path)) profiles = load_emulator_profiles(str(REPO_ROOT / "emulators")) for owner, name in self.CASES: entry = next(e for e in profiles[owner]["files"] if e["name"] == name) with self.subTest(profile=owner, file=name): path, _status = resolve_local_file( dict(entry, source_profile=owner), db, dest_hint=entry.get("path") or entry.get("standalone_path") or "", ) self.assertIn(f"/{owner}/", path or "") class HeldFilesAreNotUnsourceable(unittest.TestCase): """`unsourceable:` says the collection cannot hold a file; once its bytes sit at the path the entry names, the flag hides them. The RTCW demo paks and the Spearhead 2.11 pak3 were collected the night their entries were flagged "does not hold yet" and the flags stayed: they reached two packs only through another game's same-named file, and left them when that path closed. """ def test_no_flagged_entry_has_a_file_at_its_path(self): db_path = REPO_ROOT / "database.json" if not db_path.exists(): self.skipTest("database.json is not built") by_suffix = load_database(str(db_path))["indexes"]["by_path_suffix"] stale = [] for name, profile in load_emulator_profiles(str(REPO_ROOT / "emulators")).items(): for entry in profile.get("files", []): if not entry.get("unsourceable"): continue for key in ("path", "standalone_path"): dest = entry.get(key) if not isinstance(dest, str) or "/" not in dest: continue if dest.endswith("/"): dest += entry.get("name", "") if dest in by_suffix: stale.append((name, dest)) self.assertEqual(stale, []) if __name__ == "__main__": unittest.main()