MineJSv0.5.1
binary/decoder.tstypescript
import { Vec3 } from "../physics/direction";
import { CONTINUATION_FLAG, CONTINUE_BIT, MAX_QUANTIZED_VALUE, SCALE_BITS, SEGMENT_BITS } from "./static";
import { StringSizeExceedLimit, UnexpectedValue } from "../base/error";
import { Tag } from "./static";
import { unzipSync } from "zlib";
import { minBigInt } from "../base/math";
import { inspect } from "util";

/** @hidden */
export function getTextFromTextComponent(component: any): string {
    if (component === null || component === undefined) return "null";
    switch (typeof component) {
        case "string":
            return component;
        case "object": {
            if (Array.isArray(component))
                return component.map(val => getTextFromTextComponent(val)).join();
            else if ("text" in component) {
                if ("extra" in component && Array.isArray(component["extra"]))
                    return component["text"] + component["extra"].map(val => getTextFromTextComponent(val)).join("");
                else return component["text"];
            } if ("translate" in component) {
                if ("fallback" in component) return component["fallback"];
                else return component["translate"];
            } else if ("keybind" in component)
                return component["keybind"];
            else
                throw new UnexpectedValue("value of component", "text component object", inspect(component, undefined, null));
        }
        default:
            throw new UnexpectedValue("type of component", "string or object", typeof component);
    }
}

/**
 * Binary decoding helper class
 */
export class BinaryDecoder {
    public buffer: Buffer = Buffer.alloc(0);
    public offset: number = 0;

    constructor(
        buffer: Buffer,
        needDecompress: boolean = false
    ) {
        if (!needDecompress)
            this.buffer = buffer;
        else
            this.buffer = unzipSync(buffer);
    }

    /**
     * Read shortcut
     * 
     * Read `size` bytes, use readFunc, then add `size` to internal offset
     */
    public read<T>(size: number, readFunc: (buffer: Buffer) => ((offset?: number) => T)): T {
        const val = readFunc(this.buffer).bind(this.buffer)(this.offset);
        this.offset += size;
        return val;
    }

    /**
     * Read 1 byte
     * @returns signed 1-byte integer
     */
    public readByte() { return this.read(1, (buffer) => buffer.readInt8); }
    /**
     * Read 1 byte
     * @returns unsigned 1-byte integer
     */
    public readUByte() { return this.read(1, (buffer) => buffer.readUint8); }
    /**
     * Read 2 bytes
     * @returns signed 2-byte integer
     */
    public readShort() { return this.read(2, (buffer) => buffer.readInt16BE); }
    /**
     * Read 2 bytes
     * @returns unsigned 2-byte integer
     */
    public readUShort() { return this.read(2, (buffer) => buffer.readUint16BE); }
    /**
     * Read 4 bytes
     * @returns signed 4-byte integer
     */
    public readInt() { return this.read(4, (buffer) => buffer.readInt32BE); }
    /**
     * Read 8 bytes
     * @returns signed 8-byte integer
     */
    public readLong() { return this.read(8, (buffer) => buffer.readBigInt64BE); }
    /**
     * Read 4 bytes
     * @returns signed 4-bytes decimal
     */
    public readFloat() { return this.read(4, (buffer) => buffer.readFloatBE); }
    /**
     * Read 8 bytes
     * @returns signed 8-byte decimal
     */
    public readDouble() { return this.read(8, (buffer) => buffer.readDoubleBE); }

    /**
     * Read 1 byte
     * @returns a boolean
     */
    public readBoolean() {
        const value = this.readByte();
        switch (value) {
            case 0x01: return true;
            case 0x00: return false;
            default: throw new Error(`unexpect value of boolean type: ${value}`);
        }
    }

