mc-anvil
Version:
A Typescript library for reading Minecraft Anvil format files and Minecraft NBT format files in the browser.
424 lines (381 loc) • 18.9 kB
text/typescript
import { BlockDataParser, BlockStates, ChunkRootTag, ChunkSectionTag, Palette } from "..";
import { BinaryParser, BitParser, findChildTagAtPath, findCompoundListChildren, NBTActions, nbtTagReducer, TagData, TagType } from "../..";
import { paletteNameList, paletteBlockList, paletteAsList } from "../block";
import { Coordinate3D } from "../types";
export function mod(n: number, m: number) {
if (n < 0) return ((n % m) + m) % m;
return n % m;
}
export function chunkCoordinateFromIndex(index: number): Coordinate3D {
return [
index % 16,
Math.floor(index / 256),
Math.floor(index / 16) % 16
];
}
export function indexFromChunkCoordinate(coordinate: Coordinate3D): number {
const [ x, y, z ] = coordinate;
return (y * 16 + z) * 16 + x;
}
export function biomeCoordinateFromIndex(index: number): Coordinate3D {
return [
index % 4,
Math.floor(index / 16),
Math.floor(index / 4) % 4
];
}
export function indexFromBiomeCoordinate(coordinate: Coordinate3D): number {
const [ x, y, z ] = coordinate;
return (y * 4 + z) * 4 + x;
}
export function sectionBlockStates(section: TagData[]): BlockStates | undefined {
const b = section.find(x => x.name === "BlockStates") || (section.find(x => x.name === "block_states")?.data as TagData[]).find(x => x.name === "data");
return b as BlockStates | undefined;
}
export function sectionPalette(section: TagData[]): Palette | undefined {
const p = section.find(x => x.name === "Palette" || x.name === "palette") || (section.find(x => x.name === "block_states")?.data as TagData[]).find(x => x.name === "palette");
return p as Palette | undefined;
}
export function blockParserFromSection(section: TagData[]): BlockDataParser | undefined {
const blockstates = sectionBlockStates(section);
const palette = sectionPalette(section);
if (!blockstates || !palette) return;
return new BlockDataParser(blockstates, palette);
}
/**
* Class for parsing, mutating, and writing chunk-format data to and from NBT-format blobs. This class handles
* NBT-related parsing and writing only; chunk compression logic is handled by the AnvilParser class.
* @member AIR hash of the string "minecraft:air()"
* @member BLOCKS_PER_CHUNK total number of blocks in a 16x16x16 chunk section
* @member root the compound NBT tag containing this chunk's data.
*/
export class Chunk {
static readonly AIR = -968583441; // hash of "minecraft:air()"
static readonly BLOCKS_PER_CHUNK = 4096; // 16 * 16 * 16
private blockStates: Map<number, number[][][]> = new Map();
private palettes: Map<number, Palette | undefined> = new Map();
private blockStatesDirty: Map<number, boolean> = new Map();
root: TagData;
/**
* Checks if the given NBT tag is a valid chunk section tag (containing a list of chunk sections).
* @param tag the tag to check.
* @returns true if the tag is a valid section tag; false otherwise.
*/
static isValidChunkSectionTag(tag?: TagData): tag is ChunkSectionTag {
const t = tag as ChunkSectionTag;
return t && t.type === TagType.LIST && t.name.toLowerCase() === "sections" && t.data && t.data.subType === TagType.COMPOUND
&& t.data.data && t.data.data.length !== undefined;
}
/**
* Checks if the given NBT tag is a valid chunk root tag.
* @param tag the tag to check.
* @returns true if the tag is a valid chunk root tag; false otherwise.
*/
static isValidChunkRootTag(tag?: TagData): tag is ChunkRootTag {
const t = tag as ChunkRootTag;
return t && t.type === TagType.COMPOUND && t.name === "" && t.data && t.data.length !== undefined;
}
/**
* Creates a zero-filled 2D array of integers for storing blocks in an x-coordinate column of a chunk or section.
* @param y the total number of y-coordinates to represent.
* @param z the total number of z-coordinates to represnt.
* @returns a zero-filled 2D array.
*/
static emptyX(y: number = 16, z: number = 16): number[][] {
const r: number[][] = [];
for (let i = 0; i < y; ++i) {
const c = [];
for (let j = 0; j < z; ++j) c.push(0);
r.push(c);
}
return r;
}
/**
* Constructs a new chunk object from a given NBT tag.
* @param root the NBT tag containing the chunk's data; must pass Chunk.isValidChunkRootTag.
*/
constructor(root: TagData) {
this.root = root;
}
/**
* Checks if the given block coordinate is contained within this chunk.
* @param c the coordinate to check.
* @returns true if this coordinate is contained within the chunk; false otherwise.
*/
containsCoordinate(c: Coordinate3D): boolean {
if (c[1] < -64 || c[1] > 256) return false;
const cc = this.getCoordinates();
if (!cc) return false;
return c[0] >= cc[0] && c[0] < cc[0] + 16 && c[2] >= cc[1] && c[2] < cc[1] + 16;
}
/**
* Encodes this chunk's location (in chunk coordinates) for use as a key.
* @returns a string representation of this chunk's coordinates which may be used as a key.
*/
coordinateKey(): string | undefined {
const coordinates = this.getChunkCoordinates();
if (!coordinates) return;
return `${coordinates[0]},${coordinates[1]}`;
}
/**
* Fetches this chunk's location in chunk coordinates.
* @returns the chunk's coordinates.
*/
getChunkCoordinates(): [ number, number ] | undefined {
if (!Chunk.isValidChunkRootTag(this.root)) return;
const x = (findChildTagAtPath("Level/xPos", this.root) || findChildTagAtPath("xPos", this.root))?.data;
const z = (findChildTagAtPath("Level/zPos", this.root) || findChildTagAtPath("zPos", this.root))?.data;
if (x !== undefined && z !== undefined) return [ x as number, z as number ];
}
/**
* Fetches the starting location of this chunk in block coordinates.
* @returns the chunk's coordinates.
*/
getCoordinates(): [ number, number ] | undefined {
const c = this.getChunkCoordinates();
if (!c) return;
return [ c[0] * 16, c[1] * 16 ];
}
/**
* Fetches the NBT tag containing the list of chunk sections within this chunk.
* @returns the section NBT tag.
*/
sections(): ChunkSectionTag | undefined {
if (!Chunk.isValidChunkRootTag(this.root)) return;
const sectionTag = findChildTagAtPath("Level/Sections", this.root) || findChildTagAtPath("sections", this.root);
if (!Chunk.isValidChunkSectionTag(sectionTag)) return;
return sectionTag;
}
/**
* Fetches a list of section NBT tags within this chunk, sorted by ascending Y coordinate.
* @returns a list of section NBT tags.
*/
sortedSections(): TagData[][] | undefined {
const s = this.sections();
if (s === undefined) return;
const yTags = findCompoundListChildren(s!, x => x.name === "Y")?.map( (v, i) => ({ v, i }) ).filter(x => x.v);
return yTags?.sort( (a, b) => (a.v!.data as number) - (b.v!.data as number) ).map( x => s!.data.data[x.i] );
}
/**
* Fetches a list of 3D coordinates where blocks of the given type occur within this chunk.
* @param name the name of the block to fetch.
* @returns a list of 3D coordinate block locations.
*/
findBlocksByName(name: string): Coordinate3D[] {
this.chunkData();
const sections = this.sortedSections();
if (sections === undefined) return [];
return sections.flatMap( section => {
let yy = (section.find(x => x.name === "Y")?.data || 0) as number * 16;
if (yy >= 4032) yy -= 4096;
const [ xx, zz ] = this.getCoordinates() || [ 0, 0 ];
const blockData = sectionBlockStates(section);
const palette = sectionPalette(section);
if (blockData === undefined || palette === undefined) return [];
return (new BlockDataParser(blockData as BlockStates, palette as Palette))
.findBlocksByName(name)
.map(chunkCoordinateFromIndex)
.map(x => [ x[0] + xx, x[1] + yy, x[2] + zz ] as Coordinate3D);
});
}
/**
* Fetches the block at the provided coordinates.
* @param coordinates the 3D coordinates at which to fetch the block.
* @returns the name and property key-value pairs for the block.
*/
getBlock(coordinates: Coordinate3D): { name: string, properties: { [key: string]: string } } {
const yIndex = coordinates[1] >= 0 ? Math.floor(coordinates[1] / 16) : Math.floor((coordinates[1] + 4096) / 16);
const [ s, palette ] = this.sectionBlockStateTensor(yIndex);
const i = s[mod(coordinates[0], 16)][(coordinates[1] + 64) % 16][mod(coordinates[2], 16)];
return (palette ? paletteAsList(palette) : [])[i];
}
getSectionContainingCoordinate(coordinates: Coordinate3D): TagData[] | undefined {
const s = this.sortedSections();
const yIndex = coordinates[1] >= 0 ? Math.floor(coordinates[1] / 16) : Math.floor((coordinates[1] + 4096) / 16);
if (!s) return;
return s.find(x => x.find(xx => xx.name === "Y")?.data === yIndex) as TagData[];
}
/**
* Places a block at the provided coordinates.
* @param coordinates the 3D coordinates at which to set the block.
* @param name the name of the block to set.
* @param properties property key-value pairs for the block.
*/
setBlock(coordinates: Coordinate3D, name: string, properties: { [key: string]: string }) {
const yIndex = coordinates[1] >= 0 ? Math.floor(coordinates[1] / 16) : Math.floor((coordinates[1] + 4096) / 16);
const fullName = `${name}(${Object.keys(properties).map(k => `${k}:${properties[k]}`).sort((a, b) => a.localeCompare(b)).join(",")})`;
const [ s, palette ] = this.sectionBlockStateTensor(yIndex);
const nameOrder = palette ? paletteBlockList(palette) : [];
const i = nameOrder.findIndex(x => x === fullName);
const index = i !== -1 ? i : nameOrder.length;
s[mod(coordinates[0], 16)][(coordinates[1] + 64) % 16][mod(coordinates[2], 16)] = index;
if (i === -1) this.palettes.set(yIndex, nbtTagReducer(palette || { type: TagType.LIST, name: "palette", data: { subType: TagType.COMPOUND, data: [] } }, {
type: NBTActions.NBT_ADD_COMPOUND_LIST_ITEM,
path: "",
index,
tags: [{
type: TagType.STRING,
name: "Name",
data: name
}, ...(Object.keys(properties).length === 0 ? [] : [{
type: TagType.COMPOUND,
name: "Properties",
data: Object.keys(properties).map(k => ({
type: TagType.STRING,
name: k,
data: properties[k]
}))
}])] as TagData[]
}) as Palette);
}
/**
* Obtains a set of unique block names contained within the chunk.
* @returns a set of block name strings.
*/
uniqueBlockNames(): Set<string> {
return new Set(
this.sortedSections()?.flatMap(x => paletteNameList(x.find(xx => xx.name === "Palette" || xx.name === "palette") as Palette)) || []
);
}
/**
* Fetches a 2D matrix of world heights for this chunk.
* @param name the world height metric to use; defaults to WORLD_SURFACE.
* @returns a matrix of world heights; x-coordinate is the outer coordinate and z-coordinate is the inner coordinate.
*/
worldHeights(name: string = "WORLD_SURFACE") : number[][] | undefined {
/* check if the tag is valid */
if (!Chunk.isValidChunkRootTag(this.root)) return;
/* extract the height map tag */
const r: number[][] = Chunk.emptyX(16);
const map = findChildTagAtPath(`Level/Heightmaps/${name}`, this.root) || findChildTagAtPath(`Heightmaps/${name}`, this.root);
if (map === undefined || map.type !== TagType.LONG_ARRAY || !map.data) return;
/* initialize bitwise parser for the height map tag */
const d = new BinaryParser(map.data as ArrayBuffer);
const b = new BigUint64Array(d.remainingLength() / 8);
for (let i = 0; i < b.length; ++i) b[i] = d.getUInt64LE();
const p = new BitParser(b.buffer);
/* loop the tag extracting height values */
for (let i = 0; i < 259; ++i) {
const ii = i + 6 - 2 * (i % 7);
const x = ii % 16;
const z = Math.floor(ii / 16);
if (i % 7 === 0) p.getBits(1);
const cc = p.getBits(9);
if (x < 16 && z < 16) r[x][z] = cc;
}
return r;
}
/**
* Extracts a 3D tensor containing block states for the chunk section at the given y coordinates and the palette NBT tag.
* @param yIndex the y index of the coordinate (in chunk coordinates).
* @returns a 3D tensor of block state indexes (corresponding to indexes in the block state palette for the section) and the palette NBT tag.
*/
sectionBlockStateTensor(yIndex: number): [ number[][][], Palette | undefined ] {
this.blockStatesDirty.set(yIndex, true);
if (this.blockStates.get(yIndex) && this.palettes.get(yIndex)) return [ this.blockStates.get(yIndex)!, this.palettes.get(yIndex)! ];
const r: number[][][] = [];
for (let x = 0; x < 16; ++x) r.push(Chunk.emptyX());
const sections = this.sortedSections();
if (!sections) return [ r, undefined ];
const section = sections.find(x => x.find(xx => xx.name === "Y")?.data === yIndex) as TagData[];
const blockData = section && sectionBlockStates(section);
const palette = section && sectionPalette(section);
this.blockStates.set(yIndex, r);
this.palettes.set(yIndex, palette as Palette);
if (blockData === undefined) return [ r, palette as Palette ];
const b = new BlockDataParser(blockData as BlockStates, palette as Palette);
b.getRawBlocks().forEach( (v, i) => {
const [ x, y, z ] = chunkCoordinateFromIndex(i);
r[x][y][z] = v || 0;
});
return [ r, palette as Palette ];
}
/**
* Extracts a 3D tensor of block states for the chunk.
* @returns a 3D tensor of block states for the chunk, each corresponding to an index in the corresponding chunk section palette.
*/
blockStateTensor(): number[][][] {
const r: number[][][] = [];
for (let x = 0; x < 16; ++x) r.push(Chunk.emptyX());
const sections = this.sortedSections() || [];
sections.forEach( (section, y) => {
const yy = y * 16;
const blockData = sectionBlockStates(section);
const palette = sectionPalette(section);
if (blockData === undefined || palette === undefined) return [];
const b = new BlockDataParser(blockData as BlockStates, palette as Palette);
b.getBlockTypeIDs().forEach( (v, i) => {
const [ x, y, z ] = chunkCoordinateFromIndex(i);
r[x][y + yy + 64][z] = v || 0;
});
});
return r;
}
/**
* Flushes unsaved changes to this chunk's NBT tag and then returns the tag. This method should be used instead of
* directly accessing the object's root tag property to ensure any unsaved block changes are reflected.
* @returns NBT tag containing this chunk's data.
*/
chunkData(): TagData {
/* get a list of sections in this chunk */
const sections = this.sections()?.data.data;
if (!sections) return this.root;
/* loop through chunks needing updates */
[ ...this.blockStatesDirty.keys() ].filter(k => this.blockStatesDirty.get(k)).forEach( yy => {
/* create a flat list of blocks in this section */
const blocks: number[] = [];
for (let y = 0; y < 16; ++y)
for (let z = 0; z < 16; ++z)
for (let x = 0; x < 16; ++x)
blocks.push(this.blockStates.get(yy)![x][y][z]);
/* write out updated blocks and palette for this section */
const [ r, palette ] = BlockDataParser.writeBlockStates(blocks, this.palettes.get(yy)!);
const index = sections.findIndex(x => x.find(xx => xx.name === "Y")?.data === yy);
this.root = (palette?.data.data.length || 0) > 1 ? nbtTagReducer(this.root, {
type: NBTActions.NBT_ADD_TAG,
overwrite: true,
path: `sections/${index}/block_states`,
tag: {
type: TagType.LONG_ARRAY,
name: 'data',
data: r
}
}) : ( findChildTagAtPath(`sections/${index}/block_states/data`, this.root) ? nbtTagReducer(this.root, {
type: NBTActions.NBT_DELETE_TAG,
recursive: true,
path: `sections/${index}/block_states/data`
}) : this.root ); // updates block state data or deletes it if the palette is a single item
this.palettes.set(yy, palette);
this.blockStates.delete(yy);
this.root = nbtTagReducer(this.root, {
type: NBTActions.NBT_ADD_TAG,
overwrite: true,
path: `sections/${index}/block_states`,
tag: (this.palettes.get(yy) || findChildTagAtPath(`sections/${index}/block_states/palette`, this.root))!
}); // updates palette
if (findChildTagAtPath(`sections/${index}/SkyLight`, this.root) !== undefined)
this.root = nbtTagReducer(this.root, {
type: NBTActions.NBT_DELETE_TAG,
path: `sections/${index}/SkyLight`
});
if (findChildTagAtPath(`sections/${index}/BlockLight`, this.root) !== undefined)
this.root = nbtTagReducer(this.root, {
type: NBTActions.NBT_DELETE_TAG,
path: `sections/${index}/BlockLight`
});
this.blockStatesDirty.set(yy, false);
});
if (findChildTagAtPath("Heightmaps", this.root) !== undefined)
this.root = nbtTagReducer(this.root, {
path: "Heightmaps",
recursive: true,
type: NBTActions.NBT_DELETE_TAG
});
this.root = nbtTagReducer(this.root, {
type: NBTActions.NBT_EDIT_TAG,
path: "Status",
tag: { name: "Status", type: TagType.STRING, data: "features" }
});
return this.root;
}
};