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>
204 lines
8.1 KiB
Python
204 lines
8.1 KiB
Python
"""
|
||
rbxl_binreader.py — низкоуровневое чтение бинарного потока Roblox.
|
||
|
||
Особенности формата:
|
||
1. Little-endian для всех целочисленных и float.
|
||
2. "Interleaved transformed" массивы: для N значений длиной L байт каждое,
|
||
байты идут не AAAA BBBB CCCC, а ABCD ABCD ABCD ABCD (interleave).
|
||
То есть сначала все first-byte'ы, потом все second-byte'ы, итд. Это
|
||
делается потому что среди одинакового свойства часто меняется только
|
||
младший байт, и interleave даёт длинные последовательности нулей и
|
||
повторяющихся байт → лучше сжимается LZ4.
|
||
3. Signed int → zigzag: (n << 1) ^ (n >> 31) — для int32, аналогично для int64.
|
||
4. Float → "Roblox float encoding": сдвиг битов чтобы знаковый бит был в конце.
|
||
Сделано опять же чтобы маленькие близкие к нулю float'ы выглядели похоже
|
||
и сжимались.
|
||
5. String: uint32 length + UTF-8 bytes.
|
||
|
||
Полная спецификация: https://dom.rojo.space/binary
|
||
"""
|
||
import struct
|
||
import io
|
||
|
||
|
||
class BinReader:
|
||
"""Тонкая обёртка над BytesIO с методами для Roblox-типов."""
|
||
|
||
def __init__(self, data: bytes):
|
||
self.buf = data
|
||
self.pos = 0
|
||
|
||
# ─── атомарные ───
|
||
|
||
def read(self, n: int) -> bytes:
|
||
out = self.buf[self.pos:self.pos + n]
|
||
if len(out) < n:
|
||
raise IOError(f"unexpected EOF: wanted {n}, got {len(out)} at pos {self.pos}")
|
||
self.pos += n
|
||
return out
|
||
|
||
def remaining(self) -> int:
|
||
return len(self.buf) - self.pos
|
||
|
||
def at_end(self) -> bool:
|
||
return self.pos >= len(self.buf)
|
||
|
||
def skip(self, n: int) -> None:
|
||
self.pos += n
|
||
|
||
# ─── простые числовые ───
|
||
|
||
def uint8(self) -> int:
|
||
return self.read(1)[0]
|
||
|
||
def uint16(self) -> int:
|
||
return struct.unpack('<H', self.read(2))[0]
|
||
|
||
def uint32(self) -> int:
|
||
return struct.unpack('<I', self.read(4))[0]
|
||
|
||
def uint64(self) -> int:
|
||
return struct.unpack('<Q', self.read(8))[0]
|
||
|
||
def int32(self) -> int:
|
||
return struct.unpack('<i', self.read(4))[0]
|
||
|
||
def int64(self) -> int:
|
||
return struct.unpack('<q', self.read(8))[0]
|
||
|
||
def float32(self) -> float:
|
||
return struct.unpack('<f', self.read(4))[0]
|
||
|
||
def float64(self) -> float:
|
||
return struct.unpack('<d', self.read(8))[0]
|
||
|
||
def bool(self) -> bool:
|
||
return self.read(1)[0] != 0
|
||
|
||
def string(self) -> str:
|
||
length = self.uint32()
|
||
if length == 0:
|
||
return ''
|
||
return self.read(length).decode('utf-8', errors='replace')
|
||
|
||
def bytes_with_len(self) -> bytes:
|
||
length = self.uint32()
|
||
return self.read(length)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
# Decoders для "interleaved transformed" массивов.
|
||
# Используются в PROP chunks где для класса с N инстансами идёт одно
|
||
# свойство, и значение лежит в interleaved виде.
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def deinterleave(data: bytes, count: int, element_size: int) -> bytes:
|
||
"""Снимает interleave. Получает count*element_size байт и возвращает
|
||
нормальный массив байт где значения идут последовательно.
|
||
|
||
interleaved: b0[0] b1[0] b2[0] ... b0[1] b1[1] b2[1] ... b0[3] b1[3] b2[3]
|
||
нормальный: b0[0] b0[1] b0[2] b0[3] b1[0] b1[1] b1[2] b1[3] ...
|
||
"""
|
||
if len(data) != count * element_size:
|
||
raise ValueError(
|
||
f"deinterleave: expected {count}*{element_size}={count*element_size} bytes, got {len(data)}"
|
||
)
|
||
if count == 0:
|
||
return b''
|
||
out = bytearray(count * element_size)
|
||
for byte_idx in range(element_size):
|
||
for value_idx in range(count):
|
||
out[value_idx * element_size + byte_idx] = data[byte_idx * count + value_idx]
|
||
return bytes(out)
|
||
|
||
|
||
def zigzag_decode_int32(n: int) -> int:
|
||
"""ZigZag decode для int32: (n >> 1) ^ -(n & 1).
|
||
|
||
Используется для PROP с типом Int32 (свойство 'Size' MeshPart и т.п.).
|
||
"""
|
||
return (n >> 1) ^ -(n & 1)
|
||
|
||
|
||
def zigzag_decode_int64(n: int) -> int:
|
||
return (n >> 1) ^ -(n & 1)
|
||
|
||
|
||
def roblox_float_decode(raw: int) -> float:
|
||
"""Roblox float encoding для PROP-массивов.
|
||
|
||
Roblox перекодирует float32 чтобы знаковый бит был в самом конце
|
||
(это даёт лучшее сжатие при близких к нулю значениях).
|
||
|
||
raw — uint32 little-endian.
|
||
"""
|
||
# Развернём биты так чтобы знаковый бит вернулся в позицию 31.
|
||
# Roblox: { mantissa_high(23 bits), exponent(8 bits), sign(1 bit) } → стандартный float
|
||
# Просто (raw >> 1) | ((raw & 1) << 31)
|
||
standard = (raw >> 1) | ((raw & 1) << 31)
|
||
return struct.unpack('<f', struct.pack('<I', standard))[0]
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
# Высокоуровневые readers для interleaved-массивов
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def read_interleaved_int32_array(reader: BinReader, count: int) -> list:
|
||
"""Читает count int32 в interleaved-zigzag форме."""
|
||
raw = reader.read(count * 4)
|
||
deint = deinterleave(raw, count, 4)
|
||
out = []
|
||
for i in range(count):
|
||
u = struct.unpack('>I', deint[i*4:i*4+4])[0] # ВНИМАНИЕ: после deinterleave порядок big-endian!
|
||
out.append(zigzag_decode_int32(u))
|
||
return out
|
||
|
||
|
||
def read_interleaved_uint32_array(reader: BinReader, count: int) -> list:
|
||
"""uint32 array (для Referent) — interleaved, но без zigzag."""
|
||
raw = reader.read(count * 4)
|
||
deint = deinterleave(raw, count, 4)
|
||
out = []
|
||
for i in range(count):
|
||
out.append(struct.unpack('>I', deint[i*4:i*4+4])[0])
|
||
return out
|
||
|
||
|
||
def read_interleaved_int64_array(reader: BinReader, count: int) -> list:
|
||
raw = reader.read(count * 8)
|
||
deint = deinterleave(raw, count, 8)
|
||
out = []
|
||
for i in range(count):
|
||
u = struct.unpack('>Q', deint[i*8:i*8+8])[0]
|
||
out.append(zigzag_decode_int64(u))
|
||
return out
|
||
|
||
|
||
def read_interleaved_float_array(reader: BinReader, count: int) -> list:
|
||
"""Float32 array — interleaved с Roblox float encoding."""
|
||
raw = reader.read(count * 4)
|
||
deint = deinterleave(raw, count, 4)
|
||
out = []
|
||
for i in range(count):
|
||
u = struct.unpack('>I', deint[i*4:i*4+4])[0]
|
||
out.append(roblox_float_decode(u))
|
||
return out
|
||
|
||
|
||
def read_referent_array(reader: BinReader, count: int) -> list:
|
||
"""Referent array — interleaved int32 array с накопительной разностью.
|
||
|
||
Каждое следующее значение = предыдущее + delta. Это даёт длинные
|
||
последовательности (1, 2, 3, 4, ...) которые после deinterleave +
|
||
zigzag отлично сжимаются в нули.
|
||
"""
|
||
deltas = read_interleaved_int32_array(reader, count)
|
||
out = []
|
||
accumulator = 0
|
||
for d in deltas:
|
||
accumulator += d
|
||
out.append(accumulator)
|
||
return out
|