diff --git a/changelogs/4.19.md b/changelogs/4.19.md new file mode 100644 index 000000000..de5da5340 --- /dev/null +++ b/changelogs/4.19.md @@ -0,0 +1,77 @@ +**For Minecraft: Bedrock Edition 1.19.70** + +### Note about API versions +Plugins which don't touch the `pocketmine\network\mcpe` namespace are compatible with any previous 4.x.y version will also run on these releases and do not need API bumps. +Plugin developers should **only** update their required API to this version if you need the changes in this build. + +**WARNING: If your plugin uses the `pocketmine\network\mcpe` namespace, you're not shielded by API change constraints.** +Consider using the `mcpe-protocol` directive in `plugin.yml` as a constraint if you're using packets directly. + +### Highlights +This version introduces support for a new, more advanced version of Timings. +This improved system provides more detail than the old system, and supports being displayed in a tree view, making it much easier to see which timers contribute to which other timers. + +In addition, some minor performance improvements have been made, along with a couple of minor API additions. + +# 4.19.0 +Released 11th April 2023. + +## General +- Updated the Timings system. + - Timings records now include parent information, allowing them to be displayed in a tree view (e.g. https://timings.pmmp.io/?id=303556). + - Timings records now include additional information, such as Peak (max time spent on any single tick), and Ticks (number of ticks the timer was active on). + - New timings have been added for every event. + - A new timer `Player Network Send - Pre-Spawn Game Data` has been added, and covers most of the time spent handling `ResourcePackClientResponsePacket`, giving a clearer picture of what's happening. +- Improved performance of the plugin event system. + - By introducing some caching, the event system now has 90% less overhead than in previous versions. +- Improved performance of the random chunk ticking system. + - The selection of ticked random chunks, and their validation for ticking, is now cached. This significantly reduces the overhead of chunk selection. + - Factions servers and other game modes with big maps and sparsely populated areas will see the most benefit from this change. + - Real-world performance benefit of this change is anywhere from 0-20%, depending on server type and configuration. +- The `timings paste` command now logs a debug message with the server response on failure to paste a timings report. + +## API +### `pocketmine\entity\object` +- The following API constants have been added: + - `ExperienceOrb::DEFAULT_DESPAWN_DELAY` - the default delay in ticks before an experience orb despawns + - `ExperienceOrb::NEVER_DESPAWN` - magic value for `setDespawnDelay()` to make an experience orb never despawn + - `ExperienceOrb::MAX_DESPAWN_DELAY` - the maximum delay in ticks before an experience orb despawns +- The following API methods have been added: + - `public ExperienceOrb->getDespawnDelay() : int` - returns the delay in ticks before this experience orb despawns + - `public ExperienceOrb->setDespawnDelay(int $despawnDelay) : void` - sets the delay in ticks before this experience orb despawns +- The following properties have been deprecated + - `ExperienceOrb->age` - superseded by despawn delay methods + +### `pocketmine\event` +- The following API methods have been added: + - `public HandlerList->getListenerList() : list` - returns an ordered list of handlers to be called for the event + +### `pocketmine\player` +- The following API methods have behavioural changes: + - `ChunkSelector->selectChunks()` now yields the distance in chunks from the center as the key, instead of an incrementing integer. +- The following classes have been deprecated: + - `PlayerChunkLoader` (this was technically internal, but never marked as such) + +### `pocketmine\timings` +- The following API constants have been deprecated: +- `Timings::INCLUDED_BY_OTHER_TIMINGS_PREFIX` - this is superseded by timings group support (see `Timings::GROUP_BREAKDOWN`) +- The following API constants have been added: +- `Timings::GROUP_BREAKDOWN` - this group makes a timer appear in the `Minecraft - Breakdown` section of a timings report +- The following API methods have been added: +- `public TimingsHandler->getGroup() : string` - returns the name of the table in which this timer will appear in a timings report +- The following API methods have changed signatures: +- `TimingsHandler->__construct()` now accepts an additional, optional `string $group` parameter, which defaults to `Minecraft`. + +### `pocketmine\world` +#### Highlights +Ticking chunks is now done using the `ChunkTicker` system, which has a much more fine-grained API than the old `TickingChunkLoader` system, as well as better performance. +It works similarly to the `ChunkLoader` system, in that chunks will be ticked as long as at least one `ChunkTicker` is registered for them. + +#### API changes +- The following classes have been deprecated: + - `TickingChunkLoader` - this has been superseded by the more powerful and performant `ChunkTicker` APIs +- The following classes have been added: + - `ChunkTicker` - an opaque object used for `registerTickingChunk()` to instruct the `World` that we want a chunk to be ticked +- The following API methods have been added: + - `public World->registerTickingChunk(ChunkTicker $ticker, int $chunkX, int $chunkZ) : void` - registers a chunk to be ticked by the given `ChunkTicker` + - `public World->unregisterTickingChunk(ChunkTicker $ticker, int $chunkX, int $chunkZ) : void` - unregisters a chunk from being ticked by the given `ChunkTicker` diff --git a/src/event/RegisteredListenerCache.php b/src/event/RegisteredListenerCache.php index 1b675c716..e5e315546 100644 --- a/src/event/RegisteredListenerCache.php +++ b/src/event/RegisteredListenerCache.php @@ -23,6 +23,9 @@ declare(strict_types=1); namespace pocketmine\event; +/** + * @internal + */ final class RegisteredListenerCache{ /** diff --git a/src/player/ChunkSelector.php b/src/player/ChunkSelector.php index d88ef5fa4..feb1b25e2 100644 --- a/src/player/ChunkSelector.php +++ b/src/player/ChunkSelector.php @@ -54,23 +54,23 @@ final class ChunkSelector{ //If the chunk is in the radius, others at the same offsets in different quadrants are also guaranteed to be. /* Top right quadrant */ - yield World::chunkHash($centerX + $x, $centerZ + $z); + yield $subRadius => World::chunkHash($centerX + $x, $centerZ + $z); /* Top left quadrant */ - yield World::chunkHash($centerX - $x - 1, $centerZ + $z); + yield $subRadius => World::chunkHash($centerX - $x - 1, $centerZ + $z); /* Bottom right quadrant */ - yield World::chunkHash($centerX + $x, $centerZ - $z - 1); + yield $subRadius => World::chunkHash($centerX + $x, $centerZ - $z - 1); /* Bottom left quadrant */ - yield World::chunkHash($centerX - $x - 1, $centerZ - $z - 1); + yield $subRadius => World::chunkHash($centerX - $x - 1, $centerZ - $z - 1); if($x !== $z){ /* Top right quadrant mirror */ - yield World::chunkHash($centerX + $z, $centerZ + $x); + yield $subRadius => World::chunkHash($centerX + $z, $centerZ + $x); /* Top left quadrant mirror */ - yield World::chunkHash($centerX - $z - 1, $centerZ + $x); + yield $subRadius => World::chunkHash($centerX - $z - 1, $centerZ + $x); /* Bottom right quadrant mirror */ - yield World::chunkHash($centerX + $z, $centerZ - $x - 1); + yield $subRadius => World::chunkHash($centerX + $z, $centerZ - $x - 1); /* Bottom left quadrant mirror */ - yield World::chunkHash($centerX - $z - 1, $centerZ - $x - 1); + yield $subRadius => World::chunkHash($centerX - $z - 1, $centerZ - $x - 1); } } } diff --git a/src/player/Player.php b/src/player/Player.php index dc33b75d3..cc1136349 100644 --- a/src/player/Player.php +++ b/src/player/Player.php @@ -121,6 +121,8 @@ use pocketmine\utils\AssumptionFailedError; use pocketmine\utils\TextFormat; use pocketmine\world\ChunkListener; use pocketmine\world\ChunkListenerNoOpTrait; +use pocketmine\world\ChunkLoader; +use pocketmine\world\ChunkTicker; use pocketmine\world\format\Chunk; use pocketmine\world\Position; use pocketmine\world\sound\EntityAttackNoDamageSound; @@ -239,12 +241,16 @@ class Player extends Human implements CommandSender, ChunkListener, IPlayer{ protected array $loadQueue = []; protected int $nextChunkOrderRun = 5; + /** @var true[] */ + private array $tickingChunks = []; + protected int $viewDistance = -1; protected int $spawnThreshold; protected int $spawnChunkLoadCount = 0; protected int $chunksPerTick; protected ChunkSelector $chunkSelector; - protected PlayerChunkLoader $chunkLoader; + protected ChunkLoader $chunkLoader; + protected ChunkTicker $chunkTicker; /** @var bool[] map: raw UUID (string) => bool */ protected array $hiddenPlayers = []; @@ -310,8 +316,8 @@ class Player extends Human implements CommandSender, ChunkListener, IPlayer{ $this->spawnThreshold = (int) (($this->server->getConfigGroup()->getPropertyInt("chunk-sending.spawn-radius", 4) ** 2) * M_PI); $this->chunkSelector = new ChunkSelector(); - $this->chunkLoader = new PlayerChunkLoader($spawnLocation); - + $this->chunkLoader = new class implements ChunkLoader{}; + $this->chunkTicker = new ChunkTicker(); $world = $spawnLocation->getWorld(); //load the spawn chunk so we can see the terrain $xSpawnChunk = $spawnLocation->getFloorX() >> Chunk::COORD_BIT_SIZE; @@ -749,6 +755,8 @@ class Player extends Human implements CommandSender, ChunkListener, IPlayer{ $world->unregisterChunkLoader($this->chunkLoader, $x, $z); $world->unregisterChunkListener($this, $x, $z); unset($this->loadQueue[$index]); + $world->unregisterTickingChunk($this->chunkTicker, $x, $z); + unset($this->tickingChunks[$index]); } protected function spawnEntitiesOnAllChunks() : void{ @@ -800,6 +808,9 @@ class Player extends Human implements CommandSender, ChunkListener, IPlayer{ unset($this->loadQueue[$index]); $this->getWorld()->registerChunkLoader($this->chunkLoader, $X, $Z, true); $this->getWorld()->registerChunkListener($this, $X, $Z); + if(isset($this->tickingChunks[$index])){ + $this->getWorld()->registerTickingChunk($this->chunkTicker, $X, $Z); + } $this->getWorld()->requestChunkPopulation($X, $Z, $this->chunkLoader)->onCompletion( function() use ($X, $Z, $index, $world) : void{ @@ -897,16 +908,23 @@ class Player extends Human implements CommandSender, ChunkListener, IPlayer{ Timings::$playerChunkOrder->startTiming(); $newOrder = []; + $tickingChunks = []; $unloadChunks = $this->usedChunks; + $world = $this->getWorld(); + $tickingChunkRadius = $world->getChunkTickRadius(); + foreach($this->chunkSelector->selectChunks( $this->server->getAllowedViewDistance($this->viewDistance), $this->location->getFloorX() >> Chunk::COORD_BIT_SIZE, $this->location->getFloorZ() >> Chunk::COORD_BIT_SIZE - ) as $hash){ + ) as $radius => $hash){ if(!isset($this->usedChunks[$hash]) || $this->usedChunks[$hash]->equals(UsedChunkStatus::NEEDED())){ $newOrder[$hash] = true; } + if($radius < $tickingChunkRadius){ + $tickingChunks[$hash] = true; + } unset($unloadChunks[$hash]); } @@ -914,10 +932,18 @@ class Player extends Human implements CommandSender, ChunkListener, IPlayer{ World::getXZ($index, $X, $Z); $this->unloadChunk($X, $Z); } + foreach($this->tickingChunks as $hash => $_){ + //any chunks we encounter here are still used by the player, but may no longer be within ticking range + if(!isset($tickingChunks[$hash]) && !isset($newOrder[$hash])){ + World::getXZ($hash, $tickingChunkX, $tickingChunkZ); + $world->unregisterTickingChunk($this->chunkTicker, $tickingChunkX, $tickingChunkZ); + } + } $this->loadQueue = $newOrder; + $this->tickingChunks = $tickingChunks; + if(count($this->loadQueue) > 0 || count($unloadChunks) > 0){ - $this->chunkLoader->setCurrentLocation($this->location); $this->getNetworkSession()->syncViewAreaCenterPoint($this->location, $this->viewDistance); } diff --git a/src/player/PlayerChunkLoader.php b/src/player/PlayerChunkLoader.php index 65267642c..175f242d3 100644 --- a/src/player/PlayerChunkLoader.php +++ b/src/player/PlayerChunkLoader.php @@ -26,6 +26,10 @@ namespace pocketmine\player; use pocketmine\math\Vector3; use pocketmine\world\TickingChunkLoader; +/** + * @deprecated This class was only needed to implement TickingChunkLoader, which is now deprecated. + * ChunkTicker should be registered on ticking chunks to make them tick instead. + */ final class PlayerChunkLoader implements TickingChunkLoader{ public function __construct(private Vector3 $currentLocation){} diff --git a/src/world/ChunkTicker.php b/src/world/ChunkTicker.php new file mode 100644 index 000000000..c85c5f0e6 --- /dev/null +++ b/src/world/ChunkTicker.php @@ -0,0 +1,34 @@ + ChunkTicker + * @phpstan-var array + */ + public array $tickers = []; + + public bool $ready = false; +} diff --git a/src/world/TickingChunkLoader.php b/src/world/TickingChunkLoader.php index c081571c3..a44173c33 100644 --- a/src/world/TickingChunkLoader.php +++ b/src/world/TickingChunkLoader.php @@ -27,6 +27,10 @@ namespace pocketmine\world; * TickingChunkLoader includes all of the same functionality as ChunkLoader (it can be used in the same way). * However, using this version will also cause chunks around the loader's reported coordinates to get random block * updates. + * + * @deprecated + * @see World::registerTickingChunk() + * @see World::unregisterTickingChunk() */ interface TickingChunkLoader extends ChunkLoader{ diff --git a/src/world/World.php b/src/world/World.php index 0ca67023a..3e7e543f2 100644 --- a/src/world/World.php +++ b/src/world/World.php @@ -212,13 +212,24 @@ class World implements ChunkManager{ /** * @var TickingChunkLoader[] spl_object_id => TickingChunkLoader * @phpstan-var array + * + * @deprecated */ private array $tickingLoaders = []; /** * @var int[] spl_object_id => number of chunks * @phpstan-var array + * + * @deprecated */ private array $tickingLoaderCounter = []; + + /** + * @var TickingChunkEntry[] chunkHash => TickingChunkEntry + * @phpstan-var array + */ + private array $tickingChunks = []; + /** * @var ChunkLoader[][] chunkHash => [spl_object_id => ChunkLoader] * @phpstan-var array> @@ -1152,32 +1163,61 @@ class World implements ChunkManager{ } /** - * Returns the radius of chunks to be ticked around each ticking chunk loader (usually players). This is referred to - * as "simulation distance" in the Minecraft: Bedrock world options screen. + * Returns the radius of chunks to be ticked around each player. This is referred to as "simulation distance" in the + * Minecraft: Bedrock world options screen. */ public function getChunkTickRadius() : int{ return $this->chunkTickRadius; } /** - * Sets the radius of chunks ticked around each ticking chunk loader (usually players). + * Sets the radius of chunks ticked around each player. This may not take effect immediately, since each player + * needs to recalculate their tick radius. */ public function setChunkTickRadius(int $radius) : void{ $this->chunkTickRadius = $radius; } - private function tickChunks() : void{ - if($this->chunkTickRadius <= 0 || count($this->tickingLoaders) === 0){ - return; + /** + * Instructs the World to tick the specified chunk, for as long as this chunk ticker (or any other chunk ticker) is + * registered to it. + */ + public function registerTickingChunk(ChunkTicker $ticker, int $chunkX, int $chunkZ) : void{ + $chunkPosHash = World::chunkHash($chunkX, $chunkZ); + $entry = $this->tickingChunks[$chunkPosHash] ?? null; + if($entry === null){ + $entry = $this->tickingChunks[$chunkPosHash] = new TickingChunkEntry(); } + $entry->tickers[spl_object_id($ticker)] = $ticker; + } - $this->timings->randomChunkUpdatesChunkSelection->startTiming(); - - /** @var bool[] $chunkTickList chunkhash => dummy */ - $chunkTickList = []; - - $chunkTickableCache = []; + /** + * Unregisters the given chunk ticker from the specified chunk. If there are other tickers still registered to the + * chunk, it will continue to be ticked. + */ + public function unregisterTickingChunk(ChunkTicker $ticker, int $chunkX, int $chunkZ) : void{ + $chunkHash = World::chunkHash($chunkX, $chunkZ); + $tickerId = spl_object_id($ticker); + if(isset($this->tickingChunks[$chunkHash]->tickers[$tickerId])){ + unset($this->tickingChunks[$chunkHash]->tickers[$tickerId]); + if(count($this->tickingChunks[$chunkHash]->tickers) === 0){ + unset($this->tickingChunks[$chunkHash]); + } + } + } + /** + * @deprecated + * + * @param true[] $chunkTickList + * @param bool[] $chunkTickableCache + * + * @phpstan-param array $chunkTickList + * @phpstan-param array $chunkTickableCache + * @phpstan-param-out array $chunkTickList + * @phpstan-param-out array $chunkTickableCache + */ + private function selectTickableChunksLegacy(array &$chunkTickList, array &$chunkTickableCache) : void{ $centerChunks = []; $selector = new ChunkSelector(); @@ -1202,6 +1242,38 @@ class World implements ChunkManager{ } } } + } + + private function tickChunks() : void{ + if($this->chunkTickRadius <= 0 || (count($this->tickingChunks) === 0 && count($this->tickingLoaders) === 0)){ + return; + } + + $this->timings->randomChunkUpdatesChunkSelection->startTiming(); + + /** @var bool[] $chunkTickList chunkhash => dummy */ + $chunkTickList = []; + + $chunkTickableCache = []; + + foreach($this->tickingChunks as $hash => $entry){ + if(!$entry->ready){ + World::getXZ($hash, $chunkX, $chunkZ); + if($this->isChunkTickable($chunkX, $chunkZ, $chunkTickableCache)){ + $entry->ready = true; + }else{ + //the chunk has been flagged as temporarily not tickable, so we don't want to tick it this time + continue; + } + } + $chunkTickList[$hash] = true; + } + + //TODO: REMOVE THIS + //backwards compatibility for TickingChunkLoader, although I'm not sure this is really necessary in practice + if(count($this->tickingLoaders) !== 0){ + $this->selectTickableChunksLegacy($chunkTickList, $chunkTickableCache); + } $this->timings->randomChunkUpdatesChunkSelection->stopTiming(); @@ -1253,11 +1325,28 @@ class World implements ChunkManager{ return true; } + /** + * Marks the 3x3 chunks around the specified chunk as not ready to be ticked. This is used to prevent chunk ticking + * while a chunk is being populated, light-populated, or unloaded. + * Each chunk will be rechecked every tick until it is ready to be ticked again. + */ + private function markTickingChunkUnavailable(int $chunkX, int $chunkZ) : void{ + for($cx = -1; $cx <= 1; ++$cx){ + for($cz = -1; $cz <= 1; ++$cz){ + $chunkHash = World::chunkHash($chunkX + $cx, $chunkZ + $cz); + if(isset($this->tickingChunks[$chunkHash])){ + $this->tickingChunks[$chunkHash]->ready = false; + } + } + } + } + private function orderLightPopulation(int $chunkX, int $chunkZ) : void{ $chunkHash = World::chunkHash($chunkX, $chunkZ); $lightPopulatedState = $this->chunks[$chunkHash]->isLightPopulated(); if($lightPopulatedState === false){ $this->chunks[$chunkHash]->setLightPopulated(null); + $this->markTickingChunkUnavailable($chunkX, $chunkZ); $this->workerPool->submitTask(new LightPopulationTask( $this->chunks[$chunkHash], @@ -2332,6 +2421,7 @@ class World implements ChunkManager{ throw new \InvalidArgumentException("Chunk $chunkX $chunkZ is already locked"); } $this->chunkLock[$chunkHash] = $lockId; + $this->markTickingChunkUnavailable($chunkX, $chunkZ); } /** @@ -2397,6 +2487,7 @@ class World implements ChunkManager{ unset($this->blockCache[$chunkHash]); unset($this->changedBlocks[$chunkHash]); $chunk->setTerrainDirty(); + $this->markTickingChunkUnavailable($chunkX, $chunkZ); //this replacement chunk may not meet the conditions for ticking if(!$this->isChunkInUse($chunkX, $chunkZ)){ $this->unloadChunkRequest($chunkX, $chunkZ); @@ -2819,6 +2910,8 @@ class World implements ChunkManager{ unset($this->chunks[$chunkHash]); unset($this->blockCache[$chunkHash]); unset($this->changedBlocks[$chunkHash]); + unset($this->tickingChunks[$chunkHash]); + $this->markTickingChunkUnavailable($x, $z); if(array_key_exists($chunkHash, $this->chunkPopulationRequestMap)){ $this->logger->debug("Rejecting population promise for chunk $x $z"); diff --git a/tests/plugins/TesterPlugin/src/EventHandlerInheritanceTest.php b/tests/plugins/TesterPlugin/src/EventHandlerInheritanceTest.php new file mode 100644 index 000000000..efe20f8d8 --- /dev/null +++ b/tests/plugins/TesterPlugin/src/EventHandlerInheritanceTest.php @@ -0,0 +1,88 @@ +getPlugin(); + $plugin->getServer()->getPluginManager()->registerEvent( + ParentEvent::class, + function(ParentEvent $event) : void{ + $this->callOrder[] = ParentEvent::class; + }, + EventPriority::NORMAL, + $plugin + ); + $plugin->getServer()->getPluginManager()->registerEvent( + ChildEvent::class, + function(ChildEvent $event) : void{ + $this->callOrder[] = ChildEvent::class; + }, + EventPriority::NORMAL, + $plugin + ); + $plugin->getServer()->getPluginManager()->registerEvent( + GrandchildEvent::class, + function(GrandchildEvent $event) : void{ + $this->callOrder[] = GrandchildEvent::class; + }, + EventPriority::NORMAL, + $plugin + ); + + $event = new GrandchildEvent(); + $event->call(); + + if($this->callOrder === self::EXPECTED_ORDER){ + $this->setResult(Test::RESULT_OK); + }else{ + $plugin->getLogger()->error("Expected order: " . implode(", ", self::EXPECTED_ORDER) . ", got: " . implode(", ", $this->callOrder)); + $this->setResult(Test::RESULT_FAILED); + } + } +} diff --git a/tests/plugins/TesterPlugin/src/Main.php b/tests/plugins/TesterPlugin/src/Main.php index 2afd9d6ae..26d3441f4 100644 --- a/tests/plugins/TesterPlugin/src/Main.php +++ b/tests/plugins/TesterPlugin/src/Main.php @@ -56,7 +56,9 @@ class Main extends PluginBase implements Listener{ } }), 10); - $this->waitingTests = []; + $this->waitingTests = [ + new EventHandlerInheritanceTest($this), + ]; } public function onServerCommand(CommandEvent $event) : void{ diff --git a/tests/plugins/TesterPlugin/src/event/ChildEvent.php b/tests/plugins/TesterPlugin/src/event/ChildEvent.php new file mode 100644 index 000000000..b71d2627f --- /dev/null +++ b/tests/plugins/TesterPlugin/src/event/ChildEvent.php @@ -0,0 +1,28 @@ +