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.
This commit is contained in:
Abdessamad Derraz committed 2026-10-20 19:36:09 +02:00
1 parent 0a309c79a8
commit eba5f1dd62
2 files changed
+99 -7

No files matched your search

+63 -7
View File
@@ -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'<x {name}="{match.group(4)}"/>') == {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 <bios> 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" <bios {rendered} />"
def emit(platform: str, fullname: str, body: list[str]) -> None:
lines.append(
f" <system fullname={quoteattr(fullname)} platform={quoteattr(platform)}>"
)
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" <system fullname={quoteattr(fullname)} platform={quoteattr(platform)}>"
)
lines.extend(body)
lines.append(" </system>")
@@ -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()])
+36
View File
@@ -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('<bios path="a" md5="1,2" />'),
_stated('<bios path="a" md5="2,1" />'))
def test_one_attribute_changes_in_place(self):
from exporter.recalbox_exporter import patch_element # noqa: PLC0415
kept = ' <bios path="kick.rom" md5="aa" core="libretro/puae" />'
mine = ' <bios path="kick.rom" md5="aa" core="libretro/puae" mandatory="false" />'
self.assertEqual(patch_element(kept, mine),
' <bios path="kick.rom" md5="aa" core="libretro/puae"'
' mandatory="false" />')
dropped = patch_element(
' <bios path="x" md5="aa" mandatory="false" core="c" />',
' <bios path="x" md5="bb" core="c" />')
self.assertEqual(dropped, ' <bios path="x" md5="bb" core="c" />')
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