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.
240 lines (222 loc) • 7.88 kB
text/typescript
import assert from 'assert';
/**
* The Instance class is a lightweight representation of an object in a
* Roblox place or model. Each instance has a class name, a name, an
* optional parent, a list of children and a set of arbitrary properties.
* Instances can form a tree and provide utilities for traversing that tree.
*/
export class Instance {
/**
* Child instances belonging to this instance.
*/
public readonly Children: Instance[] = [];
/**
* A dictionary of properties keyed by their name. Each entry holds the
* type of the property and its value. Types mirror the DataTypes found
* in Roblox binary files (e.g. string, bool, int, float, Instance, etc.).
*/
public readonly Properties: { [key: string]: { type: string; value: any } } = {};
/**
* A dictionary of attribute values attached to this instance. Attributes
* are exposed as plain JavaScript values. This library does not
* currently attempt to decode the attribute serialization format present
* in .rbxm/.rbxl files; consumers may populate this dictionary manually.
*/
public Attributes: { [key: string]: any } = {};
/**
* Create a new Instance of the given class. The Name property
* defaults to the literal string "Instance" and the Parent property
* defaults to undefined. Use setProperty() to adjust these values.
*/
constructor(className: string) {
assert(typeof className === 'string', 'className must be a string');
// initialise core properties
this.setProperty('ClassName', className, 'string');
this.setProperty('Name', 'Instance', 'string');
this.setProperty('Parent', undefined, 'Instance');
}
/**
* Convenience factory to create a new instance.
*/
static new(className: string): Instance {
return new Instance(className);
}
/**
* Getter for the ClassName property.
*/
get ClassName(): string {
return this.Properties['ClassName'].value;
}
/**
* Getter for the Name property.
*/
get Name(): string {
return this.Properties['Name'].value;
}
/**
* Getter for the Parent property.
*/
get Parent(): Instance | InstanceRoot | undefined {
return this.Properties['Parent'].value;
}
/**
* Set a property on this instance. If the property already exists its
* type must match the previously specified type. Certain properties
* receive special treatment: when setting the Parent property the
* Children arrays of the old and new parent are updated accordingly.
*/
setProperty(name: string, value: any, type?: string): void {
// Attempt to infer a type if none is provided
if (!type) {
if (typeof value === 'boolean') type = 'bool';
else if (value instanceof Instance || value instanceof InstanceRoot) type = 'Instance';
else if (typeof value === 'string') type = 'string';
else if (typeof value === 'number') type = 'number';
else throw new TypeError('You need to specify property type since it cannot be inferred');
}
let descriptor = this.Properties[name];
if (descriptor) {
assert(descriptor.type === type, `Property type mismatch: ${type} !== ${descriptor.type}`);
// handle removal from previous parent
if (name === 'Parent' && descriptor.value instanceof Instance) {
const idx = descriptor.value.Children.indexOf(this);
if (idx !== -1) descriptor.value.Children.splice(idx, 1);
}
descriptor.value = value;
} else {
descriptor = this.Properties[name] = { type, value };
}
if (name === 'Parent' && value instanceof Instance) {
value.Children.push(this);
}
// Mirror non-reserved properties onto the instance for convenience
if (name !== 'Children' && name !== 'Properties' && !(name in Object.getPrototypeOf(this))) {
(this as any)[name] = value;
}
}
/**
* Retrieve the value of a property by name. If caseInsensitive is
* true then a case insensitive lookup will be performed.
*/
getProperty(name: string, caseInsensitive = false): any {
const descriptor = this.Properties[name] || (caseInsensitive && Object.entries(this.Properties).find(([k]) => k.toLowerCase() === name.toLowerCase())?.[1]);
return descriptor ? descriptor.value : undefined;
}
/**
* Return this instance's children.
*/
getChildren(): Instance[] {
return this.Children;
}
/**
* Recursively walk the instance tree and return all descendants.
*/
getDescendants(): Instance[] {
const list: Instance[] = [];
const stack = [...this.Children];
while (stack.length > 0) {
const inst = stack.shift()!;
list.push(inst);
stack.push(...inst.Children);
}
return list;
}
/**
* Recursively find the first child of this instance with a given name.
*/
findFirstChild(name: string, recursive = false): Instance | undefined {
for (const child of this.Children) {
if (child.getProperty('Name') === name) return child;
}
if (recursive) {
for (const child of this.Children) {
const found = child.findFirstChild(name, true);
if (found) return found;
}
}
return undefined;
}
/**
* Recursively find the first child of this instance with a given class name.
*/
findFirstChildOfClass(className: string, recursive = false): Instance | undefined {
for (const child of this.Children) {
if (child.getProperty('ClassName') === className) return child;
}
if (recursive) {
for (const child of this.Children) {
const found = child.findFirstChildOfClass(className, true);
if (found) return found;
}
}
return undefined;
}
/**
* Compute the dotted path of this instance by traversing parents up to the root.
*/
getFullName(): string {
if (this.Parent && !(this.Parent instanceof InstanceRoot)) {
return `${(this.Parent as Instance).getFullName()}.${this.Name}`;
} else {
return `${this.Name}`;
}
}
/**
* Test whether this instance defines a property with the given name.
*/
hasProperty(name: string, caseInsensitive = false): boolean {
if (name in this.Properties) return true;
if (caseInsensitive) {
return !!Object.keys(this.Properties).find((k) => k.toLowerCase() === name.toLowerCase());
}
return false;
}
/**
* Retrieve the value of an attribute by name.
*/
getAttribute(name: string): any {
return this.Attributes[name];
}
}
/**
* The InstanceRoot class is simply an array of Instance objects with a few
* helper methods. It acts as the top-level container returned by the
* parser. The root itself is not an actual Roblox instance; it is a
* convenience wrapper that exposes findFirstChild and findFirstChildOfClass
* on the root container.
*/
export class InstanceRoot extends Array<Instance> {
getChildren(): Instance[] { return this as Instance[]; }
getDescendants(): Instance[] {
const desc: Instance[] = [...this];
for (const inst of this) {
desc.push(...inst.getDescendants());
}
return desc;
}
findFirstChild(name: string, recursive = false): Instance | undefined {
for (const child of this) {
if (child.getProperty('Name') === name) return child;
}
if (recursive) {
for (const child of this) {
const found = child.findFirstChild(name, true);
if (found) return found;
}
}
return undefined;
}
findFirstChildOfClass(className: string, recursive = false): Instance | undefined {
for (const child of this) {
if (child.getProperty('ClassName') === className) return child;
}
if (recursive) {
for (const child of this) {
const found = child.findFirstChildOfClass(className, true);
if (found) return found;
}
}
return undefined;
}
}
export default Instance;