feat(rbxl): XML-������ ������ .rbxl + Day/Night + Tool/Mouse/Backpack flow #38
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc
Normal file
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc
Normal file
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc
Normal file
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc
Normal file
Binary file not shown.
@ -122,11 +122,22 @@ 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'<roblox!'):
|
||||
return jsonify({'error': 'not a .rbxl binary file (missing <roblox! magic)'}), 400
|
||||
# Авто-детект XML vs Binary формата.
|
||||
# Бинарный: <roblox!\x89\xff\r\n\x1a\n (magic bytes).
|
||||
# XML (старые карты до 2010): <roblox version="4">...
|
||||
stripped = blob.lstrip()
|
||||
is_binary = stripped.startswith(b'<roblox!')
|
||||
is_xml = stripped.startswith(b'<roblox') and not is_binary
|
||||
|
||||
if not is_binary and not is_xml:
|
||||
return jsonify({'error': 'not a .rbxl file (no <roblox magic)'}), 400
|
||||
|
||||
# Парсим
|
||||
try:
|
||||
if is_xml:
|
||||
from rbxl_xml_parser import parse_xml
|
||||
model = parse_xml(blob)
|
||||
else:
|
||||
model = parse(blob)
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'parse failed: {e}'}), 422
|
||||
|
||||
342
rbxl-importer/src/rbxl_xml_parser.py
Normal file
342
rbxl-importer/src/rbxl_xml_parser.py
Normal file
@ -0,0 +1,342 @@
|
||||
"""
|
||||
rbxl_xml_parser.py — парсер XML-формата .rbxl (старые карты до 2010 года).
|
||||
|
||||
Roblox-XML формат — текстовый предок бинарного .rbxl. Файл начинается с
|
||||
<roblox version="4"> и содержит дерево <Item class="...">...</Item>.
|
||||
|
||||
Возвращает тот же `RobloxModel` что и rbxl_parser.parse — чтобы converter.py
|
||||
работал без изменений.
|
||||
|
||||
Пример входного файла:
|
||||
<roblox version="4">
|
||||
<Item class="Workspace">
|
||||
<Properties>
|
||||
<string name="Name">Workspace</string>
|
||||
</Properties>
|
||||
<Item class="Part">
|
||||
<Properties>
|
||||
<CoordinateFrame name="CFrame">
|
||||
<X>0</X><Y>10</Y><Z>0</Z>
|
||||
<R00>1</R00>...<R22>1</R22>
|
||||
</CoordinateFrame>
|
||||
<Vector3 name="size"><X>4</X><Y>1</Y><Z>2</Z></Vector3>
|
||||
<Color3uint8 name="Color3uint8">4286611584</Color3uint8>
|
||||
<token name="BrickColor">21</token>
|
||||
</Properties>
|
||||
</Item>
|
||||
</Item>
|
||||
</roblox>
|
||||
|
||||
Поддерживает все типичные 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'<roblox'
|
||||
|
||||
|
||||
def is_xml_rbxl(blob: bytes) -> bool:
|
||||
"""Проверяет XML это или нет. Бинарный начинается с <roblox!..."""
|
||||
stripped = blob.lstrip()
|
||||
if stripped.startswith(b'<roblox') and not stripped.startswith(b'<roblox!'):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _text(el: ET.Element, default: str = '') -> 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:
|
||||
"""<Color3 name="..."><R>...</R><G>...</G><B>...</B></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:
|
||||
"""<Color3uint8>4286611584</Color3uint8> — 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 цвет хранится как <int name="BrickColor">21</int>,
|
||||
# а 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:
|
||||
"""Рекурсивный обход <Item class="..."> элементов."""
|
||||
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 <Item class="..."> идут под <roblox>
|
||||
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)
|
||||
|
||||
# Версия из атрибута <roblox version="4">
|
||||
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=[],
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user