""" rbxl_parser.py — главный парсер Roblox Binary Level (.rbxl) v1. Использование: from rbxl_parser import parse, RobloxModel with open('map.rbxl', 'rb') as f: model = parse(f.read()) for inst in model.instances: print(inst.class_name, inst.properties.get('Name')) Архитектура: 1. parse() читает все chunks (META, SSTR, INST, PROP, PRNT, END). 2. Из INST chunks извлекаем какие классы (Part, Script, ...) есть в файле и сколько инстансов у каждого. 3. Из PROP chunks извлекаем значения свойств (по одному chunk на свойство для каждого класса). 4. Из PRNT chunk — иерархия parent-ссылок (referent->referent). 5. Собираем плоский список Instance'ов с привязкой properties + parent. Полная документация формата: https://dom.rojo.space/binary """ import struct import io import lz4.block from dataclasses import dataclass, field from typing import List, Dict, Optional, Any from rbxl_binreader import ( BinReader, read_interleaved_uint32_array, read_referent_array, ) from rbxl_types import ( decode_prop_chunk, PropChunk, ) RBXL_SIGNATURE = b' tuple: """Возвращает (header, [RawChunk]). Каждый chunk: name (4 bytes ASCII), compressed_len (uint32), uncompressed_len (uint32), reserved (uint32), payload (compressed_len bytes if compressed_len>0 else uncompressed_len bytes). Если compressed_len == 0 — payload не сжат. """ if not blob.startswith(RBXL_SIGNATURE): raise RbxlParseError( f"signature mismatch: got {blob[:14].hex()}, expected {RBXL_SIGNATURE.hex()}" ) stream = io.BytesIO(blob[len(RBXL_SIGNATURE):]) # Header version, class_count, instance_count = struct.unpack(' InstChunk: """Декодирует INST chunk. Структура: class_id (uint32) class_name (string) is_service (uint8) count (uint32) — число инстансов класса referents (interleaved-zigzag-累加 int32, count штук) if is_service: services_flags (count байт) """ r = BinReader(payload) class_id = r.uint32() class_name = r.string() is_service = bool(r.uint8()) count = r.uint32() referents = read_referent_array(r, count) if is_service: # Пропускаем сервис-флаги (один байт на инстанс) r.read(count) return InstChunk( class_id=class_id, class_name=class_name, is_service=is_service, referent_ids=referents, ) # ────────────────────────────────────────────────────────────────────── # Decode SSTR chunk # ────────────────────────────────────────────────────────────────────── def _decode_sstr_chunk(payload: bytes) -> List[bytes]: """SSTR (Shared Strings) chunk. Структура: version (uint32) count (uint32) для каждого: md5_hash (16 bytes) — игнорируется length (uint32) data (length bytes) """ r = BinReader(payload) _version = r.uint32() count = r.uint32() strings = [] for _ in range(count): r.read(16) # md5 length = r.uint32() strings.append(r.read(length)) return strings # ────────────────────────────────────────────────────────────────────── # Decode PRNT chunk (parent hierarchy) # ────────────────────────────────────────────────────────────────────── def _decode_prnt_chunk(payload: bytes, instance_count: int) -> List[tuple]: """PRNT (Parents) chunk: для каждого instance его parent. Структура: version (uint8) — обычно 0 count (uint32) — instance_count child_referents: interleaved int32 array (length=count, cumulative) parent_referents: interleaved int32 array (length=count, cumulative) Возвращает [(child_referent, parent_referent)]. parent_referent == -1 означает что parent — root (Workspace/DataModel). """ r = BinReader(payload) _version = r.uint8() count = r.uint32() children = read_referent_array(r, count) parents = read_referent_array(r, count) return list(zip(children, parents)) # ────────────────────────────────────────────────────────────────────── # Decode META chunk # ────────────────────────────────────────────────────────────────────── def _decode_meta_chunk(payload: bytes) -> Dict[str, str]: """META chunk: пары ключ-значение.""" r = BinReader(payload) count = r.uint32() meta = {} for _ in range(count): k = r.string() v = r.string() meta[k] = v return meta # ────────────────────────────────────────────────────────────────────── # Главная функция parse() # ────────────────────────────────────────────────────────────────────── def parse(blob: bytes) -> RobloxModel: """Парсит .rbxl-байты в RobloxModel со списком Instance'ов.""" header, raw_chunks = _read_chunks(blob) warnings: List[str] = [] # 1. Извлекаем META meta = {} for c in raw_chunks: if c.name == 'META': try: meta = _decode_meta_chunk(c.payload) except Exception as e: warnings.append(f"META decode failed: {e}") # 2. SSTR shared_strings: List[bytes] = [] for c in raw_chunks: if c.name == 'SSTR': try: shared_strings = _decode_sstr_chunk(c.payload) except Exception as e: warnings.append(f"SSTR decode failed: {e}") break # 3. INST — описания классов inst_chunks: Dict[int, InstChunk] = {} for c in raw_chunks: if c.name == 'INST': try: ic = _decode_inst_chunk(c.payload) inst_chunks[ic.class_id] = ic except Exception as e: warnings.append(f"INST decode failed: {e}") # 4. Создаём пустые Instance'ы по референтам by_referent: Dict[int, Instance] = {} instances: List[Instance] = [] for class_id, ic in inst_chunks.items(): for ref in ic.referent_ids: inst = Instance(referent=ref, class_name=ic.class_name) by_referent[ref] = inst instances.append(inst) # 5. PROP — заполняем свойства for c in raw_chunks: if c.name == 'PROP': try: # Узнаём class_id (первые 4 байта чанка) чтобы взять count class_id = struct.unpack(' Dict[str, int]: """Возвращает {class_name: count} — сколько объектов каждого типа.""" out: Dict[str, int] = {} for inst in model.instances: out[inst.class_name] = out.get(inst.class_name, 0) + 1 return out def summarize(model: RobloxModel, top_classes: int = 30) -> str: """Печатает человекочитаемый отчёт о модели.""" lines = [] lines.append(f"=== Roblox model ===") lines.append(f" version: {model.version}") lines.append(f" class count: {model.class_count}") lines.append(f" instance count: {model.instance_count}") lines.append(f" shared strings: {len(model.shared_strings)}") lines.append(f" warnings: {len(model.warnings)}") if model.meta: lines.append(" meta:") for k, v in list(model.meta.items())[:5]: lines.append(f" {k}: {v}") lines.append("") lines.append(f"=== Top {top_classes} classes ===") hist = class_histogram(model) for cls, n in sorted(hist.items(), key=lambda x: -x[1])[:top_classes]: lines.append(f" {n:>5d} {cls}") lines.append("") if model.warnings: lines.append(f"=== Warnings (first 20) ===") for w in model.warnings[:20]: lines.append(f" ! {w}") 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\n") model = parse(blob) print(summarize(model, top_classes=40)) # Дополнительная статистика: что внутри у Part print("\n=== Sample Part properties ===") parts = [i for i in model.instances if i.class_name == 'Part'] if parts: p = parts[0] print(f" Sample Part (referent={p.referent}):") for k, v in p.properties.items(): sv = str(v) if len(sv) > 80: sv = sv[:77] + '...' print(f" {k}: {sv}") print(f"\n Total Parts: {len(parts)}")