    // Source: https://minecraft.wiki/w/Java_Edition_protocol/Packets#VarInt_and_VarLong
    /**
     * Read n bytes of integer
     * 
     * A VarInt packet would look like this:
     * 
     * `[7 bits of data][1 bit indicate if the VarInt ended here]`
     * 
     * @returns signed n-bit integer
     */
    public readVarInt() {
        let value = 0;
        let position = 0;
        let currentByte;

        while (true) {
            currentByte = this.readUByte();
            value |= (currentByte & SEGMENT_BITS) << position;

            if ((currentByte & CONTINUE_BIT) == 0) break;

            position += 7;

            if (position >= 32) throw new Error("VarInt is too big");
        }

        return value;
    }
    /**
     * Read n bytes of integer
     * 
     * Similar to VarInt, A VarLong packet would look like this:
     * 
     * `[7 bits of data][1 bit indicate if the VarLong ended here]`
     * 
     * @returns signed n-bit integer
     */
    public readVarLong() {
        let value: bigint = BigInt(0);
        let position = 0;
        let currentByte;

        while (true) {
            currentByte = this.readUByte();
            value |= BigInt((currentByte & SEGMENT_BITS) << position);

            if ((currentByte & CONTINUE_BIT) == 0) break;

            position += 7;

            if (position >= 64) throw new Error("VarLong is too big");
        }

        return value;
    }

    /**
     * Read a string
     * 
     * A string packet would look like this:
     * 
     * `[VarInt as length, if not provided][N bits of data]`
     * 
     * @returns a string
     */
    public readString(length?: number) {
        length ||= this.readVarInt();
        if (length == 0) return "";
        if (this.offset + length > this.buffer.length)
            throw new StringSizeExceedLimit();

        const string = this.buffer.subarray(this.offset, this.offset + length);
        this.offset += length;
        return string.toString("utf-8");
    }

    /**
     * Read a 16-byte UUID
     */
    public readUUID() {
        const buffer = this.buffer.subarray(this.offset, this.offset + 16);
        this.offset += 16;
        return buffer.toString('hex');
    }

    /**
     * Read a Position
     * 
     * A position packet is a 64-bit value, split into three signed integer parts:
     * ```
     * x: 26 MSBs
     * z: 26 middle bits
     * y: 12 LSBs
     * ```
     * 
     * @returns a Position
     */
    public readPosition(): { x: number, y: number, z: number } {
        const val = this.readLong(),
            x = val >> BigInt(38),
            y = val << BigInt(52) >> BigInt(52),
            z = val << BigInt(26) >> BigInt(38);
        return {
            x: Number(x),
            y: Number(y),
            z: Number(z),
        };
    }

    /**
     * Read an array with known length
     * 
     * @returns an array
     */
    public readArray<T>(length: number, readFunc: (decoder: BinaryDecoder, ind: number) => T): T[] {
        return Array.from({ length }).map((_, ind) => readFunc(this, ind));
    }

    /**
     * Read a length-prefixed array
     * 
     * An array packet would look like this:
     * `[VarInt as length][N bytes of data]`
     * 
     * @returns a length-prefixed array
     */
    public readPrefixedArray<T>(readFunc: (decoder: BinaryDecoder, ind: number) => T): T[] {
        const length = this.readVarInt();
        return this.readArray(length, readFunc);
    }

    /**
     * Read an array with known length and return Buffer
     * 
     * @param size size of the item, in byte
     * @returns an array
     */
    public readRawArray(length: number, size: number) {
        const buffer = this.buffer.subarray(this.offset, this.offset + size * length);
        this.offset += size * length;
        return buffer;
    }

    /**
     * Read a length-prefixed array and return the raw buffer
     * 
     * An array packet would look like this:
     * `[VarInt as length][N bytes of data]`
     * 
     * @param size size of the item, in byte
     * @returns a length-prefixed array
     */
    public readRawPrefixedArray(size: number) {
        const length = this.readVarInt();
        return this.readRawArray(length, size);
    }

    /**
     * Read a Teleport Flag packet
     * 
     * A Teleport Flag packet, which is bit mask represented as an int, would look like this:
     * | Hex Mask | Field                  |
     * |----------|------------------------|
     * | 0x0001   | Is X relative          |
     * | 0x0002   | Is Y relative          |
     * | 0x0004   | Is Z relative          |
     * | 0x0008   | Is Yaw relative        |
     * | 0x0010   | Is Pitch relative      |
     * | 0x0020   | Is Velocity X relative |
     * | 0x0040   | Is Velocity Y relative |
     * | 0x0080   | Is Velocity Z relative |
     * | 0x0100   | Rotate velocity        |
     */
    public readTeleportFlag() {
        const flags = this.readInt();
        const x = (flags & 0x0001) === 1,
            y = (flags & 0x0002) === 1,
            z = (flags & 0x0004) === 1,
            yaw = (flags & 0x0008) === 1,
            pitch = (flags & 0x0010) === 1,
            velX = (flags & 0x0020) === 1,
            velY = (flags & 0x0040) === 1,
            velZ = (flags & 0x0080) === 1,
            rotateVelocity = (flags & 0x0100) === 1;
        return {
            x,
            y,
            z,
            yaw,
            pitch,
            velX,
            velY,
            velZ,
            rotateVelocity,
        };
    }

