LEVELDB_ZLIB_RAW_COMPRESSION, "block_size" => 64 * 1024 //64KB, big enough for most chunks ]); } public function __construct(string $path){ self::checkForLevelDBExtension(); parent::__construct($path); try{ $this->db = self::createDB($path); }catch(\LevelDBException $e){ //we can't tell the difference between errors caused by bad permissions and actual corruption :( throw new CorruptedWorldException(trim($e->getMessage()), 0, $e); } } protected function loadLevelData() : WorldData{ return new BedrockWorldData(Path::join($this->getPath(), "level.dat")); } public function getWorldMinY() : int{ return 0; } public function getWorldMaxY() : int{ return 256; } public static function isValid(string $path) : bool{ return file_exists(Path::join($path, "level.dat")) && is_dir(Path::join($path, "db")); } public static function generate(string $path, string $name, WorldCreationOptions $options) : void{ self::checkForLevelDBExtension(); $dbPath = Path::join($path, "db"); if(!file_exists($dbPath)){ mkdir($dbPath, 0777, true); } BedrockWorldData::generate($path, $name, $options); } /** * @throws CorruptedChunkException */ protected function deserializePaletted(BinaryStream $stream) : PalettedBlockArray{ $bitsPerBlock = $stream->getByte() >> 1; try{ $words = $stream->get(PalettedBlockArray::getExpectedWordArraySize($bitsPerBlock)); }catch(\InvalidArgumentException $e){ throw new CorruptedChunkException("Failed to deserialize paletted storage: " . $e->getMessage(), 0, $e); } $nbt = new LittleEndianNbtSerializer(); $palette = []; $idMap = LegacyBlockIdToStringIdMap::getInstance(); for($i = 0, $paletteSize = $stream->getLInt(); $i < $paletteSize; ++$i){ try{ $offset = $stream->getOffset(); $tag = $nbt->read($stream->getBuffer(), $offset)->mustGetCompoundTag(); $stream->setOffset($offset); $id = $idMap->stringToLegacy($tag->getString("name")) ?? BlockLegacyIds::INFO_UPDATE; $data = $tag->getShort("val"); $palette[] = ($id << Block::INTERNAL_METADATA_BITS) | $data; }catch(NbtException $e){ throw new CorruptedChunkException("Invalid blockstate NBT at offset $i in paletted storage: " . $e->getMessage(), 0, $e); } } //TODO: exceptions return PalettedBlockArray::fromData($bitsPerBlock, $words, $palette); } protected static function deserializeExtraDataKey(int $chunkVersion, int $key, ?int &$x, ?int &$y, ?int &$z) : void{ if($chunkVersion >= ChunkVersion::v1_0_0){ $x = ($key >> 12) & 0xf; $z = ($key >> 8) & 0xf; $y = $key & 0xff; }else{ //pre-1.0, 7 bits were used because the build height limit was lower $x = ($key >> 11) & 0xf; $z = ($key >> 7) & 0xf; $y = $key & 0x7f; } } /** * @return PalettedBlockArray[] */ protected function deserializeLegacyExtraData(string $index, int $chunkVersion) : array{ if(($extraRawData = $this->db->get($index . ChunkDataKey::LEGACY_BLOCK_EXTRA_DATA)) === false || $extraRawData === ""){ return []; } /** @var PalettedBlockArray[] $extraDataLayers */ $extraDataLayers = []; $binaryStream = new BinaryStream($extraRawData); $count = $binaryStream->getLInt(); for($i = 0; $i < $count; ++$i){ $key = $binaryStream->getLInt(); $value = $binaryStream->getLShort(); self::deserializeExtraDataKey($chunkVersion, $key, $x, $fullY, $z); $ySub = ($fullY >> SubChunk::COORD_BIT_SIZE); $y = $key & SubChunk::COORD_MASK; $blockId = $value & 0xff; $blockData = ($value >> 8) & 0xf; if(!isset($extraDataLayers[$ySub])){ $extraDataLayers[$ySub] = new PalettedBlockArray(BlockLegacyIds::AIR << Block::INTERNAL_METADATA_BITS); } $extraDataLayers[$ySub]->set($x, $y, $z, ($blockId << Block::INTERNAL_METADATA_BITS) | $blockData); } return $extraDataLayers; } /** * @throws CorruptedChunkException */ public function loadChunk(int $chunkX, int $chunkZ) : ?ChunkData{ $index = LevelDB::chunkIndex($chunkX, $chunkZ); $chunkVersionRaw = $this->db->get($index . ChunkDataKey::OLD_VERSION); if($chunkVersionRaw === false){ return null; } /** @var SubChunk[] $subChunks */ $subChunks = []; /** @var BiomeArray|null $biomeArray */ $biomeArray = null; $chunkVersion = ord($chunkVersionRaw); $hasBeenUpgraded = $chunkVersion < self::CURRENT_LEVEL_CHUNK_VERSION; switch($chunkVersion){ case ChunkVersion::v1_12_0_4_beta: case ChunkVersion::v1_11_1: case ChunkVersion::v1_11_0_4_beta: case ChunkVersion::v1_11_0_3_beta: case ChunkVersion::v1_11_0_1_beta: case ChunkVersion::v1_9_0: case ChunkVersion::v1_8_0: case ChunkVersion::v1_2_13: case ChunkVersion::v1_2_0: case ChunkVersion::v1_2_0_2_beta: case ChunkVersion::v1_1_0: //TODO: check beds case ChunkVersion::v1_0_0: $convertedLegacyExtraData = $this->deserializeLegacyExtraData($index, $chunkVersion); for($y = Chunk::MIN_SUBCHUNK_INDEX; $y <= Chunk::MAX_SUBCHUNK_INDEX; ++$y){ if(($data = $this->db->get($index . ChunkDataKey::SUBCHUNK . chr($y))) === false){ continue; } $binaryStream = new BinaryStream($data); if($binaryStream->feof()){ throw new CorruptedChunkException("Unexpected empty data for subchunk $y"); } $subChunkVersion = $binaryStream->getByte(); if($subChunkVersion < self::CURRENT_LEVEL_SUBCHUNK_VERSION){ $hasBeenUpgraded = true; } switch($subChunkVersion){ case SubChunkVersion::CLASSIC: case SubChunkVersion::CLASSIC_BUG_2: //these are all identical to version 0, but vanilla respects these so we should also case SubChunkVersion::CLASSIC_BUG_3: case SubChunkVersion::CLASSIC_BUG_4: case SubChunkVersion::CLASSIC_BUG_5: case SubChunkVersion::CLASSIC_BUG_6: case SubChunkVersion::CLASSIC_BUG_7: try{ $blocks = $binaryStream->get(4096); $blockData = $binaryStream->get(2048); if($chunkVersion < ChunkVersion::v1_1_0){ $binaryStream->get(4096); //legacy light info, discard it $hasBeenUpgraded = true; } }catch(BinaryDataException $e){ throw new CorruptedChunkException($e->getMessage(), 0, $e); } $storages = [SubChunkConverter::convertSubChunkXZY($blocks, $blockData)]; if(isset($convertedLegacyExtraData[$y])){ $storages[] = $convertedLegacyExtraData[$y]; } $subChunks[$y] = new SubChunk(BlockLegacyIds::AIR << Block::INTERNAL_METADATA_BITS, $storages); break; case SubChunkVersion::PALETTED_SINGLE: $storages = [$this->deserializePaletted($binaryStream)]; if(isset($convertedLegacyExtraData[$y])){ $storages[] = $convertedLegacyExtraData[$y]; } $subChunks[$y] = new SubChunk(BlockLegacyIds::AIR << Block::INTERNAL_METADATA_BITS, $storages); break; case SubChunkVersion::PALETTED_MULTI: //legacy extradata layers intentionally ignored because they aren't supposed to exist in v8 $storageCount = $binaryStream->getByte(); if($storageCount > 0){ $storages = []; for($k = 0; $k < $storageCount; ++$k){ $storages[] = $this->deserializePaletted($binaryStream); } $subChunks[$y] = new SubChunk(BlockLegacyIds::AIR << Block::INTERNAL_METADATA_BITS, $storages); } break; default: //TODO: set chunks read-only so the version on disk doesn't get overwritten throw new CorruptedChunkException("don't know how to decode LevelDB subchunk format version $subChunkVersion"); } } if(($maps2d = $this->db->get($index . ChunkDataKey::HEIGHTMAP_AND_2D_BIOMES)) !== false){ $binaryStream = new BinaryStream($maps2d); try{ $binaryStream->get(512); //heightmap, discard it $biomeArray = new BiomeArray($binaryStream->get(256)); //never throws }catch(BinaryDataException $e){ throw new CorruptedChunkException($e->getMessage(), 0, $e); } } break; case ChunkVersion::v0_9_5: case ChunkVersion::v0_9_2: case ChunkVersion::v0_9_0: $convertedLegacyExtraData = $this->deserializeLegacyExtraData($index, $chunkVersion); $legacyTerrain = $this->db->get($index . ChunkDataKey::LEGACY_TERRAIN); if($legacyTerrain === false){ throw new CorruptedChunkException("Missing expected LEGACY_TERRAIN tag for format version $chunkVersion"); } $binaryStream = new BinaryStream($legacyTerrain); try{ $fullIds = $binaryStream->get(32768); $fullData = $binaryStream->get(16384); $binaryStream->get(32768); //legacy light info, discard it }catch(BinaryDataException $e){ throw new CorruptedChunkException($e->getMessage(), 0, $e); } for($yy = 0; $yy < 8; ++$yy){ $storages = [SubChunkConverter::convertSubChunkFromLegacyColumn($fullIds, $fullData, $yy)]; if(isset($convertedLegacyExtraData[$yy])){ $storages[] = $convertedLegacyExtraData[$yy]; } $subChunks[$yy] = new SubChunk(BlockLegacyIds::AIR << Block::INTERNAL_METADATA_BITS, $storages); } try{ $binaryStream->get(256); //heightmap, discard it /** @var int[] $unpackedBiomeArray */ $unpackedBiomeArray = unpack("N*", $binaryStream->get(1024)); //unpack() will never fail here $biomeArray = new BiomeArray(ChunkUtils::convertBiomeColors(array_values($unpackedBiomeArray))); //never throws }catch(BinaryDataException $e){ throw new CorruptedChunkException($e->getMessage(), 0, $e); } break; default: //TODO: set chunks read-only so the version on disk doesn't get overwritten throw new CorruptedChunkException("don't know how to decode chunk format version $chunkVersion"); } $nbt = new LittleEndianNbtSerializer(); /** @var CompoundTag[] $entities */ $entities = []; if(($entityData = $this->db->get($index . ChunkDataKey::ENTITIES)) !== false && $entityData !== ""){ try{ $entities = array_map(fn(TreeRoot $root) => $root->mustGetCompoundTag(), $nbt->readMultiple($entityData)); }catch(NbtDataException $e){ throw new CorruptedChunkException($e->getMessage(), 0, $e); } } /** @var CompoundTag[] $tiles */ $tiles = []; if(($tileData = $this->db->get($index . ChunkDataKey::BLOCK_ENTITIES)) !== false && $tileData !== ""){ try{ $tiles = array_map(fn(TreeRoot $root) => $root->mustGetCompoundTag(), $nbt->readMultiple($tileData)); }catch(NbtDataException $e){ throw new CorruptedChunkException($e->getMessage(), 0, $e); } } $finalisationChr = $this->db->get($index . ChunkDataKey::FINALIZATION); if($finalisationChr !== false){ $finalisation = ord($finalisationChr); $terrainPopulated = $finalisation === self::FINALISATION_DONE; }else{ //older versions didn't have this tag $terrainPopulated = true; } //TODO: tile ticks, biome states (?) $chunk = new Chunk( $subChunks, $biomeArray ?? BiomeArray::fill(BiomeIds::OCEAN), //TODO: maybe missing biomes should be an error? $terrainPopulated ); if($hasBeenUpgraded){ $chunk->setTerrainDirty(); //trigger rewriting chunk to disk if it was converted from an older format } return new ChunkData($chunk, $entities, $tiles); } public function saveChunk(int $chunkX, int $chunkZ, ChunkData $chunkData) : void{ $idMap = LegacyBlockIdToStringIdMap::getInstance(); $index = LevelDB::chunkIndex($chunkX, $chunkZ); $write = new \LevelDBWriteBatch(); $write->put($index . ChunkDataKey::OLD_VERSION, chr(self::CURRENT_LEVEL_CHUNK_VERSION)); $chunk = $chunkData->getChunk(); if($chunk->getTerrainDirtyFlag(Chunk::DIRTY_FLAG_BLOCKS)){ $subChunks = $chunk->getSubChunks(); foreach($subChunks as $y => $subChunk){ $key = $index . ChunkDataKey::SUBCHUNK . chr($y); if($subChunk->isEmptyAuthoritative()){ $write->delete($key); }else{ $subStream = new BinaryStream(); $subStream->putByte(self::CURRENT_LEVEL_SUBCHUNK_VERSION); $layers = $subChunk->getBlockLayers(); $subStream->putByte(count($layers)); foreach($layers as $blocks){ if($blocks->getBitsPerBlock() !== 0){ $subStream->putByte($blocks->getBitsPerBlock() << 1); $subStream->put($blocks->getWordArray()); }else{ //TODO: we use these in-memory, but they aren't supported on disk by the game yet //polyfill them with a zero'd 1-bpb instead $subStream->putByte(1 << 1); $subStream->put(str_repeat("\x00", PalettedBlockArray::getExpectedWordArraySize(1))); } $palette = $blocks->getPalette(); $subStream->putLInt(count($palette)); $tags = []; foreach($palette as $p){ $tags[] = new TreeRoot(CompoundTag::create() ->setString("name", $idMap->legacyToString($p >> Block::INTERNAL_METADATA_BITS) ?? "minecraft:info_update") ->setInt("oldid", $p >> Block::INTERNAL_METADATA_BITS) //PM only (debugging), vanilla doesn't have this ->setShort("val", $p & Block::INTERNAL_METADATA_MASK)); } $subStream->put((new LittleEndianNbtSerializer())->writeMultiple($tags)); } $write->put($key, $subStream->getBuffer()); } } } if($chunk->getTerrainDirtyFlag(Chunk::DIRTY_FLAG_BIOMES)){ $write->put($index . ChunkDataKey::HEIGHTMAP_AND_2D_BIOMES, str_repeat("\x00", 512) . $chunk->getBiomeIdArray()); } //TODO: use this properly $write->put($index . ChunkDataKey::FINALIZATION, chr($chunk->isPopulated() ? self::FINALISATION_DONE : self::FINALISATION_NEEDS_POPULATION)); $this->writeTags($chunkData->getTileNBT(), $index . ChunkDataKey::BLOCK_ENTITIES, $write); $this->writeTags($chunkData->getEntityNBT(), $index . ChunkDataKey::ENTITIES, $write); $write->delete($index . ChunkDataKey::HEIGHTMAP_AND_2D_BIOME_COLORS); $write->delete($index . ChunkDataKey::LEGACY_TERRAIN); $this->db->write($write); } /** * @param CompoundTag[] $targets */ private function writeTags(array $targets, string $index, \LevelDBWriteBatch $write) : void{ if(count($targets) > 0){ $nbt = new LittleEndianNbtSerializer(); $write->put($index, $nbt->writeMultiple(array_map(fn(CompoundTag $tag) => new TreeRoot($tag), $targets))); }else{ $write->delete($index); } } public function getDatabase() : \LevelDB{ return $this->db; } public static function chunkIndex(int $chunkX, int $chunkZ) : string{ return Binary::writeLInt($chunkX) . Binary::writeLInt($chunkZ); } public function doGarbageCollection() : void{ } public function close() : void{ unset($this->db); } public function getAllChunks(bool $skipCorrupted = false, ?\Logger $logger = null) : \Generator{ foreach($this->db->getIterator() as $key => $_){ if(strlen($key) === 9 && substr($key, -1) === ChunkDataKey::OLD_VERSION){ $chunkX = Binary::readLInt(substr($key, 0, 4)); $chunkZ = Binary::readLInt(substr($key, 4, 4)); try{ if(($chunk = $this->loadChunk($chunkX, $chunkZ)) !== null){ yield [$chunkX, $chunkZ] => $chunk; } }catch(CorruptedChunkException $e){ if(!$skipCorrupted){ throw $e; } if($logger !== null){ $logger->error("Skipped corrupted chunk $chunkX $chunkZ (" . $e->getMessage() . ")"); } } } } } public function calculateChunkCount() : int{ $count = 0; foreach($this->db->getIterator() as $key => $_){ if(strlen($key) === 9 && substr($key, -1) === ChunkDataKey::OLD_VERSION){ $count++; } } return $count; } }