PocketMine-MP 5.23.3 git-976fc63567edab7a6fb6aeae739f43cf9fe57de4
Loading...
Searching...
No Matches
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;
74 protected array $locationTable = [];
75 protected RegionGarbageMap $garbageTable;
76 public int $lastUsed;
77
81 private function __construct(
82 protected string $filePath
83 ){
84 $this->garbageTable = new RegionGarbageMap([]);
85 $this->lastUsed = time();
86
87 $filePointer = fopen($this->filePath, "r+b");
88 if($filePointer === false) throw new AssumptionFailedError("fopen() should not fail here");
89 $this->filePointer = $filePointer;
90 stream_set_read_buffer($this->filePointer, 1024 * 16); //16KB
91 stream_set_write_buffer($this->filePointer, 1024 * 16); //16KB
92 }
93
97 public static function loadExisting(string $filePath) : self{
98 clearstatcache(false, $filePath);
99 if(!file_exists($filePath)){
100 throw new \RuntimeException("File $filePath does not exist");
101 }
102 if(filesize($filePath) % 4096 !== 0){
103 throw new CorruptedRegionException("Region file should be padded to a multiple of 4KiB");
104 }
105
106 $result = new self($filePath);
107 $result->loadLocationTable();
108 return $result;
109 }
110
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");
115 }
116 touch($filePath);
117
118 $result = new self($filePath);
119 $result->createBlank();
120 return $result;
121 }
122
123 public function __destruct(){
124 if(is_resource($this->filePointer)){
125 fclose($this->filePointer);
126 }
127 }
128
129 protected function isChunkGenerated(int $index) : bool{
130 return $this->locationTable[$index] !== null;
131 }
132
137 public function readChunk(int $x, int $z) : ?string{
138 $index = self::getChunkOffset($x, $z);
139
140 $this->lastUsed = time();
141
142 if($this->locationTable[$index] === null){
143 return null;
144 }
145
146 fseek($this->filePointer, $this->locationTable[$index]->getFirstSector() << 12);
147
148 /*
149 * this might cause us to read some junk, but under normal circumstances it won't be any more than 4096 bytes wasted.
150 * doing this in a single call is faster than making two seeks and reads to fetch the chunk.
151 * this relies on the assumption that the end of the file is always padded to a multiple of 4096 bytes.
152 */
153 $bytesToRead = $this->locationTable[$index]->getSectorCount() << 12;
154 $payload = fread($this->filePointer, $bytesToRead);
155
156 if($payload === false || strlen($payload) !== $bytesToRead){
157 throw new CorruptedChunkException("Corrupted chunk detected (unexpected EOF, truncated or non-padded chunk found)");
158 }
159 $stream = new BinaryStream($payload);
160
161 try{
162 $length = $stream->getInt();
163 if($length <= 0){ //TODO: if we reached here, the locationTable probably needs updating
164 return null;
165 }
166
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 . ")");
170 }
171
172 return $stream->get($length - 1); //length prefix includes the compression byte
173 }catch(BinaryDataException $e){
174 throw new CorruptedChunkException("Corrupted chunk detected: " . $e->getMessage(), 0, $e);
175 }
176 }
177
181 public function chunkExists(int $x, int $z) : bool{
182 return $this->isChunkGenerated(self::getChunkOffset($x, $z));
183 }
184
185 private function disposeGarbageArea(RegionLocationTableEntry $oldLocation) : void{
186 /* release the area containing the old copy to the garbage pool */
187 $this->garbageTable->add($oldLocation);
188
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);
194 }
195
196 if($nextSector !== $this->nextSector){
197 $this->nextSector = $nextSector;
198 ftruncate($this->filePointer, $this->nextSector << 12);
199 }
200 }
201
206 public function writeChunk(int $x, int $z, string $chunkData) : void{
207 $this->lastUsed = time();
208
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);
212 }
213
214 $newSize = (int) ceil(($length + 4) / 4096);
215 $index = self::getChunkOffset($x, $z);
216
217 /*
218 * look for an unused area big enough to hold this data
219 * this is corruption-resistant (it leaves the old data intact if a failure occurs when writing new data), and
220 * also allows the file to become more compact across consecutive writes without introducing a dedicated garbage
221 * collection mechanism.
222 */
223 $newLocation = $this->garbageTable->allocate($newSize);
224
225 /* if no gaps big enough were found, append to the end of the file instead */
226 if($newLocation === null){
227 $newLocation = new RegionLocationTableEntry($this->nextSector, $newSize, time());
228 $this->bumpNextFreeSector($newLocation);
229 }
230
231 /* write the chunk data into the chosen location */
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));
234
235 /*
236 * update the file header - we do this after writing the main data, so that if a failure occurs while writing,
237 * the header will still point to the old (intact) copy of the chunk, instead of a potentially broken new
238 * version of the file (e.g. partially written).
239 */
240 $oldLocation = $this->locationTable[$index];
241 $this->locationTable[$index] = $newLocation;
242 $this->writeLocationIndex($index);
243
244 if($oldLocation !== null){
245 $this->disposeGarbageArea($oldLocation);
246 }
247 }
248
252 public function removeChunk(int $x, int $z) : void{
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);
259 }
260 }
261
265 protected static function getChunkOffset(int $x, int $z) : int{
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");
268 }
269 return $x | ($z << 5);
270 }
271
278 protected static function getChunkCoords(int $offset, ?int &$x, ?int &$z) : void{
279 $x = $offset & 0x1f;
280 $z = ($offset >> 5) & 0x1f;
281 }
282
286 public function close() : void{
287 if(is_resource($this->filePointer)){
288 fclose($this->filePointer);
289 }
290 }
291
295 protected function loadLocationTable() : void{
296 fseek($this->filePointer, 0);
297
298 $headerRaw = fread($this->filePointer, self::REGION_HEADER_LENGTH);
299 if($headerRaw === false || strlen($headerRaw) !== self::REGION_HEADER_LENGTH){
300 throw new CorruptedRegionException("Corrupted region header (unexpected end of file)");
301 }
302
304 $data = unpack("N*", $headerRaw);
305
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];
311
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));
316 }else{
317 self::getChunkCoords($i, $chunkXX, $chunkZZ);
318 throw new CorruptedRegionException("Invalid region header entry for x=$chunkXX z=$chunkZZ, offset overlaps with header");
319 }
320 }
321
322 $this->checkLocationTableValidity();
323
324 $this->garbageTable = RegionGarbageMap::buildFromLocationTable($this->locationTable);
325
326 fseek($this->filePointer, 0);
327 }
328
332 private function checkLocationTableValidity() : void{
333 $usedOffsets = [];
334
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];
339 if($entry === null){
340 continue;
341 }
342
343 self::getChunkCoords($i, $x, $z);
344 $offset = $entry->getFirstSector();
345 $fileOffset = $offset << 12;
346
347 //TODO: more validity checks
348
349 if($fileOffset >= $fileSize){
350 throw new CorruptedRegionException("Region file location offset x=$x,z=$z points to invalid file location $fileOffset");
351 }
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");
355 }
356 $usedOffsets[$offset] = $i;
357 }
358 ksort($usedOffsets, SORT_NUMERIC);
359 $prevLocationIndex = null;
360 foreach($usedOffsets as $locationTableIndex){
361 if($this->locationTable[$locationTableIndex] === null){
362 continue;
363 }
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)");
370 }
371 }
372 $prevLocationIndex = $locationTableIndex;
373 }
374 }
375
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);
383 }
384
385 protected function createBlank() : void{
386 fseek($this->filePointer, 0);
387 ftruncate($this->filePointer, 8192); // this fills the file with the null byte
388 for($i = 0; $i < 1024; ++$i){
389 $this->locationTable[$i] = null;
390 }
391 }
392
393 private function bumpNextFreeSector(RegionLocationTableEntry $entry) : void{
394 $this->nextSector = max($this->nextSector, $entry->getLastSector() + 1);
395 }
396
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;
401 }
402 foreach($this->locationTable as $locationTableEntry){
403 if($locationTableEntry === null){
404 continue;
405 }
406 foreach($locationTableEntry->getUsedSectors() as $sectorIndex){
407 if($sectorIndex >= strlen($result)){
408 throw new AssumptionFailedError("This should never happen...");
409 }
410 if($result[$sectorIndex] === $usedChar){
411 throw new AssumptionFailedError("Overlap detected");
412 }
413 $result[$sectorIndex] = $usedChar;
414 }
415 }
416 return $result;
417 }
418
422 public function getProportionUnusedSpace() : float{
423 $size = $this->nextSector;
424 $used = self::FIRST_SECTOR; //header is always allocated
425 foreach($this->locationTable as $entry){
426 if($entry !== null){
427 $used += $entry->getSectorCount();
428 }
429 }
430 return 1 - ($used / $size);
431 }
432
433 public function getFilePath() : string{
434 return $this->filePath;
435 }
436
437 public function calculateChunkCount() : int{
438 $count = 0;
439 for($i = 0; $i < 1024; ++$i){
440 if($this->isChunkGenerated($i)){
441 $count++;
442 }
443 }
444 return $count;
445 }
446}
static getChunkCoords(int $offset, ?int &$x, ?int &$z)
writeChunk(int $x, int $z, string $chunkData)