""" rbxl_xml_parser.py — парсер XML-формата .rbxl (старые карты до 2010 года). Roblox-XML формат — текстовый предок бинарного .rbxl. Файл начинается с и содержит дерево .... Возвращает тот же `RobloxModel` что и rbxl_parser.parse — чтобы converter.py работал без изменений. Пример входного файла: Workspace 0100 1...1 412 4286611584 21 Поддерживает все типичные property-теги: string, bool, int, float, double, token, Vector3, Vector2, CoordinateFrame, Color3, Color3uint8, BrickColor, Content, ProtectedString, Ref, BinaryString, UDim, UDim2, Rect2D. """ from __future__ import annotations from typing import Dict, List, Any, Optional, Tuple import xml.etree.ElementTree as ET import re import base64 import struct from rbxl_parser import Instance, RobloxModel from rbxl_types import ( Vector3, Vector2, Color3, CFrame, BrickColor, EnumValue, PhysicalProperties, OptionalCFrame, ) # Magic для XML-формата XML_MAGIC = b' bool: """Проверяет XML это или нет. Бинарный начинается с str: """Текст элемента (None → default).""" return (el.text if el.text is not None else default).strip() def _f(el: ET.Element, default: float = 0.0) -> float: """Float из text.""" try: return float(_text(el, '0')) except (ValueError, TypeError): return default def _i(el: ET.Element, default: int = 0) -> int: """Int из text.""" try: s = _text(el, '0') # Roblox иногда пишет '1.0' где ожидается int return int(float(s)) if '.' in s else int(s) except (ValueError, TypeError): return default def _parse_vector3(el: ET.Element) -> Vector3: x = _f(el.find('X')) y = _f(el.find('Y')) z = _f(el.find('Z')) return Vector3(x, y, z) def _parse_vector2(el: ET.Element) -> Vector2: x = _f(el.find('X')) y = _f(el.find('Y')) return Vector2(x, y) def _parse_cframe(el: ET.Element) -> CFrame: """CoordinateFrame: 3 позиции + 9 элементов матрицы ротации.""" pos = Vector3(_f(el.find('X')), _f(el.find('Y')), _f(el.find('Z'))) matrix = tuple(_f(el.find(f'R{i}{j}'), 1.0 if i == j else 0.0) for i in range(3) for j in range(3)) return CFrame(position=pos, matrix=matrix) def _parse_color3(el: ET.Element) -> Color3: """.........""" r = _f(el.find('R')) g = _f(el.find('G')) b = _f(el.find('B')) return Color3(r, g, b) def _parse_color3uint8(el: ET.Element) -> Color3: """4286611584 — packed RGB как uint32.""" val = _i(el, 0) # uint32 = 0xFFRRGGBB (alpha=FF). r=byte2, g=byte1, b=byte0 b = (val & 0xff) / 255.0 g = ((val >> 8) & 0xff) / 255.0 r = ((val >> 16) & 0xff) / 255.0 return Color3(r, g, b) def _parse_property(prop_el: ET.Element) -> Tuple[str, Any]: """Парсит один <тип name="имя">значение. Возвращает (name, value).""" tag = prop_el.tag name = prop_el.attrib.get('name', '') if tag == 'string' or tag == 'ProtectedString' or tag == 'Content': return name, _text(prop_el) if tag == 'bool': return name, _text(prop_el).lower() == 'true' if tag in ('int', 'int64'): val = _i(prop_el) # В старом XML цвет хранится как 21, # а converter ожидает BrickColor-объект с .code. if name == 'BrickColor': return name, BrickColor(code=val) return name, val if tag in ('float', 'double'): return name, _f(prop_el) if tag == 'token': # token — int-значение enum return name, EnumValue(value=_i(prop_el)) if tag == 'Vector3': return name, _parse_vector3(prop_el) if tag == 'Vector2': return name, _parse_vector2(prop_el) if tag == 'CoordinateFrame': return name, _parse_cframe(prop_el) if tag == 'Color3': return name, _parse_color3(prop_el) if tag == 'Color3uint8': return name, _parse_color3uint8(prop_el) if tag == 'BrickColor': return name, BrickColor(code=_i(prop_el)) if tag == 'Ref': # Ссылка на другой Item по referent (например "RBX42" или "null") txt = _text(prop_el, 'null') if txt in ('null', 'nil', ''): return name, None return name, txt # храним как строку-referent if tag == 'BinaryString': # base64 → bytes try: return name, base64.b64decode(_text(prop_el)) except Exception: return name, b'' if tag == 'UDim': scale = _f(prop_el.find('S')) offset = _i(prop_el.find('O')) return name, {'scale': scale, 'offset': offset} if tag == 'UDim2': xs = _f(prop_el.find('XS')) xo = _i(prop_el.find('XO')) ys = _f(prop_el.find('YS')) yo = _i(prop_el.find('YO')) return name, {'x_scale': xs, 'x_offset': xo, 'y_scale': ys, 'y_offset': yo} if tag == 'Rect2D': # min/max min_el = prop_el.find('min') max_el = prop_el.find('max') return name, { 'min': _parse_vector2(min_el) if min_el is not None else Vector2(0, 0), 'max': _parse_vector2(max_el) if max_el is not None else Vector2(0, 0), } if tag == 'OptionalCoordinateFrame': cf_el = prop_el.find('CFrame') return name, OptionalCFrame(cframe=_parse_cframe(cf_el)) if cf_el is not None else OptionalCFrame(cframe=None) if tag == 'PhysicalProperties': cust = prop_el.find('CustomPhysics') custom = cust is not None and _text(cust).lower() == 'true' return name, PhysicalProperties( custom_physics=custom, density=_f(prop_el.find('Density'), 0.7), friction=_f(prop_el.find('Friction'), 0.3), elasticity=_f(prop_el.find('Elasticity'), 0.5), friction_weight=_f(prop_el.find('FrictionWeight'), 1.0), elasticity_weight=_f(prop_el.find('ElasticityWeight'), 1.0), ) if tag == 'NumberRange': return name, {'min': _f(prop_el.find('Min')), 'max': _f(prop_el.find('Max'))} # SharedString / Uri / другие незнакомые — оставляем как текст return name, _text(prop_el) # Регекс для извлечения referent из строк типа "RBX42" _REF_RE = re.compile(r'^RBX(\d+)$') def _ref_to_int(ref: Optional[str]) -> Optional[int]: """RBX42 → 42, null → None. Если уникальной номер не найден — None.""" if ref is None or ref == 'null': return None m = _REF_RE.match(str(ref)) if m: return int(m.group(1)) return None def parse_xml(blob: bytes) -> RobloxModel: """Главный entry: bytes → RobloxModel.""" try: text = blob.decode('utf-8', errors='replace') except Exception: text = blob.decode('latin-1', errors='replace') # XML может иметь BOM или leading whitespace text = text.lstrip('').lstrip() root = ET.fromstring(text) instances: List[Instance] = [] by_referent: Dict[int, Instance] = {} roots: List[Instance] = [] # Auto-increment id для Item'ов без referent (старые форматы) next_id_counter = [100000] def _walk(item_el: ET.Element, parent_ref: Optional[int]) -> None: """Рекурсивный обход элементов.""" cls = item_el.attrib.get('class', 'Unknown') # Referent из атрибута (например referent="RBX42") ref_attr = item_el.attrib.get('referent') or item_el.attrib.get('Referent') ref_int = _ref_to_int(ref_attr) if ref_attr else None if ref_int is None: # Назначаем auto-id чтобы converter мог отслеживать parent_referent ref_int = next_id_counter[0] next_id_counter[0] += 1 # Парсим properties props: Dict[str, Any] = {} props_el = item_el.find('Properties') if props_el is not None: for prop_el in props_el: try: pname, pval = _parse_property(prop_el) if pname: props[pname] = pval except Exception: continue # Roblox в старых картах использовал имена с маленькой первой буквы: # name → Name, size → Size, shape → Shape, и т.д. Converter ожидает # PascalCase. Делаем алиасы (старое имя остаётся, новое добавляется). _ALIAS_TO_PASCAL = { 'name': 'Name', 'size': 'Size', 'shape': 'Shape', 'archivable': 'Archivable', 'shape3d': 'Shape', } for old, new in _ALIAS_TO_PASCAL.items(): if old in props and new not in props: props[new] = props[old] # Convert Ref-properties (string "RBX42") в parent_referent если нужно # — пока оставляем как строки. inst = Instance( referent=ref_int, class_name=cls, properties=props, parent_referent=parent_ref, children=[], ) instances.append(inst) by_referent[ref_int] = inst if parent_ref is None: roots.append(inst) # Рекурсивно дочерние Item'ы for child in item_el.findall('Item'): _walk(child, ref_int) # Roblox-XML: top-level идут под for item in root.findall('Item'): _walk(item, None) # Заполняем children после полного прохода (для удобства converter'а) for inst in instances: if inst.parent_referent is not None: parent = by_referent.get(inst.parent_referent) if parent is not None: parent.children.append(inst) # Версия из атрибута version_attr = root.attrib.get('version', '4') try: version = int(version_attr) except ValueError: version = 4 return RobloxModel( version=version, class_count=len(set(i.class_name for i in instances)), instance_count=len(instances), instances=instances, by_referent=by_referent, roots=roots, shared_strings=[], meta={}, warnings=[], )