    /**
     * Read a Prefixed Optional field
     * 
     * A Prefixed Optional would look like this:
     * `[Boolean to indicate whenether the following field is presented][N bytes of data if the boolean before is true, or else empty]`
     * 
     * @returns 
     */
    public readPrefixedOptional<T>(readFunc: (decoder: BinaryDecoder) => T): T | null {
        const isPresent = this.readBoolean();
        if (!isPresent) return null;
        return readFunc(this);
    }

    /**
     * Read a NBT
     */
    public readNBT() {
        const remainData = this.buffer.subarray(this.offset);
        const nbtDecoder = new NBTDecoder(remainData);
        const val = nbtDecoder.decode();
        this.offset += nbtDecoder.offset;
        return val;
    }

    // For reading LpVec3
    private unpack(value: number | bigint) {
        return minBigInt(
            BigInt(BigInt(value) & 32767n),
            BigInt(MAX_QUANTIZED_VALUE)
        ) * BigInt(2.0) / BigInt(MAX_QUANTIZED_VALUE) - 1n;
    }

    /**
     * Read LpVec3
     * 
     * Read this article for more information: https://minecraft.wiki/w/Java_Edition_protocol/Data_types#LpVec3
     */
    public readLpVec3(): Vec3 {
        const byte1 = this.readUByte();
        if (byte1 === 0) {
            return Vec3.Zero;
        }

        const byte2 = this.readUByte();
        const bytes3To6 = this.readInt(); // Should be UInt
        const packed = BigInt(bytes3To6 << 16) | BigInt(byte2 << 8) | BigInt(byte1);
        let scaleFactor = BigInt(byte1) & SCALE_BITS;
        if ((BigInt(byte1) & CONTINUATION_FLAG) != 0n)
            scaleFactor |= BigInt(this.readVarInt()) << 2n;

        const scaleFactorD = BigInt(scaleFactor);
        return new Vec3(
            Number(this.unpack(packed >> 3n) * scaleFactorD),
            Number(this.unpack(packed >> 18n) * scaleFactorD),
            Number(this.unpack(packed >> 33n) * scaleFactorD)
        );
    }

    /**
     * Resolve a fixed point to a double
     * 
     * A fixed point is a certain number of bits represent the signed integer part (number to the left of the decimal point) 
     * and the rest represent the fractional part (to the right).
     * 
     * @param x number to resole
     * @param n n fraction bits
     */
    public readFixedPoint(x: number, n: number) {
        return x / (1 << n);
    }

    /**
     * Read an angle.
     * 
     * An angle is a rotation angle in steps of 1/256 of a full turn
     */
    public readAngle() {
        return this.readByte();
    }

    /**
     * Represents a data record of type X, either inline, or by reference to a registry implied by context
     */
    public readIdOrX(readX: (decoder: BinaryDecoder) => any) {
        const id = this.readVarInt();
        if (id !== 0) return id;
        return readX(this);
    }

    /**
     * Represents a set of IDs in a certain registry (implied by context), either directly (enumerated IDs) or indirectly (tag name).
     * | Field Name | Field Type | Meaning |
     * |------------|------------|---------|
     * | Type       | VarInt     | Value used to determine the data that follows. It can be either: 0 - Represents a named set of IDs defined by a tag. Anything else - Represents an ad-hoc set of IDs enumerated inline. |
     * | Tag Name   | Optional Identifier | The registry tag defining the ID set. Only present if Type is 0. |
     * | IDs        | Optional Array of VarInt | An array of registry IDs. Only present if Type is not 0. The size of the array is equal to Type - 1. |
     */
    public readIdSet() {
        const type = this.readVarInt();
        let tagName: string | null = null,
            ids: number[] | null = null;
        if (type === 0) tagName = this.readString();
        else ids = this.readArray(type - 1, decoder => decoder.readVarInt());
        return {
            type,
            tagName,
            ids
        };
    }

