All checks were successful
Тест-фича для МИНа. Полное описание в 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>
126 lines
4.4 KiB
Python
126 lines
4.4 KiB
Python
"""
|
||
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))
|