PocketMine-MP 5.15.1 git-5ef247620a7c6301a849b54e5ef1009217729fc8
RegionLoader.php
1<?php
2
3/*
4 *
5 * ____ _ _ __ __ _ __ __ ____
6 * | _ \ ___ ___| | _____| |_| \/ (_)_ __ ___ | \/ | _ \
7 * | |_) / _ \ / __| |/ / _ \ __| |\/| | | '_ \ / _ \_____| |\/| | |_) |
8 * | __/ (_) | (__| < __/ |_| | | | | | | | __/_____| | | | __/
9 * |_| \___/ \___|_|\_\___|\__|_| |_|_|_| |_|\___| |_| |_|_|
10 *
11 * This program is free software: you can redistribute it and/or modify
12 * it under the terms of the GNU Lesser General Public License as published by
13 * the Free Software Foundation, either version 3 of the License, or
14 * (at your option) any later version.
15 *
16 * @author PocketMine Team
17 * @link http://www.pocketmine.net/
18 *
19 *
20 */
21
22declare(strict_types=1);
23
24namespace pocketmine\world\format\io\region;
25
32use function assert;
33use function ceil;
34use function chr;
35use function clearstatcache;
36use function fclose;
37use function file_exists;
38use function filesize;
39use function fopen;
40use function fread;
41use function fseek;
42use function ftruncate;
43use function fwrite;
44use function is_resource;
45use function ksort;
46use function max;
47use function str_pad;
48use function str_repeat;
49use function stream_set_read_buffer;
50use function stream_set_write_buffer;
51use function strlen;
52use function time;
53use function touch;
54use function unpack;
55use const SORT_NUMERIC;
56use const STR_PAD_RIGHT;
57
59 public const COMPRESSION_GZIP = 1;
60 public const COMPRESSION_ZLIB = 2;
61
62 private const MAX_SECTOR_LENGTH = 255 << 12; //255 sectors (~0.996 MiB)
63 private const REGION_HEADER_LENGTH = 8192; //4096 location table + 4096 timestamps
64
65 public const FIRST_SECTOR = 2; //location table occupies 0 and 1
66
68 protected $filePointer;
69 protected int $nextSector = self::FIRST_SECTOR;
71 protected array $locationTable = [];
72 protected RegionGarbageMap $garbageTable;
73 public int $lastUsed;
74
78 private function __construct(
79 protected string $filePath
80 ){
81 $this->garbageTable = new RegionGarbageMap([]);
82 $this->lastUsed = time();
83
84 $filePointer = fopen($this->filePath, "r+b");
85 if($filePointer === false) throw new AssumptionFailedError("fopen() should not fail here");
86 $this->filePointer = $filePointer;
87 stream_set_read_buffer($this->filePointer, 1024 * 16); //16KB
88 stream_set_write_buffer($this->filePointer, 1024 * 16); //16KB
89 }
90
94 public static function loadExisting(string $filePath) : self{
95 clearstatcache(false, $filePath);
96 if(!file_exists($filePath)){
97 throw new \RuntimeException("File $filePath does not exist");
98 }
99 if(filesize($filePath) % 4096 !== 0){
100 throw new CorruptedRegionException("Region file should be padded to a multiple of 4KiB");
101 }
102
103 $result = new self($filePath);
104 $result->loadLocationTable();
105 return $result;
106 }
107
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");
112 }
113 touch($filePath);
114
115 $result = new self($filePath);
116 $result->createBlank();
117 return $result;
118 }
119
120 public function __destruct(){
121 if(is_resource($this->filePointer)){
122 fclose($this->filePointer);
123 }
124 }
125
126 protected function isChunkGenerated(int $index) : bool{
127 return $this->locationTable[$index] !== null;
128 }
129
134 public function readChunk(int $x, int $z) : ?string{
135 $index = self::getChunkOffset($x, $z);
136
137 $this->lastUsed = time();
138
139 if($this->locationTable[$index] === null){
140 return null;
141 }
142
143 fseek($this->filePointer, $this->locationTable[$index]->getFirstSector() << 12);
144
145 /*
146 * this might cause us to read some junk, but under normal circumstances it won't be any more than 4096 bytes wasted.
147 * doing this in a single call is faster than making two seeks and reads to fetch the chunk.
148 * this relies on the assumption that the end of the file is always padded to a multiple of 4096 bytes.
149 */
150 $bytesToRead = $this->locationTable[$index]->getSectorCount() << 12;
151 $payload = fread($this->filePointer, $bytesToRead);
152
153 if($payload === false || strlen($payload) !== $bytesToRead){
154 throw new CorruptedChunkException("Corrupted chunk detected (unexpected EOF, truncated or non-padded chunk found)");
155 }
156 $stream = new BinaryStream($payload);
157
158 try{
159 $length = $stream->getInt();
160 if($length <= 0){ //TODO: if we reached here, the locationTable probably needs updating
161 return null;
162 }
163
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 . ")");
167 }
168
169 return $stream->get($length - 1); //length prefix includes the compression byte
170 }catch(BinaryDataException $e){
171 throw new CorruptedChunkException("Corrupted chunk detected: " . $e->getMessage(), 0, $e);
172 }
173 }
174
178 public function chunkExists(int $x, int $z) : bool{
179 return $this->isChunkGenerated(self::getChunkOffset($x, $z));
180 }
181
182 private function disposeGarbageArea(RegionLocationTableEntry $oldLocation) : void{
183 /* release the area containing the old copy to the garbage pool */
184 $this->garbageTable->add($oldLocation);
185
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);
191 }
192
193 if($nextSector !== $this->nextSector){
194 $this->nextSector = $nextSector;
195 ftruncate($this->filePointer, $this->nextSector << 12);
196 }
197 }
198
203 public function writeChunk(int $x, int $z, string $chunkData) : void{
204 $this->lastUsed = time();
205
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);
209 }
210
211 $newSize = (int) ceil(($length + 4) / 4096);
212 $index = self::getChunkOffset($x, $z);
213
214 /*
215 * look for an unused area big enough to hold this data
216 * this is corruption-resistant (it leaves the old data intact if a failure occurs when writing new data), and
217 * also allows the file to become more compact across consecutive writes without introducing a dedicated garbage
218 * collection mechanism.
219 */
220 $newLocation = $this->garbageTable->allocate($newSize);
221
222 /* if no gaps big enough were found, append to the end of the file instead */
223 if($newLocation === null){
224 $newLocation = new RegionLocationTableEntry($this->nextSector, $newSize, time());
225 $this->bumpNextFreeSector($newLocation);
226 }
227
228 /* write the chunk data into the chosen location */
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));
231
232 /*
233 * update the file header - we do this after writing the main data, so that if a failure occurs while writing,
234 * the header will still point to the old (intact) copy of the chunk, instead of a potentially broken new
235 * version of the file (e.g. partially written).
236 */
237 $oldLocation = $this->locationTable[$index];
238 $this->locationTable[$index] = $newLocation;
239 $this->writeLocationIndex($index);
240
241 if($oldLocation !== null){
242 $this->disposeGarbageArea($oldLocation);
243 }
244 }
245
249 public function removeChunk(int $x, int $z) : void{
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);
256 }
257 }
258
262 protected static function getChunkOffset(int $x, int $z) : int{
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");
265 }
266 return $x | ($z << 5);
267 }
268
275 protected static function getChunkCoords(int $offset, ?int &$x, ?int &$z) : void{
276 $x = $offset & 0x1f;
277 $z = ($offset >> 5) & 0x1f;
278 }
279
283 public function close() : void{
284 if(is_resource($this->filePointer)){
285 fclose($this->filePointer);
286 }
287 }
288
292 protected function loadLocationTable() : void{
293 fseek($this->filePointer, 0);
294
295 $headerRaw = fread($this->filePointer, self::REGION_HEADER_LENGTH);
296 if($headerRaw === false || strlen($headerRaw) !== self::REGION_HEADER_LENGTH){
297 throw new CorruptedRegionException("Corrupted region header (unexpected end of file)");
298 }
299
301 $data = unpack("N*", $headerRaw);
302
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];
308
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));
313 }else{
314 self::getChunkCoords($i, $chunkXX, $chunkZZ);
315 throw new CorruptedRegionException("Invalid region header entry for x=$chunkXX z=$chunkZZ, offset overlaps with header");
316 }
317 }
318
319 $this->checkLocationTableValidity();
320
321 $this->garbageTable = RegionGarbageMap::buildFromLocationTable($this->locationTable);
322
323 fseek($this->filePointer, 0);
324 }
325
329 private function checkLocationTableValidity() : void{
331 $usedOffsets = [];
332
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];
337 if($entry === null){
338 continue;
339 }
340
341 self::getChunkCoords($i, $x, $z);
342 $offset = $entry->getFirstSector();
343 $fileOffset = $offset << 12;
344
345 //TODO: more validity checks
346
347 if($fileOffset >= $fileSize){
348 throw new CorruptedRegionException("Region file location offset x=$x,z=$z points to invalid file location $fileOffset");
349 }
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");
353 }
354 $usedOffsets[$offset] = $i;
355 }
356 ksort($usedOffsets, SORT_NUMERIC);
357 $prevLocationIndex = null;
358 foreach($usedOffsets as $startOffset => $locationTableIndex){
359 if($this->locationTable[$locationTableIndex] === null){
360 continue;
361 }
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)");
368 }
369 }
370 $prevLocationIndex = $locationTableIndex;
371 }
372 }
373
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);
381 }
382
383 protected function createBlank() : void{
384 fseek($this->filePointer, 0);
385 ftruncate($this->filePointer, 8192); // this fills the file with the null byte
386 for($i = 0; $i < 1024; ++$i){
387 $this->locationTable[$i] = null;
388 }
389 }
390
391 private function bumpNextFreeSector(RegionLocationTableEntry $entry) : void{
392 $this->nextSector = max($this->nextSector, $entry->getLastSector() + 1);
393 }
394
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;
399 }
400 foreach($this->locationTable as $locationTableEntry){
401 if($locationTableEntry === null){
402 continue;
403 }
404 foreach($locationTableEntry->getUsedSectors() as $sectorIndex){
405 if($sectorIndex >= strlen($result)){
406 throw new AssumptionFailedError("This should never happen...");
407 }
408 if($result[$sectorIndex] === $usedChar){
409 throw new AssumptionFailedError("Overlap detected");
410 }
411 $result[$sectorIndex] = $usedChar;
412 }
413 }
414 return $result;
415 }
416
420 public function getProportionUnusedSpace() : float{
421 $size = $this->nextSector;
422 $used = self::FIRST_SECTOR; //header is always allocated
423 foreach($this->locationTable as $entry){
424 if($entry !== null){
425 $used += $entry->getSectorCount();
426 }
427 }
428 return 1 - ($used / $size);
429 }
430
431 public function getFilePath() : string{
432 return $this->filePath;
433 }
434
435 public function calculateChunkCount() : int{
436 $count = 0;
437 for($i = 0; $i < 1024; ++$i){
438 if($this->isChunkGenerated($i)){
439 $count++;
440 }
441 }
442 return $count;
443 }
444}
static getChunkCoords(int $offset, ?int &$x, ?int &$z)
writeChunk(int $x, int $z, string $chunkData)