studio/rbxl-importer/src/rbxl_parser_v0.py
min c375ae01ac
All checks were successful
CI / Lint (pull_request) Successful in 1m6s
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 26s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(rbxl-import): импорт Roblox .rbxl карт в Rublox-проекты
Тест-фича для МИНа. Полное описание в rbxl-importer/INFO_PROCESS.md.

Backend (rbxl-importer/ на VM 130 S1):
- Python-парсер Roblox Binary (28+ типов значений)
- Asset downloader через Marfusha proxy + .ROBLOSECURITY cookie
- Mesh→GLB конвертер (v1-v5)
- Converter Roblox-классов → project_data
- Flask API: /analyze + /create

Frontend:
- API.js + components/RbxlImportModal.jsx (drag-n-drop)

Тестовый импорт Easy Obby: project_id 2697,
2244 primitives + 742 lua-scripts + 5 ассетов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 18:24:27 +03:00

126 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
rbxl_parser_v0.py — голый парсер Roblox Binary Level (.rbxl) v0.
Эта первая итерация — только структура файла:
- проверка signature (<roblox!\x89\xff\r\n\x1a\n)
- чтение header (version, class count, instance count)
- чтение chunks: name (4 байт), compressed_len, uncompressed_len, reserved, payload
- LZ4-декомпрессия payload
- выдача списка chunks с распакованным содержимым
Здесь НЕ ДЕЛАЕМ декодирование INST/PROP per-type — только видим что есть в файле.
Это нужно для первого smoke-test'а на твоей Easy Obby.
Полная спецификация: https://dom.rojo.space/binary
"""
import struct
import io
import lz4.block
from dataclasses import dataclass, field
from typing import List, Optional
RBXL_SIGNATURE = b'<roblox!\x89\xff\r\n\x1a\n'
@dataclass
class RbxlChunk:
name: str # 'META', 'SSTR', 'INST', 'PROP', 'PRNT', 'SIGN', 'END '
compressed_len: int
uncompressed_len: int
payload: bytes # уже декомпрессированный
@dataclass
class RbxlHeader:
version: int # обычно 0
class_count: int # сколько разных классов (Part, Script, ...)
instance_count: int # сколько объектов всего
@dataclass
class RbxlFile:
header: RbxlHeader
chunks: List[RbxlChunk] = field(default_factory=list)
class RbxlParseError(Exception):
pass
def parse(blob: bytes) -> RbxlFile:
"""Парсит .rbxl байты в RbxlFile. Бросает RbxlParseError на ошибке."""
if not blob.startswith(RBXL_SIGNATURE):
raise RbxlParseError(
f"signature mismatch: got {blob[:14].hex()}, "
f"expected {RBXL_SIGNATURE.hex()}"
)
stream = io.BytesIO(blob[len(RBXL_SIGNATURE):])
# Header: version (uint16), class_count (uint32), instance_count (uint32), reserved (8 bytes)
version, class_count, instance_count = struct.unpack('<HII', stream.read(10))
stream.read(8) # reserved
header = RbxlHeader(version, class_count, instance_count)
chunks = []
while True:
# Chunk header: name (4 bytes), compressed_len (uint32), uncompressed_len (uint32), reserved (uint32)
header_bytes = stream.read(16)
if len(header_bytes) < 16:
break
name_raw, compressed_len, uncompressed_len, _ = struct.unpack('<4sIII', header_bytes)
name = name_raw.decode('ascii', errors='replace').rstrip('\x00').rstrip()
# Payload
payload_compressed = stream.read(compressed_len if compressed_len > 0 else uncompressed_len)
if compressed_len == 0:
# Не сжато
payload = payload_compressed
else:
# LZ4 raw block decompress
try:
payload = lz4.block.decompress(payload_compressed, uncompressed_size=uncompressed_len)
except Exception as e:
raise RbxlParseError(
f"LZ4 decompress failed for chunk {name!r}: {e}"
)
chunks.append(RbxlChunk(name, compressed_len, uncompressed_len, payload))
if name.startswith('END'):
break
return RbxlFile(header=header, chunks=chunks)
def summarize(rbxl: RbxlFile) -> str:
"""Печатает компактный отчёт о структуре файла для smoke-test."""
lines = []
lines.append(f"=== rbxl file ===")
lines.append(f" version: {rbxl.header.version}")
lines.append(f" classes: {rbxl.header.class_count}")
lines.append(f" instances: {rbxl.header.instance_count}")
lines.append(f" chunks: {len(rbxl.chunks)}")
lines.append("")
lines.append(f"=== chunks ===")
for c in rbxl.chunks:
ratio = (c.compressed_len / c.uncompressed_len * 100) if c.uncompressed_len > 0 else 0
lines.append(
f" {c.name:6s} compressed={c.compressed_len:>8d} uncompressed={c.uncompressed_len:>10d} "
f"({ratio:5.1f}%) first 16 bytes: {c.payload[:16].hex()}"
)
return "\n".join(lines)
if __name__ == '__main__':
import sys
path = sys.argv[1] if len(sys.argv) > 1 else '/opt/rbxl-importer/reference_files/easy_obby.rbxl'
with open(path, 'rb') as f:
blob = f.read()
print(f"file size: {len(blob)} bytes")
rbxl = parse(blob)
print(summarize(rbxl))