UNPKG

rbx-reader-rts

Version:

A modern TypeScript library for parsing Roblox binary files (.rbxm, .rbxl) in Node.js and the browser. Provides utilities to extract Animation and Sound asset IDs and is easily extensible.

483 lines (472 loc) 18.9 kB
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] = '<Optional>'; } } 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;