diff --git a/rbxl-importer/src/__pycache__/converter.cpython-314.pyc b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc index af16ebf..6f7af91 100644 Binary files a/rbxl-importer/src/__pycache__/converter.cpython-314.pyc and b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc differ diff --git a/rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc new file mode 100644 index 0000000..946eaaf Binary files /dev/null and b/rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc differ diff --git a/rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc new file mode 100644 index 0000000..6d971cf Binary files /dev/null and b/rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc differ diff --git a/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc new file mode 100644 index 0000000..44b6b88 Binary files /dev/null and b/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc differ diff --git a/rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc new file mode 100644 index 0000000..c20812b Binary files /dev/null and b/rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc differ diff --git a/rbxl-importer/src/app.py b/rbxl-importer/src/app.py index 00a113c..b9788db 100644 --- a/rbxl-importer/src/app.py +++ b/rbxl-importer/src/app.py @@ -122,12 +122,23 @@ def analyze(): blob = upload.read() if len(blob) > MAX_RBXL_SIZE: return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413 - if not blob.startswith(b'... + stripped = blob.lstrip() + is_binary = stripped.startswith(b' и содержит дерево .... + +Возвращает тот же `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=[], + )