From eba5f1dd6266cfbc8cc54845a614662c5626bc02 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:36:09 +0200 Subject: [PATCH] fix: patch Recalbox's attributes, not its elements A changed element was rendered again, losing its alignment and the place of its attributes, and an element whose md5 list was only reordered (neogeo.zip, the same 24 digests) was rewritten too: Recalbox accepts any digest of the list (Bios.cpp:109-130), so the order is not content. A changed attribute is now replaced where it stands, one dropped goes with its space, one added goes before />, and each system's opening tag keeps its own indentation. An empty truth gives back the cached es_bios.xml byte for byte. --- scripts/exporter/recalbox_exporter.py | 70 ++++++++++++++++++++++++--- tests/test_exporters.py | 36 ++++++++++++++ 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/scripts/exporter/recalbox_exporter.py b/scripts/exporter/recalbox_exporter.py index 58b75a56..da43fc82 100644 --- a/scripts/exporter/recalbox_exporter.py +++ b/scripts/exporter/recalbox_exporter.py @@ -45,10 +45,57 @@ def _stated(text: str) -> dict[str, str]: line breaks inside a value and the case of a digest are not content.""" attrs = {k: " ".join(html.unescape(v).split()) for k, v in _ATTRIBUTE.findall(text)} if "md5" in attrs: - attrs["md5"] = ",".join(h.strip().lower() for h in attrs["md5"].split(",")) + # Recalbox accepts any digest of the list (Bios.cpp:109-130): the + # order is not content, and neogeo.zip's 24, reordered, were + # written again. + attrs["md5"] = ",".join(sorted({h.strip().lower() for h in attrs["md5"].split(",")})) return attrs +_SPACED_ATTRIBUTE = re.compile(r'(\s+)([A-Za-z][A-Za-z0-9]*)(\s*=\s*")([^"]*)(")') + + +def patch_element(kept: str, mine: str) -> str: + """Recalbox's element with only the attributes that changed rewritten. + + Rendered again, a corrected element lost its alignment and the place + of its attributes. A changed value is replaced where it stands, an + attribute ours drops goes with the space before it, and one ours adds + goes before the closing />. + """ + target = dict(_ATTRIBUTE.findall(mine)) + target_stated = _stated(mine) + present: set[str] = set() + + def rewrite(match: re.Match[str]) -> str: + name = match.group(2) + present.add(name) + if name not in target: + return "" + if _stated(f'') == {name: target_stated[name]}: + return match.group(0) + return match.group(1) + name + match.group(3) + target[name] + match.group(5) + + patched = _SPACED_ATTRIBUTE.sub(rewrite, kept) + added = "".join(f' {name}="{value}"' for name, value in target.items() if name not in present) + if added: + close = re.search(r"\s*/>\s*$", patched) + if close is None: + return mine + patched = patched[:close.start()] + added + patched[close.start():] + return patched + + +def _original_openings(original: str) -> list[str]: + """Each system block's opening tag as written, its indentation included: + cdi's is indented four spaces where the others take two (l.604).""" + openings = [] + for block in _SYSTEM_BLOCK.finditer(original): + line_start = original.rfind("\n", 0, block.start()) + 1 + openings.append(original[line_start:block.start()] + block.group(0).split(">", 1)[0] + ">") + return openings + + def _original_elements(original: str) -> list[dict[str, str]]: """Each system block's elements as written, by path, in order.""" blocks = [] @@ -200,10 +247,14 @@ class Exporter(BaseExporter): rendered = " ".join(f"{k}={quoteattr(v)}" for k, v in attrs.items()) return f" " - def emit(platform: str, fullname: str, body: list[str]) -> None: - lines.append( - f" " - ) + def emit(platform: str, fullname: str, body: list[str], opening: str = "") -> None: + stated = _stated(opening) if opening else {} + if stated.get("platform") == platform and stated.get("fullname") == fullname: + lines.append(opening) + else: + lines.append( + f" " + ) lines.extend(body) lines.append(" ") @@ -211,6 +262,7 @@ class Exporter(BaseExporter): # states what Recalbox wrote keeps Recalbox's text: re-rendered, 222 # unchanged elements made 180 lines of diff the report never named. written = _original_elements(original) if original else [] + openings = _original_openings(original) if original else [] blocks: list[tuple[str, str, list[str]]] = [] for index, (platform, fullname, children) in enumerate(theirs): elements = ours.get(platform, (None, {}))[1] @@ -225,6 +277,10 @@ class Exporter(BaseExporter): kept = raw.get(" ".join(path.split())) if kept is not None and (mine is None or _stated(mine[1]) == _stated(kept)): body.append(kept) + elif kept is not None and mine is not None and _stated( + patched := patch_element(kept, mine[1]) + ) == _stated(mine[1]): + body.append(patched) else: body.append(mine[1] if mine else element(child)) blocks.append((platform, fullname, body)) @@ -242,8 +298,8 @@ class Exporter(BaseExporter): key = (platform, str(fe.native("native_name", ""))) blocks[named.get(key, first[platform])][2].append(rendered) - for platform, fullname, body in blocks: - emit(platform, fullname, body) + for index, (platform, fullname, body) in enumerate(blocks): + emit(platform, fullname, body, openings[index] if index < len(openings) else "") for platform in sorted(ours): system, elements = ours[platform] emit(platform, self.display_name(system), [r for _, r in elements.values()]) diff --git a/tests/test_exporters.py b/tests/test_exporters.py index df8aa98f..d0608c2c 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 RecalboxPatchesAttributesNotElements(unittest.TestCase): + """A changed element was rendered again, losing its alignment and the + place of its attributes, and an element whose md5 list was only + reordered (neogeo.zip, the same 24 digests) was rewritten too.""" + + def test_an_empty_truth_hands_back_the_file(self): + path = UPSTREAM_CACHE / "recalbox" / "es_bios.xml" + if not path.exists(): + self.skipTest("upstream cache not populated") + original = path.read_text(encoding="utf-8") + exporter = discover_exporters()["recalbox"]() + config = native_layer(load_platform_config("recalbox", str(REPO_ROOT / "platforms"))) + systems, report = build_native_model({"systems": {}}, config) + produced = exporter.render(systems, report, {"es_bios.xml": original}, config) + self.assertEqual(produced["es_bios.xml"], original) + + def test_a_reordered_md5_list_states_the_same(self): + from exporter.recalbox_exporter import _stated # noqa: PLC0415 + + self.assertEqual(_stated(''), + _stated('')) + + def test_one_attribute_changes_in_place(self): + from exporter.recalbox_exporter import patch_element # noqa: PLC0415 + + kept = ' ' + mine = ' ' + self.assertEqual(patch_element(kept, mine), + ' ') + dropped = patch_element( + ' ', + ' ') + self.assertEqual(dropped, ' ') + + class RetrobatListComesBackAsWritten(unittest.TestCase): """RetroBat separates each system with a blank line. json.dumps dropped all 100 of them, 100 hunks of noise in a diff meant to show