diff --git a/scripts/common.py b/scripts/common.py index cb5a3348..6f44b40b 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -489,6 +489,51 @@ def _owner_tokens(file_entry: dict) -> set[str]: return tokens +def _foreign_tree(path: str, owners: set[str]) -> bool: + """Whether a path lives in the tree of an emulator that is not the asker. + + bios/Other// holds what one emulator ships. A same-named file + found there answers that emulator's entry, not another's: EmuCoreX's + unlock.wav was served from NetherSX2's tree, and upstream PCSX2's + GameIndex.yaml from EmuCoreX's, while the system directory held a copy. + """ + parts = path.split("/") + if not owners or len(parts) < 4 or parts[0] != "bios" or parts[1] != "Other": + return False + return not (owners & _affinity_tokens(parts[2])) + + +def _rank_tail_candidates( + candidates: list[str], start: int, tail: str, files_db: dict, owners: set[str] +) -> list[str]: + """Order the files a destination tail designates, the likeliest first. + + A shortened tail found only in another emulator's tree is that + emulator's asset, not evidence about this entry. A full tail claimed + twice goes to the file whose path carries the least on top of the + destination, then to the owner's own copy (super3's Games.xml was served + to Supermodel-Dojo by index order alone), then to any copy outside + another emulator's tree (upstream PCSX2 took EmuCoreX's GameIndex.yaml + over the system directory's). + """ + def path_of(sha1: str) -> str: + return files_db.get(sha1, {}).get("path", "") + + if start: + candidates = [h for h in candidates if not _foreign_tree(path_of(h), owners)] + if len(candidates) < 2: + return candidates + depth = len(tail.split("/")) + return sorted( + candidates, + key=lambda h: ( + len(path_of(h).split("/")) - depth, + not owners & _affinity_tokens(path_of(h).rsplit("/", 1)[0]), + _foreign_tree(path_of(h), owners), + ), + ) + + def _by_affinity(paths: list[str], file_entry: dict, dest_hint: str) -> list[str]: """Same-named candidates, the likeliest first. @@ -507,14 +552,15 @@ def _by_affinity(paths: list[str], file_entry: dict, dest_hint: str) -> list[str owners = _owner_tokens(file_entry) sized = any(file_entry.get(k) for k in ("size", "min_size", "max_size")) - def score(path: str) -> tuple[int, bool, int]: + def score(path: str) -> tuple[int, bool, bool, int]: fits = 0 if sized and os.path.exists(path): fits = int(size_fits(file_entry, os.path.getsize(path))) tokens = _affinity_tokens(path.rsplit("/", 1)[0]) # The owner's own copy ranks above any shared tree segment: NetherSX2's # shaders/common/ beat armsx2's own fxaa.fx on two directory tokens. - return fits, bool(owners & tokens), len(wanted & tokens) + # Another emulator's own tree ranks below everything else. + return fits, bool(owners & tokens), not _foreign_tree(path, owners | wanted), len(wanted & tokens) return sorted(paths, key=score, reverse=True) @@ -716,9 +762,14 @@ def resolve_local_file( # stops describing this file and starts describing whichever emulator # happens to store the same asset tree. hint_parts = dest_hint.split("/") + # The asking profile and the destination's own directories both name + # whose tree a copy may come from. + owners = _owner_tokens(file_entry) | _affinity_tokens("/".join(hint_parts[:-1])) for start in range(min(2, len(hint_parts) - 1)): tail = "/".join(hint_parts[start:]) - candidates = by_path_suffix.get(tail, []) + candidates = _rank_tail_candidates( + by_path_suffix.get(tail, []), start, tail, files_db, owners + ) if start and len(candidates) > 1: continue # The index names the file this destination designates. Not on @@ -729,20 +780,6 @@ def resolve_local_file( for h in candidates ): own_file_absent = True - if len(candidates) > 1: - depth = len(tail.split("/")) - # At equal depth the owner's own copy: super3's Games.xml was - # served to Supermodel-Dojo by index order alone. - owners = _owner_tokens(file_entry) - candidates = sorted( - candidates, - key=lambda h: ( - len(files_db.get(h, {}).get("path", "").split("/")) - depth, - not owners & _affinity_tokens( - files_db.get(h, {}).get("path", "").rsplit("/", 1)[0] - ), - ), - ) for match_sha1 in candidates: if match_sha1 not in files_db: continue diff --git a/tests/test_foreign_tree.py b/tests/test_foreign_tree.py new file mode 100644 index 00000000..e4530590 --- /dev/null +++ b/tests/test_foreign_tree.py @@ -0,0 +1,72 @@ +"""A file in another emulator's tree does not answer for this one. + +bios/Other// holds what that emulator ships. The path step took a +shortened tail that only NetherSX2's tree answered as proof for EmuCoreX's +unlock.wav, and broke a full-tail tie between EmuCoreX's GameIndex.yaml and +the system directory's copy by index order, serving a fork's game database +to upstream PCSX2. +""" + +from __future__ import annotations + +import hashlib +import os +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import generate_db # noqa: E402 +from common import resolve_local_file # noqa: E402 + + +class ForeignTree(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self._cwd = os.getcwd() + os.chdir(self._tmp.name) + files = {} + for rel, payload in ( + ("bios/Other/forkemu/resources/GameIndex.yaml", b"fork db"), + ("bios/Sony/PlayStation 2/resources/GameIndex.yaml", b"system copy"), + ("bios/Other/forkemu/resources/sounds/unlock.wav", b"fork sound"), + ): + path = Path(rel) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload) + sha1 = hashlib.sha1(payload).hexdigest() + files[sha1] = { + "path": rel, "name": path.name, "size": len(payload), "sha1": sha1, + "md5": hashlib.md5(payload).hexdigest(), + "sha256": hashlib.sha256(payload).hexdigest(), "crc32": "00000001", + } + self.db = {"files": files, "indexes": generate_db.build_indexes(files, {})} + + def tearDown(self): + os.chdir(self._cwd) + self._tmp.cleanup() + + def _resolve(self, owner: str, path: str): + entry = {"name": path.rsplit("/", 1)[-1], "path": path, "source_profile": owner} + return resolve_local_file(entry, self.db, {}, dest_hint=path) + + def test_a_full_tail_tie_goes_to_the_system_directory(self): + local, status = self._resolve("pcsx2", "resources/GameIndex.yaml") + self.assertEqual(status, "path_exact") + self.assertTrue(local.startswith("bios/Sony/"), local) + + def test_the_fork_still_gets_its_own_copy(self): + local, status = self._resolve("forkemu", "resources/GameIndex.yaml") + self.assertEqual(status, "path_exact") + self.assertTrue(local.startswith("bios/Other/forkemu/"), local) + + def test_a_shortened_tail_in_a_foreign_tree_is_not_path_evidence(self): + _local, status = self._resolve("otheremu", "assets/sounds/unlock.wav") + self.assertNotEqual(status, "path_exact") + + +if __name__ == "__main__": + unittest.main()