    /**
     * Read Chat Type Decoration
     * 
     * The chat type decorations look like:
     * | Name            | Type                          | Description                      |
     * |-----------------|-------------------------------|----------------------------------|
     * | Translation Key | String                        |                                  |
     * | Parameters      | Prefixed Array of VarInt Enum | 0: sender, 1: target, 2: content |
     * | Style           | NBT                           |                                  |
     */
    public readChatTypeDecoration() {
        const translationKey = this.readString(),
            parameters = this.readPrefixedArray(decoder => decoder.readVarInt()),
            style = this.readNBT();
        return { translationKey, parameters, style };
    }

    /**
     * Read Chat Type
     */
    public readChatType() {
        return {
            chat: this.readChatTypeDecoration(),
            narration: this.readChatTypeDecoration()
        };
    }
}

export class NBTDecoder extends BinaryDecoder {
    /**
     * Read a Compound string
     * 
     * A string packet would look like this:
     * 
     * `[2 bytes as length][N bits of data]`
     * 
     * @returns a string
     */
    public readCompoundString() {
        const length = this.readUShort();
        if (length == 0) return "";

        return this.readString(length);
    }

    /**
     * Read a list / array of tag
     * 
     * A list / array packet should look like this:
     * [1 byte of type, if not provided][4 bytes of length][N bits of data]
     * 
     * @returns 
     */
    public readCompoundList(type?: number): any[] {
        type ||= this.readByte();
        const length = this.readInt();
        const array = Array.from({ length })
            .map(() => this.readCompoundValue(type));
        return array;
    }

    /**
     * Read compound value base on provided tag
     */
    public readCompoundValue(tag: number): any {
        switch (tag) {
            case Tag.Byte: return this.readByte();
            case Tag.Short: return this.readShort();
            case Tag.Int: return this.readInt();
            case Tag.Long: return this.readLong();
            case Tag.Float: return this.readFloat();
            case Tag.Double: return this.readDouble();
            case Tag.ByteArray: return this.readCompoundList(Tag.Byte);
            case Tag.String: return this.readCompoundString();
            case Tag.List: return this.readCompoundList();
            case Tag.Compound: return this.readCompound();
            case Tag.IntArray: return this.readCompoundList(Tag.Int);
            case Tag.LongArray: return this.readCompoundList(Tag.Long);
            default: throw new UnexpectedValue("compound tag", tag.toString());
        }
    }


    /**
     * Read n bits
     * 
     * A Compound entry would look like this:
     * 
     * [1 byte of Type][2 byte of field name length][N bits of field length][N bits of data]
     * 
     * @returns signed n-bit integer
     */
    public readCompound() {
        const obj: Record<string, any> = {};

        while (true) {
            const type = this.readByte();
            if (type === Tag.End) return obj;

            const name = this.readCompoundString();
            const value = this.readCompoundValue(type);
            obj[name] = value;
        }
    }

    /**
     * Read an NBT
     * 
     * Note that Network NBT slice some root data so I have to specified it through `isNetwork` param
     * 
     * @param isNetwork
     * @returns
     */
    public decode(isNetwork: boolean = true) {
        const type = this.readByte();
        if (type === Tag.Compound || type === Tag.List) {
            if (!isNetwork) this.readCompoundString();
            const value = this.readCompoundValue(type);

            return value;
        } else if (type === Tag.End) return {};
        else {
            if (!isNetwork) this.readCompoundString();
            const value = this.readCompoundValue(type);
            return value;
        }
    }
}

export class SNBTDecoder {
    private offset = 0;

    constructor(private readonly source: string) { }

    private skipWhiteSpace() {
        while (this.offset < this.source.length) {
            // console.log({
            //     off: this.offset,
            //     ch: this.source[this.offset],
            //     a: this.source[this.offset] !== " ",
            //     b: this.source[this.offset] !== "\n"
            // });
            if (
                this.source[this.offset] !== " " &&
                this.source[this.offset] !== "," &&
                this.source[this.offset] !== "\n"
            ) return;
            this.offset += 1;
        }
        throw new UnexpectedValue("end of SNBT", "white space");
    }

