59 public const COMPRESSION_GZIP = 1;
60 public const COMPRESSION_ZLIB = 2;
62 private const MAX_SECTOR_LENGTH = 255 << 12;
63 private const REGION_HEADER_LENGTH = 8192;
65 public const FIRST_SECTOR = 2;
68 protected $filePointer;
69 protected int $nextSector = self::FIRST_SECTOR;
74 protected array $locationTable = [];
81 private function __construct(
82 protected string $filePath
85 $this->lastUsed = time();
87 $filePointer = fopen($this->filePath,
"r+b");
89 $this->filePointer = $filePointer;
90 stream_set_read_buffer($this->filePointer, 1024 * 16);
91 stream_set_write_buffer($this->filePointer, 1024 * 16);
98 clearstatcache(false, $filePath);
99 if(!file_exists($filePath)){
100 throw new \RuntimeException(
"File $filePath does not exist");
102 if(filesize($filePath) % 4096 !== 0){
106 $result =
new self($filePath);
107 $result->loadLocationTable();
111 public static function createNew(
string $filePath) : self{
112 clearstatcache(false, $filePath);
113 if(file_exists($filePath)){
114 throw new \RuntimeException(
"Region file $filePath already exists");
118 $result =
new self($filePath);
119 $result->createBlank();
123 public function __destruct(){
124 if(is_resource($this->filePointer)){
125 fclose($this->filePointer);
129 protected function isChunkGenerated(
int $index) : bool{
130 return $this->locationTable[$index] !== null;
138 $index = self::getChunkOffset($x, $z);
140 $this->lastUsed = time();
142 if($this->locationTable[$index] ===
null){
146 fseek($this->filePointer, $this->locationTable[$index]->getFirstSector() << 12);
153 $bytesToRead = $this->locationTable[$index]->getSectorCount() << 12;
154 $payload = fread($this->filePointer, $bytesToRead);
156 if($payload ===
false || strlen($payload) !== $bytesToRead){
157 throw new CorruptedChunkException(
"Corrupted chunk detected (unexpected EOF, truncated or non-padded chunk found)");
162 $length = $stream->getInt();
167 $compression = $stream->getByte();
168 if($compression !== self::COMPRESSION_ZLIB && $compression !== self::COMPRESSION_GZIP){
169 throw new CorruptedChunkException(
"Invalid compression type (got $compression, expected " . self::COMPRESSION_ZLIB .
" or " . self::COMPRESSION_GZIP .
")");
172 return $stream->get($length - 1);
173 }
catch(BinaryDataException $e){
174 throw new CorruptedChunkException(
"Corrupted chunk detected: " . $e->getMessage(), 0, $e);
182 return $this->isChunkGenerated(self::getChunkOffset($x, $z));
187 $this->garbageTable->add($oldLocation);
189 $endGarbage = $this->garbageTable->end();
190 $nextSector = $this->nextSector;
191 for(; $endGarbage !==
null && $endGarbage->getLastSector() + 1 === $nextSector; $endGarbage = $this->garbageTable->end()){
192 $nextSector = $endGarbage->getFirstSector();
193 $this->garbageTable->remove($endGarbage);
196 if($nextSector !== $this->nextSector){
197 $this->nextSector = $nextSector;
198 ftruncate($this->filePointer, $this->nextSector << 12);
206 public function writeChunk(
int $x,
int $z,
string $chunkData) : void{
207 $this->lastUsed = time();
209 $length = strlen($chunkData) + 1;
210 if($length + 4 > self::MAX_SECTOR_LENGTH){
211 throw new ChunkException(
"Chunk is too big! " . ($length + 4) .
" > " . self::MAX_SECTOR_LENGTH);
214 $newSize = (int) ceil(($length + 4) / 4096);
215 $index = self::getChunkOffset($x, $z);
223 $newLocation = $this->garbageTable->allocate($newSize);
226 if($newLocation ===
null){
228 $this->bumpNextFreeSector($newLocation);
232 fseek($this->filePointer, $newLocation->getFirstSector() << 12);
233 fwrite($this->filePointer, str_pad(Binary::writeInt($length) . chr(self::COMPRESSION_ZLIB) . $chunkData, $newSize << 12,
"\x00", STR_PAD_RIGHT));
240 $oldLocation = $this->locationTable[$index];
241 $this->locationTable[$index] = $newLocation;
242 $this->writeLocationIndex($index);
244 if($oldLocation !==
null){
245 $this->disposeGarbageArea($oldLocation);
253 $index = self::getChunkOffset($x, $z);
254 $oldLocation = $this->locationTable[$index];
255 $this->locationTable[$index] =
null;
256 $this->writeLocationIndex($index);
257 if($oldLocation !==
null){
258 $this->disposeGarbageArea($oldLocation);
266 if($x < 0 || $x > 31 || $z < 0 || $z > 31){
267 throw new \InvalidArgumentException(
"Invalid chunk position in region, expected x/z in range 0-31, got x=$x, z=$z");
269 return $x | ($z << 5);
278 protected static function getChunkCoords(
int $offset, ?
int &$x, ?
int &$z) : void{
280 $z = ($offset >> 5) & 0x1f;
287 if(is_resource($this->filePointer)){
288 fclose($this->filePointer);
296 fseek($this->filePointer, 0);
298 $headerRaw = fread($this->filePointer, self::REGION_HEADER_LENGTH);
299 if($headerRaw ===
false || strlen($headerRaw) !== self::REGION_HEADER_LENGTH){
304 $data = unpack(
"N*", $headerRaw);
306 for($i = 0; $i < 1024; ++$i){
307 $index = $data[$i + 1];
308 $offset = $index >> 8;
309 $sectorCount = $index & 0xff;
310 $timestamp = $data[$i + 1025];
312 if($offset === 0 || $sectorCount === 0){
313 $this->locationTable[$i] =
null;
314 }elseif($offset >= self::FIRST_SECTOR){
315 $this->bumpNextFreeSector($this->locationTable[$i] =
new RegionLocationTableEntry($offset, $sectorCount, $timestamp));
317 self::getChunkCoords($i, $chunkXX, $chunkZZ);
318 throw new CorruptedRegionException(
"Invalid region header entry for x=$chunkXX z=$chunkZZ, offset overlaps with header");
322 $this->checkLocationTableValidity();
324 $this->garbageTable = RegionGarbageMap::buildFromLocationTable($this->locationTable);
326 fseek($this->filePointer, 0);
332 private function checkLocationTableValidity() : void{
335 $fileSize = filesize($this->filePath);
336 if($fileSize ===
false)
throw new AssumptionFailedError(
"filesize() should not return false here");
337 for($i = 0; $i < 1024; ++$i){
338 $entry = $this->locationTable[$i];
343 self::getChunkCoords($i, $x, $z);
344 $offset = $entry->getFirstSector();
345 $fileOffset = $offset << 12;
349 if($fileOffset >= $fileSize){
350 throw new CorruptedRegionException(
"Region file location offset x=$x,z=$z points to invalid file location $fileOffset");
352 if(isset($usedOffsets[$offset])){
353 self::getChunkCoords($usedOffsets[$offset], $existingX, $existingZ);
354 throw new CorruptedRegionException(
"Found two chunk offsets (chunk1: x=$existingX,z=$existingZ, chunk2: x=$x,z=$z) pointing to the file location $fileOffset");
356 $usedOffsets[$offset] = $i;
358 ksort($usedOffsets, SORT_NUMERIC);
359 $prevLocationIndex =
null;
360 foreach($usedOffsets as $locationTableIndex){
361 if($this->locationTable[$locationTableIndex] ===
null){
364 if($prevLocationIndex !==
null){
365 assert($this->locationTable[$prevLocationIndex] !==
null);
366 if($this->locationTable[$locationTableIndex]->overlaps($this->locationTable[$prevLocationIndex])){
367 self::getChunkCoords($locationTableIndex, $chunkXX, $chunkZZ);
368 self::getChunkCoords($prevLocationIndex, $prevChunkXX, $prevChunkZZ);
369 throw new CorruptedRegionException(
"Overlapping chunks detected in region header (chunk1: x=$chunkXX,z=$chunkZZ, chunk2: x=$prevChunkXX,z=$prevChunkZZ)");
372 $prevLocationIndex = $locationTableIndex;
376 protected function writeLocationIndex(
int $index) : void{
377 $entry = $this->locationTable[$index];
378 fseek($this->filePointer, $index << 2);
379 fwrite($this->filePointer, Binary::writeInt($entry !==
null ? ($entry->getFirstSector() << 8) | $entry->getSectorCount() : 0), 4);
380 fseek($this->filePointer, 4096 + ($index << 2));
381 fwrite($this->filePointer, Binary::writeInt($entry !==
null ? $entry->getTimestamp() : 0), 4);
382 clearstatcache(
false, $this->filePath);
385 protected function createBlank() : void{
386 fseek($this->filePointer, 0);
387 ftruncate($this->filePointer, 8192);
388 for($i = 0; $i < 1024; ++$i){
389 $this->locationTable[$i] =
null;
393 private function bumpNextFreeSector(RegionLocationTableEntry $entry) : void{
394 $this->nextSector = max($this->nextSector, $entry->getLastSector() + 1);
397 public function generateSectorMap(
string $usedChar,
string $freeChar) : string{
398 $result = str_repeat($freeChar, $this->nextSector);
399 for($i = 0; $i < self::FIRST_SECTOR; ++$i){
400 $result[$i] = $usedChar;
402 foreach($this->locationTable as $locationTableEntry){
403 if($locationTableEntry ===
null){
406 foreach($locationTableEntry->getUsedSectors() as $sectorIndex){
407 if($sectorIndex >= strlen($result)){
408 throw new AssumptionFailedError(
"This should never happen...");
410 if($result[$sectorIndex] === $usedChar){
411 throw new AssumptionFailedError(
"Overlap detected");
413 $result[$sectorIndex] = $usedChar;
423 $size = $this->nextSector;
424 $used = self::FIRST_SECTOR;
425 foreach($this->locationTable as $entry){
427 $used += $entry->getSectorCount();
430 return 1 - ($used / $size);
433 public function getFilePath() : string{
434 return $this->filePath;
437 public function calculateChunkCount() : int{
439 for($i = 0; $i < 1024; ++$i){
440 if($this->isChunkGenerated($i)){