From ca6462c510ca4f84596d8c3520f18998305484be Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:22:22 +0200 Subject: [PATCH] fix: order same-named files by their owner's copy --- .../sounds/achievements}/message.wav | Bin scripts/common.py | 36 ++++++++++++++++- scripts/generate_pack.py | 2 +- scripts/verify.py | 2 +- tests/test_game_data_homonyms.py | 37 ++++++++++++++++++ 5 files changed, 73 insertions(+), 4 deletions(-) rename bios/{Other/armsx2 => Sony/PlayStation 2/resources/sounds/achievements}/message.wav (100%) diff --git a/bios/Other/armsx2/message.wav b/bios/Sony/PlayStation 2/resources/sounds/achievements/message.wav similarity index 100% rename from bios/Other/armsx2/message.wav rename to bios/Sony/PlayStation 2/resources/sounds/achievements/message.wav diff --git a/scripts/common.py b/scripts/common.py index 55f460eb..8564968c 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -468,6 +468,36 @@ def _resolve_agnostic(file_entry: dict, files_db: dict, has_strong_hash: bool): return None +def _affinity_tokens(text: str) -> set[str]: + return { + token + for token in (re.sub(r"[^0-9a-z]", "", part.casefold()) for part in text.split("/")) + if token + } + + +def _by_affinity(paths: list[str], file_entry: dict, dest_hint: str) -> list[str]: + """Same-named candidates, the ones stored with the file's owner first. + + A name step without a tie-break took whichever path sorted first: the + TheXTech coin.ogg for Syobon Action, a Hurrican sound for ARMSX2. The + destination's directories and the profile that asks name where its own + copy lives; the order is otherwise kept. + """ + hint = dest_hint or file_entry.get("path") or file_entry.get("destination") or "" + wanted = _affinity_tokens(hint.rsplit("/", 1)[0] if "/" in hint else "") + for owner in (file_entry.get("source_profile"), file_entry.get("source_emulator")): + if owner: + wanted |= _affinity_tokens(str(owner).replace(" ", "")) + if not wanted or len(paths) < 2: + return paths + + def score(path: str) -> int: + return len(wanted & _affinity_tokens(path.rsplit("/", 1)[0])) + + return sorted(paths, key=score, reverse=True) + + def resolve_local_file( file_entry: dict, db: dict, @@ -737,6 +767,7 @@ def resolve_local_file( if candidates: if zipped_file: candidates = [p for p in candidates if ".zip" in os.path.basename(p)] + candidates = _by_affinity(candidates, file_entry, dest_hint) primary = [p for p in candidates if "/.variants/" not in p] if primary or candidates: return (primary[0] if primary else candidates[0]), "name_exact" @@ -795,8 +826,9 @@ def resolve_local_file( return (primary[0] if primary else valid[0][0]), "hash_mismatch" # No candidate contains the zipped_file -fall through to step 5 elif not unsourceable: - primary = [c[0] for c in candidates if "/.variants/" not in c[0]] - return (primary[0] if primary else candidates[0][0]), "hash_mismatch" + ordered = _by_affinity([c[0] for c in candidates], file_entry, dest_hint) + primary = [c for c in ordered if "/.variants/" not in c] + return (primary[0] if primary else ordered[0]), "hash_mismatch" # 5. zipped_file content match via pre-built index (last resort: # matches inner ROM MD5 across ALL ZIPs in the repo, so only use diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index d83bdbe1..dbad2d53 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -1437,7 +1437,7 @@ def generate_emulator_pack( dest_hint = fe.get("path", "") local_path, status = resolve_file( - fe, + {**fe, "source_profile": emu_name}, db, bios_dir, zip_contents, diff --git a/scripts/verify.py b/scripts/verify.py index e8b7fd93..68516e6b 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -1382,7 +1382,7 @@ def verify_emulator( dest_hint = file_entry.get("path", "") local_path, resolve_status = resolve_local_file( - file_entry, + {**file_entry, "source_profile": emu_name}, db, zip_contents, dest_hint=dest_hint, diff --git a/tests/test_game_data_homonyms.py b/tests/test_game_data_homonyms.py index bb5ea96b..dc7b3bd2 100644 --- a/tests/test_game_data_homonyms.py +++ b/tests/test_game_data_homonyms.py @@ -194,6 +194,43 @@ class ReleaseAssetIsNotANameMatch(unittest.TestCase): 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"