diff --git a/composer.lock b/composer.lock index 84725eb77..da33cbb69 100644 --- a/composer.lock +++ b/composer.lock @@ -1602,16 +1602,16 @@ }, { "name": "phpunit/php-file-iterator", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "4ac5b3e13df14829daa60a2eb4fdd2f2b7d33cf4" + "reference": "eba15e538f2bb3fe018b7bbb47d2fe32d404bfd2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/4ac5b3e13df14829daa60a2eb4fdd2f2b7d33cf4", - "reference": "4ac5b3e13df14829daa60a2eb4fdd2f2b7d33cf4", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/eba15e538f2bb3fe018b7bbb47d2fe32d404bfd2", + "reference": "eba15e538f2bb3fe018b7bbb47d2fe32d404bfd2", "shasum": "" }, "require": { @@ -1654,20 +1654,20 @@ "type": "github" } ], - "time": "2020-04-18T05:02:12+00:00" + "time": "2020-06-15T12:54:35+00:00" }, { "name": "phpunit/php-invoker", - "version": "3.0.0", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "7579d5a1ba7f3ac11c80004d205877911315ae7a" + "reference": "62f696ad0d140e0e513e69eaafdebb674d622b4c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/7579d5a1ba7f3ac11c80004d205877911315ae7a", - "reference": "7579d5a1ba7f3ac11c80004d205877911315ae7a", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/62f696ad0d140e0e513e69eaafdebb674d622b4c", + "reference": "62f696ad0d140e0e513e69eaafdebb674d622b4c", "shasum": "" }, "require": { @@ -1707,25 +1707,34 @@ "keywords": [ "process" ], - "time": "2020-02-07T06:06:11+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T13:10:07+00:00" }, { "name": "phpunit/php-text-template", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "526dc996cc0ebdfa428cd2dfccd79b7b53fee346" + "reference": "0c69cbf965d5317ba33f24a352539f354a25db09" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/526dc996cc0ebdfa428cd2dfccd79b7b53fee346", - "reference": "526dc996cc0ebdfa428cd2dfccd79b7b53fee346", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c69cbf965d5317ba33f24a352539f354a25db09", + "reference": "0c69cbf965d5317ba33f24a352539f354a25db09", "shasum": "" }, "require": { "php": "^7.3" }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, "type": "library", "extra": { "branch-alias": { @@ -1753,7 +1762,13 @@ "keywords": [ "template" ], - "time": "2020-02-01T07:43:44+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T12:52:43+00:00" }, { "name": "phpunit/php-timer", @@ -1867,16 +1882,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.2.2", + "version": "9.2.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "8fd0d8f80029682da89516a554f4d5f5a030345c" + "reference": "c1b1d62095ef78427f112a7a1c1502d4607e3c00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8fd0d8f80029682da89516a554f4d5f5a030345c", - "reference": "8fd0d8f80029682da89516a554f4d5f5a030345c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c1b1d62095ef78427f112a7a1c1502d4607e3c00", + "reference": "c1b1d62095ef78427f112a7a1c1502d4607e3c00", "shasum": "" }, "require": { @@ -1961,20 +1976,20 @@ "type": "github" } ], - "time": "2020-06-07T14:14:21+00:00" + "time": "2020-06-15T10:51:34+00:00" }, { "name": "sebastian/code-unit", - "version": "1.0.2", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "ac958085bc19fcd1d36425c781ef4cbb5b06e2a5" + "reference": "d650ef9b1fece15ed4d6eaed6e6b469b7b81183a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/ac958085bc19fcd1d36425c781ef4cbb5b06e2a5", - "reference": "ac958085bc19fcd1d36425c781ef4cbb5b06e2a5", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/d650ef9b1fece15ed4d6eaed6e6b469b7b81183a", + "reference": "d650ef9b1fece15ed4d6eaed6e6b469b7b81183a", "shasum": "" }, "require": { @@ -2013,20 +2028,20 @@ "type": "github" } ], - "time": "2020-04-30T05:58:10+00:00" + "time": "2020-06-15T13:11:26+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "5b5dbe0044085ac41df47e79d34911a15b96d82e" + "reference": "c771130f0e8669104a4320b7101a81c2cc2963ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5b5dbe0044085ac41df47e79d34911a15b96d82e", - "reference": "5b5dbe0044085ac41df47e79d34911a15b96d82e", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/c771130f0e8669104a4320b7101a81c2cc2963ef", + "reference": "c771130f0e8669104a4320b7101a81c2cc2963ef", "shasum": "" }, "require": { @@ -2058,20 +2073,26 @@ ], "description": "Looks up which function or method a line of code belongs to", "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", - "time": "2020-02-07T06:20:13+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T12:56:39+00:00" }, { "name": "sebastian/comparator", - "version": "4.0.0", + "version": "4.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "85b3435da967696ed618ff745f32be3ff4a2b8e8" + "reference": "266d85ef789da8c41f06af4093c43e9798af2784" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85b3435da967696ed618ff745f32be3ff4a2b8e8", - "reference": "85b3435da967696ed618ff745f32be3ff4a2b8e8", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/266d85ef789da8c41f06af4093c43e9798af2784", + "reference": "266d85ef789da8c41f06af4093c43e9798af2784", "shasum": "" }, "require": { @@ -2122,7 +2143,13 @@ "compare", "equality" ], - "time": "2020-02-07T06:08:51+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T15:04:48+00:00" }, { "name": "sebastian/diff", @@ -2188,16 +2215,16 @@ }, { "name": "sebastian/environment", - "version": "5.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "c753f04d68cd489b6973cf9b4e505e191af3b05c" + "reference": "16eb0fa43e29c33d7f2117ed23072e26fc5ab34e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/c753f04d68cd489b6973cf9b4e505e191af3b05c", - "reference": "c753f04d68cd489b6973cf9b4e505e191af3b05c", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/16eb0fa43e29c33d7f2117ed23072e26fc5ab34e", + "reference": "16eb0fa43e29c33d7f2117ed23072e26fc5ab34e", "shasum": "" }, "require": { @@ -2243,20 +2270,20 @@ "type": "github" } ], - "time": "2020-04-14T13:36:52+00:00" + "time": "2020-06-15T13:00:01+00:00" }, { "name": "sebastian/exporter", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "80c26562e964016538f832f305b2286e1ec29566" + "reference": "d12fbca85da932d01d941b59e4b71a0d559db091" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/80c26562e964016538f832f305b2286e1ec29566", - "reference": "80c26562e964016538f832f305b2286e1ec29566", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/d12fbca85da932d01d941b59e4b71a0d559db091", + "reference": "d12fbca85da932d01d941b59e4b71a0d559db091", "shasum": "" }, "require": { @@ -2310,7 +2337,13 @@ "export", "exporter" ], - "time": "2020-02-07T06:10:52+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T13:12:44+00:00" }, { "name": "sebastian/global-state", @@ -2368,16 +2401,16 @@ }, { "name": "sebastian/object-enumerator", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "e67516b175550abad905dc952f43285957ef4363" + "reference": "15f319d67c49fc55ebcdbffb3377433125588455" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/e67516b175550abad905dc952f43285957ef4363", - "reference": "e67516b175550abad905dc952f43285957ef4363", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/15f319d67c49fc55ebcdbffb3377433125588455", + "reference": "15f319d67c49fc55ebcdbffb3377433125588455", "shasum": "" }, "require": { @@ -2411,20 +2444,26 @@ ], "description": "Traverses array structures and object graphs to enumerate all referenced objects", "homepage": "https://github.com/sebastianbergmann/object-enumerator/", - "time": "2020-02-07T06:12:23+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T13:15:25+00:00" }, { "name": "sebastian/object-reflector", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "f4fd0835cabb0d4a6546d9fe291e5740037aa1e7" + "reference": "14e04b3c25b821cc0702d4837803fe497680b062" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/f4fd0835cabb0d4a6546d9fe291e5740037aa1e7", - "reference": "f4fd0835cabb0d4a6546d9fe291e5740037aa1e7", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/14e04b3c25b821cc0702d4837803fe497680b062", + "reference": "14e04b3c25b821cc0702d4837803fe497680b062", "shasum": "" }, "require": { @@ -2456,20 +2495,26 @@ ], "description": "Allows reflection of object attributes, including inherited and non-public ones", "homepage": "https://github.com/sebastianbergmann/object-reflector/", - "time": "2020-02-07T06:19:40+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T13:08:02+00:00" }, { "name": "sebastian/recursion-context", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "cdd86616411fc3062368b720b0425de10bd3d579" + "reference": "a32789e5f0157c10cf216ce6c5136db12a12b847" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cdd86616411fc3062368b720b0425de10bd3d579", - "reference": "cdd86616411fc3062368b720b0425de10bd3d579", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/a32789e5f0157c10cf216ce6c5136db12a12b847", + "reference": "a32789e5f0157c10cf216ce6c5136db12a12b847", "shasum": "" }, "require": { @@ -2509,20 +2554,26 @@ ], "description": "Provides functionality to recursively process PHP variables", "homepage": "http://www.github.com/sebastianbergmann/recursion-context", - "time": "2020-02-07T06:18:20+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T13:06:44+00:00" }, { "name": "sebastian/resource-operations", - "version": "3.0.0", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "8c98bf0dfa1f9256d0468b9803a1e1df31b6fa98" + "reference": "71421c1745788de4facae1b79af923650bd3ec15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/8c98bf0dfa1f9256d0468b9803a1e1df31b6fa98", - "reference": "8c98bf0dfa1f9256d0468b9803a1e1df31b6fa98", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/71421c1745788de4facae1b79af923650bd3ec15", + "reference": "71421c1745788de4facae1b79af923650bd3ec15", "shasum": "" }, "require": { @@ -2554,7 +2605,13 @@ ], "description": "Provides a list of PHP built-in functions that operate on resources", "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "time": "2020-02-07T06:13:02+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T13:17:14+00:00" }, { "name": "sebastian/type", diff --git a/src/world/format/io/region/RegionGarbageMap.php b/src/world/format/io/region/RegionGarbageMap.php new file mode 100644 index 000000000..20840204f --- /dev/null +++ b/src/world/format/io/region/RegionGarbageMap.php @@ -0,0 +1,154 @@ +entries[$entry->getFirstSector()] = $entry; + } + } + + /** + * @param RegionLocationTableEntry[]|null[] $locationTable + */ + public static function buildFromLocationTable(array $locationTable) : self{ + /** @var RegionLocationTableEntry[] $usedMap */ + $usedMap = []; + foreach($locationTable as $entry){ + if($entry === null){ + continue; + } + if(isset($usedMap[$entry->getFirstSector()])){ + throw new AssumptionFailedError("Overlapping entries detected"); + } + $usedMap[$entry->getFirstSector()] = $entry; + } + + ksort($usedMap, SORT_NUMERIC); + + /** @var RegionLocationTableEntry[] $garbageMap */ + $garbageMap = []; + + /** @var RegionLocationTableEntry|null $prevEntry */ + $prevEntry = null; + foreach($usedMap as $firstSector => $entry){ + $expectedStart = ($prevEntry !== null ? $prevEntry->getLastSector() + 1 : RegionLoader::FIRST_SECTOR); + $actualStart = $entry->getFirstSector(); + if($expectedStart < $actualStart){ + //found a gap in the table + $garbageMap[$expectedStart] = new RegionLocationTableEntry($expectedStart, $actualStart - $expectedStart, 0); + } + $prevEntry = $entry; + } + + return new self($garbageMap); + } + + /** + * @return RegionLocationTableEntry[] + * @phpstan-return array + */ + public function getArray() : array{ + if(!$this->clean){ + ksort($this->entries, SORT_NUMERIC); + + /** @var int|null $prevIndex */ + $prevIndex = null; + foreach($this->entries as $k => $entry){ + if($prevIndex !== null and $this->entries[$prevIndex]->getLastSector() + 1 === $entry->getFirstSector()){ + //this SHOULD overwrite the previous index and not appear at the end + $this->entries[$prevIndex] = new RegionLocationTableEntry( + $this->entries[$prevIndex]->getFirstSector(), + $this->entries[$prevIndex]->getSectorCount() + $entry->getSectorCount(), + 0 + ); + unset($this->entries[$k]); + }else{ + $prevIndex = $k; + } + } + $this->clean = true; + } + return $this->entries; + } + + public function add(RegionLocationTableEntry $entry) : void{ + if(isset($this->entries[$k = $entry->getFirstSector()])){ + throw new \InvalidArgumentException("Overlapping entry starting at " . $k); + } + $this->entries[$k] = $entry; + $this->clean = false; + } + + public function remove(RegionLocationTableEntry $entry) : void{ + if(isset($this->entries[$k = $entry->getFirstSector()])){ + //removal doesn't affect ordering and shouldn't affect fragmentation + unset($this->entries[$k]); + } + } + + public function end() : ?RegionLocationTableEntry{ + $array = $this->getArray(); + $end = end($array); + return $end !== false ? $end : null; + } + + public function allocate(int $newSize) : ?RegionLocationTableEntry{ + foreach($this->getArray() as $start => $candidate){ + $candidateSize = $candidate->getSectorCount(); + if($candidateSize < $newSize){ + continue; + } + + $newLocation = new RegionLocationTableEntry($candidate->getFirstSector(), $newSize, time()); + $this->remove($candidate); + + if($candidateSize > $newSize){ //we're not using the whole area, just take part of it + $newGarbageStart = $candidate->getFirstSector() + $newSize; + $newGarbageSize = $candidateSize - $newSize; + $this->add(new RegionLocationTableEntry($newGarbageStart, $newGarbageSize, 0)); + } + return $newLocation; + + } + + return null; + } +} diff --git a/src/world/format/io/region/RegionLoader.php b/src/world/format/io/region/RegionLoader.php index 19bd04819..36d12c160 100644 --- a/src/world/format/io/region/RegionLoader.php +++ b/src/world/format/io/region/RegionLoader.php @@ -27,6 +27,7 @@ use pocketmine\utils\AssumptionFailedError; use pocketmine\utils\Binary; use pocketmine\world\format\ChunkException; use pocketmine\world\format\io\exception\CorruptedChunkException; +use function assert; use function ceil; use function chr; use function fclose; @@ -39,9 +40,11 @@ use function fseek; use function ftruncate; use function fwrite; use function is_resource; +use function ksort; use function max; use function ord; use function str_pad; +use function str_repeat; use function stream_set_read_buffer; use function stream_set_write_buffer; use function strlen; @@ -49,6 +52,7 @@ use function substr; use function time; use function touch; use function unpack; +use const SORT_NUMERIC; use const STR_PAD_RIGHT; class RegionLoader{ @@ -58,7 +62,7 @@ class RegionLoader{ private const MAX_SECTOR_LENGTH = 255 << 12; //255 sectors (~0.996 MiB) private const REGION_HEADER_LENGTH = 8192; //4096 location table + 4096 timestamps - private const FIRST_SECTOR = 2; //location table occupies 0 and 1 + public const FIRST_SECTOR = 2; //location table occupies 0 and 1 /** @var int */ public static $COMPRESSION_LEVEL = 7; @@ -69,13 +73,16 @@ class RegionLoader{ protected $filePointer; /** @var int */ protected $nextSector = self::FIRST_SECTOR; - /** @var RegionLocationTableEntry[] */ + /** @var RegionLocationTableEntry[]|null[] */ protected $locationTable = []; + /** @var RegionGarbageMap */ + protected $garbageTable; /** @var int */ public $lastUsed = 0; public function __construct(string $filePath){ $this->filePath = $filePath; + $this->garbageTable = new RegionGarbageMap([]); } /** @@ -110,7 +117,7 @@ class RegionLoader{ } protected function isChunkGenerated(int $index) : bool{ - return !$this->locationTable[$index]->isNull(); + return $this->locationTable[$index] !== null; } /** @@ -122,7 +129,7 @@ class RegionLoader{ $this->lastUsed = time(); - if(!$this->isChunkGenerated($index)){ + if($this->locationTable[$index] === null){ return null; } @@ -182,19 +189,50 @@ class RegionLoader{ $newSize = (int) ceil(($length + 4) / 4096); $index = self::getChunkOffset($x, $z); - $offset = $this->locationTable[$index]->getFirstSector(); - if($this->locationTable[$index]->getSectorCount() < $newSize){ - $offset = $this->nextSector; + /* + * 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); } - $this->locationTable[$index] = new RegionLocationTableEntry($offset, $newSize, time()); - $this->bumpNextFreeSector($this->locationTable[$index]); - - fseek($this->filePointer, $offset << 12); + /* 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); + } + } } /** @@ -202,7 +240,7 @@ class RegionLoader{ */ public function removeChunk(int $x, int $z) : void{ $index = self::getChunkOffset($x, $z); - $this->locationTable[$index] = new RegionLocationTableEntry(0, 0, 0); + $this->locationTable[$index] = null; $this->writeLocationIndex($index); } @@ -241,8 +279,8 @@ class RegionLoader{ fseek($this->filePointer, 0); $headerRaw = fread($this->filePointer, self::REGION_HEADER_LENGTH); - if(($len = strlen($headerRaw)) !== self::REGION_HEADER_LENGTH){ - throw new CorruptedRegionException("Invalid region file header, expected " . self::REGION_HEADER_LENGTH . " bytes, got " . $len . " bytes"); + if($headerRaw === false or strlen($headerRaw) !== self::REGION_HEADER_LENGTH){ + throw new CorruptedRegionException("Corrupted region header (unexpected end of file)"); } $data = unpack("N*", $headerRaw); @@ -250,18 +288,23 @@ class RegionLoader{ for($i = 0; $i < 1024; ++$i){ $index = $data[$i + 1]; $offset = $index >> 8; + $sectorCount = $index & 0xff; $timestamp = $data[$i + 1025]; - if($offset === 0){ - $this->locationTable[$i] = new RegionLocationTableEntry(0, 0, 0); + 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{ - $this->locationTable[$i] = new RegionLocationTableEntry($offset, $index & 0xff, $timestamp); - $this->bumpNextFreeSector($this->locationTable[$i]); + 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); } @@ -274,7 +317,7 @@ class RegionLoader{ for($i = 0; $i < 1024; ++$i){ $entry = $this->locationTable[$i]; - if($entry->isNull()){ + if($entry === null){ continue; } @@ -294,25 +337,78 @@ class RegionLoader{ } $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(($this->locationTable[$index]->getFirstSector() << 8) | $this->locationTable[$index]->getSectorCount()), 4); + 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($this->locationTable[$index]->getTimestamp()), 4); + 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] = new RegionLocationTableEntry(0, 0, 0); + $this->locationTable[$i] = null; } } private function bumpNextFreeSector(RegionLocationTableEntry $entry) : void{ - $this->nextSector = max($this->nextSector, $entry->getLastSector()) + 1; + $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{ diff --git a/src/world/format/io/region/RegionLocationTableEntry.php b/src/world/format/io/region/RegionLocationTableEntry.php index acca6a80b..8a7fdfaeb 100644 --- a/src/world/format/io/region/RegionLocationTableEntry.php +++ b/src/world/format/io/region/RegionLocationTableEntry.php @@ -38,12 +38,12 @@ class RegionLocationTableEntry{ * @throws \InvalidArgumentException */ public function __construct(int $firstSector, int $sectorCount, int $timestamp){ - if($firstSector < 0){ + if($firstSector < 0 or $firstSector >= 2 ** 24){ throw new \InvalidArgumentException("Start sector must be positive, got $firstSector"); } $this->firstSector = $firstSector; - if($sectorCount < 0 or $sectorCount > 255){ - throw new \InvalidArgumentException("Sector count must be in range 0...255, got $sectorCount"); + if($sectorCount < 1){ + throw new \InvalidArgumentException("Sector count must be positive, got $sectorCount"); } $this->sectorCount = $sectorCount; $this->timestamp = $timestamp; @@ -73,7 +73,16 @@ class RegionLocationTableEntry{ return $this->timestamp; } - public function isNull() : bool{ - return $this->firstSector === 0 or $this->sectorCount === 0; + public function overlaps(RegionLocationTableEntry $other) : bool{ + $overlapCheck = static function(RegionLocationTableEntry $entry1, RegionLocationTableEntry $entry2) : bool{ + $entry1Last = $entry1->getLastSector(); + $entry2Last = $entry2->getLastSector(); + + return ( + ($entry2->firstSector >= $entry1->firstSector and $entry2->firstSector <= $entry1Last) or + ($entry2Last >= $entry1->firstSector and $entry2Last <= $entry1Last) + ); + }; + return $overlapCheck($this, $other) or $overlapCheck($other, $this); } } diff --git a/tests/phpstan/configs/l7-baseline.neon b/tests/phpstan/configs/l7-baseline.neon index 5b4ba2a74..b0cb94d4b 100644 --- a/tests/phpstan/configs/l7-baseline.neon +++ b/tests/phpstan/configs/l7-baseline.neon @@ -1040,16 +1040,6 @@ parameters: count: 1 path: ../../../src/world/format/io/region/RegionLoader.php - - - message: "#^Parameter \\#1 \\$string of function strlen expects string, string\\|false given\\.$#" - count: 1 - path: ../../../src/world/format/io/region/RegionLoader.php - - - - message: "#^Parameter \\#2 \\$data of function unpack expects string, string\\|false given\\.$#" - count: 1 - path: ../../../src/world/format/io/region/RegionLoader.php - - message: "#^Argument of an invalid type array\\\\|false supplied for foreach, only iterables are supported\\.$#" count: 1 diff --git a/tests/phpunit/world/format/io/region/RegionLocationTableEntryTest.php b/tests/phpunit/world/format/io/region/RegionLocationTableEntryTest.php new file mode 100644 index 000000000..fed45a9d0 --- /dev/null +++ b/tests/phpunit/world/format/io/region/RegionLocationTableEntryTest.php @@ -0,0 +1,52 @@ + + */ + public function overlapDataProvider() : \Generator{ + yield [new RegionLocationTableEntry(2, 1, 0), new RegionLocationTableEntry(2, 1, 0), true]; + yield [new RegionLocationTableEntry(2, 1, 0), new RegionLocationTableEntry(3, 1, 0), false]; + yield [new RegionLocationTableEntry(2, 2, 0), new RegionLocationTableEntry(3, 2, 0), true]; + yield [new RegionLocationTableEntry(2, 2, 0), new RegionLocationTableEntry(4, 2, 0), false]; + yield [new RegionLocationTableEntry(2, 2, 0), new RegionLocationTableEntry(2, 1, 0), true]; + yield [new RegionLocationTableEntry(2, 4, 0), new RegionLocationTableEntry(3, 1, 0), true]; + } + + /** + * @dataProvider overlapDataProvider + */ + public function testOverlap(RegionLocationTableEntry $entry1, RegionLocationTableEntry $entry2, bool $overlaps) : void{ + $stringify = function(RegionLocationTableEntry $entry) : string{ + return sprintf("entry first=%d last=%d size=%d", $entry->getFirstSector(), $entry->getLastSector(), $entry->getSectorCount()); + }; + self::assertSame($overlaps, $entry1->overlaps($entry2), $stringify($entry1) . " expected to " . ($overlaps ? "overlap" : "not overlap") . " with " . $stringify($entry2)); + self::assertSame($overlaps, $entry2->overlaps($entry1), $stringify($entry2) . " expected to " . ($overlaps ? "overlap" : "not overlap") . " with " . $stringify($entry1)); + } +}