MineJSv0.5.1
binary/encoder.tstypescript
import { Vec3 } from "../physics/direction";
import { CONTINUATION_FLAG, CONTINUE_BIT, isFixedSizeTag, isTag, MAX_QUANTIZED_VALUE, SCALE_BITS, SEGMENT_BITS } from "./static";
import { InvalidValue, NumberTooBig, UnexpectedValue } from "../base/error";
import { FixedSizeTag, Tag } from "./static";

/** @hidden */
export function makeMovementFlag(onGround: boolean, pushingWall: boolean) {
    let flag = 0;
    if (onGround)
        flag &= 1;
    if (pushingWall)
        flag |= 1 << 2;
    return flag;
}

/**
 * Binary encoding helper
 */
export class BinaryEncoder {
    private buffer = Buffer.alloc(0);

    public getBuffer() {
        return Buffer.from(this.buffer);
    }

    public wipeBuffer() {
        this.buffer = Buffer.alloc(0);
    }

    public writeRaw(buf: Buffer) {
        this.buffer = Buffer.concat([this.buffer, buf]);
        return this;
    }


    /**
     * Write shortcut
     * 
     * Allocate `size`-byte buffer, use writeFunc to write the data, then concatenate to internal buffer;
     */
    public write(size: number, val: any, writeFunc: (buf: Buffer) => ((val: any) => void)) {
        const buf = Buffer.alloc(size);
        writeFunc(buf).bind(buf)(val);
        this.writeRaw(buf);
        return this;
    }

    /**
     * Write 1 byte singed integer
     */
    public writeByte(val: number) { return this.write(1, val, (buf) => buf.writeInt8); }
    /**
     * Write 1 byte unsinged integer
     */
    public writeUByte(val: number) { return this.write(1, val, (buf) => buf.writeUInt8); }
    /**
     * Write 2 bytes singed integer
     */
    public writeShort(val: number) { return this.write(2, val, (buf) => buf.writeInt16BE); }
    /**
     * Write 2 bytes unsinged integer
     */
    public writeUShort(val: number) { return this.write(2, val, (buf) => buf.writeUInt16BE); }
    /**
     * Write 4 bytes singed integer
     */
    public writeInt(val: number) { return this.write(4, val, (buf) => buf.writeInt32BE); }
    /**
     * Write 8 bytes singed integer
     */
    public writeLong(val: bigint | number) {
        // Cast to BigInt 
        const v = typeof val === "bigint" ? val : BigInt(val);
        return this.write(8, v, (buf) => buf.writeBigInt64BE); 
    }
    /**
     * Write 4 bytes float
     */
    public writeFloat(val: number) { return this.write(4, val, (buf) => buf.writeFloatBE); }
    /**
     * Write 8 bytes double
     */
    public writeDouble(val: number) { return this.write(8, val, (buf) => buf.writeDoubleBE); }

    /**
     * Write boolean
     */
    public writeBoolean(val: boolean) { return this.writeByte(val == true ? 1 : 0); }

    // Source: https://minecraft.wiki/w/Java_Edition_protocol/Packets#VarInt_and_VarLong
    /**
     * Write a VarInt
     * 
     * A VarInt packet would look like this:
     * 
     * `[7 bits of data][1 bit indicate if the VarInt ended here]`
     */
    public writeVarInt(value: number) {
        while (true) {
            if ((value & ~SEGMENT_BITS) == 0) {
                this.writeUByte(value);
                return this;
            }

            this.writeUByte((value & SEGMENT_BITS) | CONTINUE_BIT);

            // Note: >>> means that the leftmost bits are filled with zeroes regardless of the sign,
            // rather than being filled with copies of the sign bit to preserve the sign.
            // In languages that don't have a ">>>" operator, This behavior can often be selected by
            // performing the shift on an unsigned type.
            value >>>= 7;
        }
    }
    /**
     * Write a VarLong 
     * 
     * Similar to VarInt, A VarLong packet would look like this:
     * 
     * `[7 bits of data][1 bit indicate if the VarLong ended here]`
     */
    public writeVarLong(value: bigint) {
        // Force the value to behave as an unsigned 64-bit integer for two's complement arithmetic
        value = BigInt.asUintN(64, value);

        while (true) {
            if ((value & ~BigInt(SEGMENT_BITS)) == BigInt(0)) {
                this.writeUByte(Number(value));
                return this;
            }

            this.writeUByte(Number(value & BigInt(SEGMENT_BITS)) | CONTINUE_BIT);

            // Use '>>=' instead of '>>>=' because 'bigint' does not support unsigned right shifts.
            // Since we applied BigInt.asUintN earlier, the value is guaranteed to be positive,
            // so '>>' will correctly fill the leftmost bits with zeroes.
            // Edited by Copilot since I have no idea what this is :D
            value >>= BigInt(7);
        }
    }

