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.
214 lines (209 loc) • 9.19 kB
text/typescript
import { Instance, InstanceRoot } from './Instance';
/**
* Escape a string for inclusion in XML. Only the minimal set of characters
* are escaped; this is sufficient for Roblox XML files.
*/
function escapeXml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Serialize an Instance tree to a minimal Roblox XML (.rbxmx/.rbxlx) representation.
* This function produces an XML string that can be saved as a .rbxmx/.rbxlx file.
*
* Note: Only primitive property types are serialized (string/number/boolean).
* Other types (Vector3, CFrame, etc.) are converted to their string
* representation via `toString()`. SharedString and Attributes are ignored.
*
* @param root The root InstanceRoot returned by parseBuffer().
*/
/**
* Convert the provided Instance tree into a Roblox XML representation. The
* resulting XML is intended to be saved as a .rbxmx or .rbxlx file. This
* serializer attempts to follow the structure produced by Roblox Studio as
* closely as practical. Each Instance becomes an <Item> element with a
* class and referent attribute; properties are serialized using the
* appropriate tag based on their type. Unknown property types fall back
* to string representation. Note: this serializer does not include
* attributes or custom shared string tables.
*
* @param root The root container returned by parseBuffer().
*/
export function toXmlString(root: InstanceRoot): string {
// Assign referent identifiers to every instance. Roblox XML uses
// "R<num>" strings to refer to instances in property values. The order
// here follows the order returned by getDescendants() so parents are
// guaranteed to have a lower referent than their children. We skip the
// InstanceRoot itself and only refer to actual instances.
const referentMap = new Map<Instance, string>();
let nextRef = 0;
function assignRefs(inst: Instance): void {
nextRef++;
referentMap.set(inst, 'R' + nextRef);
inst.Children.forEach(assignRefs);
}
root.getChildren().forEach(assignRefs);
// Serialize an individual property based on its type. Returns an XML
// fragment without indentation. Unknown or unsupported types fall back
// to string serialization.
function serializeProperty(name: string, type: string, value: any): string | null {
if (value === undefined || value === null) return null;
const propName = escapeXml(name);
switch (type) {
case 'string': {
return `<string name="${propName}">${escapeXml(String(value))}</string>`;
}
case 'bool': {
return `<bool name="${propName}">${value ? 'true' : 'false'}</bool>`;
}
case 'int': {
return `<int name="${propName}">${value}</int>`;
}
case 'float':
case 'double': {
return `<float name="${propName}">${value}</float>`;
}
case 'UDim': {
// Value is [scale, offset]
const [scale, offset] = value as [number, number];
// Roblox uses Scale, Offset as child tags on UDim
return `<UDim name="${propName}"><Scale>${scale}</Scale><Offset>${offset}</Offset></UDim>`;
}
case 'UDim2': {
// Value is [[sx, ox], [sy, oy]]
const [[sx, ox], [sy, oy]] = value as [[number, number], [number, number]];
return `<UDim2 name="${propName}"><X><Scale>${sx}</Scale><Offset>${ox}</Offset></X><Y><Scale>${sy}</Scale><Offset>${oy}</Offset></Y></UDim2>`;
}
case 'Vector2': {
const [x, y] = value as [number, number];
return `<Vector2 name="${propName}">${x},${y}</Vector2>`;
}
case 'Vector3': {
const [x, y, z] = value as [number, number, number];
return `<Vector3 name="${propName}">${x},${y},${z}</Vector3>`;
}
case 'Color3': {
const [r, g, b] = value as [number, number, number];
return `<Color3 name="${propName}">${r},${g},${b}</Color3>`;
}
case 'Color3uint8': {
const [r, g, b] = value as [number, number, number];
return `<Color3 name="${propName}">${r},${g},${b}</Color3>`;
}
case 'BrickColor': {
return `<BrickColor name="${propName}">${value}</BrickColor>`;
}
case 'CFrame': {
// Value is an array of 12 numbers [x,y,z,r00,r01,r02,r10,r11,r12,r20,r21,r22]
const arr = value as number[];
return `<CoordinateFrame name="${propName}">${arr.join(',')}</CoordinateFrame>`;
}
case 'Enum': {
// Without reflection metadata we cannot convert to token names. Output as integer.
return `<int name="${propName}">${value}</int>`;
}
case 'Instance': {
// Value is another Instance; replace with its referent if present
const ref = referentMap.get(value as Instance);
if (ref) return `<Ref name="${propName}">${ref}</Ref>`;
return null;
}
case 'NumberSequence': {
// Value is array of keys with Time, Value, Envelope
const seq = value as Array<{ Time: number; Value: number; Envelope: number }>;
let xml = `<NumberSequence name="${propName}">`;
for (const key of seq) {
xml += `<NumberSequenceKey time="${key.Time}" value="${key.Value}" envelope="${key.Envelope}" />`;
}
xml += `</NumberSequence>`;
return xml;
}
case 'ColorSequence': {
const seq = value as Array<{ Time: number; Color: [number, number, number]; EnvelopeMaybe: number }>;
let xml = `<ColorSequence name="${propName}">`;
for (const key of seq) {
const [r, g, b] = key.Color;
const env = key.EnvelopeMaybe;
xml += `<ColorSequenceKey time="${key.Time}" value="${r},${g},${b}" envelope="${env}" />`;
}
xml += `</ColorSequence>`;
return xml;
}
case 'NumberRange': {
const { Min, Max } = value as { Min: number; Max: number };
return `<NumberRange name="${propName}">${Min},${Max}</NumberRange>`;
}
case 'Rect2D': {
const [x0, y0, x1, y1] = value as [number, number, number, number];
return `<Rect2D name="${propName}">${x0},${y0},${x1},${y1}</Rect2D>`;
}
case 'PhysicalProperties': {
const phys = value as any;
let xml = `<PhysicalProperties name="${propName}">`;
xml += `<CustomPhysics>${phys.CustomPhysics ? 'true' : 'false'}</CustomPhysics>`;
if (phys.CustomPhysics) {
xml += `<Density>${phys.Density}</Density><Friction>${phys.Friction}</Friction><Elasticity>${phys.Elasticity}</Elasticity>`;
xml += `<FrictionWeight>${phys.FrictionWeight}</FrictionWeight><ElasticityWeight>${phys.ElasticityWeight}</ElasticityWeight>`;
}
xml += `</PhysicalProperties>`;
return xml;
}
case 'int64': {
return `<int64 name="${propName}">${value}</int64>`;
}
case 'SharedString': {
// SharedStrings in binary files are already decoded to strings. Encode as base64.
let b64: string;
if (typeof Buffer !== 'undefined') {
// Node environment
b64 = Buffer.from(value as string).toString('base64');
} else {
// Browser fallback using btoa; encodeURIComponent ensures UTF-8 correctness
const utf8 = encodeURIComponent(value as string).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16)));
b64 = btoa(utf8);
}
return `<SharedString name="${propName}"><Data>${b64}</Data></SharedString>`;
}
case 'UniqueId': {
return `<UniqueId name="${propName}">${value}</UniqueId>`;
}
default: {
// Fallback: stringify unknown types
return `<string name="${propName}">${escapeXml(String(value))}</string>`;
}
}
}
// Recursively serialize an instance and its children. Each Item includes
// its class name and referent. Properties are indented by two spaces.
function serializeInstance(inst: Instance, indent: string): string {
const ref = referentMap.get(inst)!;
let xml = `${indent}<Item class="${escapeXml(inst.ClassName)}" referent="${ref}">\n`;
xml += `${indent} <Properties>\n`;
// Serialize Name explicitly from getter; skip ClassName and Parent properties
const nameProp = inst.Name;
xml += `${indent} <string name="Name">${escapeXml(String(nameProp))}</string>\n`;
for (const prop of Object.keys(inst.Properties)) {
if (prop === 'ClassName' || prop === 'Name' || prop === 'Parent') continue;
const { type, value } = inst.Properties[prop];
const propXml = serializeProperty(prop, type, value);
if (propXml) xml += `${indent} ${propXml}\n`;
}
xml += `${indent} </Properties>\n`;
for (const child of inst.Children) {
xml += serializeInstance(child, indent);
}
xml += `${indent}</Item>\n`;
return xml;
}
// Build the document
let xmlDoc = '<roblox version="4">\n';
for (const child of root.getChildren()) {
xmlDoc += serializeInstance(child, '');
}
xmlDoc += '</roblox>\n';
return xmlDoc;
}