"""Parser for clrmamepro / Logiqx XML (.dat) files. Supports both DAT layouts the project uses: * clrmamepro text DAT (No-Intro): parenthesised blocks, e.g.:: clrmamepro ( name "Sony - PlayStation Portable" version "2021.10.24" ) game ( name "WipEout Pure (Europe)" region "Europe" serial "ULES-00011" rom ( name "WipEout Pure (Europe).iso" size 123 crc ABCD1234 md5 ... sha1 ... ) ) * Logiqx XML DAT (Redump): ```` elements with ```` children. Both ``parse_dat_text`` and ``parse_dat_xml`` return ``(header, entries)``. Each entry is a dict with scalar keys (``name``, ``description``, ``region``, ``serial``, ...) and repeated nested blocks exposed as lists under their block key (``rom``). """ import re import xml.etree.ElementTree as ET from pathlib import Path _TOKEN_RE = re.compile( r""" (?P\s+) | (?P\() | (?P\)) | (?P"(?:\\.|[^"\\])*") | (?P[^"\s()]+) """, re.VERBOSE, ) class _Token: __slots__ = ("kind", "value") def __init__(self, kind, value): self.kind = kind self.value = value def _tokenize(text): tokens = [] for match in _TOKEN_RE.finditer(text): kind = match.lastgroup value = match.group() if kind == "ws": continue if kind == "string": value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\") kind = "string" elif kind == "lparen": kind = "lparen" value = "(" elif kind == "rparen": kind = "rparen" value = ")" else: kind = "ident" tokens.append(_Token(kind, value)) return tokens class _Parser: def __init__(self, tokens): self._tokens = tokens self._pos = 0 def _peek(self): return self._tokens[self._pos] if self._pos < len(self._tokens) else None def _next(self): token = self._peek() self._pos += 1 return token def parse_block(self, name=None): if name is None: name_token = self._next() if name_token is None or name_token.kind != "ident": raise ValueError(f"expected block name at token {self._pos}") name = name_token.value lparen = self._next() if lparen is None or lparen.kind != "lparen": raise ValueError(f"expected '(' after '{name}'") block = {"_type": name} while True: token = self._peek() if token is None: raise ValueError(f"unexpected EOF inside block '{name}'") if token.kind == "rparen": self._next() break if token.kind != "ident": raise ValueError(f"unexpected token '{token.value}' in block '{name}'") key = self._next().value nxt = self._peek() if nxt is not None and nxt.kind == "lparen": child = self.parse_block(name=key) block.setdefault(key, []).append(child) else: value = self._next() if value is None: raise ValueError(f"expected value for '{key}' in block '{name}'") block[key] = value.value return block def parse_dat_text(text): tokens = _tokenize(text) parser = _Parser(tokens) header = None entries = [] while parser._peek() is not None: block = parser.parse_block() kind = block.get("_type") if kind == "clrmamepro": header = block elif kind in ("game", "machine"): entries.append(block) # ignore any other top-level blocks return header, entries def _xml_text(element, key): child = element.find(key) if child is not None and child.text: return child.text.strip() return None def parse_dat_xml(text): """Parse a Logiqx XML DAT (the format redump.org uses). Returns ``(header, entries)`` in the same shape as :func:`parse_dat_text`. """ root = ET.fromstring(text) header = {} header_el = root.find("header") if header_el is not None: for child in header_el: if child.text: header[child.tag] = child.text.strip() entries = [] for game in root.iter(): if game.tag not in ("game", "machine"): continue entry = {"name": (game.get("name") or "").strip()} for key in ("description", "serial", "region", "category"): value = _xml_text(game, key) if value: entry[key] = value entry["rom"] = [ { "name": rom.get("name", ""), "size": rom.get("size"), "crc": rom.get("crc"), "md5": rom.get("md5"), "sha1": rom.get("sha1"), "serial": rom.get("serial"), } for rom in game.findall("rom") ] entries.append(entry) return header, entries def parse_dat_file(path): text = Path(path).read_text(encoding="utf-8-sig") stripped = text.lstrip() if stripped.startswith("