filePath = $filePath; $this->garbageTable = new RegionGarbageMap([]); } /** * @throws CorruptedRegionException */ public function open() : void{ $exists = file_exists($this->filePath); if(!$exists){ touch($this->filePath); }elseif(filesize($this->filePath) % 4096 !== 0){ throw new CorruptedRegionException("Region file should be padded to a multiple of 4KiB"); } $filePointer = fopen($this->filePath, "r+b"); if($filePointer === false) throw new AssumptionFailedError("fopen() should not fail here"); $this->filePointer = $filePointer; stream_set_read_buffer($this->filePointer, 1024 * 16); //16KB stream_set_write_buffer($this->filePointer, 1024 * 16); //16KB if(!$exists){ $this->createBlank(); }else{ $this->loadLocationTable(); } $this->lastUsed = time(); } public function __destruct(){ if(is_resource($this->filePointer)){ fclose($this->filePointer); } } protected function isChunkGenerated(int $index) : bool{ return $this->locationTable[$index] !== null; } /** * @throws \InvalidArgumentException if invalid coordinates are given * @throws CorruptedChunkException if chunk corruption is detected */ public function readChunk(int $x, int $z) : ?string{ $index = self::getChunkOffset($x, $z); $this->lastUsed = time(); if($this->locationTable[$index] === null){ return null; } fseek($this->filePointer, $this->locationTable[$index]->getFirstSector() << 12); $prefix = fread($this->filePointer, 4); if($prefix === false or strlen($prefix) !== 4){ throw new CorruptedChunkException("Corrupted chunk header detected (unexpected end of file reading length prefix)"); } $length = Binary::readInt($prefix); if($length <= 0){ //TODO: if we reached here, the locationTable probably needs updating return null; } if($length > self::MAX_SECTOR_LENGTH){ //corrupted throw new CorruptedChunkException("Length for chunk x=$x,z=$z ($length) is larger than maximum " . self::MAX_SECTOR_LENGTH); } if($length > ($this->locationTable[$index]->getSectorCount() << 12)){ //Invalid chunk, bigger than defined number of sectors \GlobalLogger::get()->error("Chunk x=$x,z=$z length mismatch (expected " . ($this->locationTable[$index]->getSectorCount() << 12) . " sectors, got $length sectors)"); $old = $this->locationTable[$index]; $this->locationTable[$index] = new RegionLocationTableEntry($old->getFirstSector(), $length >> 12, time()); $this->writeLocationIndex($index); } $chunkData = fread($this->filePointer, $length); if($chunkData === false or strlen($chunkData) !== $length){ throw new CorruptedChunkException("Corrupted chunk detected (unexpected end of file reading chunk data)"); } $compression = ord($chunkData[0]); if($compression !== self::COMPRESSION_ZLIB and $compression !== self::COMPRESSION_GZIP){ throw new CorruptedChunkException("Invalid compression type (got $compression, expected " . self::COMPRESSION_ZLIB . " or " . self::COMPRESSION_GZIP . ")"); } return substr($chunkData, 1); } /** * @throws \InvalidArgumentException */ public function chunkExists(int $x, int $z) : bool{ return $this->isChunkGenerated(self::getChunkOffset($x, $z)); } /** * @throws ChunkException * @throws \InvalidArgumentException */ public function writeChunk(int $x, int $z, string $chunkData) : void{ $this->lastUsed = time(); $length = strlen($chunkData) + 1; if($length + 4 > self::MAX_SECTOR_LENGTH){ throw new ChunkException("Chunk is too big! " . ($length + 4) . " > " . self::MAX_SECTOR_LENGTH); } $newSize = (int) ceil(($length + 4) / 4096); $index = self::getChunkOffset($x, $z); /* * look for an unused area big enough to hold this data * this is corruption-resistant (it leaves the old data intact if a failure occurs when writing new data), and * also allows the file to become more compact across consecutive writes without introducing a dedicated garbage * collection mechanism. */ $newLocation = $this->garbageTable->allocate($newSize); /* if no gaps big enough were found, append to the end of the file instead */ if($newLocation === null){ $newLocation = new RegionLocationTableEntry($this->nextSector, $newSize, time()); $this->bumpNextFreeSector($newLocation); } /* write the chunk data into the chosen location */ fseek($this->filePointer, $newLocation->getFirstSector() << 12); fwrite($this->filePointer, str_pad(Binary::writeInt($length) . chr(self::COMPRESSION_ZLIB) . $chunkData, $newSize << 12, "\x00", STR_PAD_RIGHT)); /* * update the file header - we do this after writing the main data, so that if a failure occurs while writing, * the header will still point to the old (intact) copy of the chunk, instead of a potentially broken new * version of the file (e.g. partially written). */ $oldLocation = $this->locationTable[$index]; $this->locationTable[$index] = $newLocation; $this->writeLocationIndex($index); if($oldLocation !== null){ /* release the area containing the old copy to the garbage pool */ $this->garbageTable->add($oldLocation); $endGarbage = $this->garbageTable->end(); $nextSector = $this->nextSector; for(; $endGarbage !== null and $endGarbage->getLastSector() + 1 === $nextSector; $endGarbage = $this->garbageTable->end()){ $nextSector = $endGarbage->getFirstSector(); $this->garbageTable->remove($endGarbage); } if($nextSector !== $this->nextSector){ $this->nextSector = $nextSector; ftruncate($this->filePointer, $this->nextSector << 12); } } } /** * @throws \InvalidArgumentException */ public function removeChunk(int $x, int $z) : void{ $index = self::getChunkOffset($x, $z); $this->locationTable[$index] = null; $this->writeLocationIndex($index); } /** * @throws \InvalidArgumentException */ protected static function getChunkOffset(int $x, int $z) : int{ if($x < 0 or $x > 31 or $z < 0 or $z > 31){ throw new \InvalidArgumentException("Invalid chunk position in region, expected x/z in range 0-31, got x=$x, z=$z"); } return $x | ($z << 5); } /** * @param int $x reference parameter * @param int $z reference parameter */ protected static function getChunkCoords(int $offset, ?int &$x, ?int &$z) : void{ $x = $offset & 0x1f; $z = ($offset >> 5) & 0x1f; } /** * Closes the file */ public function close() : void{ if(is_resource($this->filePointer)){ fclose($this->filePointer); } } /** * @throws CorruptedRegionException */ protected function loadLocationTable() : void{ fseek($this->filePointer, 0); $headerRaw = fread($this->filePointer, self::REGION_HEADER_LENGTH); if($headerRaw === false or strlen($headerRaw) !== self::REGION_HEADER_LENGTH){ throw new CorruptedRegionException("Corrupted region header (unexpected end of file)"); } $data = unpack("N*", $headerRaw); for($i = 0; $i < 1024; ++$i){ $index = $data[$i + 1]; $offset = $index >> 8; $sectorCount = $index & 0xff; $timestamp = $data[$i + 1025]; if($offset === 0 or $sectorCount === 0){ $this->locationTable[$i] = null; }elseif($offset >= self::FIRST_SECTOR){ $this->bumpNextFreeSector($this->locationTable[$i] = new RegionLocationTableEntry($offset, $sectorCount, $timestamp)); }else{ self::getChunkCoords($i, $chunkXX, $chunkZZ); throw new CorruptedRegionException("Invalid region header entry for x=$chunkXX z=$chunkZZ, offset overlaps with header"); } } $this->checkLocationTableValidity(); $this->garbageTable = RegionGarbageMap::buildFromLocationTable($this->locationTable); fseek($this->filePointer, 0); } /** * @throws CorruptedRegionException */ private function checkLocationTableValidity() : void{ /** @var int[] $usedOffsets */ $usedOffsets = []; for($i = 0; $i < 1024; ++$i){ $entry = $this->locationTable[$i]; if($entry === null){ continue; } self::getChunkCoords($i, $x, $z); $offset = $entry->getFirstSector(); $fileOffset = $offset << 12; //TODO: more validity checks fseek($this->filePointer, $fileOffset); if(feof($this->filePointer)){ throw new CorruptedRegionException("Region file location offset x=$x,z=$z points to invalid file location $fileOffset"); } if(isset($usedOffsets[$offset])){ self::getChunkCoords($usedOffsets[$offset], $existingX, $existingZ); throw new CorruptedRegionException("Found two chunk offsets (chunk1: x=$existingX,z=$existingZ, chunk2: x=$x,z=$z) pointing to the file location $fileOffset"); } $usedOffsets[$offset] = $i; } ksort($usedOffsets, SORT_NUMERIC); $prevLocationIndex = null; foreach($usedOffsets as $startOffset => $locationTableIndex){ if($this->locationTable[$locationTableIndex] === null){ continue; } if($prevLocationIndex !== null){ assert($this->locationTable[$prevLocationIndex] !== null); if($this->locationTable[$locationTableIndex]->overlaps($this->locationTable[$prevLocationIndex])){ self::getChunkCoords($locationTableIndex, $chunkXX, $chunkZZ); self::getChunkCoords($prevLocationIndex, $prevChunkXX, $prevChunkZZ); throw new CorruptedRegionException("Overlapping chunks detected in region header (chunk1: x=$chunkXX,z=$chunkZZ, chunk2: x=$prevChunkXX,z=$prevChunkZZ)"); } } $prevLocationIndex = $locationTableIndex; } } protected function writeLocationIndex(int $index) : void{ $entry = $this->locationTable[$index]; fseek($this->filePointer, $index << 2); fwrite($this->filePointer, Binary::writeInt($entry !== null ? ($entry->getFirstSector() << 8) | $entry->getSectorCount() : 0), 4); fseek($this->filePointer, 4096 + ($index << 2)); fwrite($this->filePointer, Binary::writeInt($entry !== null ? $entry->getTimestamp() : 0), 4); } protected function createBlank() : void{ fseek($this->filePointer, 0); ftruncate($this->filePointer, 8192); // this fills the file with the null byte for($i = 0; $i < 1024; ++$i){ $this->locationTable[$i] = null; } } private function bumpNextFreeSector(RegionLocationTableEntry $entry) : void{ $this->nextSector = max($this->nextSector, $entry->getLastSector() + 1); } public function generateSectorMap(string $usedChar, string $freeChar) : string{ $result = str_repeat($freeChar, $this->nextSector); for($i = 0; $i < self::FIRST_SECTOR; ++$i){ $result[$i] = $usedChar; } foreach($this->locationTable as $locationTableEntry){ if($locationTableEntry === null){ continue; } foreach($locationTableEntry->getUsedSectors() as $sectorIndex){ if($sectorIndex >= strlen($result)){ throw new AssumptionFailedError("This should never happen..."); } if($result[$sectorIndex] === $usedChar){ throw new AssumptionFailedError("Overlap detected"); } $result[$sectorIndex] = $usedChar; } } return $result; } /** * Returns a float between 0 and 1 indicating what fraction of the file is currently unused space. */ public function getProportionUnusedSpace() : float{ $size = $this->nextSector; $used = self::FIRST_SECTOR; //header is always allocated foreach($this->locationTable as $entry){ if($entry !== null){ $used += $entry->getSectorCount(); } } return 1 - ($used / $size); } public function getFilePath() : string{ return $this->filePath; } public function calculateChunkCount() : int{ $count = 0; for($i = 0; $i < 1024; ++$i){ if($this->isChunkGenerated($i)){ $count++; } } return $count; } }