import ByteReader from './ByteReader'; import Instance, { InstanceRoot } from './Instance'; /** * Types used by the binary parser. A Group holds a class name and the * instances that belong to that group. ParserResult is returned from * BinaryParser.parse() and contains the root of the instance tree and a * flat list of all instances, along with scratch arrays used during * parsing. */ export interface ParserResult { result: InstanceRoot; reader: ByteReader; instances: Instance[]; groups: Group[]; sharedStrings: any[]; meta: { [key: string]: any }; arrays: any[][]; arrayIndex: number; } export interface Group { ClassName: string; Objects: Instance[]; } /** * BinaryParser decodes the Roblox binary model format (.rbxm/.rbxl). The * implementation is adapted from the original rbx-reader project but * stripped of attribute deserialization. Unsupported or unknown data types * are left as raw values. The parser exposes a single parse() method * which accepts an ArrayBuffer and returns a ParserResult containing the * parsed object hierarchy. */ const BinaryParser = { // Magic header bytes that begin every Roblox binary file HeaderBytes: [0x3C, 0x72, 0x6F, 0x62, 0x6C, 0x6F, 0x78, 0x21, 0x89, 0xFF, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00], // Precomputed face vectors used when decoding CFrame rotations from // compressed indices. Faces: [[1, 0, 0], [0, 1, 0], [0, 0, 1], [-1, 0, 0], [0, -1, 0], [0, 0, -1]], // Mapping of Roblox data type identifiers to descriptive strings. See // https://github.com/RobloxAPI/rbx-binformat for details. DataTypes: [ null, 'string', 'bool', 'int', 'float', 'double', 'UDim', 'UDim2', /*7*/ 'Ray', 'Faces', 'Axes', 'BrickColor', 'Color3', 'Vector2', 'Vector3', 'Vector2int16', /*15*/ 'CFrame', 'Quaternion', 'Enum', 'Instance', 'Vector3int16', 'NumberSequence', 'ColorSequence', /*22*/ 'NumberRange', 'Rect2D', 'PhysicalProperties', 'Color3uint8', 'int64', 'SharedString', 'UnknownScriptFormat', /*29*/ 'Optional', 'UniqueId' ], /** * Parse a Roblox binary buffer and return a ParserResult. This method * throws if the header is invalid or if the format version is not * recognised. */ parse(buffer: ArrayBuffer): ParserResult { const reader = new ByteReader(buffer); // Validate magic header if (!reader.Match(this.HeaderBytes)) { throw new Error('[RobloxBinaryParser] Header bytes did not match (Did binary format change?)'); } // Read group and instance counts then skip 8 reserved bytes const groupsCount = reader.UInt32LE(); const instancesCount = reader.UInt32LE(); reader.Jump(8); // Initialise parser state const parser: ParserResult = { result: new InstanceRoot(), reader, instances: [], groups: [], sharedStrings: [], meta: {}, arrays: [], arrayIndex: 0 }; // Preallocate scratch arrays for various interleaved reading operations for (let i = 0; i < 6; i++) { parser.arrays.push(new Array(256)); } // Determine the starting position of each chunk const chunkIndices: number[] = []; let maxChunkSize = 0; while (reader.GetRemaining() >= 4) { chunkIndices.push(reader.GetIndex()); // Skip chunk signature reader.String(4); const comLength = reader.UInt32LE(); const decomLength = reader.UInt32LE(); if (comLength > 0) { reader.Jump(4 + comLength); if (decomLength > maxChunkSize) maxChunkSize = decomLength; } else { reader.Jump(4 + decomLength); } } reader.chunkBuffer = new Uint8Array(maxChunkSize); // Parse each chunk in sequence for (const startIndex of chunkIndices) { this.parseChunk(parser, startIndex); } return parser; }, /** * Given the starting offset of a chunk, read its type and dispatch to the * appropriate handler. Unknown chunk types are ignored. */ parseChunk(parser: ParserResult, startIndex: number): void { parser.reader.SetIndex(startIndex); const chunkType = parser.reader.String(4); if (chunkType === 'END\0') return; const chunkData = parser.reader.LZ4(parser.reader.chunkBuffer); const chunkReader = new ByteReader(chunkData); parser.arrayIndex = 0; switch (chunkType) { case 'INST': this.parseINST(parser, chunkReader); break; case 'PROP': this.parsePROP(parser, chunkReader); break; case 'PRNT': this.parsePRNT(parser, chunkReader); break; case 'SSTR': this.parseSSTR(parser, chunkReader); break; case 'META': this.parseMETA(parser, chunkReader); break; case 'SIGN': // Signature chunk, ignore break; default: // Unknown chunk type, skip break; } }, /** * Parse the META chunk which contains arbitrary key/value string pairs. */ parseMETA(parser: ParserResult, chunk: ByteReader): void { const numEntries = chunk.UInt32LE(); for (let i = 0; i < numEntries; i++) { const key = chunk.String(chunk.UInt32LE()); const value = chunk.String(chunk.UInt32LE()); parser.meta[key] = value; } }, /** * Parse the SSTR chunk which defines shared strings. Shared strings * reference large string values by an MD5 hash and are used by later * chunks to avoid duplication. The parser stores them in the * sharedStrings array. */ parseSSTR(parser: ParserResult, chunk: ByteReader): void { chunk.UInt32LE(); // version, unused const stringCount = chunk.UInt32LE(); for (let i = 0; i < stringCount; i++) { const md5 = chunk.Array(16); const length = chunk.UInt32LE(); const value = chunk.String(length); parser.sharedStrings[i] = { md5, value }; } }, /** * Parse the INST chunk which declares a group of instances of a given * class. Each INST record creates one or more Instance objects and * stores them in the parser's groups list. */ parseINST(parser: ParserResult, chunk: ByteReader): void { const groupId = chunk.UInt32LE(); const className = chunk.String(chunk.UInt32LE()); chunk.Byte(); // isService flag, unused const instCount = chunk.UInt32LE(); const instIds = chunk.RBXInterleavedInt32(instCount, parser.arrays[parser.arrayIndex++]); const group: Group = parser.groups[groupId] = { ClassName: className, Objects: [] }; let instId = 0; for (let i = 0; i < instCount; i++) { instId += instIds[i]; group.Objects.push(parser.instances[instId] = Instance.new(className)); } }, /** * Parse the PROP chunk which assigns properties to a previously created * group of instances. Depending on the data type, values may be read * interleaved or sequentially. Unsupported types are ignored. */ parsePROP(parser: ParserResult, chunk: ByteReader): void { const group = parser.groups[chunk.UInt32LE()]; const prop = chunk.String(chunk.UInt32LE()); if (chunk.GetRemaining() <= 0) return; const instCount = group.Objects.length; const values = parser.arrays[parser.arrayIndex++]; let dataType = chunk.Byte(); let typeName = this.DataTypes[dataType] as string | null; let isOptional = typeName === 'Optional'; if (isOptional) { dataType = chunk.Byte(); typeName = this.DataTypes[dataType] as string | null; } let resultTypeName: string = typeName || 'Unknown'; switch (typeName) { case 'string': for (let i = 0; i < instCount; i++) { const len = chunk.UInt32LE(); values[i] = chunk.String(len); } break; case 'bool': for (let i = 0; i < instCount; i++) values[i] = chunk.Byte() !== 0; break; case 'int': chunk.RBXInterleavedInt32(instCount, values); break; case 'float': chunk.RBXInterleavedFloat(instCount, values); break; case 'double': for (let i = 0; i < instCount; i++) values[i] = chunk.DoubleLE(); break; case 'UDim': { const scale = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); const offset = chunk.RBXInterleavedInt32(instCount, parser.arrays[parser.arrayIndex++]); for (let i = 0; i < instCount; i++) values[i] = [scale[i], offset[i]]; break; } case 'UDim2': { const scaleX = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); const scaleY = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); const offsetX = chunk.RBXInterleavedInt32(instCount, parser.arrays[parser.arrayIndex++]); const offsetY = chunk.RBXInterleavedInt32(instCount, parser.arrays[parser.arrayIndex++]); for (let i = 0; i < instCount; i++) values[i] = [[scaleX[i], offsetX[i]], [scaleY[i], offsetY[i]]]; break; } case 'Ray': { for (let i = 0; i < instCount; i++) { values[i] = [ [chunk.FloatLE(), chunk.FloatLE(), chunk.FloatLE()], [chunk.FloatLE(), chunk.FloatLE(), chunk.FloatLE()] ]; } break; } case 'Faces': for (let i = 0; i < instCount; i++) { const data = chunk.Byte(); values[i] = { Right: !!(data & 1), Top: !!(data & 2), Back: !!(data & 4), Left: !!(data & 8), Bottom: !!(data & 16), Front: !!(data & 32) }; } break; case 'Axes': for (let i = 0; i < instCount; i++) { const data = chunk.Byte(); values[i] = { X: !!(data & 1), Y: !!(data & 2), Z: !!(data & 4) }; } break; case 'BrickColor': chunk.RBXInterleavedUint32(instCount, values); break; case 'Color3': { const red = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); const green = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); const blue = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); for (let i = 0; i < instCount; i++) values[i] = [red[i], green[i], blue[i]]; break; } case 'Vector2': { const vecX = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); const vecY = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); for (let i = 0; i < instCount; i++) values[i] = [vecX[i], vecY[i]]; break; } case 'Vector3': { const vecX = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); const vecY = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); const vecZ = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); for (let i = 0; i < instCount; i++) values[i] = [vecX[i], vecY[i], vecZ[i]]; break; } case 'CFrame': { for (let vi = 0; vi < instCount; vi++) { const value = values[vi] = [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]; const type = chunk.Byte(); if (type !== 0) { const right = this.Faces[Math.floor((type - 1) / 6)]; const up = this.Faces[Math.floor((type - 1) % 6)]; const back = [ right[1] * up[2] - up[1] * right[2], right[2] * up[0] - up[2] * right[0], right[0] * up[1] - up[0] * right[1] ]; for (let i = 0; i < 3; i++) { value[3 + i * 3] = right[i]; value[4 + i * 3] = up[i]; value[5 + i * 3] = back[i]; } } else { for (let i = 0; i < 9; i++) value[i + 3] = chunk.FloatLE(); } } const vecX = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); const vecY = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); const vecZ = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); for (let i = 0; i < instCount; i++) { values[i][0] = vecX[i]; values[i][1] = vecY[i]; values[i][2] = vecZ[i]; } break; } case 'Enum': chunk.RBXInterleavedUint32(instCount, values); break; case 'Instance': { const refIds = chunk.RBXInterleavedInt32(instCount, parser.arrays[parser.arrayIndex++]); let refId = 0; for (let i = 0; i < instCount; i++) { refId += refIds[i]; values[i] = parser.instances[refId]; } break; } case 'NumberSequence': { for (let i = 0; i < instCount; i++) { const seqLength = chunk.UInt32LE(); const seq: any[] = values[i] = []; for (let j = 0; j < seqLength; j++) { seq.push({ Time: chunk.FloatLE(), Value: chunk.FloatLE(), Envelope: chunk.FloatLE() }); } } break; } case 'ColorSequence': { for (let i = 0; i < instCount; i++) { const seqLength = chunk.UInt32LE(); const seq: any[] = values[i] = []; for (let j = 0; j < seqLength; j++) { seq.push({ Time: chunk.FloatLE(), Color: [chunk.FloatLE(), chunk.FloatLE(), chunk.FloatLE()], EnvelopeMaybe: chunk.FloatLE() }); } } break; } case 'NumberRange': { for (let i = 0; i < instCount; i++) { values[i] = { Min: chunk.FloatLE(), Max: chunk.FloatLE() }; } break; } case 'Rect2D': { const x0 = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); const y0 = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); const x1 = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); const y1 = chunk.RBXInterleavedFloat(instCount, parser.arrays[parser.arrayIndex++]); for (let i = 0; i < instCount; i++) values[i] = [x0[i], y0[i], x1[i], y1[i]]; break; } case 'PhysicalProperties': { for (let i = 0; i < instCount; i++) { const enabled = chunk.Byte() !== 0; values[i] = { CustomPhysics: enabled, Density: enabled ? chunk.FloatLE() : undefined, Friction: enabled ? chunk.FloatLE() : undefined, Elasticity: enabled ? chunk.FloatLE() : undefined, FrictionWeight: enabled ? chunk.FloatLE() : undefined, ElasticityWeight: enabled ? chunk.FloatLE() : undefined }; } break; } case 'Color3uint8': { const rgb = chunk.Array(instCount * 3); for (let i = 0; i < instCount; i++) { values[i] = [rgb[i] / 255, rgb[i + instCount] / 255, rgb[i + instCount * 2] / 255]; } resultTypeName = 'Color3'; break; } case 'int64': { const bytes = chunk.Array(instCount * 8); for (let i = 0; i < instCount; i++) { let byte0 = bytes[i + instCount * 0] * (256 ** 3) + bytes[i + instCount * 1] * (256 ** 2) + bytes[i + instCount * 2] * 256 + bytes[i + instCount * 3]; let byte1 = bytes[i + instCount * 4] * (256 ** 3) + bytes[i + instCount * 5] * (256 ** 2) + bytes[i + instCount * 6] * 256 + bytes[i + instCount * 7]; const neg = byte1 % 2; byte1 = (byte0 % 2) * (2 ** 31) + (byte1 + neg) / 2; byte0 = Math.floor(byte0 / 2); if (byte0 < 2097152) { const value = byte0 * (256 ** 4) + byte1; values[i] = neg ? -value : value; } else { // Slow path to convert arbitrary precision integer into decimal string let result = ''; while (byte1 || byte0) { const cur0 = byte0; const res0 = cur0 % 10; byte0 = (cur0 - res0) / 10; const cur1 = byte1 + res0 * (256 ** 4); const res1 = cur1 % 10; byte1 = (cur1 - res1) / 10; result = res1 + result; } values[i] = (neg ? '-' : '') + (result || '0'); } } break; } case 'SharedString': { const indices = chunk.RBXInterleavedUint32(instCount, parser.arrays[parser.arrayIndex++]); for (let i = 0; i < instCount; i++) { values[i] = parser.sharedStrings[indices[i]].value; } break; } case 'UniqueId': { const bytes = chunk.Array(instCount * 16); for (let i = 0; i < instCount; i++) { let result = ''; for (let j = 0; j < 16; j++) { const byte = bytes[j * instCount + i]; result += ('0' + byte.toString(16)).slice(-2); } values[i] = result; } break; } default: { // Unknown or unimplemented data type if (!typeName) { // Unknown datatype identifier } else { // Known but unsupported type (e.g. Quaternion, Vector3int16) } break; } } // If optional, consume the optional mask and skip any unset values if (isOptional) { if (this.DataTypes[chunk.Byte()] !== 'bool' || chunk.GetRemaining() !== instCount) { // Invalid optional mask, treat as not optional isOptional = false; for (let i = 0; i < instCount; i++) values[i] = ''; } } for (let index = 0; index < instCount; index++) { if (isOptional) { if (chunk.Byte() === 0) continue; } // NOTE: In the original implementation, the AttributesSerialize property // is decoded via a WASM-powered parser. That functionality has been // removed in this project, so AttributesSerialize is stored as a raw // binary Buffer on the instance. Consumers may implement their own // attribute parser if required. group.Objects[index].setProperty(prop, values[index], resultTypeName); } }, /** * Parse the PRNT chunk which establishes the parent/child relationships * between previously declared instances. A parentId of -1 indicates * membership at the root level. */ parsePRNT(parser: ParserResult, chunk: ByteReader): void { chunk.Byte(); // always zero in current format const parentCount = chunk.UInt32LE(); const childIds = chunk.RBXInterleavedInt32(parentCount, parser.arrays[parser.arrayIndex++]); const parentIds = chunk.RBXInterleavedInt32(parentCount, parser.arrays[parser.arrayIndex++]); let childId = 0; let parentId = 0; for (let i = 0; i < parentCount; i++) { childId += childIds[i]; parentId += parentIds[i]; const child = parser.instances[childId]; if (parentId === -1) { parser.result.push(child); } else { child.setProperty('Parent', parser.instances[parentId], 'Instance'); } } } }; export default BinaryParser;