@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
673 lines (672 loc) • 26.6 kB
JavaScript
"use strict";
/**
* ARCHITECTURE: LiveWorldState
*
* Client-side world model that maintains chunk data received from the server.
* This is the browser's "copy" of the nearby world — chunks are loaded/unloaded
* as the server sends LevelChunkPackets and NetworkChunkPublisherUpdatePackets.
*
* DATA FLOW:
* Server → proxy (decodes Bedrock protocol) → WebSocket → LiveWorldState
* LiveWorldState marks chunks dirty → WorldRenderer reads dirty chunks →
* ChunkMeshBuilder converts to Babylon.js meshes
*
* BLOCK ACCESS:
* getBlock(x, y, z) → { runtimeId, name } or undefined
* setBlock(x, y, z, runtimeId) → marks chunk dirty
* getBlockName(runtimeId) → block identifier string via palette lookup
*
* CHUNK STORAGE:
* Chunks keyed by "chunkX,chunkZ" string in a Map.
* Each chunk has up to 24 subchunks (y = -64 to 319 in overworld).
* Each subchunk is 16×16×16 blocks stored as a flat Int32Array of runtime IDs.
* Storage order is XZY: index = x*256 + z*16 + y (Bedrock SubChunk format).
*
* BLOCK PALETTE:
* Maps runtime IDs (32-bit hashes) → block names (e.g., "minecraft:stone").
* Populated from StartGamePacket.block_palette and block_palette events.
* CRITICAL: Chunks loaded before the palette arrives will have zero rendered
* blocks. When the palette arrives, all pre-loaded chunks are marked dirty
* so they get rebuilt with correct block names.
*
* DIRTY TRACKING:
* _dirtyChunks tracks which chunk keys need mesh rebuilds.
* Chunks are dirtied when: new chunk data arrives, blocks are modified,
* palette arrives (mass re-dirty), or adjacent chunks load (face-culling
* recalculation at chunk boundaries).
*
* CLEAR ZONE:
* Optional filter that replaces non-whitelisted blocks above a Y threshold
* with air. Used to clear terrain for building or to isolate structures.
* Applied to incoming chunk data as it arrives.
*
* COORDINATE SYSTEMS:
* - World coords: (x, y, z) — absolute block position
* - Chunk coords: (chunkX, chunkZ) = (x >> 4, z >> 4)
* - Local coords: (x & 15, y & 15, z & 15) within a subchunk
* - Subchunk index: (y - minY) >> 4
*
* PACKET HANDLERS:
* Each handle*() method processes a specific Bedrock protocol packet type.
* Packet interfaces are defined at the top of this file. The proxy pre-parses
* binary protocol data into JSON objects before sending via WebSocket.
*
* RELATED FILES:
* - WorldRenderer.ts — reads dirty chunks and builds scene meshes
* - ChunkMeshBuilder.ts — converts chunk data to Babylon.js meshes
* - EntityManager.ts — tracks entity state (separate from world blocks)
* - WorldChunk.ts — existing chunk model (used for file-based worlds)
* - BlockPalette.ts — block runtime ID → type mapping
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const Log_1 = __importDefault(require("../../core/Log"));
class LiveWorldState {
_chunks = new Map();
_blockPalette = new Map();
_dirtyChunks = new Set();
// World settings from StartGamePacket
_worldName = "";
_gameMode = 0; // 0=survival, 1=creative, 2=adventure
_spawnPosition = { x: 0, y: 64, z: 0 };
_worldTime = 6000; // Default to noon for good sky lighting
_minY = -64;
_maxY = 319;
_dimensionId = 0; // 0=overworld, 1=nether, 2=end
_difficulty = 1;
// Clear zone: when set, incoming SubChunk data is automatically filtered.
// Natural terrain blocks above clearAboveY are replaced with air unless they're
// in the keepNames whitelist AND below maxKeepY. This prevents SubChunk data
// races from restoring terrain that was previously cleared.
_clearZone;
// Stats
_loadedChunkCount = 0;
get worldName() {
return this._worldName;
}
get gameMode() {
return this._gameMode;
}
get spawnPosition() {
return this._spawnPosition;
}
get worldTime() {
return this._worldTime;
}
set worldTime(t) {
this._worldTime = t;
}
get dimensionId() {
return this._dimensionId;
}
get minY() {
return this._minY;
}
get loadedChunkCount() {
return this._loadedChunkCount;
}
get dirtyChunkCount() {
return this._dirtyChunks.size;
}
/**
* Return the center position (in world coordinates) of all loaded chunks.
* Falls back to spawnPosition if no chunks are loaded.
*/
getChunkCenter() {
if (this._chunks.size === 0)
return { ...this._spawnPosition };
let sumX = 0;
let sumZ = 0;
for (const key of this._chunks.keys()) {
const parts = key.split(",");
const cx = parseInt(parts[0], 10);
const cz = parseInt(parts[1], 10);
// Chunk center in world coords: chunkX * 16 + 8
sumX += cx * 16 + 8;
sumZ += cz * 16 + 8;
}
return {
x: sumX / this._chunks.size,
y: this._spawnPosition.y,
z: sumZ / this._chunks.size,
};
}
/**
* Set a clear zone that automatically filters incoming SubChunk data.
* Any block above clearAboveY is replaced with air UNLESS it's in keepNames
* AND at or below maxKeepY. This prevents SubChunk data races from restoring
* natural terrain that was previously cleared.
*/
setClearZone(clearAboveY, maxKeepY, keepNames) {
this._clearZone = { clearAboveY, maxKeepY, keepNames };
}
clearClearZone() {
this._clearZone = undefined;
}
/**
* Initialize from StartGamePacket data.
*/
initFromStartGame(data) {
if (!data)
return;
this._gameMode = data.player_gamemode ?? data.gamemode ?? 1;
this._worldName = data.world_name ?? "";
this._difficulty = data.difficulty ?? 1;
this._dimensionId = data.dimension ?? 0;
if (data.world_spawn) {
// Bedrock uses Y >= 32768 as a sentinel meaning "spawn on top of highest block".
// Anything above ~32700 should be treated the same — never a real Y coordinate.
// Clamp to a reasonable surface default since we don't have chunk data yet.
let spawnY = data.world_spawn.y ?? 64;
if (spawnY >= 32700) {
Log_1.default.verbose(`LiveWorldState.initFromStartGame: world_spawn Y=${spawnY} is spawn-on-surface sentinel, clamping to 80`);
spawnY = 80;
}
this._spawnPosition = {
x: data.world_spawn.x ?? 0,
y: spawnY,
z: data.world_spawn.z ?? 0,
};
}
if (data.itemstates) {
// Build block palette from item states (contains runtime IDs)
// The actual block palette comes in the start_game packet
}
// Block palette from start_game
if (data.block_palette) {
for (const entry of data.block_palette) {
if (entry.runtime_id !== undefined && entry.name) {
this._blockPalette.set(entry.runtime_id, {
name: entry.name,
states: entry.states,
});
}
}
}
}
/**
* Get a block at world coordinates.
*/
getBlock(x, y, z) {
const chunkX = x >> 4;
const chunkZ = z >> 4;
const chunk = this._chunks.get(`${chunkX},${chunkZ}`);
if (!chunk)
return undefined;
const subchunkIndex = (y - this._minY) >> 4;
if (subchunkIndex < 0 || subchunkIndex >= chunk.subchunks.length)
return undefined;
const subchunk = chunk.subchunks[subchunkIndex];
if (!subchunk)
return undefined;
const localX = ((x % 16) + 16) % 16;
const localY = (((y - this._minY) % 16) + 16) % 16;
const localZ = ((z % 16) + 16) % 16;
// Bedrock SubChunk storage order is XZY: index = x*256 + z*16 + y
const runtimeId = subchunk.blocks[localX * 256 + localZ * 16 + localY];
const paletteEntry = this._blockPalette.get(runtimeId);
return {
runtimeId,
name: paletteEntry?.name ?? `unknown_${runtimeId}`,
};
}
/**
* Set a block at world coordinates (for client-side prediction).
*/
setBlock(x, y, z, runtimeId) {
const chunkX = x >> 4;
const chunkZ = z >> 4;
const key = `${chunkX},${chunkZ}`;
let chunk = this._chunks.get(key);
if (!chunk)
return;
const subchunkIndex = (y - this._minY) >> 4;
if (subchunkIndex < 0 || subchunkIndex >= chunk.subchunks.length)
return;
let subchunk = chunk.subchunks[subchunkIndex];
if (!subchunk) {
subchunk = {
blocks: new Int32Array(4096),
dirty: true,
};
chunk.subchunks[subchunkIndex] = subchunk;
}
const localX = ((x % 16) + 16) % 16;
const localY = (((y - this._minY) % 16) + 16) % 16;
const localZ = ((z % 16) + 16) % 16;
subchunk.blocks[localX * 256 + localZ * 16 + localY] = runtimeId;
subchunk.dirty = true;
chunk.dirty = true;
this._dirtyChunks.add(key);
}
/**
* Check if the chunk containing this position has been loaded.
*/
isChunkLoaded(x, z) {
const chunkX = x >> 4;
const chunkZ = z >> 4;
return this._chunks.has(`${chunkX},${chunkZ}`);
}
/**
* Check if a block position is solid (for collision detection).
*/
isSolid(x, y, z) {
const block = this.getBlock(x, y, z);
if (!block)
return false;
const name = block.name ?? "";
// Air and fluids are not solid
if (name === "minecraft:air" || name === "" || name === "minecraft:water" || name === "minecraft:lava") {
return false;
}
// Runtime ID 0 is usually air
if (block.runtimeId === 0)
return false;
// Non-solid blocks that you can't target/interact with
const short = name.startsWith("minecraft:") ? name.substring(10) : name;
if (short === "structure_void" || short === "barrier" || short === "light_block" || short.includes("fire")) {
return false;
}
return true;
}
/**
* Handle block_palette event from the proxy.
* This provides the mapping of runtime IDs to block names.
*/
handleBlockPalette(entries) {
const prevSize = this._blockPalette.size;
for (const entry of entries) {
if (entry.rid !== undefined && entry.name) {
this._blockPalette.set(entry.rid, { name: entry.name });
}
}
if (prevSize === 0 || this._blockPalette.size < 100) {
Log_1.default.verbose(`LiveWorldState.handleBlockPalette: added ${entries.length} entries, palette size now ${this._blockPalette.size}`);
}
// CRITICAL: Mark all loaded chunks as dirty so they get rebuilt with the new palette.
// Chunks loaded before the palette arrives have 0 rendered blocks because getBlockName()
// returned undefined for all runtime IDs. Now that we have the palette, they need rebuilding.
if (prevSize === 0 && this._blockPalette.size > 0) {
let rebuildCount = 0;
for (const [key, chunk] of this._chunks) {
if (chunk.meshGenerated) {
chunk.meshGenerated = false;
this._dirtyChunks.add(key);
rebuildCount++;
}
}
if (rebuildCount > 0) {
Log_1.default.verbose(`LiveWorldState.handleBlockPalette: marked ${rebuildCount} pre-palette chunks for rebuild`);
}
}
}
/**
* Handle a level_chunk packet from the server.
* The proxy may have pre-parsed subchunk data for us.
*/
_levelChunkLogCount = 0;
_subchunkLogCount = 0;
handleLevelChunk(packet) {
const chunkX = packet.x;
const chunkZ = packet.z;
const key = `${chunkX},${chunkZ}`;
const totalSubchunks = (this._maxY - this._minY + 1) >> 4;
const chunk = {
x: chunkX,
z: chunkZ,
subchunks: new Array(totalSubchunks).fill(undefined),
dirty: true,
meshGenerated: false,
};
// Log first few level_chunk packets for debugging
if (this._levelChunkLogCount < 3) {
this._levelChunkLogCount++;
Log_1.default.verbose(`LiveWorldState.handleLevelChunk: chunk(${chunkX},${chunkZ}) sub_chunk_count=${packet.sub_chunk_count} hasSubchunks=${!!(packet.subchunks && Array.isArray(packet.subchunks))} subchunkArrayLen=${packet.subchunks?.length ?? "N/A"} hasPayload=${!!packet.payload}`);
}
// If proxy sent parsed subchunk arrays, use them directly
if (packet.subchunks && Array.isArray(packet.subchunks)) {
let nonEmptyCount = 0;
for (const sc of packet.subchunks) {
const subchunkIndex = sc.y !== undefined ? sc.y - (this._minY >> 4) : 0;
if (subchunkIndex >= 0 && subchunkIndex < totalSubchunks && sc.blocks) {
const blocks = new Int32Array(4096);
let nonZero = 0;
for (let i = 0; i < Math.min(sc.blocks.length, 4096); i++) {
blocks[i] = sc.blocks[i];
if (sc.blocks[i] !== 0)
nonZero++;
}
chunk.subchunks[subchunkIndex] = { blocks, dirty: true };
if (nonZero > 0)
nonEmptyCount++;
// Apply clear zone filter to incoming level_chunk data
if (this._clearZone) {
this._applyClearZoneToSubchunk(blocks, subchunkIndex);
}
}
}
if (this._levelChunkLogCount <= 3) {
Log_1.default.verbose(` -> parsed ${packet.subchunks.length} subchunks, ${nonEmptyCount} non-empty`);
}
}
else if (packet.sub_chunk_count !== undefined && packet.payload) {
// Legacy: proxy sent raw payload
this._parseLevelChunkPayload(chunk, packet);
}
this._chunks.set(key, chunk);
this._dirtyChunks.add(key);
this._loadedChunkCount = this._chunks.size;
}
/**
* Handle a subchunk packet (from the proxy's parsed SubChunk response).
* The proxy resolves PalettedBlockStorage and sends pre-parsed block arrays.
* Origin is in sub-chunk coordinates (chunkX, subchunkY, chunkZ) — already
* in chunk coordinate space. Do NOT right-shift by 4 (that would be double-shifting).
*/
handleSubChunk(packet) {
if (!packet.entries || !Array.isArray(packet.entries)) {
if (this._subchunkLogCount < 3) {
this._subchunkLogCount++;
Log_1.default.verbose(`LiveWorldState.handleSubChunk: no entries array. keys=${Object.keys(packet).join(",")}`);
}
return;
}
const originX = packet.origin?.x ?? 0;
const originZ = packet.origin?.z ?? 0;
// Origin is in sub-chunk coordinates (chunkX, subchunkY, chunkZ).
// x and z are already chunk coordinates — use directly without >> 4.
const baseChunkX = originX;
const baseChunkZ = originZ;
let successCount = 0;
let blockDataCount = 0;
for (const entry of packet.entries) {
if (entry.result !== "success" || !entry.blocks)
continue;
successCount++;
const chunkX = baseChunkX + (entry.dx ?? 0);
const chunkZ = baseChunkZ + (entry.dz ?? 0);
const subY = entry.dy ?? 0;
const yIndex = entry.yIndex ?? subY;
const key = `${chunkX},${chunkZ}`;
let chunk = this._chunks.get(key);
if (!chunk) {
const totalSubchunks = (this._maxY - this._minY + 1) >> 4;
chunk = {
x: chunkX,
z: chunkZ,
subchunks: new Array(totalSubchunks).fill(undefined),
dirty: true,
meshGenerated: false,
};
this._chunks.set(key, chunk);
}
const subchunkIndex = yIndex - (this._minY >> 4);
if (subchunkIndex >= 0 && subchunkIndex < chunk.subchunks.length) {
const blocks = new Int32Array(4096);
const srcBlocks = entry.blocks;
let nonZero = 0;
for (let i = 0; i < Math.min(srcBlocks.length, 4096); i++) {
blocks[i] = srcBlocks[i];
if (srcBlocks[i] !== 0)
nonZero++;
}
if (nonZero > 0)
blockDataCount++;
chunk.subchunks[subchunkIndex] = {
blocks: blocks,
dirty: true,
};
// Apply clear zone filter to incoming SubChunk data
if (this._clearZone) {
this._applyClearZoneToSubchunk(blocks, subchunkIndex);
}
}
chunk.dirty = true;
this._dirtyChunks.add(key);
// Mark adjacent chunks dirty for face-culling recalculation.
// When a chunk loads, its neighbors' edge blocks may have been rendered
// as "exposed" because this chunk's data wasn't available yet.
// Now that we have data, neighbors need to recalculate face culling.
for (const [adjDx, adjDz] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
]) {
const adjKey = `${chunkX + adjDx},${chunkZ + adjDz}`;
const adjChunk = this._chunks.get(adjKey);
if (adjChunk && adjChunk.meshGenerated) {
adjChunk.meshGenerated = false;
this._dirtyChunks.add(adjKey);
}
}
}
if (this._subchunkLogCount < 5) {
this._subchunkLogCount++;
Log_1.default.verbose(`LiveWorldState.handleSubChunk: origin=(${originX},${originZ}) entries=${packet.entries.length} success=${successCount} withBlockData=${blockDataCount}`);
if (packet.entries.length > 0) {
const e = packet.entries[0];
Log_1.default.verbose(` first entry: result=${e.result} hasBlocks=${!!e.blocks} blocksLen=${e.blocks?.length ?? "N/A"} dx=${e.dx} dy=${e.dy} yIndex=${e.yIndex}`);
}
}
this._loadedChunkCount = this._chunks.size;
}
/**
* Handle update_block packet.
*/
handleUpdateBlock(packet) {
const pos = packet.position ?? packet.block_position;
if (!pos)
return;
const x = pos.x ?? pos.X ?? 0;
const y = pos.y ?? pos.Y ?? 0;
const z = pos.z ?? pos.Z ?? 0;
const runtimeId = packet.block_runtime_id ?? packet.runtime_id ?? 0;
this.setBlock(x, y, z, runtimeId);
}
/**
* Handle update_subchunk_blocks — batch block updates within a subchunk.
*/
handleUpdateSubChunkBlocks(packet) {
const updates = packet.blocks ?? packet.standard_blocks ?? [];
const extraUpdates = packet.extra_blocks ?? [];
for (const update of [...updates, ...extraUpdates]) {
const pos = update.block_position ?? update.position;
if (!pos)
continue;
const x = pos.x ?? pos.X ?? 0;
const y = pos.y ?? pos.Y ?? 0;
const z = pos.z ?? pos.Z ?? 0;
const runtimeId = update.block_runtime_id ?? update.runtime_id ?? 0;
this.setBlock(x, y, z, runtimeId);
}
}
/**
* Handle network_chunk_publisher_update — defines which chunks should be loaded.
*/
handleChunkPublisherUpdate(packet) {
// Could unload chunks outside the radius, but for now keep everything
}
/**
* Get chunk at chunk coordinates.
*/
getChunk(chunkX, chunkZ) {
return this._chunks.get(`${chunkX},${chunkZ}`);
}
/**
* Get all dirty chunks and clear dirty flags.
*/
consumeDirtyChunks() {
const dirty = [];
for (const key of this._dirtyChunks) {
const chunk = this._chunks.get(key);
if (chunk) {
dirty.push(chunk);
chunk.dirty = false;
if (chunk.subchunks) {
for (const sc of chunk.subchunks) {
if (sc)
sc.dirty = false;
}
}
}
}
this._dirtyChunks.clear();
return dirty;
}
/**
* Mark a chunk as dirty so it will be rebuilt on next consumeDirtyChunks call.
*/
markChunkDirty(chunk) {
chunk.dirty = true;
this._dirtyChunks.add(`${chunk.x},${chunk.z}`);
}
/**
* Get all loaded chunks.
*/
getAllChunks() {
return Array.from(this._chunks.values());
}
/**
* Read-only access to the chunks map for iteration.
* Used by WorldRenderer.ensureNearbyChunkMeshes() to scan for chunks
* within render distance that need mesh generation.
*/
get chunks() {
return this._chunks;
}
/**
* Get a chunk by its key string ("chunkX,chunkZ").
*/
getChunkByKey(key) {
return this._chunks.get(key);
}
/**
* Add or replace a chunk column directly.
* Used by WorldViewer to feed pre-built chunk data from file-based MCWorld.
*/
setChunkColumn(chunk) {
const key = `${chunk.x},${chunk.z}`;
this._chunks.set(key, chunk);
this._loadedChunkCount = this._chunks.size;
if (chunk.dirty) {
this._dirtyChunks.add(key);
}
}
/**
* Mark ALL loaded chunks as dirty, forcing a complete mesh rebuild.
* Useful after fill commands or other bulk world modifications.
*/
markAllChunksDirty() {
for (const [key, chunk] of this._chunks) {
chunk.dirty = true;
this._dirtyChunks.add(key);
}
}
/**
* Get block palette entry by runtime ID.
*/
_lookupLogCount = 0;
_lookupMissCount = 0;
_lookupHitCount = 0;
getBlockName(runtimeId) {
const entry = this._blockPalette.get(runtimeId);
if (entry) {
this._lookupHitCount++;
}
else {
this._lookupMissCount++;
if (this._lookupLogCount < 5 && runtimeId !== 0) {
this._lookupLogCount++;
Log_1.default.verbose(`LiveWorldState.getBlockName: MISS runtimeId=${runtimeId} paletteSize=${this._blockPalette.size} hits=${this._lookupHitCount} misses=${this._lookupMissCount}`);
}
}
return entry?.name ?? "minecraft:air";
}
/**
* Unload all chunks (e.g., on dimension change).
*/
clear() {
this._chunks.clear();
this._dirtyChunks.clear();
this._loadedChunkCount = 0;
}
/**
* Parse a level_chunk payload into subchunk data.
* The payload format depends on the network protocol version, but decoded
* packets typically give us the raw subchunk data as a Buffer.
*/
_parseLevelChunkPayload(chunk, packet) {
// For now, create placeholder subchunks from the data.
// In many cases the decoded packet already parsed the payload structure for us.
const subchunkCount = packet.sub_chunk_count;
if (typeof subchunkCount === "number" && subchunkCount > 0) {
// If payload is a buffer, we need to parse sub-chunk format
// Each subchunk uses PalettedBlockStorage format
// For the initial implementation, we'll populate from parsed data when available
for (let i = 0; i < Math.min(subchunkCount, chunk.subchunks.length); i++) {
if (!chunk.subchunks[i]) {
chunk.subchunks[i] = {
blocks: new Int32Array(4096),
dirty: true,
};
}
}
}
}
/**
* Parse raw subchunk data into an ISubChunk.
* Bedrock uses PalettedBlockStorage format per subchunk.
*/
_parseSubChunkData(payload) {
const subchunk = {
blocks: new Int32Array(4096),
dirty: true,
};
// If payload is already parsed (array of block entries), use directly
if (payload && payload.blocks) {
for (let i = 0; i < Math.min(payload.blocks.length, 4096); i++) {
subchunk.blocks[i] = payload.blocks[i];
}
}
return subchunk;
}
/**
* Apply the clear zone filter to a subchunk's block data.
* Replaces non-whitelisted blocks above clearAboveY with air (runtime ID 0).
*/
_applyClearZoneToSubchunk(blocks, subchunkIndex) {
const zone = this._clearZone;
if (!zone)
return;
const scBaseY = this._minY + subchunkIndex * 16;
if (scBaseY + 15 < zone.clearAboveY)
return; // entirely below clear zone
// Find air runtime ID
let airId = 0;
if (this._blockPalette) {
for (const [rid, entry] of this._blockPalette) {
if (entry.name === "minecraft:air") {
airId = rid;
break;
}
}
}
for (let i = 0; i < 4096; i++) {
if (blocks[i] === airId || blocks[i] === 0)
continue;
const localY = i % 16;
const worldY = scBaseY + localY;
if (worldY < zone.clearAboveY)
continue;
const entry = this._blockPalette?.get?.(blocks[i]);
const name = entry?.name ?? "";
if (worldY <= zone.maxKeepY && zone.keepNames.has(name))
continue;
blocks[i] = airId;
}
}
}
exports.default = LiveWorldState;