From 3ed5f68b821dfc76a88c5b2f2fc5670f6dbdba0d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:41:26 +0200 Subject: [PATCH] fix: hand RetroDECK manifests back as written With nothing to correct, nine of ten manifests still changed: a final newline was added where the original ends without one, "md5": null was written 39 times where no md5 key was, and PPSSPP's single bios object became a list rebuilt from our data. A manifest now ends as the original does, keeps no key it did not have, and a single object stays one while nothing is added. An empty truth gives back every cached manifest byte for byte. --- scripts/exporter/retrodeck_exporter.py | 25 ++++++++++++++---- tests/test_exporters.py | 36 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/scripts/exporter/retrodeck_exporter.py b/scripts/exporter/retrodeck_exporter.py index 157b077d..03694079 100644 --- a/scripts/exporter/retrodeck_exporter.py +++ b/scripts/exporter/retrodeck_exporter.py @@ -222,6 +222,9 @@ class Exporter(BaseExporter): merged: list[OrderedDict] = [] corrected: set[tuple[str, str]] = set() + # PPSSPP writes its one BIOS as an object, not a list of one. + if isinstance(existing, dict): + existing = [existing] entries = [e for e in existing if isinstance(e, dict)] if isinstance(existing, list) else [] # A name and system declared several times are several revisions (the # 64DD IPL USA and Dev): one entry of ours corrects the revision it @@ -260,7 +263,12 @@ class Exporter(BaseExporter): if field in cls.carries() and field not in by_key[keys[0]]: combined.pop(field, None) if _md5_set(entry.get("md5")) == _md5_set(combined.get("md5")): - combined["md5"] = entry.get("md5") + # Their own spelling, and no key where they wrote none: an + # equal empty set wrote "md5": null 39 times. + if "md5" in entry: + combined["md5"] = entry["md5"] + else: + combined.pop("md5", None) elif isinstance(entry.get("md5"), list) and isinstance(combined.get("md5"), str): combined["md5"] = [combined["md5"]] merged.append(combined) @@ -322,8 +330,11 @@ class Exporter(BaseExporter): ) else: container, key = holder - container[key] = self._merge( - container.get(key), entries, additions, appended + written = container.get(key) + merged = self._merge(written, entries, additions, appended) + # A single object stays one while nothing is added to it. + container[key] = ( + merged[0] if isinstance(written, dict) and len(merged) == 1 else merged ) break # A declared entry whose revision the platform does not hold is @@ -334,7 +345,10 @@ class Exporter(BaseExporter): and (str(fe.name), str(fe.native_system)) in appended ) - produced[path] = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n" + # Nine of ten manifests end without a newline: the file ends as + # theirs does. + ending = "\n" if original.endswith("\n") else "" + produced[path] = json.dumps(manifest, indent=2, ensure_ascii=False) + ending return produced @@ -366,7 +380,8 @@ class Exporter(BaseExporter): if holder is None: continue container, key = holder - for entry in container[key]: + listed = container[key] + for entry in [listed] if isinstance(listed, dict) else listed: declared.add(entry.get("filename", "")) placed.add((entry.get("filename", ""), _paths_key(entry))) if not component_value.get("name") and not component_value.get( diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 08099915..e6de511c 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -1420,6 +1420,42 @@ class BizhawkExport(unittest.TestCase): self.assertFalse(exporter.states(entry, "sha1")) +class RetrodeckManifestsComeBackAsWritten(unittest.TestCase): + """With nothing to correct, a manifest is the platform's own bytes. The + export added a final newline to nine of ten, wrote "md5": null where no + md5 key was, and turned PPSSPP's single bios object into a list.""" + + def test_an_empty_truth_hands_back_every_manifest(self): + cache = UPSTREAM_CACHE / "retrodeck" + manifests = sorted(cache.glob("*/component_manifest.json")) + if not manifests: + self.skipTest("upstream cache not populated") + exporter = discover_exporters()["retrodeck"]() + config = native_layer(load_platform_config("retrodeck", str(REPO_ROOT / "platforms"))) + systems, report = build_native_model({"systems": {}}, config) + originals = {f"{m.parent.name}/component_manifest.json": m.read_text(encoding="utf-8") + for m in manifests} + produced = exporter.render(systems, report, originals, config) + for path, text in produced.items(): + with self.subTest(manifest=path): + self.assertEqual(text, originals[path]) + + def test_no_md5_key_is_made_up(self): + from exporter.retrodeck_exporter import Exporter # noqa: PLC0415 + + mine = OrderedDict([("filename", "bios.bin"), ("system", "xbox"), ("required", "Yes")]) + (merged,) = Exporter._merge([OrderedDict(mine)], [OrderedDict(mine)]) + self.assertNotIn("md5", merged) + + def test_a_single_bios_object_stays_an_object(self): + from exporter.retrodeck_exporter import Exporter # noqa: PLC0415 + + theirs = OrderedDict([("filename", "ppge_atlas.zim"), ("system", "psp"), + ("md5", "a" * 32)]) + merged = Exporter._merge(OrderedDict(theirs), []) + self.assertEqual(merged, [theirs]) + + class RetrodeckExport(unittest.TestCase): A, B, C = "a" * 32, "b" * 32, "c" * 32