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;
71 protected array $locationTable = [];
78 private function __construct(
79 protected string $filePath
82 $this->lastUsed = time();
84 $filePointer = fopen($this->filePath,
"r+b");
86 $this->filePointer = $filePointer;
87 stream_set_read_buffer($this->filePointer, 1024 * 16);
88 stream_set_write_buffer($this->filePointer, 1024 * 16);
95 clearstatcache(false, $filePath);
96 if(!file_exists($filePath)){
97 throw new \RuntimeException(
"File $filePath does not exist");
99 if(filesize($filePath) % 4096 !== 0){
103 $result =
new self($filePath);
104 $result->loadLocationTable();
108 public static function createNew(
string $filePath) : self{
109 clearstatcache(false, $filePath);
110 if(file_exists($filePath)){
111 throw new \RuntimeException(
"Region file $filePath already exists");
115 $result =
new self($filePath);
116 $result->createBlank();
120 public function __destruct(){
121 if(is_resource($this->filePointer)){
122 fclose($this->filePointer);
126 protected function isChunkGenerated(
int $index) : bool{
127 return $this->locationTable[$index] !== null;
135 $index = self::getChunkOffset($x, $z);
137 $this->lastUsed = time();
139 if($this->locationTable[$index] ===
null){
143 fseek($this->filePointer, $this->locationTable[$index]->getFirstSector() << 12);
150 $bytesToRead = $this->locationTable[$index]->getSectorCount() << 12;
151 $payload = fread($this->filePointer, $bytesToRead);
153 if($payload ===
false || strlen($payload) !== $bytesToRead){
154 throw new CorruptedChunkException(
"Corrupted chunk detected (unexpected EOF, truncated or non-padded chunk found)");
159 $length = $stream->getInt();
164 $compression = $stream->getByte();
165 if($compression !== self::COMPRESSION_ZLIB && $compression !== self::COMPRESSION_GZIP){
166 throw new CorruptedChunkException(
"Invalid compression type (got $compression, expected " . self::COMPRESSION_ZLIB .
" or " . self::COMPRESSION_GZIP .
")");
169 return $stream->get($length - 1);
170 }
catch(BinaryDataException $e){
171 throw new CorruptedChunkException(
"Corrupted chunk detected: " . $e->getMessage(), 0, $e);
179 return $this->isChunkGenerated(self::getChunkOffset($x, $z));
184 $this->garbageTable->add($oldLocation);
186 $endGarbage = $this->garbageTable->end();
187 $nextSector = $this->nextSector;
188 for(; $endGarbage !==
null && $endGarbage->getLastSector() + 1 === $nextSector; $endGarbage = $this->garbageTable->end()){
189 $nextSector = $endGarbage->getFirstSector();
190 $this->garbageTable->remove($endGarbage);
193 if($nextSector !== $this->nextSector){
194 $this->nextSector = $nextSector;
195 ftruncate($this->filePointer, $this->nextSector << 12);
203 public function writeChunk(
int $x,
int $z,
string $chunkData) : void{
204 $this->lastUsed = time();
206 $length = strlen($chunkData) + 1;
207 if($length + 4 > self::MAX_SECTOR_LENGTH){
208 throw new ChunkException(
"Chunk is too big! " . ($length + 4) .
" > " . self::MAX_SECTOR_LENGTH);
211 $newSize = (int) ceil(($length + 4) / 4096);
212 $index = self::getChunkOffset($x, $z);
220 $newLocation = $this->garbageTable->allocate($newSize);
223 if($newLocation ===
null){
225 $this->bumpNextFreeSector($newLocation);
229 fseek($this->filePointer, $newLocation->getFirstSector() << 12);
230 fwrite($this->filePointer, str_pad(Binary::writeInt($length) . chr(self::COMPRESSION_ZLIB) . $chunkData, $newSize << 12,
"\x00", STR_PAD_RIGHT));
237 $oldLocation = $this->locationTable[$index];
238 $this->locationTable[$index] = $newLocation;
239 $this->writeLocationIndex($index);
241 if($oldLocation !==
null){
242 $this->disposeGarbageArea($oldLocation);
250 $index = self::getChunkOffset($x, $z);
251 $oldLocation = $this->locationTable[$index];
252 $this->locationTable[$index] =
null;
253 $this->writeLocationIndex($index);
254 if($oldLocation !==
null){
255 $this->disposeGarbageArea($oldLocation);
263 if($x < 0 || $x > 31 || $z < 0 || $z > 31){
264 throw new \InvalidArgumentException(
"Invalid chunk position in region, expected x/z in range 0-31, got x=$x, z=$z");
266 return $x | ($z << 5);
275 protected static function getChunkCoords(
int $offset, ?
int &$x, ?
int &$z) : void{
277 $z = ($offset >> 5) & 0x1f;
284 if(is_resource($this->filePointer)){
285 fclose($this->filePointer);
293 fseek($this->filePointer, 0);
295 $headerRaw = fread($this->filePointer, self::REGION_HEADER_LENGTH);
296 if($headerRaw ===
false || strlen($headerRaw) !== self::REGION_HEADER_LENGTH){
301 $data = unpack(
"N*", $headerRaw);
303 for($i = 0; $i < 1024; ++$i){
304 $index = $data[$i + 1];
305 $offset = $index >> 8;
306 $sectorCount = $index & 0xff;
307 $timestamp = $data[$i + 1025];
309 if($offset === 0 || $sectorCount === 0){
310 $this->locationTable[$i] =
null;
311 }elseif($offset >= self::FIRST_SECTOR){
312 $this->bumpNextFreeSector($this->locationTable[$i] =
new RegionLocationTableEntry($offset, $sectorCount, $timestamp));
314 self::getChunkCoords($i, $chunkXX, $chunkZZ);
315 throw new CorruptedRegionException(
"Invalid region header entry for x=$chunkXX z=$chunkZZ, offset overlaps with header");
319 $this->checkLocationTableValidity();
321 $this->garbageTable = RegionGarbageMap::buildFromLocationTable($this->locationTable);
323 fseek($this->filePointer, 0);
329 private function checkLocationTableValidity() : void{
333 $fileSize = filesize($this->filePath);
334 if($fileSize ===
false)
throw new AssumptionFailedError(
"filesize() should not return false here");
335 for($i = 0; $i < 1024; ++$i){
336 $entry = $this->locationTable[$i];
341 self::getChunkCoords($i, $x, $z);
342 $offset = $entry->getFirstSector();
343 $fileOffset = $offset << 12;
347 if($fileOffset >= $fileSize){
348 throw new CorruptedRegionException(
"Region file location offset x=$x,z=$z points to invalid file location $fileOffset");
350 if(isset($usedOffsets[$offset])){
351 self::getChunkCoords($usedOffsets[$offset], $existingX, $existingZ);
352 throw new CorruptedRegionException(
"Found two chunk offsets (chunk1: x=$existingX,z=$existingZ, chunk2: x=$x,z=$z) pointing to the file location $fileOffset");
354 $usedOffsets[$offset] = $i;
356 ksort($usedOffsets, SORT_NUMERIC);
357 $prevLocationIndex =
null;
358 foreach($usedOffsets as $startOffset => $locationTableIndex){
359 if($this->locationTable[$locationTableIndex] ===
null){
362 if($prevLocationIndex !==
null){
363 assert($this->locationTable[$prevLocationIndex] !==
null);
364 if($this->locationTable[$locationTableIndex]->overlaps($this->locationTable[$prevLocationIndex])){
365 self::getChunkCoords($locationTableIndex, $chunkXX, $chunkZZ);
366 self::getChunkCoords($prevLocationIndex, $prevChunkXX, $prevChunkZZ);
367 throw new CorruptedRegionException(
"Overlapping chunks detected in region header (chunk1: x=$chunkXX,z=$chunkZZ, chunk2: x=$prevChunkXX,z=$prevChunkZZ)");
370 $prevLocationIndex = $locationTableIndex;
374 protected function writeLocationIndex(
int $index) : void{
375 $entry = $this->locationTable[$index];
376 fseek($this->filePointer, $index << 2);
377 fwrite($this->filePointer, Binary::writeInt($entry !==
null ? ($entry->getFirstSector() << 8) | $entry->getSectorCount() : 0), 4);
378 fseek($this->filePointer, 4096 + ($index << 2));
379 fwrite($this->filePointer, Binary::writeInt($entry !==
null ? $entry->getTimestamp() : 0), 4);
380 clearstatcache(
false, $this->filePath);
383 protected function createBlank() : void{
384 fseek($this->filePointer, 0);
385 ftruncate($this->filePointer, 8192);
386 for($i = 0; $i < 1024; ++$i){
387 $this->locationTable[$i] =
null;
391 private function bumpNextFreeSector(RegionLocationTableEntry $entry) : void{
392 $this->nextSector = max($this->nextSector, $entry->getLastSector() + 1);
395 public function generateSectorMap(
string $usedChar,
string $freeChar) : string{
396 $result = str_repeat($freeChar, $this->nextSector);
397 for($i = 0; $i < self::FIRST_SECTOR; ++$i){
398 $result[$i] = $usedChar;
400 foreach($this->locationTable as $locationTableEntry){
401 if($locationTableEntry ===
null){
404 foreach($locationTableEntry->getUsedSectors() as $sectorIndex){
405 if($sectorIndex >= strlen($result)){
406 throw new AssumptionFailedError(
"This should never happen...");
408 if($result[$sectorIndex] === $usedChar){
409 throw new AssumptionFailedError(
"Overlap detected");
411 $result[$sectorIndex] = $usedChar;
421 $size = $this->nextSector;
422 $used = self::FIRST_SECTOR;
423 foreach($this->locationTable as $entry){
425 $used += $entry->getSectorCount();
428 return 1 - ($used / $size);
431 public function getFilePath() : string{
432 return $this->filePath;
435 public function calculateChunkCount() : int{
437 for($i = 0; $i < 1024; ++$i){
438 if($this->isChunkGenerated($i)){