LEVELDB_ZLIB_RAW_COMPRESSION, "block_size" => 64 * 1024 //64KB, big enough for most chunks ]; private static function dbRegionPath(string $base, int $regionLength) : string{ return Path::join($base, "leveldb-regions-$regionLength"); } public static function isValid(string $path, int $regionLength) : bool{ return file_exists(Path::join($path, "level.dat")) && is_dir(self::dbRegionPath($path, $regionLength)); } public static function generate(string $path, string $name, WorldCreationOptions $options, int $regionLength) : void{ self::baseGenerate($path, $name, $options); touch(Path::join($path, 'NOT_BEDROCK_COMPATIBLE.txt')); @mkdir(self::dbRegionPath($path, $regionLength), 0777, true); } /** * @var \LevelDB[]|null[] * @phpstan-var array */ private array $databases = []; /** * @var int[] * @phpstan-var array */ private array $databasesLastUsed = []; public function __construct( string $path, \Logger $logger, private readonly int $regionLength ){ parent::__construct($path, $logger); } protected function coordsFromChunkIndex(string $chunkIndex) : array{ //TODO: these indexes don't need to use long in separated DBs, we could make them smaller and save space return morton2d_decode(Binary::readLong($chunkIndex)); } protected function coordsToChunkIndex(int $chunkX, int $chunkZ) : string{ return Binary::writeLong(morton2d_encode($chunkX, $chunkZ)); } protected function getDBPathForCoords(int $chunkX, int $chunkZ) : string{ return Path::join(self::dbRegionPath($this->path, $this->regionLength), sprintf( "db.%d.%d", intdiv($chunkX, $this->regionLength), intdiv($chunkZ, $this->regionLength) )); } protected function getDBIndexForCoords(int $chunkX, int $chunkZ) : int{ return morton2d_encode(intdiv($chunkX, $this->regionLength), intdiv($chunkZ, $this->regionLength)); } protected function fetchDBForCoords(int $chunkX, int $chunkZ, bool $createIfMissing) : ?\LevelDB{ $index = $this->getDBIndexForCoords($chunkX, $chunkZ); $db = $this->databases[$index] ?? null; if( !array_key_exists($index, $this->databases) || //we haven't tried to fetch this DB yet ($db === null && $createIfMissing) //or we know it doesn't exist and want to create it (for writing) ){ $options = self::DB_DEFAULT_OPTIONS; $options["create_if_missing"] = $createIfMissing; $dbPath = $this->getDBPathForCoords($chunkX, $chunkZ); try{ $this->databases[$index] = new \LevelDB($dbPath, $options); }catch(\LevelDBException $e){ //no other way to detect error type :( if(!str_contains($e->getMessage(), "(create_if_missing is false)")){ throw new CorruptedChunkException("Couldn't open LevelDB region $dbPath for $chunkX.$chunkZ: " . $e->getMessage(), 0, $e); } //remember that this DB doesn't exist, so we don't have to hit the disk hundreds of times looking for it $this->databases[$index] = null; } } $this->databasesLastUsed[$index] = time(); return $this->databases[$index]; } public function loadChunk(int $chunkX, int $chunkZ) : ?LoadedChunkData{ $db = $this->fetchDBForCoords($chunkX, $chunkZ, createIfMissing: false); return $db !== null ? $this->loadChunkFromDB($db, $chunkX, $chunkZ) : null; } public function saveChunk(int $chunkX, int $chunkZ, ChunkData $chunkData, int $dirtyFlags) : void{ $db = $this->fetchDBForCoords($chunkX, $chunkZ, createIfMissing: true) ?? throw new AssumptionFailedError("We asked fetch to create a DB, it shouldn't return null"); $this->saveChunkToDB($db, $chunkX, $chunkZ, $chunkData, $dirtyFlags); } public function doGarbageCollection() : void{ $minLastUsed = time() - self::MAX_DB_CACHE_AGE; foreach($this->databasesLastUsed as $index => $time){ if($time < $minLastUsed){ //unset will close the DB unset( $this->databases[$index], $this->databasesLastUsed[$index] ); } } } public function close() : void{ //no explicit actions needed to close DBs $this->databases = []; $this->databasesLastUsed = []; } private function createRegionIterator() : \RegexIterator{ return new \RegexIterator( new \FilesystemIterator( self::dbRegionPath($this->path, $this->regionLength), \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS ), '/\/db\.(-?\d+)\.(-?\d+)\$/', \RegexIterator::GET_MATCH ); } public function getAllChunks(bool $skipCorrupted = false, ?\Logger $logger = null) : \Generator{ $iterator = $this->createRegionIterator(); /** @var string[] $region */ foreach($iterator as $region){ try{ $db = new \LevelDB($region[0], self::DB_DEFAULT_OPTIONS); //TODO: we don't need the DB name coords for now, but we might in the future if the key format is //changed to be relative yield from $this->getAllChunksFromDB($db, $skipCorrupted, $logger); }catch(\LevelDBException $e){ //TODO: detect permission errors - although I'm not sure what we could do differently if(!$skipCorrupted){ throw new CorruptedChunkException($e->getMessage(), 0, $e); } if($logger !== null){ $logger->error($e->getMessage()); } } } } public function calculateChunkCount() : int{ $iterator = $this->createRegionIterator(); $total = 0; /** @var string[] $region */ foreach($iterator as $region){ //TODO: calculateChunkCount has no accounting for corruption errors $db = new \LevelDB($region[0], self::DB_DEFAULT_OPTIONS); //TODO: we'd need a specialized calculate impl if we change the key length $total += $this->calculateChunkCountInDB($db); } return $total; } }