From d457948143cfcf429f65b52d61c09cef12bf5f39 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:16:30 +0100 Subject: [PATCH] fix: keep standalone copies of a twice-held file A profile standalone copy was dropped when the manifest held its file under two destinations. With the libretro PS2 cores' pcsx2/bios/ scan, DuckStation's ps2_scph50006.bin and ps2_scph50008.bin copies left the Batocera, Recalbox and RetroBat manifests. Same bytes at every path are one source: the copy whose cores name the emulator, else the shortest path. --- scripts/generate_pack.py | 24 ++++++++++++++++++++++-- tests/test_agnostic_scope.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 8c1018563..fcda2e74d 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -4091,8 +4091,20 @@ def _data_root_target(system: str, text: str) -> str | None: return None +def _one_content(held: list[str], entries: dict[str, dict], emulator: str) -> list[str]: + """The one dest a copy reads when every held dest carries the same bytes. + + Unchanged when the contents differ: the name then designates nothing. + """ + if len({entries[dest].get("sha1") for dest in held}) != 1: + return held + own = [dest for dest in held if emulator in (entries[dest].get("cores") or ())] + return [min(own or held, key=lambda dest: (dest.count("/"), len(dest), dest))] + + def _profile_copies( profiles: dict[str, dict], dests: list[str], declared: list[dict], + entries: dict[str, dict] | None = None, ) -> list[dict]: """The standalone copies the emulator profiles document: each file with a standalone_path, into the data_root of each system that names a place. @@ -4105,6 +4117,10 @@ def _profile_copies( file found by content (found_by) sits wherever the folder the user set for the scan points: Amiberry reads its ROMs from rom_path, which amiberry.conf moves, so its name under data_root proves nothing. + ``entries`` maps each dest to its manifest entry: one file carried at + two paths with the same bytes is one source, the copy whose cores name + the emulator, else the shortest path. DuckStation's ps2_scph50006.bin + sits at the BIOS root and under pcsx2/bios/ for the libretro PS2 cores. """ from fnmatch import fnmatch # noqa: PLC0415 @@ -4143,6 +4159,8 @@ def _profile_copies( or entry.get("type") == "directory" or entry.get("found_by") == "content"): continue held = by_tail.get(entry.get("path") or entry["name"], []) + if len(held) > 1 and entries is not None: + held = _one_content(held, entries, profile_name) if len(held) != 1: continue source = held[0] @@ -4171,8 +4189,10 @@ def _standalone_copies( whose registry entry declares none gets none.""" if not declared: return [] - dests = [entry["dest"] for entry in manifest_files if entry.get("root") != "save"] - return _placed_copies(declared, dests) + _profile_copies(profiles, dests, declared) + kept = [entry for entry in manifest_files if entry.get("root") != "save"] + dests = [entry["dest"] for entry in kept] + entries = {entry["dest"]: entry for entry in kept} + return _placed_copies(declared, dests) + _profile_copies(profiles, dests, declared, entries) def _stable_hashes(local_path: str, db: dict) -> dict: diff --git a/tests/test_agnostic_scope.py b/tests/test_agnostic_scope.py index 1326f8242..bb54e0332 100644 --- a/tests/test_agnostic_scope.py +++ b/tests/test_agnostic_scope.py @@ -115,6 +115,36 @@ class AgnosticScan(unittest.TestCase): _agnostic_scan_extras({"l": placeholders}, {"l"}, db, by_name, set(), ""), []) +class OneContentTwoPaths(unittest.TestCase): + """A standalone copy reads one file the manifest carries at two paths. + + The libretro PS2 cores put DuckStation's ps2_scph50006.bin under + pcsx2/bios/ beside the BIOS root copy, and the copy into DuckStation's + folder was dropped as ambiguous on Batocera, Recalbox and RetroBat. + """ + + DUCK = {"data_root": {"linux": {"path": "~/.local/share/duckstation", "source_ref": "x"}}, + "files": [{"name": "ps2_scph50006.bin", "standalone_path": "bios/ps2_scph50006.bin"}]} + + def _copies(self, entries): + from generate_pack import _profile_copies + + return _profile_copies({"duckstation": self.DUCK}, list(entries), [], entries) + + def test_same_bytes_take_the_copy_that_serves_the_emulator(self): + (copy,) = self._copies({ + "pcsx2/bios/ps2_scph50006.bin": {"sha1": "a", "cores": ["lrps2"]}, + "ps2_scph50006.bin": {"sha1": "a", "cores": ["duckstation", "pcsx2"]}, + }) + self.assertEqual(copy["file"], "ps2_scph50006.bin") + + def test_other_bytes_under_one_name_designate_nothing(self): + self.assertEqual(self._copies({ + "pcsx2/bios/ps2_scph50006.bin": {"sha1": "a", "cores": ["lrps2"]}, + "ps2_scph50006.bin": {"sha1": "b", "cores": ["duckstation"]}, + }), []) + + class BundledHashIsProof(unittest.TestCase): """A bundled file with a declared hash is served by those bytes or flagged.