@kleppe/litematic-reader
Version:
Example: ```ts import { readFile } from "fs/promises"; import { Litematic } from '@kleppe/litematic-reader'
408 lines (379 loc) • 13.4 kB
text/typescript
/**
* An NBT parser/serializer which gives results as plain JS objects
* and avoids copying large arrays, instead giving DataViews of the
* original data.
*
* In order to provide type safety and distinguish types that have the
* same JS representation, you can provide a shape object describing
* the NBT data.
*/
import { DataViewReader, DataViewWriter } from "./data_view_stream";
import { DefaultEndianDataView } from "./default_endian_data_view";
import { checkExhaustive } from "./util";
/** The possible Nbt tags. */
const enum Tags {
End = 0,
Byte = 1,
Short = 2,
Int = 3,
Long = 4,
Float = 5,
Double = 6,
ByteArray = 7,
String = 8,
List = 9,
Compound = 10,
IntArray = 11,
LongArray = 12,
}
interface NbtCompoundShape {
readonly [key: string]: NbtShape;
}
type NbtListShape = readonly [NbtShape];
interface NbtCompoundMapShape {
readonly '*': NbtShape;
}
/**
* A description of the shape of an Nbt file.
* Used for parsing validation and as a guide for serialization.
*/
export type NbtShape =
'end' | 'byte' | 'short' | 'int' | 'long' | 'float' | 'double' | 'byteArray' | 'intArray' | 'longArray' | 'string'
// Allow anything -- for reading arbitrary nbt data, can't be written back.
| '*'
// A List containing elements of the containing type.
| NbtListShape
// A compound with arbitrary keys of the given type.
| NbtCompoundMapShape
// A compound with the given keys with the given types.
| NbtCompoundShape;
/** Maps simple shape names to their JS representation */
export interface SimpleShapeToInterface {
end: never;
byte: number;
short: number;
int: number;
long: bigint;
float: number;
double: number;
// Use a DataView for the int arrays so that
// we can just provide a view into the decompressed binary,
// so that we don't need to make a copy. We can't use
// a TypedArray because the data is big endian, and TypedArrays
// are platform endian.
byteArray: DataView;
intArray: DataView;
longArray: DataView;
string: string;
'*': unknown;
}
/**
* Given a shape T, gives the type of the parsed
* result of an Nbt value of that shape.
*/
export type ShapeToInterface<T> =
T extends keyof SimpleShapeToInterface ? SimpleShapeToInterface[T] :
T extends readonly [infer V] ? Array<ShapeToInterface<V>> :
T extends ReadonlyArray<infer V> ? Array<ShapeToInterface<V>> :
T extends { readonly '*': infer V } ? { [key: string]: ShapeToInterface<V> } :
{ [K in keyof T]: ShapeToInterface<T[K]> };
/**
* Given an object or array shape and a prop, gives the shape of the prop.
*/
function shapeGet(shape: { '*': NbtShape } | { [key: string]: NbtShape } | '*' | [NbtShape], prop: string | 0): NbtShape {
if (shape === '*') {
return '*';
} else if (Array.isArray(shape)) {
return shape[0];
} else if (shape['*']) {
return shape['*'];
} else {
return (shape as any)[prop] ?? '*';
}
}
function assert(a: boolean, message: string, path?: string): asserts a is true {
if (!a) {
throw new Error(`${message}\n${path}`);
}
}
/**
* A Nbt parser and serializer for a given shape.
* Keeping the shape separate from the data allows
* the parse result to be a plain JS object without
* extra metadata on the object types.
* For example, byte and short are both numbers when read,
* but must be written differently.
*/
export class Nbt<S extends { [key: string]: NbtShape } | '*'> {
constructor(private shape: S) { }
/**
* Parses the data in the Uint8Array into the JS object
* given by the shape of this Nbt parser.
*/
parse(data: Uint8Array, littleEndian = false): ShapeToInterface<S> {
const asView = new DefaultEndianDataView(littleEndian, data.buffer, data.byteOffset, data.byteLength);
const reader = new DataViewReader(asView);
return this.parseRoot(reader);
}
/**
* Serializes the JS object into a Uint8Array given
* by the shape of this Nbt serializer.
*/
serialize(value: ShapeToInterface<S>): Uint8Array {
const dataView = this.serializeRoot(value, this.shape);
return new Uint8Array(dataView.buffer, dataView.byteOffset, dataView.byteLength);
}
private parseRoot(data: DataViewReader): ShapeToInterface<S> {
assert(data.byte() === Tags.Compound, 'Expected a compound at root');
data.string();
return this.parsePayload(data, Tags.Compound, this.shape, 'root') as ShapeToInterface<S>;
}
/**
* Parses the payload value at the current position of the DataViewReader.
*
* @param data The data view reader
* @param tagType The tag of the payload to parse
* @param shape The shape of the data
* @param path The path so far, used for error messages
* @returns The payload value, based
*/
private parsePayload(data: DataViewReader, tagType: Tags, shape: NbtShape, path: string): unknown {
switch (tagType) {
case Tags.End:
return undefined;
case Tags.Byte:
this.assertSimpleShape(shape, 'byte', path);
return data.byte();
case Tags.Short:
this.assertSimpleShape(shape, 'short', path);
return data.short();
case Tags.Int:
this.assertSimpleShape(shape, 'int', path);
return data.int();
case Tags.Long:
this.assertSimpleShape(shape, 'long', path);
return data.long();
case Tags.Float:
this.assertSimpleShape(shape, 'float', path);
return data.float();
case Tags.Double:
this.assertSimpleShape(shape, 'double', path);
return data.double();
case Tags.String:
this.assertSimpleShape(shape, 'string', path);
return data.string();
case Tags.ByteArray:
this.assertSimpleShape(shape, 'byteArray', path);
return data.array(1);
case Tags.IntArray:
this.assertSimpleShape(shape, 'intArray', path);
return data.array(4);
case Tags.LongArray:
this.assertSimpleShape(shape, 'longArray', path);
return data.array(8);
case Tags.Compound: {
this.assertCompoundShape(shape, path);
const result: { [key: string]: unknown } = {};
let tagType: Tags;
while ((tagType = data.byte()) !== Tags.End) {
const name = data.string();
if (shape === '*' || shape['*'] || name in shape) {
result[name] = this.parsePayload(data, tagType, shapeGet(shape, name), `${path}.${name}`);
} else {
this.skipPayload(data, tagType);
}
}
return result;
}
case Tags.List: {
this.assertListShape(shape, path);
const itemType = data.byte() as Tags;
const nItems = data.int();
const result: unknown[] = [];
for (let i = 0; i < nItems; i++) {
result.push(this.parsePayload(data, itemType, shapeGet(shape, 0), `${path}[${i}]`));
}
return result;
}
default:
checkExhaustive(tagType);
}
}
/**
* Skips the payload of the given type at the current position of the
* DataViewReader. Simply advances with minimal processing of the data,
* so that we can quickly skip over parts that we don't understand.
* @param data The data view reader
* @param tagType The tag of the payload to skip
*/
private skipPayload(data: DataViewReader, tagType: Tags): void {
switch (tagType) {
case Tags.End:
return undefined;
case Tags.Byte:
data.skip(1);
return;
case Tags.Short:
data.skip(2);
return;
case Tags.Int:
data.skip(4);
return;
case Tags.Long:
data.skip(8);
return;
case Tags.Float:
data.skip(4);
return;
case Tags.Double:
data.skip(8);
return;
case Tags.String:
data.skipString();
return;
case Tags.ByteArray:
data.skipArray(1);
return;
case Tags.IntArray:
data.skipArray(4);
return;
case Tags.LongArray:
data.skipArray(8);
return;
case Tags.Compound: {
let tagType: Tags;
while ((tagType = data.byte()) !== Tags.End) {
data.skipString();
this.skipPayload(data, tagType);
}
return;
}
case Tags.List: {
const itemType = data.byte() as Tags;
const nItems = data.int();
for (let i = 0; i < nItems; i++) {
this.skipPayload(data, itemType);
}
return;
}
default:
checkExhaustive(tagType);
}
}
private assertSimpleShape<T extends NbtShape>(shape: NbtShape, t: T, path: string): asserts shape is T | '*' {
assert(shape === '*' || shape === t, `Found a ${t}, but expected ${shape}`, path);
}
private assertCompoundShape(shape: NbtShape, path: string): asserts shape is { [key: string]: NbtShape } | '*' {
assert(shape === '*' || !Array.isArray(shape) && typeof shape === 'object', `Found ${shape}, but expected a compound.`, path);
}
private assertListShape(shape: NbtShape, path: string): asserts shape is [NbtShape] | '*' {
assert(shape === '*' || Array.isArray(shape), `Found ${shape}, but expected a list`, path);
}
private serializeRoot(value: unknown, shape: NbtShape): DataView {
const writer = new DataViewWriter();
this.assertObject(value, 'root');
writer.byte(Tags.Compound);
writer.string('');
this.serializePayload(writer, value, shape, 'root');
return writer.final();
}
private serializePayload(writer: DataViewWriter, value: unknown, shape: NbtShape, path: string) {
switch (shape) {
case 'byte':
return writer.byte(this.assertNumber(value, path));
case 'short':
return writer.short(this.assertNumber(value, path));
case 'int':
return writer.int(this.assertNumber(value, path));
case 'long':
return writer.long(this.assertBigInt(value, path));
case 'float':
return writer.float(this.assertNumber(value, path));
case 'double':
return writer.double(this.assertNumber(value, path));
case 'byteArray':
return writer.array(this.assertDataView(value, path), 1);
case 'intArray':
return writer.array(this.assertDataView(value, path), 4);
case 'longArray':
return writer.array(this.assertDataView(value, path), 8);
case 'string':
return writer.string(this.assertString(value, path));
case 'end':
// nothing to write
return;
case '*':
assert(false, `Can't write value of unknown type: ${value}`, path);
return;
default:
if (Array.isArray(shape)) {
const [itemShape] = shape;
const array = this.assertArray(value, path);
writer.byte(this.getTagTypeForShape(itemShape));
writer.int(array.length);
let i = 0;
for (const item of array) {
this.serializePayload(writer, item, itemShape, `${path}[${i++}]`);
}
return;
} else if (typeof shape === 'object') {
this.assertCompoundShape(shape, path);
const obj = this.assertObject(value, path);
const keys = Object.keys(obj).sort();
for (const key of keys) {
const itemShape = shapeGet(shape, key);
writer.byte(this.getTagTypeForShape(itemShape));
writer.string(key);
this.serializePayload(writer, obj[key], itemShape, `${path}.${key}`);
}
writer.byte(Tags.End);
return;
}
checkExhaustive(shape);
}
}
private assertNumber(n: unknown, path: string): number {
assert(typeof n === 'number', `Expected a number, got ${n}`, path);
return n as number;
}
private assertBigInt(n: unknown, path: string): bigint {
assert(typeof n === 'bigint', `Expected a bigint, got ${n}`, path);
return n as bigint;
}
private assertDataView(n: unknown, path: string): DataView {
assert(n instanceof DataView, `Expected a DataView, got ${n}`, path);
return n as DataView;
}
private assertString(n: unknown, path: string): string {
assert(typeof n === 'string', `Expected a string, got ${n}`, path);
return n as string;
}
private assertArray(n: unknown, path: string): unknown[] {
assert(Array.isArray(n), `Expected an array, got ${n}`, path);
return n as unknown[];
}
private assertObject(n: unknown, path: string): { [key: string]: unknown } {
assert(typeof n === 'object', `Expected an object, got ${n}`, path);
return n as { [key: string]: unknown };
}
private getTagTypeForShape(shape: NbtShape): Tags {
switch (shape) {
case 'byte': return Tags.Byte;
case 'short': return Tags.Short;
case 'int': return Tags.Int;
case 'long': return Tags.Long;
case 'float': return Tags.Float;
case 'double': return Tags.Double;
case 'byteArray': return Tags.ByteArray;
case 'intArray': return Tags.IntArray;
case 'longArray': return Tags.LongArray;
case 'string': return Tags.String;
case 'end': return Tags.End;
case '*': return Tags.End; // don't know what to write here.
default:
if (Array.isArray(shape)) { return Tags.List; }
else if (typeof shape === 'object') { return Tags.Compound; }
checkExhaustive(shape);
}
}
}