    /**
     * Write a string
     * 
     * A string packet would look like this:
     * 
     * `[VarInt as length, if not provided][N bits of data]`
     */
    public writeString(str: string) {
        this.writeVarInt(str.length);
        this.writeRaw(Buffer.from(str));
        return this;
    }

    /**
     * Write 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 writePosition(position: Vec3) {
        const val = BigInt(
            ((position.x & 0x3FFFFFF) << 38) |
            ((position.z & 0x3FFFFFF) << 12) |
            (position.y & 0xFFF)
        );
        this.writeLong(val);
        return this;
    }

    /**
     * Write a length-prefixed array with 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 writePrefixedArray(length: number, writeFunc: (encoder: BinaryEncoder, index: number) => BinaryEncoder | Buffer) {
        const array: Buffer[] = Array.from({ length }, (_, index) => {
            const val = writeFunc(new BinaryEncoder(), index);
            if (val instanceof BinaryEncoder) return val.getBuffer();
            else return val;
        });
        this.writeVarInt(length);
        this.writeRaw(Buffer.concat(array));
        return this;
    }

    /**
     * Write a length-prefixed array with 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 writeRawPrefixedArray(data: Buffer, size: number) {
        const length = Math.ceil(data.length / size);
        this.writeVarInt(length);
        this.writeRaw(data);
        return this;
    }

    // For writing LpVec3
    private pack(value: number): number {
        return Math.round((value * 0.5 + 0.5) * MAX_QUANTIZED_VALUE);
    }

    /**
     * Write LpVec3
     * 
     * Read this article for more information: https://minecraft.wiki/w/Java_Edition_protocol/Data_types#LpVec3
     */
    public writeLpVec3(vec3: Vec3) {
        const maxCoordinate = Math.max(Math.abs(vec3.x), Math.max(Math.abs(vec3.y), Math.abs(vec3.z)));

        // Checking for NaN values in our maxCoordinate
        if (isNaN(maxCoordinate) || maxCoordinate < 1 / MAX_QUANTIZED_VALUE) {
            this.writeByte(0);
        } else {
            const scaleFactor = BigInt(Math.ceil(maxCoordinate));
            const needContinuation = (scaleFactor & SCALE_BITS) != scaleFactor;

            const packedScale = needContinuation ? scaleFactor & SCALE_BITS | CONTINUATION_FLAG : scaleFactor;
            const packedX = BigInt(this.pack(vec3.x)) / scaleFactor << 3n;
            const packedY = BigInt(this.pack(vec3.y)) / scaleFactor << 18n;
            const packedZ = BigInt(this.pack(vec3.z)) / scaleFactor << 33n;
            const packed = packedZ | packedY | packedX | packedScale;

            this.writeByte(Number(packed));
            this.writeByte(Number(packed >> 8n));
            this.writeInt(Number(packed >> 16n));
            if (needContinuation) {
                this.writeVarInt(Number(scaleFactor >> 2n));
            }
        }

        return this;
    }
}

type CompoundField = any | {
    tag: Tag,
    value: any,
}

// Check if N is floating point
// Got from https://stackoverflow.com/a/3886106/17106809
const isFloat = (n: number | bigint) =>
    (typeof (n) == "number" && n % 1 !== 0) ||
    (typeof (n) == "bigint" && n % BigInt(1) !== BigInt(0));

function getTagOfNumber(n: number | bigint): FixedSizeTag {
    if (isFloat(n)) {
        if (-Math.pow(2, 4 * 8 - 1) <= n && n <= Math.pow(2, 4 * 8 - 1) - 1) return Tag.Float;
        else if (BigInt(-Math.pow(2, 8 * 8 - 1)) <= n && n <= BigInt(Math.pow(2, 8 * 8 - 1) - 1)) return Tag.Double;
        else throw new NumberTooBig(n);
    } else {
        if (-Math.pow(2, 1 * 8 - 1) <= n && n <= Math.pow(2, 1 * 8 - 1) - 1) return Tag.Byte;
        else if (-Math.pow(2, 2 * 8 - 1) <= n && n <= Math.pow(2, 2 * 8 - 1) - 1) return Tag.Short;
        else if (-Math.pow(2, 4 * 8 - 1) <= n && n <= Math.pow(2, 4 * 8 - 1) - 1) return Tag.Int;
        else if (BigInt(-Math.pow(2, 8 * 8 - 1)) <= n && n <= BigInt(Math.pow(2, 8 * 8 - 1) - 1)) return Tag.Long;
        else throw new NumberTooBig(n);
    }
}

export interface StaticTypedField {
    __isStatic: true,
    type: Tag,
    value: any
}

export class NBTEncoder extends BinaryEncoder {
    constructor(private object: Record<string, CompoundField>) {
        super();
    }

    /**
     * Write buffer to buffer
     */
    public writeTag(tag: Tag) {
        this.writeFixedSize(Tag.Byte, tag);
        return this;
    }