    private convertValue(val: string): number | bigint | boolean | string {
        if (
            (!isNaN(Number(val))) ||
            val.endsWith("b") || val.endsWith("B") ||
            val.endsWith("s") || val.endsWith("S") ||
            val.endsWith("i") || val.endsWith("I") ||
            val.endsWith("F") || val.endsWith("F")
        ) return Number(val);
        if (
            val.endsWith("l") || val.endsWith("L") ||
            val.endsWith("d") || val.endsWith("D") ||
            BigInt(val) % BigInt(1) !== BigInt(0)
        ) return BigInt(val);
        if (val === "true") return true;
        if (val === "false") return false;
        if (
            (val.startsWith("'") && val.endsWith("'")) ||
            (val.startsWith('"') && val.endsWith("'"))
        ) return String(val.slice(1, -1));
        return val;
    }

    // eoc = End Of Compound
    private seek(): [fieldName: string, value: any] | undefined {
        this.skipWhiteSpace();

        if (this.source[this.offset] == "}")
            return undefined;

        let fieldName = "";
        let sliceQuote: "'" | '"' | undefined = undefined;
        let ch: string;
        if (this.source[this.offset] === "'" || this.source[this.offset] === '"') {
            sliceQuote = this.source[this.offset] as "'" | '"';
            this.offset++;
        }
        while (true) {
            ch = this.source[this.offset]!;
            if (
                ((sliceQuote == undefined && ch === ":") ||
                    (sliceQuote && ch === sliceQuote)) ||
                this.offset >= this.source.length
            ) {
                this.offset++;
                break;
            }
            fieldName += ch;
            this.offset++;
        }
        if (sliceQuote)
            this.offset++;

        this.skipWhiteSpace();

        let stringtifiedVal = "",
            convertedVal: any = undefined,
            type: "number" | "string_1" | "string_2" | "list" | "array" = "number";

        outer:
        while (true) {
            ch = this.source[this.offset]!;

            switch (ch) {
                case "[":
                    type = "list";
                    break;

                // End of Array or Line
                case "]":
                    if (type === "array" || type === "list") {
                        this.offset++;
                        break outer;
                    }
                    stringtifiedVal += ch;
                    break;

                case ",":
                    if (type !== "array" && type !== "list") {
                        this.offset++;
                        break outer;
                    }
                    stringtifiedVal += ch;
                    break;

                // String indicator, if found repeated mean it is the end of the string
                case "'":
                    if (stringtifiedVal.endsWith("\\") || type == "list") {
                        stringtifiedVal += ch;
                        break;
                    }
                    if (type == "string_1") {
                        this.offset++;
                        break outer;
                    }
                    type = "string_1";
                    break;
                case '"':
                    if (stringtifiedVal.endsWith("\\") || type == "list") {
                        stringtifiedVal += ch;
                        break;
                    }
                    if (type == "string_2") {
                        this.offset++;
                        break outer;
                    }
                    type = "string_2";
                    break;

                // Array type indicator
                case "B":
                case "I":
                case "L":
                    type = "array";
                    this.offset++;
                    break;

                case "{":
                    if (type != "list") {
                        convertedVal = this.obj();
                        break outer;
                    } else {
                        stringtifiedVal += ch;
                        break;
                    }

                default:
                    if (ch === " " && type == "number") break outer;
                    stringtifiedVal += ch;
                    break;
            }

            this.offset++;
        }

        if (stringtifiedVal !== "" && !convertedVal) {
            if (type == "array")
                convertedVal = stringtifiedVal.split(",")
                    .map(val =>
                        this.convertValue(val.trim())
                    );
            else if (type == "list") {
                convertedVal = stringtifiedVal.split(",")
                    .map(val => {
                        val = val.trim();
                        if (val.startsWith("{") && val.endsWith("}")) {
                            const decoder = new SNBTDecoder(val);
                            const obj = decoder.decode();
                            if (
                                Object.keys(obj).length != 1 ||
                                !("" in obj)
                            ) return obj;
                            return obj[""];
                        } else return this.convertValue(val);
                    });
            } else {
                if (type == "string_1" || type == "string_2")
                    convertedVal = stringtifiedVal;
                else
                    convertedVal = this.convertValue(stringtifiedVal);
            }
        }

        return [fieldName, convertedVal];
    }

    private obj() {
        if (this.source[this.offset] !== "{")
            throw new UnexpectedValue("start of compound", "{", this.source[this.offset]);
        this.offset++;
        const obj: Record<string, any> = {};
        while (this.offset < this.source.length) {
            const val = this.seek();
            if (!val) break;

            const [fieldName, fieldVal] = val;
            obj[fieldName] = fieldVal;
        }
        return obj;
    }


    public decode() {
        return this.obj();
    }
}