import assert from 'assert'; /* * ByteReader is a utility class adapted from the `rbx-reader` project. It * extends Uint8Array to provide a higher level API for reading primitive * values (integers, floats, strings) from an ArrayBuffer. It also * implements a handful of helper methods used by Roblox's binary model * format such as interleaved integer and float decoding. * * This version has been lightly modified for use in the modern-rbx-parser * package. It preserves the original behaviour but removes functionality * related to attribute parsing, which in this package is handled elsewhere * or omitted entirely. */ function bufferToString(buffer: ArrayBuffer | Uint8Array): string { if (buffer instanceof ArrayBuffer) { buffer = new Uint8Array(buffer); } const result: string[] = []; if (buffer instanceof Uint8Array) { for (let i = 0; i < buffer.length; i += 0x8000) { result.push(String.fromCharCode.apply(undefined, buffer.subarray(i, i + 0x8000) as any)); } } return result.join(''); } class ByteReader extends Uint8Array { index: number; chunkBuffer: Uint8Array = new Uint8Array(); constructor(...args: any[]) { if (args[0] instanceof Uint8Array) { // If an Uint8Array is passed in, create a view on its underlying buffer args[1] = args[0].byteOffset; args[2] = args[0].byteLength; args[0] = args[0].buffer; } assert(args[0] instanceof ArrayBuffer, 'buffer is not an ArrayBuffer'); super(args[0], args[1], args[2]); this.index = 0; } /** * Advance the reader by `n` bytes. */ Jump(n: number): void { this.index += n; } /** * Get the current reader index. */ GetIndex(): number { return this.index; } /** * Set the current reader index. */ SetIndex(n: number): void { this.index = n; } /** * Get the remaining number of bytes in the buffer. */ GetRemaining(): number { return this.length - this.index; } /** * Get the length of the buffer. */ GetLength(): number { return this.length; } /** * Read an arbitrary number of bytes and return them as a new Uint8Array. This * advances the read index by the number of bytes requested. */ Array(n: number): Uint8Array { const result = new Uint8Array(this.buffer, this.index, n); this.index += n; return result; } /** * Compare a sequence of bytes against the bytes at the current index. If * they match, advance the index past the compared bytes and return true. * Otherwise return false. */ Match(arr: any): boolean { const begin = this.index; this.index += arr.length; for (let i = 0; i < arr.length; i++) { if (arr[i] !== this[begin + i]) { return false; } } return true; } /** * Read a single byte and return it as a number. */ Byte(): number { return this[this.index++]; } UInt8(): number { return this[this.index++]; } UInt16LE(): number { return this[this.index++] + (this[this.index++] * 256); } UInt16BE(): number { return (this[this.index++] * 256) + this[this.index++]; } UInt32LE(): number { return (this[this.index++] + (this[this.index++] * 256) + (this[this.index++] * 65536) + (this[this.index++] * 16777216)) >>> 0; } UInt32BE(): number { return ((this[this.index++] * 16777216) + (this[this.index++] * 65536) + (this[this.index++] * 256) + this[this.index++]) >>> 0; } Int8(): number { return (this[this.index++]) << 24 >> 24; } Int16LE(): number { return (this[this.index++] + (this[this.index++] * 256)) << 16 >> 16; } Int16BE(): number { return ((this[this.index++] * 256) + this[this.index++]) << 16 >> 16; } Int32LE(): number { const val = (this[this.index++] + (this[this.index++] * 256) + (this[this.index++] * 65536) + (this[this.index++] * 16777216)); return val >> 0; } Int32BE(): number { const val = ((this[this.index++] * 16777216) + (this[this.index++] * 65536) + (this[this.index++] * 256) + this[this.index++]); return val >> 0; } /** * Parse a 32‑bit IEEE754 float stored in Roblox's custom floating point * representation. See the original source for more details. */ static ParseRBXFloat(long: number): number { const exp = long >>> 24; if (exp === 0) return 0; const mant = (long >>> 1) & 0x7FFFFF; const sign = long & 1; const result = 2 ** (exp - 127) * (1 + mant / 0x7FFFFF); return sign ? -result : result; } static ParseFloat(long: number): number { const exp = (long >>> 23) & 255; if (exp === 0) return 0; const frac = long & 0x7FFFFF; const value = 2 ** (exp - 127) * (1 + frac / 0x7FFFFF); return long > 0x7FFFFFFF ? -value : value; } static ParseDouble(long0: number, long1: number): number { const exp = (long0 >>> 20) & 0x7FF; const frac = (((long0 & 0xFFFFF) * 0x100000000) + long1) / 0x10000000000000; const neg = long0 & 0x80000000; if (exp === 0) { if (frac === 0) return -0; const double = 2 ** (exp - 1023) * frac; return neg ? -double : double; } else if (exp === 2047) { return frac === 0 ? Infinity : NaN; } const double = 2 ** (exp - 1023) * (1 + frac); return neg ? -double : double; } FloatLE(): number { return ByteReader.ParseFloat(this.UInt32LE()); } FloatBE(): number { return ByteReader.ParseFloat(this.UInt32BE()); } DoubleLE(): number { const lo = this.UInt32LE(); const hi = this.UInt32LE(); return ByteReader.ParseDouble(hi, lo); } DoubleBE(): number { const hi = this.UInt32BE(); const lo = this.UInt32BE(); return ByteReader.ParseDouble(hi, lo); } /** * Read an ASCII/UTF‑8 string of length `n` from the buffer. */ String(n: number): string { return bufferToString(this.Array(n)); } /** * Roblox binary format stores compressed chunks using LZ4. This method * reads the length headers, decompresses the following bytes into the * provided buffer and returns a Uint8Array view. For chunks with a * compression length of 0, it simply returns the raw data. */ LZ4(buffer: any): Uint8Array { const comLength = this.UInt32LE(); const decomLength = this.UInt32LE(); this.Jump(4); // skip unknown 4 bytes if (comLength === 0) { // Roblox currently does not use this path, but keep as reference assert(this.GetRemaining() >= decomLength, '[ByteReader.LZ4] unexpected eof'); return this.Array(decomLength); } assert(this.GetRemaining() >= comLength, '[ByteReader.LZ4] unexpected eof'); if (!buffer || buffer.length < decomLength) { buffer = new Uint8Array(decomLength); } const start = this.index; const end = start + comLength; const data: Uint8Array = buffer.length === decomLength ? buffer : buffer.subarray(0, decomLength); let destIndex = 0; while (this.index < end) { const token = this.Byte(); let litLen = token >>> 4; if (litLen === 0xF) { let lenByte; do { lenByte = this.Byte(); litLen += lenByte; } while (lenByte === 0xFF); } assert(this.index + litLen <= end, '[ByteReader.LZ4] unexpected eof'); // copy literals for (let i = 0; i < litLen; i++) { data[destIndex++] = this.Byte(); } if (this.index >= end) break; // read match length const matchOffset = this.UInt16LE(); let matchLen = (token & 0x0F); if (matchLen === 0xF) { let lenByte; do { lenByte = this.Byte(); matchLen += lenByte; } while (lenByte === 0xFF); } matchLen += 4; assert(matchOffset > 0 && matchOffset <= destIndex, '[ByteReader.LZ4] invalid match offset'); // copy match for (let i = 0; i < matchLen; i++) { data[destIndex] = data[destIndex - matchOffset]; destIndex++; } } return data; } /** * Roblox interleaves integers and floats in a specific pattern. These * helpers decode those interleaved values into a destination array. See * https://github.com/RobloxAPI/rbx-binformat for details. */ RBXInterleavedUint32(count: number, result: any[]): any[] { for (let i = 0; i < count; i++) { result[i] = (this[this.index + i] << 24) + (this[this.index + i + count] << 16) + (this[this.index + i + count * 2] << 8) + this[this.index + i + count * 3]; } this.Jump(count * 4); return result; } RBXInterleavedInt32(count: number, result: any[]): any[] { this.RBXInterleavedUint32(count, result); for (let i = 0; i < count; i++) { result[i] = (result[i] % 2 === 1 ? -(result[i] + 1) / 2 : result[i] / 2); } return result; } RBXInterleavedFloat(count: number, result: any[]): any[] { this.RBXInterleavedUint32(count, result); for (let i = 0; i < count; i++) { result[i] = ByteReader.ParseRBXFloat(result[i]); } return result; } /** * Peek methods read data without advancing the current index. They are * generated automatically based off of the basic read functions. */ } // Dynamically create peek methods for ByteReader (function () { const peekMethods = ['Byte', 'UInt8', 'UInt16LE', 'UInt16BE', 'UInt32LE', 'UInt32BE', 'FloatLE', 'FloatBE', 'DoubleLE', 'DoubleBE', 'String']; peekMethods.forEach((key: string) => { const fn = (ByteReader.prototype as any)[key]; (ByteReader.prototype as any)[`Peek${key}`] = function (...args: any[]): any { const index = this.GetIndex(); const result = fn.apply(this, args); this.SetIndex(index); return result; }; }); })(); export default ByteReader;