    /**
     * Write a fixed-size tag to buffer 
     */
    public writeFixedSize(
        type: FixedSizeTag,
        value: any
    ) {
        switch (type) {
            case Tag.Byte: this.writeByte(value); break;
            case Tag.Short: this.writeShort(value); break;
            case Tag.Int: this.writeInt(value); break;
            case Tag.Long: this.writeLong(BigInt(value)); break;
            case Tag.Float: this.writeFloat(value); break;
            case Tag.Double: this.writeDouble(value); break;
            default: throw new UnexpectedValue("fixed-size tag", (type as any).toString(16));
        }
        return this;
    }

    /**
     * Auto find the suitable tag and write it to the buffer
     */
    public writeNumber(val: number | bigint) {
        const tag = getTagOfNumber(val);
        this.writeFixedSize(tag, val);
        return this;
    }

    /**
     * Write a string
     */
    public writeString(str: string) {
        this.writeUShort(str.length);
        if (str.length === 0) return this;
        this.writeRaw(Buffer.from(str));
        return this;
    }

    /**
     * Write a list
     * Unsafe
     */
    public writeList(type: Tag, arr: any[]) {
        this.writeFixedSize(Tag.Byte, type);
        this.writeInt(arr.length);
        if (arr.length === 0) return this;
        if (type == Tag.String)
            for (const item of arr)
                this.writeString(item);
        else
            this.writeRaw(Buffer.from(arr));
        return this;
    }

    /**
     * Write a byte / int / long array
     */
    public writeArray(type: Extract<Tag, Tag.ByteArray | Tag.IntArray | Tag.LongArray>, arr: any[]) {
        this.writeInt(arr.length);
        for (const item of arr)
            switch (type) {
                case Tag.ByteArray: this.writeByte(item); break;
                case Tag.IntArray: this.writeInt(item); break;
                case Tag.LongArray: this.writeLong(BigInt(item)); break;
            }
        return this;
    }

    /**
     * Write a compound
     */
    public writeObject(obj: Record<string, any>) {
        for (const [name, val] of Object.entries(obj)) {
            switch (typeof val) {
                case "string": {
                    this.writeTag(Tag.String)
                        .writeString(name)
                        .writeString(val);
                    break;
                }
                case "number":
                case "bigint": {
                    const tag = getTagOfNumber(val);
                    this.writeTag(tag)
                        .writeString(name)
                        .writeFixedSize(tag, val);
                    break;
                }
                case "object": {
                    if (
                        typeof val === "object" &&
                        "__isStatic" in val &&
                        val["__isStatic"] === true &&
                        "type" in val && isTag(val["type"]) &&
                        "value" in val
                    ) {
                        const tag: Tag = val["type"];
                        const value = val["value"];

                        // TODO: Handle other type
                        this.writeTag(tag);
                        if (isFixedSizeTag(tag))
                            this.writeFixedSize(tag, value);
                        else if (tag === Tag.String)
                            this.writeString(value);
                    }
                    else if (Array.isArray(val)) {
                        let suitableTagForArray: Exclude<FixedSizeTag, Tag.Float | Tag.Double> | Tag.List = Tag.Byte,
                            listItemTag: Tag = Tag.Float;
                        for (const item of val) {
                            if (isNaN(Number(item))) {
                                suitableTagForArray = Tag.List;

                                // Just support list of string and float for now
                                if (typeof item !== "string")
                                    throw new InvalidValue("list item", typeof item);

                                listItemTag = Tag.String;

                                break;
                            }
                            const itemTag = getTagOfNumber(item);
                            if (itemTag == Tag.Float || itemTag == Tag.Double) {
                                suitableTagForArray = Tag.List;
                                listItemTag = itemTag;
                                break;
                            }
                            if (suitableTagForArray < itemTag)
                                suitableTagForArray = itemTag;
                        }

                        if (suitableTagForArray === Tag.List)
                            this.writeTag(Tag.List)
                                .writeString(name)
                                .writeList(listItemTag, val);
                        else {
                            let arrTag: Tag;
                            switch (suitableTagForArray) {
                                case Tag.Byte: arrTag = Tag.ByteArray; break;
                                case Tag.Short:
                                case Tag.Int: arrTag = Tag.IntArray; break;
                                case Tag.Long: arrTag = Tag.LongArray; break;
                            }
                            this.writeTag(arrTag)
                                .writeString(name)
                                .writeArray(arrTag as any, val);
                        }
                    } else {
                        this.writeTag(Tag.Compound)
                            .writeString(name)
                            .writeObject(val);
                    }
                    break;
                }
            }
        }
        this.writeTag(Tag.End);
        return this;
    }

    /**
     * Start encode
     */
    public encode() {
        this.writeTag(Tag.Compound)
            .writeString("") // Root name is empty
            .writeObject(this.object);
        return this.getBuffer();
    }
}