diff --git a/changelogs/5.34.md b/changelogs/5.34.md new file mode 100644 index 000000000..f21e02e1c --- /dev/null +++ b/changelogs/5.34.md @@ -0,0 +1,104 @@ +# 5.34.0 +Released 26th September 2025. + +This is a minor feature release containing performance improvements, new gameplay features, new API additions and network changes. + +**Plugin compatibility:** Plugins for previous 5.x versions will run unchanged on this release, unless they use internal APIs, reflection, or packages like the `pocketmine\network\mcpe` or `pocketmine\data` namespace. +Do not update plugin minimum API versions unless you need new features added in this release. + +**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. + +## General +- PocketMine-MP now requires and uses [`pmmp/ext-encoding`](https://github.com/pmmp/ext-encoding) version `1.0.0`, a PHP extension designed to significantly improve performance of data encoding and decoding (@dktapps). + - This first pass only implements support in low-risk areas, such as network protocol and read-only data. Further integration will follow in future minor versions with additional performance improvements. + - While ext-encoding has been heavily tested, we can't be sure there won't be major issues once it reaches production. Please report any bugs you find on the GitHub issue tracker. +- New Bedrock OpenID authentication support has been implemented (@dries-c, @dktapps). + - This system fetches keys from an API provided by Microsoft. Therefore, your server must now have internet access to authenticate players. +- `/timings paste` now creates private reports by default on `timings.pmmp.io` (@dktapps). + - Private reports require an access token to view, so your timings reports can no longer be viewed by others just by guessing the ID. + - If you're using a custom timings host, be sure to update it to get support for this feature. + - The command will generate a warning in the console if the target timings host doesn't support private reports. + +## Performance +- Significantly improved performance of packet encoding and decoding using `ext-encoding` (@dktapps). +- Unnecessary NBT is now stripped from items before sending them over the network. This significantly improves performance when working with writable books, shulker boxes, etc. (@dktapps). +- Improved performance of item saving in `ItemSerializer` by avoiding slow `hasNamedTag()` call followed by `getNamedTag()` (both will rebuild the NBT) (@dktapps). + +## Gameplay +- Implemented basic Trident functionality (@IvanCraft623). +- Implemented Firework and Firework Star (@IvanCraft623). +- Editing the rear side of signs is now supported (@dktapps). +- Sneaking hitbox height has been adjusted to match vanilla (@Dasciam). + +## API +### General +- `pocketmine/nbt` version `1.2.0` is now used ([changelog](https://github.com/pmmp/NBT/releases/tag/1.2.0)). +- `pmmp/ext-encoding` version `1.0.0` is now required and used. + - This can be used as a faster alternative to `BinaryStream` and `Binary` in most use cases. However, please note that its API is very different, and it hasn't been battle-tested yet. + - A recent JetBrains IDE stub can be found in our [custom stubs repository](https://github.com/pmmp/phpstorm-stubs/blob/fork/encoding/encoding.php). + +### `pocketmine\block` +- The following API methods have been added: + - `public BaseSign->getFaceText(bool $frontFace) : SignText` + - `public BaseSign->setFaceText(bool $frontFace, SignText $text) : $this` + - `public BaseSign->updateFaceText(Player $author, bool $frontFace, SignText $text) : bool` - called by the network system when a player edits a sign, triggers `SignChangeEvent` etc. + - `protected BaseSign->getHitboxCenter() : Vector3` - returns the center of the sign's hitbox, used to decide which face of the sign the player is editing + - `protected BaseSign->getFacingDegrees() : float` (to become abstract in PM6) - returns the horizontal facing of the sign in degrees, used to decide which face of the sign the player is editing +- The following API methods have been deprecated: + - `public BaseSign->getText() : SignText` - use `getFaceText()` instead + - `public BaseSign->setText(SignText $text) : $this` - use `setFaceText()` instead + - `public BaseSign->updateText(Player $author, SignText $text) : bool` - use `updateFaceText()` instead + +### `pocketmine\entity` +- The following API classes have been added: + - `NeverSavedWithChunkEntity` - implement this instead of overriding `canSaveWithChunk()` if your entity will never need a save ID + - Used currently by `Player` and `FireworkRocket`. + - `animation\FireworkParticlesAnimation` + - `object\FireworkRocket` + - `projectile\Trident` +- The following API methods have been added: + - `public Living->getSneakOffset() : float` - returns how much the entity's hitbox is shortened and eye height lowered when sneaking (0 by default) + - `protected Projectile->despawnsOnEntityHit() : bool` - returns `true` by default, overridden by tridents (to be removed in a future major version in favour of cleaner BC-breaking methods) + +### `pocketmine\event\block` +- The following API methods have been added: + - `public SignChangeEvent->isFrontFace() : bool` - returns `true` if the front face of the sign is being edited, `false` for the rear face + +### `pocketmine\inventory\transaction` +- `InventoryTransaction` no longer shuffles actions before executing a transaction. + - This was intended to prevent dependency on weird client behaviour, but it is no longer necessary, as the order is now consistent since the introduction of the `ItemStackRequest` system. + +### `pocketmine\item` +- The following API classes have been added: + - `FireworkRocket` + - `FireworkRocketExplosion` + - `FireworkRocketType` (enum) + - `FireworkStar` + - `Trident` +- The following API methods have been added: + - `VanillaItems::FIREWORK_ROCKET() : FireworkRocket` + - `VanillaItems::FIREWORK_STAR() : FireworkStar` + - `VanillaItems::TRIDENT() : Trident` + +### `pocketmine\player` +- The following API methods have signature changes: + - `Player->openSignEditor()` now accepts an optional `bool $frontFace = true` parameter + +### `pocketmine\world\sound` +- The following API classes have been added: + - `FireworkCrackleSound` + - `FireworkExplosionSound` + - `FireworkLargeExplosionSound` + - `FireworkLaunchSound` + - `TridentHitEntitySound` + - `TridentHitBlockSound` + - `TridentThrowSound` + +## Internals +- Many low-risk data handling areas have been switched to use `ext-encoding`, including: + - Bedrock packets + - Bedrock chunk serialization + - `FastChunkSerializer` (used for transmitting chunks between threads) + - GS4 Query + - Auxiliary read-only data loading in the `pocketmine\data\bedrock` package diff --git a/composer.json b/composer.json index bef8b1250..9451fd6eb 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "ext-ctype": "*", "ext-curl": "*", "ext-date": "*", + "ext-encoding": "~1.0.0", "ext-gmp": "*", "ext-hash": "*", "ext-igbinary": "^3.0.1", @@ -36,7 +37,7 @@ "pocketmine/bedrock-block-upgrade-schema": "~5.1.0+bedrock-1.21.60", "pocketmine/bedrock-data": "~6.0.0+bedrock-1.21.100", "pocketmine/bedrock-item-upgrade-schema": "~1.15.0+bedrock-1.21.100", - "pocketmine/bedrock-protocol": "~40.0.0+bedrock-1.21.100", + "pocketmine/bedrock-protocol": "~50.0.0+bedrock-1.21.100", "pocketmine/binaryutils": "^0.2.1", "pocketmine/callback-validator": "^1.0.2", "pocketmine/color": "^0.3.0", @@ -44,7 +45,7 @@ "pocketmine/locale-data": "~2.25.0", "pocketmine/log": "^0.4.0", "pocketmine/math": "~1.0.0", - "pocketmine/nbt": "~1.1.0", + "pocketmine/nbt": "~1.2.0", "pocketmine/raklib": "~1.2.0", "pocketmine/raklib-ipc": "~1.0.0", "pocketmine/snooze": "^0.5.0", diff --git a/composer.lock b/composer.lock index be22909f1..ce923690e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "008c888b5812dda09a0ec6e425453153", + "content-hash": "0d71d3fba23ba8c4734cac59b9e10129", "packages": [ { "name": "adhocore/json-comment", @@ -256,19 +256,20 @@ }, { "name": "pocketmine/bedrock-protocol", - "version": "40.0.0+bedrock-1.21.100", + "version": "50.0.0+bedrock-1.21.100", "source": { "type": "git", "url": "https://github.com/pmmp/BedrockProtocol.git", - "reference": "5e95cab3a6e6c24920e0c25ca4aaf887ed4b70ca" + "reference": "2d7aa27a5537ae593fb1c39158648ea462fef72a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pmmp/BedrockProtocol/zipball/5e95cab3a6e6c24920e0c25ca4aaf887ed4b70ca", - "reference": "5e95cab3a6e6c24920e0c25ca4aaf887ed4b70ca", + "url": "https://api.github.com/repos/pmmp/BedrockProtocol/zipball/2d7aa27a5537ae593fb1c39158648ea462fef72a", + "reference": "2d7aa27a5537ae593fb1c39158648ea462fef72a", "shasum": "" }, "require": { + "ext-encoding": "~1.0.0", "ext-json": "*", "php": "^8.1", "pocketmine/binaryutils": "^0.2.0", @@ -296,9 +297,9 @@ "description": "An implementation of the Minecraft: Bedrock Edition protocol in PHP", "support": { "issues": "https://github.com/pmmp/BedrockProtocol/issues", - "source": "https://github.com/pmmp/BedrockProtocol/tree/40.0.0+bedrock-1.21.100" + "source": "https://github.com/pmmp/BedrockProtocol/tree/50.0.0+bedrock-1.21.100" }, - "time": "2025-08-06T15:13:45+00:00" + "time": "2025-09-20T23:09:19+00:00" }, { "name": "pocketmine/binaryutils", @@ -576,16 +577,16 @@ }, { "name": "pocketmine/nbt", - "version": "1.1.1", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/pmmp/NBT.git", - "reference": "c3c7b0a7295daeaf7873d90fed5c5d10381d12e1" + "reference": "51b8d6a97065fb93e0b4f660b65164b6e1ed2fff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pmmp/NBT/zipball/c3c7b0a7295daeaf7873d90fed5c5d10381d12e1", - "reference": "c3c7b0a7295daeaf7873d90fed5c5d10381d12e1", + "url": "https://api.github.com/repos/pmmp/NBT/zipball/51b8d6a97065fb93e0b4f660b65164b6e1ed2fff", + "reference": "51b8d6a97065fb93e0b4f660b65164b6e1ed2fff", "shasum": "" }, "require": { @@ -595,7 +596,8 @@ }, "require-dev": { "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "2.1.0", + "phpstan/phpstan": "2.1.27", + "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^9.5" }, @@ -612,9 +614,9 @@ "description": "PHP library for working with Named Binary Tags", "support": { "issues": "https://github.com/pmmp/NBT/issues", - "source": "https://github.com/pmmp/NBT/tree/1.1.1" + "source": "https://github.com/pmmp/NBT/tree/1.2.0" }, - "time": "2025-03-09T01:46:03+00:00" + "time": "2025-09-19T18:09:30+00:00" }, { "name": "pocketmine/raklib", @@ -2796,6 +2798,7 @@ "ext-ctype": "*", "ext-curl": "*", "ext-date": "*", + "ext-encoding": "~1.0.0", "ext-gmp": "*", "ext-hash": "*", "ext-igbinary": "^3.0.1", diff --git a/src/PocketMine.php b/src/PocketMine.php index a71c9768d..c34b8e221 100644 --- a/src/PocketMine.php +++ b/src/PocketMine.php @@ -98,6 +98,7 @@ namespace pocketmine { "crypto" => "php-crypto", "ctype" => "ctype", "date" => "Date", + "encoding" => "pmmp/ext-encoding", "gmp" => "GMP", "hash" => "Hash", "igbinary" => "igbinary", @@ -155,6 +156,12 @@ namespace pocketmine { } } + if(($encoding_version = phpversion("encoding")) !== false){ + if(version_compare($encoding_version, "1.0.0") < 0 || version_compare($encoding_version, "2.0.0") >= 0){ + $messages[] = "pmmp/ext-encoding ^1.0.0 is required, while you have $encoding_version."; + } + } + if(extension_loaded("pocketmine")){ $messages[] = "The native PocketMine extension is no longer supported."; } diff --git a/src/Server.php b/src/Server.php index d6f0a8415..af9cbeda7 100644 --- a/src/Server.php +++ b/src/Server.php @@ -50,6 +50,7 @@ use pocketmine\lang\Language; use pocketmine\lang\LanguageNotFoundException; use pocketmine\lang\Translatable; use pocketmine\nbt\tag\CompoundTag; +use pocketmine\network\mcpe\auth\AuthKeyProvider; use pocketmine\network\mcpe\compression\CompressBatchPromise; use pocketmine\network\mcpe\compression\CompressBatchTask; use pocketmine\network\mcpe\compression\Compressor; @@ -270,6 +271,7 @@ class Server{ private int $maxPlayers; private bool $onlineMode = true; + private AuthKeyProvider $authKeyProvider; private Network $network; private bool $networkCompressionAsync = true; @@ -982,6 +984,8 @@ class Server{ $this->logger->warning($this->language->translate(KnownTranslationFactory::pocketmine_server_authProperty_disabled())); } + $this->authKeyProvider = new AuthKeyProvider(new \PrefixedLogger($this->logger, "Minecraft Auth Key Provider"), $this->asyncPool); + if($this->configGroup->getConfigBool(ServerProperties::HARDCORE, false) && $this->getDifficulty() < World::DIFFICULTY_HARD){ $this->configGroup->setConfigInt(ServerProperties::DIFFICULTY, World::DIFFICULTY_HARD); } @@ -1800,6 +1804,13 @@ class Server{ return $this->forceLanguage; } + /** + * @internal + */ + public function getAuthKeyProvider() : AuthKeyProvider{ + return $this->authKeyProvider; + } + public function getNetwork() : Network{ return $this->network; } diff --git a/src/VersionInfo.php b/src/VersionInfo.php index 944d6d84f..55495a959 100644 --- a/src/VersionInfo.php +++ b/src/VersionInfo.php @@ -31,8 +31,8 @@ use function str_repeat; final class VersionInfo{ public const NAME = "PocketMine-MP"; - public const BASE_VERSION = "5.33.3"; - public const IS_DEVELOPMENT_BUILD = true; + public const BASE_VERSION = "5.34.0"; + public const IS_DEVELOPMENT_BUILD = false; public const BUILD_CHANNEL = "stable"; /** diff --git a/src/block/BaseSign.php b/src/block/BaseSign.php index 0efaa603c..8c324918b 100644 --- a/src/block/BaseSign.php +++ b/src/block/BaseSign.php @@ -41,14 +41,19 @@ use pocketmine\utils\TextFormat; use pocketmine\world\BlockTransaction; use pocketmine\world\sound\DyeUseSound; use pocketmine\world\sound\InkSacUseSound; +use function abs; use function array_map; use function assert; +use function atan2; +use function fmod; +use function rad2deg; use function strlen; abstract class BaseSign extends Transparent implements WoodMaterial{ use WoodTypeTrait; - protected SignText $text; + protected SignText $text; //TODO: rename this (BC break) + protected SignText $backText; private bool $waxed = false; protected ?int $editorEntityRuntimeId = null; @@ -63,6 +68,7 @@ abstract class BaseSign extends Transparent implements WoodMaterial{ $this->woodType = $woodType; parent::__construct($idInfo, $name, $typeInfo); $this->text = new SignText(); + $this->backText = new SignText(); $this->asItemCallback = $asItemCallback; } @@ -71,6 +77,7 @@ abstract class BaseSign extends Transparent implements WoodMaterial{ $tile = $this->position->getWorld()->getTile($this->position); if($tile instanceof TileSign){ $this->text = $tile->getText(); + $this->backText = $tile->getBackText(); $this->waxed = $tile->isWaxed(); $this->editorEntityRuntimeId = $tile->getEditorEntityRuntimeId(); } @@ -83,6 +90,7 @@ abstract class BaseSign extends Transparent implements WoodMaterial{ $tile = $this->position->getWorld()->getTile($this->position); assert($tile instanceof TileSign); $tile->setText($this->text); + $tile->setBackText($this->backText); $tile->setWaxed($this->waxed); $tile->setEditorEntityRuntimeId($this->editorEntityRuntimeId); } @@ -127,11 +135,11 @@ abstract class BaseSign extends Transparent implements WoodMaterial{ } } - private function doSignChange(SignText $newText, Player $player, Item $item) : bool{ - $ev = new SignChangeEvent($this, $player, $newText); + private function doSignChange(SignText $newText, Player $player, Item $item, bool $frontFace) : bool{ + $ev = new SignChangeEvent($this, $player, $newText, $frontFace); $ev->call(); if(!$ev->isCancelled()){ - $this->text = $ev->getNewText(); + $this->setFaceText($frontFace, $ev->getNewText()); $this->position->getWorld()->setBlock($this->position, $this); $item->pop(); return true; @@ -140,8 +148,9 @@ abstract class BaseSign extends Transparent implements WoodMaterial{ return false; } - private function changeSignGlowingState(bool $glowing, Player $player, Item $item) : bool{ - if($this->text->isGlowing() !== $glowing && $this->doSignChange(new SignText($this->text->getLines(), $this->text->getBaseColor(), $glowing), $player, $item)){ + private function changeSignGlowingState(bool $glowing, Player $player, Item $item, bool $frontFace) : bool{ + $text = $this->getFaceText($frontFace); + if($text->isGlowing() !== $glowing && $this->doSignChange(new SignText($text->getLines(), $text->getBaseColor(), $glowing), $player, $item, $frontFace)){ $this->position->getWorld()->addSound($this->position, new InkSacUseSound()); return true; } @@ -168,6 +177,8 @@ abstract class BaseSign extends Transparent implements WoodMaterial{ return true; } + $frontFace = $this->interactsFront($this->getHitboxCenter(), $player->getPosition(), $this->getFacingDegrees()); + $dyeColor = $item instanceof Dye ? $item->getColor() : match($item->getTypeId()){ ItemTypeIds::BONE_MEAL => DyeColor::WHITE, ItemTypeIds::LAPIS_LAZULI => DyeColor::BLUE, @@ -176,40 +187,82 @@ abstract class BaseSign extends Transparent implements WoodMaterial{ }; if($dyeColor !== null){ $color = $dyeColor === DyeColor::BLACK ? new Color(0, 0, 0) : $dyeColor->getRgbValue(); + $text = $this->getFaceText($frontFace); if( - $color->toARGB() !== $this->text->getBaseColor()->toARGB() && - $this->doSignChange(new SignText($this->text->getLines(), $color, $this->text->isGlowing()), $player, $item) + $color->toARGB() !== $text->getBaseColor()->toARGB() && + $this->doSignChange(new SignText($text->getLines(), $color, $text->isGlowing()), $player, $item, $frontFace) ){ $this->position->getWorld()->addSound($this->position, new DyeUseSound()); return true; } }elseif(match($item->getTypeId()){ - ItemTypeIds::INK_SAC => $this->changeSignGlowingState(false, $player, $item), - ItemTypeIds::GLOW_INK_SAC => $this->changeSignGlowingState(true, $player, $item), + ItemTypeIds::INK_SAC => $this->changeSignGlowingState(false, $player, $item, $frontFace), + ItemTypeIds::GLOW_INK_SAC => $this->changeSignGlowingState(true, $player, $item, $frontFace), ItemTypeIds::HONEYCOMB => $this->wax($player, $item), default => false }){ return true; } - $player->openSignEditor($this->position); + $player->openSignEditor($this->position, $frontFace); return true; } + private function interactsFront(Vector3 $hitboxCenter, Vector3 $playerPosition, float $signFacingDegrees) : bool{ + $playerCenterDiffX = $playerPosition->x - $hitboxCenter->x; + $playerCenterDiffZ = $playerPosition->z - $hitboxCenter->z; + + $f1 = rad2deg(atan2($playerCenterDiffZ, $playerCenterDiffX)) - 90.0; + + $rotationDiff = $signFacingDegrees - $f1; + $rotation = fmod($rotationDiff + 180.0, 360.0) - 180.0; // Normalize to [-180, 180] + return abs($rotation) <= 90.0; + } + + /** + * Returns the center of the sign's hitbox. Used to decide which face of the sign to open when a player interacts. + */ + protected function getHitboxCenter() : Vector3{ + return $this->position->add(0.5, 0.5, 0.5); + } + + /** + * TODO: make this abstract (BC break) + */ + protected function getFacingDegrees() : float{ + return 0; + } + /** * Returns an object containing information about the sign text. + * @deprecated + * @see self::getFaceText() */ public function getText() : SignText{ return $this->text; } - /** @return $this */ + /** + * @deprecated + * @see self::setFaceText() + * @return $this + */ public function setText(SignText $text) : self{ $this->text = $text; return $this; } + public function getFaceText(bool $frontFace) : SignText{ + return $frontFace ? $this->text : $this->backText; + } + + /** @return $this */ + public function setFaceText(bool $frontFace, SignText $text) : self{ + $frontFace ? $this->text = $text : $this->backText = $text; + return $this; + } + /** * Returns whether the sign has been waxed using a honeycomb. If true, the sign cannot be edited by a player. */ @@ -234,13 +287,21 @@ abstract class BaseSign extends Transparent implements WoodMaterial{ return $this; } + /** + * @deprecated + * @see self::updateFaceText() + */ + public function updateText(Player $author, SignText $text) : bool{ + return $this->updateFaceText($author, true, $text); + } + /** * Called by the player controller (network session) to update the sign text, firing events as appropriate. * * @return bool if the sign update was successful. * @throws \UnexpectedValueException if the text payload is too large */ - public function updateText(Player $author, SignText $text) : bool{ + public function updateFaceText(Player $author, bool $frontFace, SignText $text) : bool{ $size = 0; foreach($text->getLines() as $line){ $size += strlen($line); @@ -248,15 +309,16 @@ abstract class BaseSign extends Transparent implements WoodMaterial{ if($size > 1000){ throw new \UnexpectedValueException($author->getName() . " tried to write $size bytes of text onto a sign (bigger than max 1000)"); } + $oldText = $this->getFaceText($frontFace); $ev = new SignChangeEvent($this, $author, new SignText(array_map(function(string $line) : string{ return TextFormat::clean($line, false); - }, $text->getLines()), $this->text->getBaseColor(), $this->text->isGlowing())); + }, $text->getLines()), $oldText->getBaseColor(), $oldText->isGlowing()), $frontFace); if($this->waxed || $this->editorEntityRuntimeId !== $author->getId()){ $ev->cancel(); } $ev->call(); if(!$ev->isCancelled()){ - $this->setText($ev->getNewText()); + $this->setFaceText($frontFace, $ev->getNewText()); $this->setEditorEntityRuntimeId(null); $this->position->getWorld()->setBlock($this->position, $this); return true; diff --git a/src/block/Block.php b/src/block/Block.php index 36e08fc0b..fd644eae4 100644 --- a/src/block/Block.php +++ b/src/block/Block.php @@ -26,6 +26,8 @@ declare(strict_types=1); */ namespace pocketmine\block; +use pmmp\encoding\BE; +use pmmp\encoding\LE; use pocketmine\block\tile\Spawnable; use pocketmine\block\tile\Tile; use pocketmine\block\utils\SupportType; @@ -49,7 +51,6 @@ use pocketmine\math\Vector3; use pocketmine\nbt\tag\CompoundTag; use pocketmine\player\Player; use pocketmine\utils\AssumptionFailedError; -use pocketmine\utils\Binary; use pocketmine\world\BlockTransaction; use pocketmine\world\format\Chunk; use pocketmine\world\Position; @@ -98,9 +99,10 @@ class Block{ * of operations required to compute the state ID (micro optimization). */ private static function computeStateIdXorMask(int $typeId) : int{ + //TODO: the mixed byte order here is probably a mistake, but it doesn't break anything for now return $typeId << self::INTERNAL_STATE_DATA_BITS | - (Binary::readLong(hash('xxh3', Binary::writeLLong($typeId), binary: true)) & self::INTERNAL_STATE_DATA_MASK); + (BE::unpackSignedLong(hash('xxh3', LE::packSignedLong($typeId), binary: true)) & self::INTERNAL_STATE_DATA_MASK); } /** diff --git a/src/block/CeilingCenterHangingSign.php b/src/block/CeilingCenterHangingSign.php index 1125de553..df6b4e229 100644 --- a/src/block/CeilingCenterHangingSign.php +++ b/src/block/CeilingCenterHangingSign.php @@ -58,4 +58,8 @@ final class CeilingCenterHangingSign extends BaseSign implements SignLikeRotatio $supportBlock->getSupportType(Facing::DOWN)->hasCenterSupport() || $supportBlock->hasTypeTag(BlockTypeTags::HANGING_SIGN); } + + protected function getFacingDegrees() : float{ + return $this->rotation * 22.5; + } } diff --git a/src/block/CeilingEdgesHangingSign.php b/src/block/CeilingEdgesHangingSign.php index 3f7b6489b..503915fa0 100644 --- a/src/block/CeilingEdgesHangingSign.php +++ b/src/block/CeilingEdgesHangingSign.php @@ -30,6 +30,7 @@ use pocketmine\item\Item; use pocketmine\math\Facing; use pocketmine\math\Vector3; use pocketmine\player\Player; +use pocketmine\utils\AssumptionFailedError; use pocketmine\world\BlockTransaction; final class CeilingEdgesHangingSign extends BaseSign implements HorizontalFacing{ @@ -65,4 +66,14 @@ final class CeilingEdgesHangingSign extends BaseSign implements HorizontalFacing $supportBlock->getSupportType(Facing::DOWN) === SupportType::FULL || (($supportBlock instanceof WallHangingSign || $supportBlock instanceof CeilingEdgesHangingSign) && Facing::axis($supportBlock->getFacing()) === Facing::axis($this->facing)); } + + protected function getFacingDegrees() : float{ + return match($this->facing){ + Facing::SOUTH => 0, + Facing::WEST => 90, + Facing::NORTH => 180, + Facing::EAST => 270, + default => throw new AssumptionFailedError("Invalid facing direction: " . $this->facing), + }; + } } diff --git a/src/block/FloorSign.php b/src/block/FloorSign.php index 94e51ffe8..08bc480d8 100644 --- a/src/block/FloorSign.php +++ b/src/block/FloorSign.php @@ -48,4 +48,8 @@ final class FloorSign extends BaseSign implements SignLikeRotation{ } return parent::place($tx, $item, $blockReplace, $blockClicked, $face, $clickVector, $player); } + + protected function getFacingDegrees() : float{ + return $this->rotation * 22.5; + } } diff --git a/src/block/WallHangingSign.php b/src/block/WallHangingSign.php index df959c720..6d4cfb95e 100644 --- a/src/block/WallHangingSign.php +++ b/src/block/WallHangingSign.php @@ -32,6 +32,7 @@ use pocketmine\math\AxisAlignedBB; use pocketmine\math\Facing; use pocketmine\math\Vector3; use pocketmine\player\Player; +use pocketmine\utils\AssumptionFailedError; use pocketmine\world\BlockTransaction; final class WallHangingSign extends BaseSign implements HorizontalFacing{ @@ -78,4 +79,14 @@ final class WallHangingSign extends BaseSign implements HorizontalFacing{ ($block instanceof WallHangingSign && Facing::axis(Facing::rotateY($block->getFacing(), clockwise: true)) === Facing::axis($face)) || $block->getSupportType(Facing::opposite($face)) === SupportType::FULL; } + + protected function getFacingDegrees() : float{ + return match($this->facing){ + Facing::SOUTH => 0, + Facing::WEST => 90, + Facing::NORTH => 180, + Facing::EAST => 270, + default => throw new AssumptionFailedError("Invalid facing direction: " . $this->facing), + }; + } } diff --git a/src/block/WallSign.php b/src/block/WallSign.php index c6b42608d..40e1ba458 100644 --- a/src/block/WallSign.php +++ b/src/block/WallSign.php @@ -30,6 +30,7 @@ use pocketmine\math\Axis; use pocketmine\math\Facing; use pocketmine\math\Vector3; use pocketmine\player\Player; +use pocketmine\utils\AssumptionFailedError; use pocketmine\world\BlockTransaction; final class WallSign extends BaseSign implements HorizontalFacing{ @@ -46,4 +47,25 @@ final class WallSign extends BaseSign implements HorizontalFacing{ $this->facing = $face; return parent::place($tx, $item, $blockReplace, $blockClicked, $face, $clickVector, $player); } + + protected function getHitboxCenter() : Vector3{ + [$xOffset, $zOffset] = match($this->facing){ + Facing::NORTH => [0, 15 / 16], + Facing::SOUTH => [0, 1 / 16], + Facing::WEST => [15 / 16, 0], + Facing::EAST => [1 / 16, 0], + default => throw new AssumptionFailedError("Invalid facing direction: " . $this->facing), + }; + return $this->position->add($xOffset, 0.5, $zOffset); + } + + protected function getFacingDegrees() : float{ + return match($this->facing){ + Facing::SOUTH => 0, + Facing::WEST => 90, + Facing::NORTH => 180, + Facing::EAST => 270, + default => throw new AssumptionFailedError("Invalid facing direction: " . $this->facing), + }; + } } diff --git a/src/block/tile/Banner.php b/src/block/tile/Banner.php index b6a143fe7..ac19d9835 100644 --- a/src/block/tile/Banner.php +++ b/src/block/tile/Banner.php @@ -69,9 +69,8 @@ class Banner extends Spawnable{ $patternTypeIdMap = BannerPatternTypeIdMap::getInstance(); - $patterns = $nbt->getListTag(self::TAG_PATTERNS); + $patterns = $nbt->getListTag(self::TAG_PATTERNS, CompoundTag::class); if($patterns !== null){ - /** @var CompoundTag $pattern */ foreach($patterns as $pattern){ $patternColor = $colorIdMap->fromInvertedId($pattern->getInt(self::TAG_PATTERN_COLOR)) ?? DyeColor::BLACK; //TODO: missing pattern colour should be an error $patternType = $patternTypeIdMap->fromId($pattern->getString(self::TAG_PATTERN_NAME)); diff --git a/src/block/tile/ChiseledBookshelf.php b/src/block/tile/ChiseledBookshelf.php index 06175e27f..90bf8f29b 100644 --- a/src/block/tile/ChiseledBookshelf.php +++ b/src/block/tile/ChiseledBookshelf.php @@ -34,6 +34,7 @@ use pocketmine\nbt\NBT; use pocketmine\nbt\tag\CompoundTag; use pocketmine\nbt\tag\ListTag; use pocketmine\nbt\tag\StringTag; +use pocketmine\nbt\UnexpectedTagTypeException; use pocketmine\world\World; use function count; @@ -86,13 +87,18 @@ class ChiseledBookshelf extends Tile implements Container{ } protected function loadItems(CompoundTag $tag) : void{ - if(($inventoryTag = $tag->getTag(Container::TAG_ITEMS)) instanceof ListTag && $inventoryTag->getTagType() === NBT::TAG_Compound){ + try{ + $inventoryTag = $tag->getListTag(Container::TAG_ITEMS, CompoundTag::class); + }catch(UnexpectedTagTypeException){ + //preserve the old behaviour of not throwing on wrong types + $inventoryTag = null; + } + if($inventoryTag !== null){ $inventory = $this->getRealInventory(); $listeners = $inventory->getListeners()->toArray(); $inventory->getListeners()->remove(...$listeners); //prevent any events being fired by initialization $newContents = []; - /** @var CompoundTag $itemNBT */ foreach($inventoryTag as $slot => $itemNBT){ try{ $count = $itemNBT->getByte(SavedItemStackData::TAG_COUNT); diff --git a/src/block/tile/ContainerTrait.php b/src/block/tile/ContainerTrait.php index fdd050a41..6b9158d7a 100644 --- a/src/block/tile/ContainerTrait.php +++ b/src/block/tile/ContainerTrait.php @@ -31,6 +31,7 @@ use pocketmine\nbt\NBT; use pocketmine\nbt\tag\CompoundTag; use pocketmine\nbt\tag\ListTag; use pocketmine\nbt\tag\StringTag; +use pocketmine\nbt\UnexpectedTagTypeException; use pocketmine\world\Position; /** @@ -43,13 +44,18 @@ trait ContainerTrait{ abstract public function getRealInventory() : Inventory; protected function loadItems(CompoundTag $tag) : void{ - if(($inventoryTag = $tag->getTag(Container::TAG_ITEMS)) instanceof ListTag && $inventoryTag->getTagType() === NBT::TAG_Compound){ + try{ + $inventoryTag = $tag->getListTag(Container::TAG_ITEMS, CompoundTag::class); + }catch(UnexpectedTagTypeException){ + //preserve the old behaviour of not throwing on wrong types + $inventoryTag = null; + } + if($inventoryTag !== null){ $inventory = $this->getRealInventory(); $listeners = $inventory->getListeners()->toArray(); $inventory->getListeners()->remove(...$listeners); //prevent any events being fired by initialization $newContents = []; - /** @var CompoundTag $itemNBT */ foreach($inventoryTag as $itemNBT){ try{ $newContents[$itemNBT->getByte(SavedItemStackData::TAG_SLOT)] = Item::nbtDeserialize($itemNBT); diff --git a/src/block/tile/Sign.php b/src/block/tile/Sign.php index aef83e3cc..3040e74c0 100644 --- a/src/block/tile/Sign.php +++ b/src/block/tile/Sign.php @@ -70,16 +70,18 @@ class Sign extends Spawnable{ } protected SignText $text; + protected SignText $backText; private bool $waxed = false; protected ?int $editorEntityRuntimeId = null; public function __construct(World $world, Vector3 $pos){ $this->text = new SignText(); + $this->backText = new SignText(); parent::__construct($world, $pos); } - private function readTextTag(CompoundTag $nbt, bool $lightingBugResolved) : void{ + private function readTextTag(CompoundTag $nbt, bool $lightingBugResolved) : SignText{ $baseColor = new Color(0, 0, 0); $glowingText = false; if(($baseColorTag = $nbt->getTag(self::TAG_TEXT_COLOR)) instanceof IntTag){ @@ -90,19 +92,27 @@ class Sign extends Spawnable{ //see https://bugs.mojang.com/browse/MCPE-117835 $glowingText = $glowingTextTag->getValue() !== 0; } - $this->text = SignText::fromBlob(mb_scrub($nbt->getString(self::TAG_TEXT_BLOB), 'UTF-8'), $baseColor, $glowingText); + return SignText::fromBlob(mb_scrub($nbt->getString(self::TAG_TEXT_BLOB), 'UTF-8'), $baseColor, $glowingText); + } + + private function writeTextTag(SignText $text) : CompoundTag{ + return CompoundTag::create() + ->setString(self::TAG_TEXT_BLOB, rtrim(implode("\n", $text->getLines()), "\n")) + ->setInt(self::TAG_TEXT_COLOR, Binary::signInt($text->getBaseColor()->toARGB())) + ->setByte(self::TAG_GLOWING_TEXT, $text->isGlowing() ? 1 : 0) + ->setByte(self::TAG_PERSIST_FORMATTING, 1); } public function readSaveData(CompoundTag $nbt) : void{ $frontTextTag = $nbt->getTag(self::TAG_FRONT_TEXT); if($frontTextTag instanceof CompoundTag){ - $this->readTextTag($frontTextTag, true); + $this->text = $this->readTextTag($frontTextTag, true); }elseif($nbt->getTag(self::TAG_TEXT_BLOB) instanceof StringTag){ //MCPE 1.2 save format $lightingBugResolved = false; if(($lightingBugResolvedTag = $nbt->getTag(self::TAG_LEGACY_BUG_RESOLVE)) instanceof ByteTag){ $lightingBugResolved = $lightingBugResolvedTag->getValue() !== 0; } - $this->readTextTag($nbt, $lightingBugResolved); + $this->text = $this->readTextTag($nbt, $lightingBugResolved); }else{ $text = []; for($i = 0; $i < SignText::LINE_COUNT; ++$i){ @@ -113,22 +123,14 @@ class Sign extends Spawnable{ } $this->text = new SignText($text); } + $backTextTag = $nbt->getTag(self::TAG_BACK_TEXT); + $this->backText = $backTextTag instanceof CompoundTag ? $this->readTextTag($backTextTag, true) : new SignText(); $this->waxed = $nbt->getByte(self::TAG_WAXED, 0) !== 0; } protected function writeSaveData(CompoundTag $nbt) : void{ - $nbt->setTag(self::TAG_FRONT_TEXT, CompoundTag::create() - ->setString(self::TAG_TEXT_BLOB, rtrim(implode("\n", $this->text->getLines()), "\n")) - ->setInt(self::TAG_TEXT_COLOR, Binary::signInt($this->text->getBaseColor()->toARGB())) - ->setByte(self::TAG_GLOWING_TEXT, $this->text->isGlowing() ? 1 : 0) - ->setByte(self::TAG_PERSIST_FORMATTING, 1) - ); - $nbt->setTag(self::TAG_BACK_TEXT, CompoundTag::create() - ->setString(self::TAG_TEXT_BLOB, "") - ->setInt(self::TAG_TEXT_COLOR, Binary::signInt(0xff_00_00_00)) - ->setByte(self::TAG_GLOWING_TEXT, 0) - ->setByte(self::TAG_PERSIST_FORMATTING, 1) - ); + $nbt->setTag(self::TAG_FRONT_TEXT, $this->writeTextTag($this->text)); + $nbt->setTag(self::TAG_BACK_TEXT, $this->writeTextTag($this->backText)); $nbt->setByte(self::TAG_WAXED, $this->waxed ? 1 : 0); } @@ -141,6 +143,10 @@ class Sign extends Spawnable{ $this->text = $text; } + public function getBackText() : SignText{ return $this->backText; } + + public function setBackText(SignText $backText) : void{ $this->backText = $backText; } + public function isWaxed() : bool{ return $this->waxed; } public function setWaxed(bool $waxed) : void{ $this->waxed = $waxed; } @@ -162,19 +168,8 @@ class Sign extends Spawnable{ } protected function addAdditionalSpawnData(CompoundTag $nbt) : void{ - $nbt->setTag(self::TAG_FRONT_TEXT, CompoundTag::create() - ->setString(self::TAG_TEXT_BLOB, rtrim(implode("\n", $this->text->getLines()), "\n")) - ->setInt(self::TAG_TEXT_COLOR, Binary::signInt($this->text->getBaseColor()->toARGB())) - ->setByte(self::TAG_GLOWING_TEXT, $this->text->isGlowing() ? 1 : 0) - ->setByte(self::TAG_PERSIST_FORMATTING, 1) //TODO: not sure what this is used for - ); - //TODO: this is not yet used by the server, but needed to rollback any client-side changes to the back text - $nbt->setTag(self::TAG_BACK_TEXT, CompoundTag::create() - ->setString(self::TAG_TEXT_BLOB, "") - ->setInt(self::TAG_TEXT_COLOR, Binary::signInt(0xff_00_00_00)) - ->setByte(self::TAG_GLOWING_TEXT, 0) - ->setByte(self::TAG_PERSIST_FORMATTING, 1) - ); + $nbt->setTag(self::TAG_FRONT_TEXT, $this->writeTextTag($this->text)); + $nbt->setTag(self::TAG_BACK_TEXT, $this->writeTextTag($this->backText)); $nbt->setByte(self::TAG_WAXED, $this->waxed ? 1 : 0); $nbt->setLong(self::TAG_LOCKED_FOR_EDITING_BY, $this->editorEntityRuntimeId ?? -1); } diff --git a/src/command/defaults/TimingsCommand.php b/src/command/defaults/TimingsCommand.php index 08a8b82aa..ffbc08491 100644 --- a/src/command/defaults/TimingsCommand.php +++ b/src/command/defaults/TimingsCommand.php @@ -148,7 +148,8 @@ class TimingsCommand extends VanillaCommand{ private function uploadReport(array $lines, CommandSender $sender) : void{ $data = [ "browser" => $agent = $sender->getServer()->getName() . " " . $sender->getServer()->getPocketMineVersion(), - "data" => implode("\n", $lines) + "data" => implode("\n", $lines), + "private" => "true" ]; $host = $sender->getServer()->getConfigGroup()->getPropertyString(YmlServerProperties::TIMINGS_HOST, "timings.pmmp.io"); @@ -181,8 +182,13 @@ class TimingsCommand extends VanillaCommand{ } $response = json_decode($result->getBody(), true); if(is_array($response) && isset($response["id"]) && (is_int($response["id"]) || is_string($response["id"]))){ - Command::broadcastCommandMessage($sender, KnownTranslationFactory::pocketmine_command_timings_timingsRead( - "https://" . $host . "/?id=" . $response["id"])); + $url = "https://" . $host . "/?id=" . $response["id"]; + if(isset($response["access_token"]) && is_string($response["access_token"])){ + $url .= "&access_token=" . $response["access_token"]; + }else{ + $sender->getServer()->getLogger()->warning("Your chosen timings host does not support private reports. Anyone will be able to see your report if they guess the ID."); + } + Command::broadcastCommandMessage($sender, KnownTranslationFactory::pocketmine_command_timings_timingsRead($url)); }else{ $sender->getServer()->getLogger()->debug("Invalid response from timings server (" . $result->getCode() . "): " . $result->getBody()); Command::broadcastCommandMessage($sender, KnownTranslationFactory::pocketmine_command_timings_pasteError()); diff --git a/src/crafting/CraftingManager.php b/src/crafting/CraftingManager.php index 93d6e1838..673095c6e 100644 --- a/src/crafting/CraftingManager.php +++ b/src/crafting/CraftingManager.php @@ -23,10 +23,11 @@ declare(strict_types=1); namespace pocketmine\crafting; +use pmmp\encoding\ByteBufferWriter; +use pmmp\encoding\VarInt; use pocketmine\item\Item; use pocketmine\nbt\LittleEndianNbtSerializer; use pocketmine\nbt\TreeRoot; -use pocketmine\utils\BinaryStream; use pocketmine\utils\DestructorCallbackTrait; use pocketmine\utils\ObjectSet; use function array_shift; @@ -114,11 +115,13 @@ class CraftingManager{ } private static function hashOutput(Item $output) : string{ - $write = new BinaryStream(); - $write->putVarInt($output->getStateId()); - $write->put((new LittleEndianNbtSerializer())->write(new TreeRoot($output->getNamedTag()))); + $write = new ByteBufferWriter(); + VarInt::writeSignedInt($write, $output->getStateId()); + //TODO: the NBT serializer allocates its own ByteBufferWriter, we should change the API in the future to + //allow passing our own to avoid this extra allocation + $write->writeByteArray((new LittleEndianNbtSerializer())->write(new TreeRoot($output->getNamedTag()))); - return $write->getBuffer(); + return $write->getData(); } /** diff --git a/src/data/bedrock/FireworkRocketTypeIdMap.php b/src/data/bedrock/FireworkRocketTypeIdMap.php new file mode 100644 index 000000000..4358c2073 --- /dev/null +++ b/src/data/bedrock/FireworkRocketTypeIdMap.php @@ -0,0 +1,45 @@ + */ + use IntSaveIdMapTrait; + + private function __construct(){ + foreach(FireworkRocketType::cases() as $case){ + $this->register(match($case){ + FireworkRocketType::SMALL_BALL => FireworkRocketTypeIds::SMALL_BALL, + FireworkRocketType::LARGE_BALL => FireworkRocketTypeIds::LARGE_BALL, + FireworkRocketType::STAR => FireworkRocketTypeIds::STAR, + FireworkRocketType::CREEPER => FireworkRocketTypeIds::CREEPER, + FireworkRocketType::BURST => FireworkRocketTypeIds::BURST, + }, $case); + } + } +} diff --git a/src/data/bedrock/FireworkRocketTypeIds.php b/src/data/bedrock/FireworkRocketTypeIds.php new file mode 100644 index 000000000..7ddf0e6b9 --- /dev/null +++ b/src/data/bedrock/FireworkRocketTypeIds.php @@ -0,0 +1,32 @@ +getUnsignedVarInt(); + $idCount = VarInt::readUnsignedInt($legacyStateMapReader); for($idIndex = 0; $idIndex < $idCount; $idIndex++){ - $id = $legacyStateMapReader->get($legacyStateMapReader->getUnsignedVarInt()); + $id = $legacyStateMapReader->readByteArray(VarInt::readUnsignedInt($legacyStateMapReader)); - $metaCount = $legacyStateMapReader->getUnsignedVarInt(); + $metaCount = VarInt::readUnsignedInt($legacyStateMapReader); for($metaIndex = 0; $metaIndex < $metaCount; $metaIndex++){ - $meta = $legacyStateMapReader->getUnsignedVarInt(); + $meta = VarInt::readUnsignedInt($legacyStateMapReader); $offset = $legacyStateMapReader->getOffset(); - $state = $nbtReader->read($legacyStateMapReader->getBuffer(), $offset)->mustGetCompoundTag(); + $state = $nbtReader->read($legacyStateMapReader->getData(), $offset)->mustGetCompoundTag(); $legacyStateMapReader->setOffset($offset); $mappingTable[$id][$meta] = $blockStateUpgrader->upgrade(BlockStateData::fromNbt($state)); } } - if(!$legacyStateMapReader->feof()){ - throw new BinaryDataException("Unexpected trailing data in legacy state map data"); + if($legacyStateMapReader->getUnreadLength() > 0){ + throw new DataDecodeException("Unexpected trailing data in legacy state map data"); } return new self($mappingTable, $idMap); diff --git a/src/data/bedrock/item/ItemSerializer.php b/src/data/bedrock/item/ItemSerializer.php index 845d5bc5f..c6edcfb8f 100644 --- a/src/data/bedrock/item/ItemSerializer.php +++ b/src/data/bedrock/item/ItemSerializer.php @@ -117,8 +117,8 @@ final class ItemSerializer{ $data = $serializer($item); } - if($item->hasNamedTag()){ - $resultTag = $item->getNamedTag(); + $resultTag = $item->getNamedTag(); + if($resultTag->count() > 0){ $extraTag = $data->getTag(); if($extraTag !== null){ $resultTag = $resultTag->merge($extraTag); diff --git a/src/data/bedrock/item/ItemSerializerDeserializerRegistrar.php b/src/data/bedrock/item/ItemSerializerDeserializerRegistrar.php index f5c03dbeb..bb4c61509 100644 --- a/src/data/bedrock/item/ItemSerializerDeserializerRegistrar.php +++ b/src/data/bedrock/item/ItemSerializerDeserializerRegistrar.php @@ -40,6 +40,7 @@ use pocketmine\data\bedrock\PotionTypeIdMap; use pocketmine\data\bedrock\SuspiciousStewTypeIdMap; use pocketmine\item\Banner; use pocketmine\item\Dye; +use pocketmine\item\FireworkStar; use pocketmine\item\GoatHorn; use pocketmine\item\Item; use pocketmine\item\Medicine; @@ -246,6 +247,7 @@ final class ItemSerializerDeserializerRegistrar{ $this->map1to1Item(Ids::EYE_ARMOR_TRIM_SMITHING_TEMPLATE, Items::EYE_ARMOR_TRIM_SMITHING_TEMPLATE()); $this->map1to1Item(Ids::FEATHER, Items::FEATHER()); $this->map1to1Item(Ids::FERMENTED_SPIDER_EYE, Items::FERMENTED_SPIDER_EYE()); + $this->map1to1Item(Ids::FIREWORK_ROCKET, Items::FIREWORK_ROCKET()); $this->map1to1Item(Ids::FIRE_CHARGE, Items::FIRE_CHARGE()); $this->map1to1Item(Ids::FISHING_ROD, Items::FISHING_ROD()); $this->map1to1Item(Ids::FLINT, Items::FLINT()); @@ -403,6 +405,7 @@ final class ItemSerializerDeserializerRegistrar{ $this->map1to1Item(Ids::TORCHFLOWER_SEEDS, Items::TORCHFLOWER_SEEDS()); $this->map1to1Item(Ids::TIDE_ARMOR_TRIM_SMITHING_TEMPLATE, Items::TIDE_ARMOR_TRIM_SMITHING_TEMPLATE()); $this->map1to1Item(Ids::TOTEM_OF_UNDYING, Items::TOTEM()); + $this->map1to1Item(Ids::TRIDENT, Items::TRIDENT()); $this->map1to1Item(Ids::TROPICAL_FISH, Items::CLOWNFISH()); $this->map1to1Item(Ids::TURTLE_HELMET, Items::TURTLE_HELMET()); $this->map1to1Item(Ids::VEX_ARMOR_TRIM_SMITHING_TEMPLATE, Items::VEX_ARMOR_TRIM_SMITHING_TEMPLATE()); @@ -500,6 +503,14 @@ final class ItemSerializerDeserializerRegistrar{ * in a unified manner. */ private function register1to1ItemWithMetaMappings() : void{ + $this->map1to1ItemWithMeta( + Ids::FIREWORK_STAR, + Items::FIREWORK_STAR(), + function(FireworkStar $item, int $meta) : void{ + // Colors will be defined by CompoundTag deserialization. + }, + fn(FireworkStar $item) => DyeColorIdMap::getInstance()->toInvertedId($item->getExplosion()->getFlashColor()) + ); $this->map1to1ItemWithMeta( Ids::GOAT_HORN, Items::GOAT_HORN(), diff --git a/src/data/bedrock/item/upgrade/ItemDataUpgrader.php b/src/data/bedrock/item/upgrade/ItemDataUpgrader.php index 7b34ffcb6..98af7ffb0 100644 --- a/src/data/bedrock/item/upgrade/ItemDataUpgrader.php +++ b/src/data/bedrock/item/upgrade/ItemDataUpgrader.php @@ -29,16 +29,14 @@ use pocketmine\data\bedrock\item\BlockItemIdMap; use pocketmine\data\bedrock\item\SavedItemData; use pocketmine\data\bedrock\item\SavedItemStackData; use pocketmine\data\SavedDataLoadingException; -use pocketmine\nbt\NBT; use pocketmine\nbt\NbtException; use pocketmine\nbt\tag\ByteTag; use pocketmine\nbt\tag\CompoundTag; -use pocketmine\nbt\tag\ListTag; use pocketmine\nbt\tag\ShortTag; use pocketmine\nbt\tag\StringTag; use pocketmine\network\mcpe\convert\BlockStateDictionary; use pocketmine\utils\Binary; -use function assert; +use function array_map; final class ItemDataUpgrader{ private const TAG_LEGACY_ID = "id"; //TAG_Short (or TAG_String for Java itemstacks) @@ -169,26 +167,6 @@ final class ItemDataUpgrader{ return new SavedItemData($newNameId, $newMeta, $blockStateData, $tag->getCompoundTag(SavedItemData::TAG_TAG)); } - /** - * @return string[] - * @throws SavedDataLoadingException - */ - private static function deserializeListOfStrings(?ListTag $list, string $tagName) : array{ - if($list === null){ - return []; - } - if($list->getTagType() !== NBT::TAG_String){ - throw new SavedDataLoadingException("Unexpected type of list for tag '$tagName', expected TAG_String"); - } - $result = []; - foreach($list as $item){ - assert($item instanceof StringTag); - $result[] = $item->getValue(); - } - - return $result; - } - /** * @throws SavedDataLoadingException */ @@ -205,8 +183,8 @@ final class ItemDataUpgrader{ //optional $slot = ($slotTag = $tag->getTag(SavedItemStackData::TAG_SLOT)) instanceof ByteTag ? Binary::unsignByte($slotTag->getValue()) : null; $wasPickedUp = ($wasPickedUpTag = $tag->getTag(SavedItemStackData::TAG_WAS_PICKED_UP)) instanceof ByteTag ? $wasPickedUpTag->getValue() : null; - $canPlaceOnList = $tag->getListTag(SavedItemStackData::TAG_CAN_PLACE_ON); - $canDestroyList = $tag->getListTag(SavedItemStackData::TAG_CAN_DESTROY); + $canPlaceOnList = $tag->getListTag(SavedItemStackData::TAG_CAN_PLACE_ON, StringTag::class); + $canDestroyList = $tag->getListTag(SavedItemStackData::TAG_CAN_DESTROY, StringTag::class); }catch(NbtException $e){ throw new SavedDataLoadingException($e->getMessage(), 0, $e); } @@ -216,8 +194,8 @@ final class ItemDataUpgrader{ $count, $slot, $wasPickedUp !== 0, - self::deserializeListOfStrings($canPlaceOnList, SavedItemStackData::TAG_CAN_PLACE_ON), - self::deserializeListOfStrings($canDestroyList, SavedItemStackData::TAG_CAN_DESTROY) + $canPlaceOnList === null ? [] : array_map(fn(StringTag $t) => $t->getValue(), $canPlaceOnList->getValue()), + $canDestroyList === null ? [] : array_map(fn(StringTag $t) => $t->getValue(), $canDestroyList->getValue()) ); } diff --git a/src/entity/Entity.php b/src/entity/Entity.php index 73a0b3a9c..89f66d6b1 100644 --- a/src/entity/Entity.php +++ b/src/entity/Entity.php @@ -492,7 +492,7 @@ abstract class Entity{ new FloatTag($this->location->pitch) ])); - if(!($this instanceof Player)){ + if(!($this instanceof NeverSavedWithChunkEntity)){ EntityFactory::getInstance()->injectSaveId(get_class($this), $nbt); if($this->getNameTag() !== ""){ diff --git a/src/entity/EntityDataHelper.php b/src/entity/EntityDataHelper.php index 60e45e535..4cce47127 100644 --- a/src/entity/EntityDataHelper.php +++ b/src/entity/EntityDataHelper.php @@ -25,7 +25,6 @@ namespace pocketmine\entity; use pocketmine\data\SavedDataLoadingException; use pocketmine\math\Vector3; -use pocketmine\nbt\NBT; use pocketmine\nbt\tag\CompoundTag; use pocketmine\nbt\tag\DoubleTag; use pocketmine\nbt\tag\FloatTag; @@ -59,11 +58,10 @@ final class EntityDataHelper{ public static function parseLocation(CompoundTag $nbt, World $world) : Location{ $pos = self::parseVec3($nbt, Entity::TAG_POS, false); - $yawPitch = $nbt->getTag(Entity::TAG_ROTATION); - if(!($yawPitch instanceof ListTag) || $yawPitch->getTagType() !== NBT::TAG_Float){ + $generic = $nbt->getTag(Entity::TAG_ROTATION); + if(!($generic instanceof ListTag) || ($yawPitch = $generic->cast(FloatTag::class)) === null){ throw new SavedDataLoadingException("'" . Entity::TAG_ROTATION . "' should be a List"); } - /** @var FloatTag[] $values */ $values = $yawPitch->getValue(); if(count($values) !== 2){ throw new SavedDataLoadingException("Expected exactly 2 entries for 'Rotation'"); @@ -78,14 +76,13 @@ final class EntityDataHelper{ * @throws SavedDataLoadingException */ public static function parseVec3(CompoundTag $nbt, string $tagName, bool $optional) : Vector3{ - $pos = $nbt->getTag($tagName); - if($pos === null && $optional){ + $generic = $nbt->getTag($tagName); + if($generic === null && $optional){ return Vector3::zero(); } - if(!($pos instanceof ListTag) || ($pos->getTagType() !== NBT::TAG_Double && $pos->getTagType() !== NBT::TAG_Float)){ + if(!($generic instanceof ListTag) || ($pos = $generic->cast(DoubleTag::class) ?? $generic->cast(FloatTag::class)) === null){ throw new SavedDataLoadingException("'$tagName' should be a List or List"); } - /** @var DoubleTag[]|FloatTag[] $values */ $values = $pos->getValue(); if(count($values) !== 3){ throw new SavedDataLoadingException("Expected exactly 3 entries in '$tagName' tag"); diff --git a/src/entity/EntityFactory.php b/src/entity/EntityFactory.php index 970fd986f..94639cd3f 100644 --- a/src/entity/EntityFactory.php +++ b/src/entity/EntityFactory.php @@ -46,6 +46,7 @@ use pocketmine\entity\projectile\ExperienceBottle; use pocketmine\entity\projectile\IceBomb; use pocketmine\entity\projectile\Snowball; use pocketmine\entity\projectile\SplashPotion; +use pocketmine\entity\projectile\Trident; use pocketmine\item\Item; use pocketmine\math\Facing; use pocketmine\math\Vector3; @@ -171,6 +172,24 @@ final class EntityFactory{ return new SplashPotion(Helper::parseLocation($nbt, $world), null, $potionType, $nbt); }, ['ThrownPotion', 'minecraft:potion', 'thrownpotion']); + $this->register(Trident::class, function(World $world, CompoundTag $nbt) : Trident{ + $itemTag = $nbt->getCompoundTag(Trident::TAG_ITEM); + if($itemTag === null){ + throw new SavedDataLoadingException("Expected \"" . Trident::TAG_ITEM . "\" NBT tag not found"); + } + + $item = Item::nbtDeserialize($itemTag); + if($item->isNull()){ + throw new SavedDataLoadingException("Trident item is invalid"); + } + return new Trident(Helper::parseLocation($nbt, $world), $item, null, $nbt); + }, [ + 'minecraft:trident', //java + 'minecraft:thrown_trident', //bedrock + 'Trident', //backwards compat for people who used #4547 before it was merged, since it was sitting around for 4 years... + 'ThrownTrident' //as above + ]); + $this->register(Squid::class, function(World $world, CompoundTag $nbt) : Squid{ return new Squid(Helper::parseLocation($nbt, $world), $nbt); }, ['Squid', 'minecraft:squid']); diff --git a/src/entity/Human.php b/src/entity/Human.php index c94b76097..97ebdefca 100644 --- a/src/entity/Human.php +++ b/src/entity/Human.php @@ -247,6 +247,10 @@ class Human extends Living implements ProjectileSource, InventoryHolder{ return $this->enderInventory; } + public function getSneakOffset() : float{ + return 0.31; + } + /** * For Human entities which are not players, sets their properties such as nametag, skin and UUID from NBT. */ @@ -295,12 +299,11 @@ class Human extends Living implements ProjectileSource, InventoryHolder{ $this->enderInventory = new PlayerEnderInventory($this); $this->initHumanData($nbt); - $inventoryTag = $nbt->getListTag(self::TAG_INVENTORY); + $inventoryTag = $nbt->getListTag(self::TAG_INVENTORY, CompoundTag::class); if($inventoryTag !== null){ $inventoryItems = []; $armorInventoryItems = []; - /** @var CompoundTag $item */ foreach($inventoryTag as $i => $item){ $slot = $item->getByte(SavedItemStackData::TAG_SLOT); if($slot >= 0 && $slot < 9){ //Hotbar @@ -324,11 +327,10 @@ class Human extends Living implements ProjectileSource, InventoryHolder{ fn(EntityEventBroadcaster $broadcaster, array $recipients) => $broadcaster->onMobOffHandItemChange($recipients, $this) ))); - $enderChestInventoryTag = $nbt->getListTag(self::TAG_ENDER_CHEST_INVENTORY); + $enderChestInventoryTag = $nbt->getListTag(self::TAG_ENDER_CHEST_INVENTORY, CompoundTag::class); if($enderChestInventoryTag !== null){ $enderChestInventoryItems = []; - /** @var CompoundTag $item */ foreach($enderChestInventoryTag as $i => $item){ $enderChestInventoryItems[$item->getByte(SavedItemStackData::TAG_SLOT)] = Item::nbtDeserialize($item); } diff --git a/src/entity/Living.php b/src/entity/Living.php index 6d62c85d2..e24e07b00 100644 --- a/src/entity/Living.php +++ b/src/entity/Living.php @@ -181,8 +181,7 @@ abstract class Living extends Entity{ $this->setAirSupplyTicks($nbt->getShort(self::TAG_BREATH_TICKS, self::DEFAULT_BREATH_TICKS)); - /** @var CompoundTag[]|ListTag|null $activeEffectsTag */ - $activeEffectsTag = $nbt->getListTag(self::TAG_ACTIVE_EFFECTS); + $activeEffectsTag = $nbt->getListTag(self::TAG_ACTIVE_EFFECTS, CompoundTag::class); if($activeEffectsTag !== null){ foreach($activeEffectsTag as $e){ $effect = EffectIdMap::getInstance()->fromId($e->getByte(self::TAG_EFFECT_ID)); @@ -242,6 +241,10 @@ abstract class Living extends Entity{ $this->absorptionAttr->setValue($absorption); } + public function getSneakOffset() : float{ + return 0.0; + } + public function isSneaking() : bool{ return $this->sneaking; } @@ -292,7 +295,7 @@ abstract class Living extends Entity{ $width = $size->getWidth(); $this->setSize((new EntitySizeInfo($width, $width, $width * 0.9))->scale($this->getScale())); }elseif($this->isSneaking()){ - $this->setSize((new EntitySizeInfo(3 / 4 * $size->getHeight(), $size->getWidth(), 3 / 4 * $size->getEyeHeight()))->scale($this->getScale())); + $this->setSize((new EntitySizeInfo($size->getHeight() - $this->getSneakOffset(), $size->getWidth(), $size->getEyeHeight() - $this->getSneakOffset()))->scale($this->getScale())); }else{ $this->setSize($size->scale($this->getScale())); } diff --git a/src/entity/NeverSavedWithChunkEntity.php b/src/entity/NeverSavedWithChunkEntity.php new file mode 100644 index 000000000..2245abdd9 --- /dev/null +++ b/src/entity/NeverSavedWithChunkEntity.php @@ -0,0 +1,36 @@ +entity->getId(), ActorEvent::FIREWORK_PARTICLES, 0) + ]; + } +} diff --git a/src/entity/object/FireworkRocket.php b/src/entity/object/FireworkRocket.php new file mode 100644 index 000000000..1077dbc39 --- /dev/null +++ b/src/entity/object/FireworkRocket.php @@ -0,0 +1,204 @@ +maxFlightTimeTicks = $maxFlightTimeTicks; + $this->setExplosions($explosions); + + parent::__construct($location, $nbt); + } + + protected function getInitialSizeInfo() : EntitySizeInfo{ return new EntitySizeInfo(0.25, 0.25); } + + protected function getInitialDragMultiplier() : float{ return 0.0; } + + protected function getInitialGravity() : float{ return 0.0; } + + /** + * Returns the total number of ticks the firework will fly for before it explodes. + */ + public function getMaxFlightTimeTicks() : int{ + return $this->maxFlightTimeTicks; + } + + /** + * Sets the total number of ticks the firework will fly for before it explodes. + * + * @return $this + */ + public function setMaxFlightTimeTicks(int $maxFlightTimeTicks) : self{ + if($maxFlightTimeTicks < 0){ + throw new \InvalidArgumentException("Max flight time ticks cannot be negative"); + } + $this->maxFlightTimeTicks = $maxFlightTimeTicks; + return $this; + } + + /** + * @return FireworkRocketExplosion[] + */ + public function getExplosions() : array{ + return $this->explosions; + } + + /** + * @param FireworkRocketExplosion[] $explosions + * + * @return $this + */ + public function setExplosions(array $explosions) : self{ + Utils::validateArrayValueType($explosions, function(FireworkRocketExplosion $_) : void{}); + $this->explosions = $explosions; + return $this; + } + + protected function onFirstUpdate(int $currentTick) : void{ + parent::onFirstUpdate($currentTick); + + $this->broadcastSound(new FireworkLaunchSound()); + } + + protected function entityBaseTick(int $tickDiff = 1) : bool{ + $hasUpdate = parent::entityBaseTick($tickDiff); + + if(!$this->isFlaggedForDespawn()){ + //Don't keep accelerating long-lived fireworks - this gets very rapidly out of control and makes the server + //die. Vanilla fireworks will only live for about 52 ticks maximum anyway, so this only makes sure plugin + //created fireworks don't murder the server + if($this->ticksLived < 60){ + $this->addMotion($this->motion->x * 0.15, 0.04, $this->motion->z * 0.15); + } + + if($this->ticksLived >= $this->maxFlightTimeTicks){ + $this->flagForDespawn(); + $this->explode(); + } + } + + return $hasUpdate; + } + + public function explode() : void{ + if(($explosionCount = count($this->explosions)) !== 0){ + $this->broadcastAnimation(new FireworkParticlesAnimation($this)); + foreach($this->explosions as $explosion){ + $this->broadcastSound($explosion->getType()->getExplosionSound()); + if($explosion->willTwinkle()){ + $this->broadcastSound(new FireworkCrackleSound()); + } + } + + $force = ($explosionCount * 2) + 5; + $world = $this->getWorld(); + foreach($world->getCollidingEntities($this->getBoundingBox()->expandedCopy(5, 5, 5), $this) as $entity){ + if(!$entity instanceof Living){ + continue; + } + + $position = $entity->getPosition(); + $distance = $position->distanceSquared($this->location); + if($distance > 25){ + continue; + } + + //cast two rays - one to the entity's feet and another to halfway up its body (according to Java, anyway) + //this seems like it'd miss some cases but who am I to argue with vanilla logic :> + $height = $entity->getBoundingBox()->getYLength(); + for($i = 0; $i < 2; $i++){ + $target = $position->add(0, 0.5 * $i * $height, 0); + foreach(VoxelRayTrace::betweenPoints($this->location, $target) as $blockPos){ + if($world->getBlock($blockPos)->calculateIntercept($this->location, $target) !== null){ + continue 2; //obstruction, try another path + } + } + + //no obstruction + $damage = $force * sqrt((5 - $position->distance($this->location)) / 5); + $ev = new EntityDamageByEntityEvent($this, $entity, EntityDamageEvent::CAUSE_ENTITY_EXPLOSION, $damage); + $entity->attack($ev); + break; + } + } + } + } + + public function canBeCollidedWith() : bool{ + return false; + } + + protected function syncNetworkData(EntityMetadataCollection $properties) : void{ + parent::syncNetworkData($properties); + + $explosions = new ListTag(); + foreach($this->explosions as $explosion){ + $explosions->push($explosion->toCompoundTag()); + } + $fireworksData = CompoundTag::create() + ->setTag(FireworkItem::TAG_FIREWORK_DATA, CompoundTag::create() + ->setTag(FireworkItem::TAG_EXPLOSIONS, $explosions) + ); + + $properties->setCompoundTag(EntityMetadataProperties::FIREWORK_ITEM, new CacheableNbt($fireworksData)); + } +} diff --git a/src/entity/projectile/Projectile.php b/src/entity/projectile/Projectile.php index 68b6c4763..09529b6b3 100644 --- a/src/entity/projectile/Projectile.php +++ b/src/entity/projectile/Projectile.php @@ -39,7 +39,6 @@ use pocketmine\event\entity\ProjectileHitEvent; use pocketmine\math\RayTraceResult; use pocketmine\math\Vector3; use pocketmine\math\VoxelRayTrace; -use pocketmine\nbt\NBT; use pocketmine\nbt\tag\CompoundTag; use pocketmine\nbt\tag\IntTag; use pocketmine\nbt\tag\ListTag; @@ -81,12 +80,11 @@ abstract class Projectile extends Entity{ $this->setHealth(1); $this->damage = $nbt->getDouble(self::TAG_DAMAGE, $this->damage); - if(($stuckOnBlockPosTag = $nbt->getListTag(self::TAG_STUCK_ON_BLOCK_POS)) !== null){ - if($stuckOnBlockPosTag->getTagType() !== NBT::TAG_Int || count($stuckOnBlockPosTag) !== 3){ + if(($stuckOnBlockPosTag = $nbt->getListTag(self::TAG_STUCK_ON_BLOCK_POS, IntTag::class)) !== null){ + if(count($stuckOnBlockPosTag) !== 3){ throw new SavedDataLoadingException(self::TAG_STUCK_ON_BLOCK_POS . " tag should be a list of 3 TAG_Int"); } - /** @var IntTag[] $values */ $values = $stuckOnBlockPosTag->getValue(); $this->blockHit = new Vector3($values[0]->getValue(), $values[1]->getValue(), $values[2]->getValue()); @@ -227,12 +225,15 @@ abstract class Projectile extends Entity{ $specificHitFunc = fn() => $this->onHitBlock($objectHit, $rayTraceResult); } + $motionBeforeOnHit = clone $this->motion; $ev->call(); $this->onHit($ev); $specificHitFunc(); $this->isCollided = $this->onGround = true; - $this->motion = Vector3::zero(); + if($motionBeforeOnHit->equals($this->motion)){ + $this->motion = Vector3::zero(); + } }else{ $this->isCollided = $this->onGround = false; $this->blockHit = null; @@ -295,7 +296,9 @@ abstract class Projectile extends Entity{ } } - $this->flagForDespawn(); + if($this->despawnsOnEntityHit()){ + $this->flagForDespawn(); + } } /** @@ -305,4 +308,11 @@ abstract class Projectile extends Entity{ $this->blockHit = $blockHit->getPosition()->asVector3(); $blockHit->onProjectileHit($this, $hitResult); } + + /** + * @deprecated This will be dropped in favor of deciding whether to despawn within `onHitEntity()` method. + */ + protected function despawnsOnEntityHit() : bool{ + return true; + } } diff --git a/src/entity/projectile/Trident.php b/src/entity/projectile/Trident.php new file mode 100644 index 000000000..20a494e8d --- /dev/null +++ b/src/entity/projectile/Trident.php @@ -0,0 +1,183 @@ +isNull()){ + throw new \InvalidArgumentException("Trident must have a count of at least 1"); + } + $this->item = clone $item; + parent::__construct($location, $shootingEntity, $nbt); + } + + protected function getInitialSizeInfo() : EntitySizeInfo{ return new EntitySizeInfo(0.35, 0.25); } + + protected function getInitialDragMultiplier() : float{ return 0.01; } + + protected function getInitialGravity() : float{ return 0.1; } + + protected function initEntity(CompoundTag $nbt) : void{ + parent::initEntity($nbt); + + $this->spawnedInCreative = $nbt->getByte(self::TAG_SPAWNED_IN_CREATIVE, 0) === 1; + } + + public function saveNBT() : CompoundTag{ + $nbt = parent::saveNBT(); + $nbt->setTag(self::TAG_ITEM, $this->item->nbtSerialize()); + $nbt->setByte(self::TAG_SPAWNED_IN_CREATIVE, $this->spawnedInCreative ? 1 : 0); + return $nbt; + } + + protected function onFirstUpdate(int $currentTick) : void{ + $owner = $this->getOwningEntity(); + $this->spawnedInCreative = $owner instanceof Player && $owner->isCreative(); + + parent::onFirstUpdate($currentTick); + } + + protected function entityBaseTick(int $tickDiff = 1) : bool{ + if($this->closed){ + return false; + } + //TODO: Loyalty enchantment. + + return parent::entityBaseTick($tickDiff); + } + + protected function despawnsOnEntityHit() : bool{ + return false; + } + + protected function onHitEntity(Entity $entityHit, RayTraceResult $hitResult) : void{ + parent::onHitEntity($entityHit, $hitResult); + + $this->canCollide = false; + $this->broadcastSound(new TridentHitEntitySound()); + $this->setMotion(new Vector3($this->motion->x * -0.01, $this->motion->y * -0.1, $this->motion->z * -0.01)); + } + + protected function onHitBlock(Block $blockHit, RayTraceResult $hitResult) : void{ + parent::onHitBlock($blockHit, $hitResult); + $this->canCollide = true; + $this->broadcastSound(new TridentHitBlockSound()); + } + + public function getItem() : Item{ + return clone $this->item; + } + + public function setItem(Item $item) : void{ + if($item->isNull()){ + throw new \InvalidArgumentException("Trident must have a count of at least 1"); + } + if($this->item->hasEnchantments() !== $item->hasEnchantments()){ + $this->networkPropertiesDirty = true; + } + $this->item = clone $item; + } + + public function canCollideWith(Entity $entity) : bool{ + return $this->canCollide && $entity->getId() !== $this->ownerId && parent::canCollideWith($entity); + } + + public function onCollideWithPlayer(Player $player) : void{ + if($this->blockHit !== null){ + $this->pickup($player); + } + } + + private function pickup(Player $player) : void{ + $shouldDespawn = false; + + $playerInventory = $player->getInventory(); + $ev = new EntityItemPickupEvent($player, $this, $this->getItem(), $playerInventory); + if($player->hasFiniteResources() && !$playerInventory->canAddItem($ev->getItem())){ + $ev->cancel(); + } + if($this->spawnedInCreative){ + $ev->cancel(); + $shouldDespawn = true; + } + + $ev->call(); + if(!$ev->isCancelled()){ + $ev->getInventory()?->addItem($ev->getItem()); + $shouldDespawn = true; + } + + if($shouldDespawn){ + //even if the item was not actually picked up, the animation must be displayed. + NetworkBroadcastUtils::broadcastEntityEvent( + $this->getViewers(), + fn(EntityEventBroadcaster $broadcaster, array $recipients) => $broadcaster->onPickUpItem($recipients, $player, $this) + ); + $this->flagForDespawn(); + } + } + + protected function syncNetworkData(EntityMetadataCollection $properties) : void{ + parent::syncNetworkData($properties); + + $properties->setGenericFlag(EntityMetadataFlags::ENCHANTED, $this->item->hasEnchantments()); + } +} diff --git a/src/event/block/SignChangeEvent.php b/src/event/block/SignChangeEvent.php index aed59a462..e337ebc36 100644 --- a/src/event/block/SignChangeEvent.php +++ b/src/event/block/SignChangeEvent.php @@ -35,11 +35,15 @@ use pocketmine\player\Player; class SignChangeEvent extends BlockEvent implements Cancellable{ use CancellableTrait; + private SignText $oldText; + public function __construct( private BaseSign $sign, private Player $player, - private SignText $text + private SignText $text, + private bool $frontFace = true ){ + $this->oldText = $this->sign->getFaceText($this->frontFace); parent::__construct($sign); } @@ -55,7 +59,7 @@ class SignChangeEvent extends BlockEvent implements Cancellable{ * Returns the text currently on the sign. */ public function getOldText() : SignText{ - return $this->sign->getText(); + return $this->oldText; } /** @@ -71,4 +75,6 @@ class SignChangeEvent extends BlockEvent implements Cancellable{ public function setNewText(SignText $text) : void{ $this->text = $text; } + + public function isFrontFace() : bool{ return $this->frontFace; } } diff --git a/src/event/player/PlayerDeathEvent.php b/src/event/player/PlayerDeathEvent.php index aacff3438..4b02b4811 100644 --- a/src/event/player/PlayerDeathEvent.php +++ b/src/event/player/PlayerDeathEvent.php @@ -26,7 +26,10 @@ namespace pocketmine\event\player; use pocketmine\block\BlockTypeIds; use pocketmine\entity\Living; use pocketmine\entity\object\FallingBlock; +use pocketmine\entity\object\FireworkRocket; +use pocketmine\entity\projectile\Trident; use pocketmine\event\entity\EntityDamageByBlockEvent; +use pocketmine\event\entity\EntityDamageByChildEntityEvent; use pocketmine\event\entity\EntityDamageByEntityEvent; use pocketmine\event\entity\EntityDamageEvent; use pocketmine\event\entity\EntityDeathEvent; @@ -113,10 +116,15 @@ class PlayerDeathEvent extends EntityDeathEvent{ } break; case EntityDamageEvent::CAUSE_PROJECTILE: - if($deathCause instanceof EntityDamageByEntityEvent){ + if($deathCause instanceof EntityDamageByChildEntityEvent){ $e = $deathCause->getDamager(); if($e instanceof Living){ - return KnownTranslationFactory::death_attack_arrow($name, $e->getDisplayName()); + $child = $deathCause->getChild(); + if($child instanceof Trident){ + return KnownTranslationFactory::death_attack_trident($name, $e->getDisplayName()); + }else{ + return KnownTranslationFactory::death_attack_arrow($name, $e->getDisplayName()); + } } } break; @@ -157,7 +165,9 @@ class PlayerDeathEvent extends EntityDeathEvent{ case EntityDamageEvent::CAUSE_ENTITY_EXPLOSION: if($deathCause instanceof EntityDamageByEntityEvent){ $e = $deathCause->getDamager(); - if($e instanceof Living){ + if($e instanceof FireworkRocket){ + return KnownTranslationFactory::death_attack_fireworks($name); + }elseif($e instanceof Living){ return KnownTranslationFactory::death_attack_explosion_player($name, $e->getDisplayName()); } } diff --git a/src/inventory/transaction/InventoryTransaction.php b/src/inventory/transaction/InventoryTransaction.php index 6e010c7b8..f8624030c 100644 --- a/src/inventory/transaction/InventoryTransaction.php +++ b/src/inventory/transaction/InventoryTransaction.php @@ -30,13 +30,11 @@ use pocketmine\inventory\transaction\action\SlotChangeAction; use pocketmine\item\Item; use pocketmine\player\Player; use pocketmine\utils\Utils; -use function array_keys; use function array_values; use function assert; use function count; use function get_class; use function min; -use function shuffle; use function spl_object_hash; use function spl_object_id; @@ -95,10 +93,13 @@ class InventoryTransaction{ } /** - * Returns an **unordered** set of actions involved in this transaction. + * Returns a set of actions involved in this transaction. * - * WARNING: This system is **explicitly designed NOT to care about ordering**. Any order seen in this set has NO - * significance and should not be relied on. + * Note: This system is designed to care only about item balances. While you can usually assume that the actions + * are provided in the correct order, it will still successfully complete transactions whose actions are provided in + * the "wrong" order, as long as the transaction balances. + * For example, you may see that an action setting a slot to a particular item may appear before the action that + * removes that item from its original slot. While unintuitive, this is still valid. * * @return InventoryAction[] * @phpstan-return array @@ -119,19 +120,6 @@ class InventoryTransaction{ } } - /** - * Shuffles actions in the transaction to prevent external things relying on any implicit ordering. - */ - private function shuffleActions() : void{ - $keys = array_keys($this->actions); - shuffle($keys); - $actions = []; - foreach($keys as $key){ - $actions[$key] = $this->actions[$key]; - } - $this->actions = $actions; - } - /** * @param Item[] $needItems * @param Item[] $haveItems @@ -308,8 +296,6 @@ class InventoryTransaction{ throw new TransactionValidationException("Transaction has already been executed"); } - $this->shuffleActions(); - $this->validate(); if(!$this->callExecuteEvent()){ diff --git a/src/item/Banner.php b/src/item/Banner.php index 2fc53f5ae..f955a8a12 100644 --- a/src/item/Banner.php +++ b/src/item/Banner.php @@ -29,7 +29,6 @@ use pocketmine\block\utils\DyeColor; use pocketmine\data\bedrock\BannerPatternTypeIdMap; use pocketmine\data\bedrock\DyeColorIdMap; use pocketmine\data\runtime\RuntimeDataDescriber; -use pocketmine\nbt\NBT; use pocketmine\nbt\tag\CompoundTag; use pocketmine\nbt\tag\ListTag; use function count; @@ -92,9 +91,8 @@ class Banner extends ItemBlockWallOrFloor{ $colorIdMap = DyeColorIdMap::getInstance(); $patternIdMap = BannerPatternTypeIdMap::getInstance(); - $patterns = $tag->getListTag(self::TAG_PATTERNS); - if($patterns !== null && $patterns->getTagType() === NBT::TAG_Compound){ - /** @var CompoundTag $t */ + $patterns = $tag->getListTag(self::TAG_PATTERNS, CompoundTag::class); + if($patterns !== null){ foreach($patterns as $t){ $patternColor = $colorIdMap->fromInvertedId($t->getInt(self::TAG_PATTERN_COLOR)) ?? DyeColor::BLACK; //TODO: missing pattern colour should be an error $patternType = $patternIdMap->fromId($t->getString(self::TAG_PATTERN_NAME)); diff --git a/src/item/FireworkRocket.php b/src/item/FireworkRocket.php new file mode 100644 index 000000000..39fe6dae2 --- /dev/null +++ b/src/item/FireworkRocket.php @@ -0,0 +1,141 @@ +flightTimeMultiplier; + } + + /** + * Sets the value that will be used to calculate a randomized flight duration + * for the firework. + * + * The higher this value, the longer the flight duration. + * + * @return $this + */ + public function setFlightTimeMultiplier(int $multiplier) : self{ + if($multiplier < 1 || $multiplier > 127){ + throw new \InvalidArgumentException("Flight time multiplier must be in range 1-127"); + } + $this->flightTimeMultiplier = $multiplier; + + return $this; + } + + /** + * @return FireworkRocketExplosion[] + */ + public function getExplosions() : array{ + return $this->explosions; + } + + /** + * @param FireworkRocketExplosion[] $explosions + * + * @return $this + */ + public function setExplosions(array $explosions) : self{ + Utils::validateArrayValueType($explosions, function(FireworkRocketExplosion $_) : void{}); + $this->explosions = $explosions; + + return $this; + } + + public function onInteractBlock(Player $player, Block $blockReplace, Block $blockClicked, int $face, Vector3 $clickVector, array &$returnedItems) : ItemUseResult{ + //TODO: this would be nicer if Vector3::getSide() accepted floats for distance + $position = $blockClicked->getPosition()->addVector($clickVector)->addVector(Vector3::zero()->getSide($face)->multiply(0.15)); + + $randomDuration = (($this->flightTimeMultiplier + 1) * 10) + mt_rand(0, 12); + + $entity = new FireworkEntity(Location::fromObject($position, $player->getWorld(), Utils::getRandomFloat() * 360, 90), $randomDuration, $this->explosions); + $entity->setOwningEntity($player); + $entity->setMotion(new Vector3( + (Utils::getRandomFloat() - Utils::getRandomFloat()) * 0.0023, + 0.05, + (Utils::getRandomFloat() - Utils::getRandomFloat()) * 0.0023 + )); + $entity->spawnToAll(); + + $this->pop(); + + return ItemUseResult::SUCCESS; + } + + protected function deserializeCompoundTag(CompoundTag $tag) : void{ + parent::deserializeCompoundTag($tag); + + $fireworkData = $tag->getCompoundTag(self::TAG_FIREWORK_DATA); + if($fireworkData === null){ + throw new SavedDataLoadingException("Missing firework data"); + } + + $this->setFlightTimeMultiplier($fireworkData->getByte(self::TAG_FLIGHT_TIME_MULTIPLIER, 1)); + + if(($explosions = $fireworkData->getListTag(self::TAG_EXPLOSIONS, CompoundTag::class)) !== null){ + foreach($explosions as $explosion){ + $this->explosions[] = FireworkRocketExplosion::fromCompoundTag($explosion); + } + } + } + + protected function serializeCompoundTag(CompoundTag $tag) : void{ + parent::serializeCompoundTag($tag); + + $fireworkData = CompoundTag::create(); + $fireworkData->setByte(self::TAG_FLIGHT_TIME_MULTIPLIER, $this->flightTimeMultiplier); + $fireworkData->setTag(self::TAG_EXPLOSIONS, new ListTag(array_map(fn(FireworkRocketExplosion $e) => $e->toCompoundTag(), $this->explosions))); + + $tag->setTag(self::TAG_FIREWORK_DATA, $fireworkData); + } +} diff --git a/src/item/FireworkRocketExplosion.php b/src/item/FireworkRocketExplosion.php new file mode 100644 index 000000000..e113a6ded --- /dev/null +++ b/src/item/FireworkRocketExplosion.php @@ -0,0 +1,190 @@ +getByteArray(self::TAG_COLORS)); + if(count($colors) === 0){ + throw new SavedDataLoadingException("Colors list cannot be empty"); + } + + return new self( + FireworkRocketTypeIdMap::getInstance()->fromId($tag->getByte(self::TAG_TYPE)) ?? throw new SavedDataLoadingException("Invalid firework type"), + $colors, + self::decodeColors($tag->getByteArray(self::TAG_FADE_COLORS)), + $tag->getByte(self::TAG_TWINKLE, 0) !== 0, + $tag->getByte(self::TAG_TRAIL, 0) !== 0 + ); + } + + /** + * @return DyeColor[] + * @phpstan-return list + * @throws SavedDataLoadingException + */ + protected static function decodeColors(string $colorsBytes) : array{ + $colors = []; + + $dyeColorIdMap = DyeColorIdMap::getInstance(); + for($i = 0, $len = strlen($colorsBytes); $i < $len; $i++){ + $colorByte = ord($colorsBytes[$i]); + $color = $dyeColorIdMap->fromInvertedId($colorByte); + if($color !== null){ + $colors[] = $color; + }else{ + throw new SavedDataLoadingException("Unknown color $colorByte"); + } + } + + return $colors; + } + + /** + * @param DyeColor[] $colors + */ + protected static function encodeColors(array $colors) : string{ + $colorsBytes = ""; + + $dyeColorIdMap = DyeColorIdMap::getInstance(); + foreach($colors as $color){ + $colorsBytes .= chr($dyeColorIdMap->toInvertedId($color)); + } + + return $colorsBytes; + } + + /** + * @param DyeColor[] $colors + * @param DyeColor[] $fadeColors + * @phpstan-param non-empty-list $colors + * @phpstan-param list $fadeColors + */ + public function __construct( + protected FireworkRocketType $type, + protected array $colors, + protected array $fadeColors = [], + protected bool $twinkle = false, + protected bool $trail = false + ){ + if(count($colors) === 0){ + throw new \InvalidArgumentException("Colors list cannot be empty"); + } + + $colorsValidator = function(DyeColor $_) : void{}; + + Utils::validateArrayValueType($colors, $colorsValidator); + Utils::validateArrayValueType($fadeColors, $colorsValidator); + } + + public function getType() : FireworkRocketType{ + return $this->type; + } + + /** + * Returns the colors of the particles. + * + * @return DyeColor[] + * @phpstan-return non-empty-list + */ + public function getColors() : array{ + return $this->colors; + } + + /** + * Returns the flash color of the explosion. + */ + public function getFlashColor() : DyeColor{ + return $this->colors[array_key_first($this->colors)]; + } + + /** + * Returns the mixure of colors from {@link FireworkRocketExplosion::getColors()}) + */ + public function getColorMix() : Color{ + /** @var Color[] $colors */ + $colors = []; + foreach($this->colors as $dyeColor){ + $colors[] = $dyeColor->getRgbValue(); + } + return Color::mix(...$colors); + } + + /** + * Returns the colors to which the particles will change their color after a few seconds. + * If it is empty, there will be no color change in the particles. + * + * @return DyeColor[] + * @phpstan-return list + */ + public function getFadeColors() : array{ + return $this->fadeColors; + } + + /** + * Returns whether the explosion has a flickering effect. + */ + public function willTwinkle() : bool{ + return $this->twinkle; + } + + /** + * Returns whether the particles have a trail effect. + */ + public function getTrail() : bool{ + return $this->trail; + } + + public function toCompoundTag() : CompoundTag{ + return CompoundTag::create() + ->setByte(self::TAG_TYPE, FireworkRocketTypeIdMap::getInstance()->toId($this->type)) + ->setByteArray(self::TAG_COLORS, self::encodeColors($this->colors)) + ->setByteArray(self::TAG_FADE_COLORS, self::encodeColors($this->fadeColors)) + ->setByte(self::TAG_TWINKLE, $this->twinkle ? 1 : 0) + ->setByte(self::TAG_TRAIL, $this->trail ? 1 : 0); + } +} diff --git a/src/item/FireworkRocketType.php b/src/item/FireworkRocketType.php new file mode 100644 index 000000000..6aa20e6d9 --- /dev/null +++ b/src/item/FireworkRocketType.php @@ -0,0 +1,46 @@ + new FireworkExplosionSound(), + self::LARGE_BALL => new FireworkLargeExplosionSound(), + }; + } +} diff --git a/src/item/FireworkStar.php b/src/item/FireworkStar.php new file mode 100644 index 000000000..ed2c3e525 --- /dev/null +++ b/src/item/FireworkStar.php @@ -0,0 +1,112 @@ +explosion = new FireworkRocketExplosion( + FireworkRocketType::SMALL_BALL, + colors: [DyeColor::BLACK], + fadeColors: [], + twinkle: false, + trail: false + ); + } + + public function getExplosion() : FireworkRocketExplosion{ + return $this->explosion; + } + + /** @return $this */ + public function setExplosion(FireworkRocketExplosion $explosion) : self{ + $this->explosion = $explosion; + return $this; + } + + /** + * Returns the displayed color of the item. + * The mixture of explosion colors, or the custom color if it is set. + */ + public function getColor() : Color{ + return $this->customColor ?? $this->explosion->getColorMix(); + } + + /** + * Returns the displayed custom color of the item that overrides + * the mixture of explosion colors, or null is it is not set. + */ + public function getCustomColor() : ?Color{ + return $this->customColor; + } + + /** + * Sets the displayed custom color of the item that overrides + * the mixture of explosion colors, or removes if $color is null. + * + * @return $this + */ + public function setCustomColor(?Color $color) : self{ + $this->customColor = $color; + return $this; + } + + protected function deserializeCompoundTag(CompoundTag $tag) : void{ + parent::deserializeCompoundTag($tag); + + $explosionTag = $tag->getTag(self::TAG_EXPLOSION); + if(!$explosionTag instanceof CompoundTag){ + throw new SavedDataLoadingException("Missing explosion data"); + } + $this->explosion = FireworkRocketExplosion::fromCompoundTag($explosionTag); + + $customColor = Color::fromARGB(Binary::unsignInt($tag->getInt(self::TAG_CUSTOM_COLOR))); + $color = $this->explosion->getColorMix(); + if(!$customColor->equals($color)){ //check that $customColor is actually custom. + $this->customColor = $customColor; + } + } + + protected function serializeCompoundTag(CompoundTag $tag) : void{ + parent::serializeCompoundTag($tag); + + $tag->setTag(self::TAG_EXPLOSION, $this->explosion->toCompoundTag()); + $tag->setInt(self::TAG_CUSTOM_COLOR, Binary::signInt($this->getColor()->toARGB())); + } +} diff --git a/src/item/Item.php b/src/item/Item.php index af7cab433..e7c86e167 100644 --- a/src/item/Item.php +++ b/src/item/Item.php @@ -293,9 +293,8 @@ class Item implements \JsonSerializable{ $display = $tag->getCompoundTag(self::TAG_DISPLAY); if($display !== null){ $this->customName = $display->getString(self::TAG_DISPLAY_NAME, $this->customName); - $lore = $display->getListTag(self::TAG_DISPLAY_LORE); - if($lore !== null && $lore->getTagType() === NBT::TAG_String){ - /** @var StringTag $t */ + $lore = $display->getListTag(self::TAG_DISPLAY_LORE, StringTag::class); + if($lore !== null){ foreach($lore as $t){ $this->lore[] = $t->getValue(); } @@ -303,9 +302,8 @@ class Item implements \JsonSerializable{ } $this->removeEnchantments(); - $enchantments = $tag->getListTag(self::TAG_ENCH); - if($enchantments !== null && $enchantments->getTagType() === NBT::TAG_Compound){ - /** @var CompoundTag $enchantment */ + $enchantments = $tag->getListTag(self::TAG_ENCH, CompoundTag::class); + if($enchantments !== null){ foreach($enchantments as $enchantment){ $magicNumber = $enchantment->getShort(self::TAG_ENCH_ID, -1); $level = $enchantment->getShort(self::TAG_ENCH_LVL, 0); @@ -322,17 +320,15 @@ class Item implements \JsonSerializable{ $this->blockEntityTag = $tag->getCompoundTag(self::TAG_BLOCK_ENTITY_TAG); $this->canPlaceOn = []; - $canPlaceOn = $tag->getListTag(self::TAG_CAN_PLACE_ON); - if($canPlaceOn !== null && $canPlaceOn->getTagType() === NBT::TAG_String){ - /** @var StringTag $entry */ + $canPlaceOn = $tag->getListTag(self::TAG_CAN_PLACE_ON, StringTag::class); + if($canPlaceOn !== null){ foreach($canPlaceOn as $entry){ $this->canPlaceOn[$entry->getValue()] = $entry->getValue(); } } $this->canDestroy = []; - $canDestroy = $tag->getListTag(self::TAG_CAN_DESTROY); - if($canDestroy !== null && $canDestroy->getTagType() === NBT::TAG_String){ - /** @var StringTag $entry */ + $canDestroy = $tag->getListTag(self::TAG_CAN_DESTROY, StringTag::class); + if($canDestroy !== null){ foreach($canDestroy as $entry){ $this->canDestroy[$entry->getValue()] = $entry->getValue(); } diff --git a/src/item/ItemTypeIds.php b/src/item/ItemTypeIds.php index af32cbcc2..3595d3afc 100644 --- a/src/item/ItemTypeIds.php +++ b/src/item/ItemTypeIds.php @@ -346,8 +346,11 @@ final class ItemTypeIds{ public const PALE_OAK_HANGING_SIGN = 20307; public const SPRUCE_HANGING_SIGN = 20308; public const WARPED_HANGING_SIGN = 20309; + public const TRIDENT = 20310; + public const FIREWORK_ROCKET = 20311; + public const FIREWORK_STAR = 20312; - public const FIRST_UNUSED_ITEM_ID = 20310; + public const FIRST_UNUSED_ITEM_ID = 20313; private static int $nextDynamicId = self::FIRST_UNUSED_ITEM_ID; diff --git a/src/item/StringToItemParser.php b/src/item/StringToItemParser.php index 2f316f66b..63fa88538 100644 --- a/src/item/StringToItemParser.php +++ b/src/item/StringToItemParser.php @@ -1366,6 +1366,9 @@ final class StringToItemParser extends StringToTParser{ $result->register("eye_drops", fn() => Items::MEDICINE()->setType(MedicineType::EYE_DROPS)); $result->register("feather", fn() => Items::FEATHER()); $result->register("fermented_spider_eye", fn() => Items::FERMENTED_SPIDER_EYE()); + $result->register("firework_rocket", fn() => Items::FIREWORK_ROCKET()); + $result->register("firework_star", fn() => Items::FIREWORK_STAR()); + $result->register("fireworks", fn() => Items::FIREWORK_ROCKET()); $result->register("fire_charge", fn() => Items::FIRE_CHARGE()); $result->register("fish", fn() => Items::RAW_FISH()); $result->register("fishing_rod", fn() => Items::FISHING_ROD()); @@ -1560,6 +1563,7 @@ final class StringToItemParser extends StringToTParser{ $result->register("torchflower_seeds", fn() => Items::TORCHFLOWER_SEEDS()); $result->register("tide_armor_trim_smithing_template", fn() => Items::TIDE_ARMOR_TRIM_SMITHING_TEMPLATE()); $result->register("totem", fn() => Items::TOTEM()); + $result->register("trident", fn() => Items::TRIDENT()); $result->register("turtle_helmet", fn() => Items::TURTLE_HELMET()); $result->register("vex_armor_trim_smithing_template", fn() => Items::VEX_ARMOR_TRIM_SMITHING_TEMPLATE()); $result->register("turtle_shell_piece", fn() => Items::SCUTE()); diff --git a/src/item/Trident.php b/src/item/Trident.php new file mode 100644 index 000000000..991f45b1d --- /dev/null +++ b/src/item/Trident.php @@ -0,0 +1,93 @@ +getLocation(); + + $diff = $player->getItemUseDuration(); + if($diff < 14){ + return ItemUseResult::FAIL; + } + + $item = $this->pop(); + if($player->hasFiniteResources()){ + $item->applyDamage(1); + } + $entity = new TridentEntity(Location::fromObject( + $player->getEyePos(), + $player->getWorld(), + ($location->yaw > 180 ? 360 : 0) - $location->yaw, + -$location->pitch + ), $item, $player); + $p = $diff / 20; + $baseForce = min((($p ** 2) + $p * 2) / 3, 1) * 2.4; + $entity->setMotion($player->getDirectionVector()->multiply($baseForce)); + + $ev = new ProjectileLaunchEvent($entity); + $ev->call(); + if($ev->isCancelled()){ + $ev->getEntity()->flagForDespawn(); + return ItemUseResult::FAIL; + } + $ev->getEntity()->spawnToAll(); + $location->getWorld()->addSound($location, new TridentThrowSound()); + + return ItemUseResult::SUCCESS; + } + + public function getAttackPoints() : int{ + return 9; + } + + public function canStartUsingItem(Player $player) : bool{ + return $this->damage < $this->getMaxDurability(); + } + + public function onAttackEntity(Entity $victim, array &$returnedItems) : bool{ + return $this->applyDamage(1); + } + + public function onDestroyBlock(Block $block, array &$returnedItems) : bool{ + if(!$block->getBreakInfo()->breaksInstantly()){ + return $this->applyDamage(2); + } + return false; + } +} diff --git a/src/item/VanillaItems.php b/src/item/VanillaItems.php index e4eeffc1d..31a62d1aa 100644 --- a/src/item/VanillaItems.php +++ b/src/item/VanillaItems.php @@ -168,6 +168,8 @@ use function strtolower; * @method static Item EYE_ARMOR_TRIM_SMITHING_TEMPLATE() * @method static Item FEATHER() * @method static Item FERMENTED_SPIDER_EYE() + * @method static FireworkRocket FIREWORK_ROCKET() + * @method static FireworkStar FIREWORK_STAR() * @method static FireCharge FIRE_CHARGE() * @method static FishingRod FISHING_ROD() * @method static Item FLINT() @@ -335,6 +337,7 @@ use function strtolower; * @method static Item TIDE_ARMOR_TRIM_SMITHING_TEMPLATE() * @method static TorchflowerSeeds TORCHFLOWER_SEEDS() * @method static Totem TOTEM() + * @method static Trident TRIDENT() * @method static TurtleHelmet TURTLE_HELMET() * @method static Item VEX_ARMOR_TRIM_SMITHING_TEMPLATE() * @method static SpawnEgg VILLAGER_SPAWN_EGG() @@ -510,6 +513,8 @@ final class VanillaItems{ self::register("experience_bottle", fn(IID $id) => new ExperienceBottle($id, "Bottle o' Enchanting")); self::register("feather", fn(IID $id) => new Item($id, "Feather")); self::register("fermented_spider_eye", fn(IID $id) => new Item($id, "Fermented Spider Eye")); + self::register("firework_rocket", fn(IID $id) => new FireworkRocket($id, "Firework Rocket")); + self::register("firework_star", fn(IID $id) => new FireworkStar($id, "Firework Star")); self::register("fire_charge", fn(IID $id) => new FireCharge($id, "Fire Charge")); self::register("fishing_rod", fn(IID $id) => new FishingRod($id, "Fishing Rod", [EnchantmentTags::FISHING_ROD])); self::register("flint", fn(IID $id) => new Item($id, "Flint")); @@ -630,6 +635,7 @@ final class VanillaItems{ self::register("sweet_berries", fn(IID $id) => new SweetBerries($id, "Sweet Berries")); self::register("torchflower_seeds", fn(IID $id) => new TorchflowerSeeds($id, "Torchflower Seeds")); self::register("totem", fn(IID $id) => new Totem($id, "Totem of Undying")); + self::register("trident", fn(IID $id) => new Trident($id, "Trident")); self::register("warped_sign", fn(IID $id) => new ItemBlockWallOrFloor($id, Blocks::WARPED_SIGN(), Blocks::WARPED_WALL_SIGN())); self::register("warped_hanging_sign", fn(IID $id) => new HangingSign($id, "Warped Hanging Sign", Blocks::WARPED_CEILING_CENTER_HANGING_SIGN(), Blocks::WARPED_CEILING_EDGES_HANGING_SIGN(), Blocks::WARPED_WALL_HANGING_SIGN())); self::register("water_bucket", fn(IID $id) => new LiquidBucket($id, "Water Bucket", Blocks::WATER())); diff --git a/src/item/WritableBookBase.php b/src/item/WritableBookBase.php index d3b9b7061..de6d5414c 100644 --- a/src/item/WritableBookBase.php +++ b/src/item/WritableBookBase.php @@ -23,7 +23,6 @@ declare(strict_types=1); namespace pocketmine\item; -use pocketmine\nbt\NBT; use pocketmine\nbt\tag\CompoundTag; use pocketmine\nbt\tag\ListTag; use pocketmine\nbt\tag\StringTag; @@ -167,14 +166,12 @@ abstract class WritableBookBase extends Item{ $pages = $tag->getListTag(self::TAG_PAGES); if($pages !== null){ - if($pages->getTagType() === NBT::TAG_Compound){ //PE format - /** @var CompoundTag $page */ - foreach($pages as $page){ + if(($compoundPages = $pages->cast(CompoundTag::class)) !== null){ //PE format + foreach($compoundPages as $page){ $this->pages[] = new WritableBookPage(mb_scrub($page->getString(self::TAG_PAGE_TEXT), 'UTF-8'), $page->getString(self::TAG_PAGE_PHOTONAME, "")); } - }elseif($pages->getTagType() === NBT::TAG_String){ //PC format - /** @var StringTag $page */ - foreach($pages as $page){ + }elseif(($stringPages = $pages->cast(StringTag::class)) !== null){ //PC format + foreach($stringPages as $page){ $this->pages[] = new WritableBookPage(mb_scrub($page->getValue(), 'UTF-8')); } } diff --git a/src/network/mcpe/ChunkRequestTask.php b/src/network/mcpe/ChunkRequestTask.php index 13b5db5b7..357904dcb 100644 --- a/src/network/mcpe/ChunkRequestTask.php +++ b/src/network/mcpe/ChunkRequestTask.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace pocketmine\network\mcpe; +use pmmp\encoding\ByteBufferWriter; use pocketmine\network\mcpe\compression\CompressBatchPromise; use pocketmine\network\mcpe\compression\Compressor; use pocketmine\network\mcpe\convert\TypeConverter; @@ -33,7 +34,6 @@ use pocketmine\network\mcpe\protocol\types\DimensionIds; use pocketmine\network\mcpe\serializer\ChunkSerializer; use pocketmine\scheduler\AsyncTask; use pocketmine\thread\NonThreadSafeValue; -use pocketmine\utils\BinaryStream; use pocketmine\world\format\Chunk; use pocketmine\world\format\io\FastChunkSerializer; use function chr; @@ -73,11 +73,11 @@ class ChunkRequestTask extends AsyncTask{ $converter = TypeConverter::getInstance(); $payload = ChunkSerializer::serializeFullChunk($chunk, $dimensionId, $converter->getBlockTranslator(), $this->tiles); - $stream = new BinaryStream(); + $stream = new ByteBufferWriter(); PacketBatch::encodePackets($stream, [LevelChunkPacket::create(new ChunkPosition($this->chunkX, $this->chunkZ), $dimensionId, $subCount, false, null, $payload)]); $compressor = $this->compressor->deserialize(); - $this->setResult(chr($compressor->getNetworkId()) . $compressor->compress($stream->getBuffer())); + $this->setResult(chr($compressor->getNetworkId()) . $compressor->compress($stream->getData())); } public function onCompletion() : void{ diff --git a/src/network/mcpe/JwtUtils.php b/src/network/mcpe/JwtUtils.php index 987ed6e61..8c25ee042 100644 --- a/src/network/mcpe/JwtUtils.php +++ b/src/network/mcpe/JwtUtils.php @@ -23,8 +23,10 @@ declare(strict_types=1); namespace pocketmine\network\mcpe; +use pmmp\encoding\BE; +use pmmp\encoding\Byte; +use pmmp\encoding\ByteBufferReader; use pocketmine\utils\AssumptionFailedError; -use pocketmine\utils\BinaryStream; use pocketmine\utils\Utils; use function base64_decode; use function base64_encode; @@ -32,6 +34,7 @@ use function bin2hex; use function chr; use function count; use function explode; +use function hex2bin; use function is_array; use function json_decode; use function json_encode; @@ -54,6 +57,7 @@ use function strlen; use function strtr; use function substr; use const JSON_THROW_ON_ERROR; +use const OPENSSL_ALGO_SHA256; use const OPENSSL_ALGO_SHA384; use const STR_PAD_LEFT; @@ -130,17 +134,17 @@ final class JwtUtils{ return self::ASN1_SEQUENCE_TAG . chr(strlen($sequence)) . $sequence; } - private static function signaturePartFromAsn1(BinaryStream $stream) : string{ - $prefix = $stream->get(1); + private static function signaturePartFromAsn1(ByteBufferReader $stream) : string{ + $prefix = $stream->readByteArray(1); if($prefix !== self::ASN1_INTEGER_TAG){ throw new \InvalidArgumentException("Expected an ASN.1 INTEGER tag, got " . bin2hex($prefix)); } //we can assume the length is 1 byte here - if it were larger than 127, more complex logic would be needed - $length = $stream->getByte(); + $length = Byte::readUnsigned($stream); if($length > self::SIGNATURE_PART_LENGTH + 1){ //each part may have an extra leading 0 byte to prevent it being interpreted as a negative number throw new \InvalidArgumentException("Expected at most 49 bytes for signature R or S, got $length"); } - $part = $stream->get($length); + $part = $stream->readByteArray($length); return str_pad(ltrim($part, "\x00"), self::SIGNATURE_PART_LENGTH, "\x00", STR_PAD_LEFT); } @@ -156,11 +160,11 @@ final class JwtUtils{ throw new \InvalidArgumentException("Invalid DER signature, expected $length sequence bytes, got " . strlen($parts)); } - $stream = new BinaryStream($parts); + $stream = new ByteBufferReader($parts); $rRaw = self::signaturePartFromAsn1($stream); $sRaw = self::signaturePartFromAsn1($stream); - if(!$stream->feof()){ + if($stream->getUnreadLength() > 0){ throw new \InvalidArgumentException("Invalid DER signature, unexpected trailing sequence data"); } @@ -170,17 +174,17 @@ final class JwtUtils{ /** * @throws JwtException */ - public static function verify(string $jwt, \OpenSSLAsymmetricKey $signingKey) : bool{ + public static function verify(string $jwt, string $signingKeyDer, bool $ec) : bool{ [$header, $body, $signature] = self::split($jwt); $rawSignature = self::b64UrlDecode($signature); - $derSignature = self::rawSignatureToDer($rawSignature); + $derSignature = $ec ? self::rawSignatureToDer($rawSignature) : $rawSignature; $v = openssl_verify( $header . '.' . $body, $derSignature, - $signingKey, - self::SIGNATURE_ALGORITHM + self::derPublicKeyToPem($signingKeyDer), + $ec ? self::SIGNATURE_ALGORITHM : OPENSSL_ALGO_SHA256 ); switch($v){ case 0: return false; @@ -238,22 +242,56 @@ final class JwtUtils{ throw new AssumptionFailedError("OpenSSL resource contains invalid public key"); } + /** + * DER supports lengths up to (2**8)**127, however, we'll only support lengths up to (2**8)**4. See + * {@link http://itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf#p=13 X.690 paragraph 8.1.3} for more information. + */ + private static function encodeDerLength(int $length) : string{ + if ($length <= 0x7F) { + return chr($length); + } + + $lengthBytes = ltrim(BE::packUnsignedInt($length), "\x00"); + + return chr(0x80 | strlen($lengthBytes)) . $lengthBytes; + } + + private static function encodeDerBytes(int $tag, string $data) : string{ + return chr($tag) . self::encodeDerLength(strlen($data)) . $data; + } + public static function parseDerPublicKey(string $derKey) : \OpenSSLAsymmetricKey{ - $signingKeyOpenSSL = openssl_pkey_get_public(sprintf("-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n", base64_encode($derKey))); + $signingKeyOpenSSL = openssl_pkey_get_public(self::derPublicKeyToPem($derKey)); if($signingKeyOpenSSL === false){ throw new JwtException("OpenSSL failed to parse key: " . openssl_error_string()); } - $details = openssl_pkey_get_details($signingKeyOpenSSL); - if($details === false){ - throw new JwtException("OpenSSL failed to get details from key: " . openssl_error_string()); - } - if(!isset($details['ec']['curve_name'])){ - throw new JwtException("Expected an EC key"); - } - $curve = $details['ec']['curve_name']; - if($curve !== self::BEDROCK_SIGNING_KEY_CURVE_NAME){ - throw new JwtException("Key must belong to curve " . self::BEDROCK_SIGNING_KEY_CURVE_NAME . ", got $curve"); - } return $signingKeyOpenSSL; } + + public static function derPublicKeyToPem(string $derKey) : string{ + return sprintf("-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n", base64_encode($derKey)); + } + + /** + * Create a public key represented in DER format from RSA modulus and exponent information + * + * @param string $nBase64 The RSA modulus encoded in Base64 + * @param string $eBase64 The RSA exponent encoded in Base64 + */ + public static function rsaPublicKeyModExpToDer(string $nBase64, string $eBase64) : string{ + $mod = self::b64UrlDecode($nBase64); + $exp = self::b64UrlDecode($eBase64); + + $modulus = self::encodeDerBytes(2, $mod); + $publicExponent = self::encodeDerBytes(2, $exp); + + $rsaPublicKey = self::encodeDerBytes(48, $modulus . $publicExponent); + + // sequence(oid(1.2.840.113549.1.1.1), null)) = rsaEncryption. + $rsaOID = hex2bin('300d06092a864886f70d0101010500'); // hex version of MA0GCSqGSIb3DQEBAQUA + $rsaPublicKey = chr(0) . $rsaPublicKey; + $rsaPublicKey = self::encodeDerBytes(3, $rsaPublicKey); + + return self::encodeDerBytes(48, $rsaOID . $rsaPublicKey); + } } diff --git a/src/network/mcpe/NetworkSession.php b/src/network/mcpe/NetworkSession.php index 234ad4765..75281e426 100644 --- a/src/network/mcpe/NetworkSession.php +++ b/src/network/mcpe/NetworkSession.php @@ -23,6 +23,9 @@ declare(strict_types=1); namespace pocketmine\network\mcpe; +use pmmp\encoding\ByteBufferReader; +use pmmp\encoding\ByteBufferWriter; +use pmmp\encoding\DataDecodeException; use pocketmine\entity\effect\EffectInstance; use pocketmine\event\player\PlayerDuplicateLoginEvent; use pocketmine\event\player\PlayerResourcePackOfferEvent; @@ -70,7 +73,6 @@ use pocketmine\network\mcpe\protocol\PlayerStartItemCooldownPacket; use pocketmine\network\mcpe\protocol\PlayStatusPacket; use pocketmine\network\mcpe\protocol\ProtocolInfo; use pocketmine\network\mcpe\protocol\serializer\PacketBatch; -use pocketmine\network\mcpe\protocol\serializer\PacketSerializer; use pocketmine\network\mcpe\protocol\ServerboundPacket; use pocketmine\network\mcpe\protocol\ServerToClientHandshakePacket; use pocketmine\network\mcpe\protocol\SetDifficultyPacket; @@ -109,8 +111,6 @@ use pocketmine\promise\PromiseResolver; use pocketmine\Server; use pocketmine\timings\Timings; use pocketmine\utils\AssumptionFailedError; -use pocketmine\utils\BinaryDataException; -use pocketmine\utils\BinaryStream; use pocketmine\utils\ObjectSet; use pocketmine\utils\TextFormat; use pocketmine\world\format\io\GlobalItemDataHandlers; @@ -401,7 +401,7 @@ class NetworkSession{ } try{ - $stream = new BinaryStream($decompressed); + $stream = new ByteBufferReader($decompressed); foreach(PacketBatch::decodeRaw($stream) as $buffer){ $this->gamePacketLimiter->decrement(); $packet = $this->packetPool->getPacket($buffer); @@ -421,7 +421,7 @@ class NetworkSession{ break; } } - }catch(PacketDecodeException|BinaryDataException $e){ + }catch(PacketDecodeException|DataDecodeException $e){ $this->logger->logException($e); throw PacketHandlingException::wrap($e, "Packet batch decode error"); } @@ -453,14 +453,14 @@ class NetworkSession{ $decodeTimings = Timings::getDecodeDataPacketTimings($packet); $decodeTimings->startTiming(); try{ - $stream = PacketSerializer::decoder($buffer, 0); + $stream = new ByteBufferReader($buffer); try{ $packet->decode($stream); }catch(PacketDecodeException $e){ throw PacketHandlingException::wrap($e); } - if(!$stream->feof()){ - $remains = substr($stream->getBuffer(), $stream->getOffset()); + if($stream->getUnreadLength() > 0){ + $remains = substr($stream->getData(), $stream->getOffset()); $this->logger->debug("Still " . strlen($remains) . " bytes unread in " . $packet->getName() . ": " . bin2hex($remains)); } }finally{ @@ -478,7 +478,7 @@ class NetworkSession{ $handlerTimings->startTiming(); try{ if($this->handler === null || !$packet->handle($this->handler)){ - $this->logger->debug("Unhandled " . $packet->getName() . ": " . base64_encode($stream->getBuffer())); + $this->logger->debug("Unhandled " . $packet->getName() . ": " . base64_encode($stream->getData())); } }finally{ $handlerTimings->stopTiming(); @@ -530,8 +530,10 @@ class NetworkSession{ if($ackReceiptResolver !== null){ $this->sendBufferAckPromises[] = $ackReceiptResolver; } + $writer = new ByteBufferWriter(); foreach($packets as $evPacket){ - $this->addToSendBuffer(self::encodePacketTimed(PacketSerializer::encoder(), $evPacket)); + $writer->clear(); //memory reuse let's gooooo + $this->addToSendBuffer(self::encodePacketTimed($writer, $evPacket)); } if($immediate){ $this->flushGamePacketQueue(); @@ -564,12 +566,12 @@ class NetworkSession{ /** * @internal */ - public static function encodePacketTimed(PacketSerializer $serializer, ClientboundPacket $packet) : string{ + public static function encodePacketTimed(ByteBufferWriter $serializer, ClientboundPacket $packet) : string{ $timings = Timings::getEncodeDataPacketTimings($packet); $timings->startTiming(); try{ $packet->encode($serializer); - return $serializer->getBuffer(); + return $serializer->getData(); }finally{ $timings->stopTiming(); } @@ -591,13 +593,13 @@ class NetworkSession{ $syncMode = false; } - $stream = new BinaryStream(); + $stream = new ByteBufferWriter(); PacketBatch::encodeRaw($stream, $this->sendBuffer); if($this->enableCompression){ - $batch = $this->server->prepareBatch($stream->getBuffer(), $this->compressor, $syncMode, Timings::$playerNetworkSendCompressSessionBuffer); + $batch = $this->server->prepareBatch($stream->getData(), $this->compressor, $syncMode, Timings::$playerNetworkSendCompressSessionBuffer); }else{ - $batch = $stream->getBuffer(); + $batch = $stream->getData(); } $this->sendBuffer = []; $ackPromises = $this->sendBufferAckPromises; diff --git a/src/network/mcpe/StandardPacketBroadcaster.php b/src/network/mcpe/StandardPacketBroadcaster.php index 7a91b397b..5d9bc533e 100644 --- a/src/network/mcpe/StandardPacketBroadcaster.php +++ b/src/network/mcpe/StandardPacketBroadcaster.php @@ -23,12 +23,11 @@ declare(strict_types=1); namespace pocketmine\network\mcpe; +use pmmp\encoding\ByteBufferWriter; use pocketmine\event\server\DataPacketSendEvent; use pocketmine\network\mcpe\protocol\serializer\PacketBatch; -use pocketmine\network\mcpe\protocol\serializer\PacketSerializer; use pocketmine\Server; use pocketmine\timings\Timings; -use pocketmine\utils\BinaryStream; use function count; use function log; use function spl_object_id; @@ -64,8 +63,10 @@ final class StandardPacketBroadcaster implements PacketBroadcaster{ $totalLength = 0; $packetBuffers = []; + $writer = new ByteBufferWriter(); foreach($packets as $packet){ - $buffer = NetworkSession::encodePacketTimed(PacketSerializer::encoder(), $packet); + $writer->clear(); //memory reuse let's gooooo + $buffer = NetworkSession::encodePacketTimed($writer, $packet); //varint length prefix + packet buffer $totalLength += (((int) log(strlen($buffer), 128)) + 1) + strlen($buffer); $packetBuffers[] = $buffer; @@ -77,9 +78,9 @@ final class StandardPacketBroadcaster implements PacketBroadcaster{ $threshold = $compressor->getCompressionThreshold(); if(count($compressorTargets) > 1 && $threshold !== null && $totalLength >= $threshold){ //do not prepare shared batch unless we're sure it will be compressed - $stream = new BinaryStream(); + $stream = new ByteBufferWriter(); PacketBatch::encodeRaw($stream, $packetBuffers); - $batchBuffer = $stream->getBuffer(); + $batchBuffer = $stream->getData(); $batch = $this->server->prepareBatch($batchBuffer, $compressor, timings: Timings::$playerNetworkSendCompressBroadcast); foreach($compressorTargets as $target){ diff --git a/src/network/mcpe/auth/AuthJwtHelper.php b/src/network/mcpe/auth/AuthJwtHelper.php new file mode 100644 index 000000000..5050c396f --- /dev/null +++ b/src/network/mcpe/auth/AuthJwtHelper.php @@ -0,0 +1,165 @@ +nbf) && $claims->nbf > $time + self::CLOCK_DRIFT_MAX){ + throw new VerifyLoginException("JWT not yet valid", KnownTranslationFactory::pocketmine_disconnect_invalidSession_tooEarly()); + } + + if(isset($claims->exp) && $claims->exp < $time - self::CLOCK_DRIFT_MAX){ + throw new VerifyLoginException("JWT expired", KnownTranslationFactory::pocketmine_disconnect_invalidSession_tooLate()); + } + } + + /** + * @throws VerifyLoginException if errors are encountered + */ + public static function validateOpenIdAuthToken(string $jwt, string $signingKeyDer, string $issuer, string $audience) : XboxAuthJwtBody{ + try{ + if(!JwtUtils::verify($jwt, $signingKeyDer, ec: false)){ + throw new VerifyLoginException("Invalid JWT signature", KnownTranslationFactory::pocketmine_disconnect_invalidSession_badSignature()); + } + }catch(JwtException $e){ + throw new VerifyLoginException($e->getMessage(), null, 0, $e); + } + + try{ + [, $claimsArray, ] = JwtUtils::parse($jwt); + }catch(JwtException $e){ + throw new VerifyLoginException("Failed to parse JWT: " . $e->getMessage(), null, 0, $e); + } + + $mapper = new \JsonMapper(); + $mapper->bExceptionOnUndefinedProperty = false; //we only care about the properties we're using in this case + $mapper->bExceptionOnMissingData = true; + $mapper->bStrictObjectTypeChecking = true; + $mapper->bEnforceMapType = false; + $mapper->bRemoveUndefinedAttributes = true; + + try{ + //nasty dynamic new for JsonMapper + $claims = $mapper->map($claimsArray, new XboxAuthJwtBody()); + }catch(\JsonMapper_Exception $e){ + throw new VerifyLoginException("Invalid chain link body: " . $e->getMessage(), null, 0, $e); + } + + if(!isset($claims->iss) || $claims->iss !== $issuer){ + throw new VerifyLoginException("Invalid JWT issuer"); + } + + if(!isset($claims->aud) || $claims->aud !== $audience){ + throw new VerifyLoginException("Invalid JWT audience"); + } + + self::checkExpiry($claims); + + return $claims; + } + + /** + * @throws VerifyLoginException if errors are encountered + */ + public static function validateLegacyAuthToken(string $jwt, ?string $expectedKeyDer) : LegacyAuthJwtBody{ + self::validateSelfSignedToken($jwt, $expectedKeyDer); + + //TODO: this parses the JWT twice and throws away a bunch of parts, optimize this + [, $claimsArray, ] = JwtUtils::parse($jwt); + + $mapper = new \JsonMapper(); + $mapper->bExceptionOnUndefinedProperty = false; //we only care about the properties we're using in this case + $mapper->bExceptionOnMissingData = true; + $mapper->bStrictObjectTypeChecking = true; + $mapper->bEnforceMapType = false; + $mapper->bRemoveUndefinedAttributes = true; + try{ + /** @var LegacyAuthJwtBody $claims */ + $claims = $mapper->map($claimsArray, new LegacyAuthJwtBody()); + }catch(\JsonMapper_Exception $e){ + throw new VerifyLoginException("Invalid chain link body: " . $e->getMessage(), null, 0, $e); + } + + self::checkExpiry($claims); + + return $claims; + } + + public static function validateSelfSignedToken(string $jwt, ?string $expectedKeyDer) : void{ + try{ + [$headersArray, ] = JwtUtils::parse($jwt); + }catch(JwtException $e){ + throw new VerifyLoginException("Failed to parse JWT: " . $e->getMessage(), null, 0, $e); + } + + $mapper = new \JsonMapper(); + $mapper->bExceptionOnMissingData = true; + $mapper->bExceptionOnUndefinedProperty = true; + $mapper->bStrictObjectTypeChecking = true; + $mapper->bEnforceMapType = false; + + try{ + /** @var SelfSignedJwtHeader $headers */ + $headers = $mapper->map($headersArray, new SelfSignedJwtHeader()); + }catch(\JsonMapper_Exception $e){ + throw new VerifyLoginException("Invalid JWT header: " . $e->getMessage(), null, 0, $e); + } + + $headerDerKey = base64_decode($headers->x5u, true); + if($headerDerKey === false){ + throw new VerifyLoginException("Invalid JWT public key: base64 decoding error decoding x5u"); + } + if($expectedKeyDer !== null && $headerDerKey !== $expectedKeyDer){ + //Fast path: if the header key doesn't match what we expected, the signature isn't going to validate anyway + throw new VerifyLoginException("Invalid JWT signature", KnownTranslationFactory::pocketmine_disconnect_invalidSession_badSignature()); + } + + try{ + if(!JwtUtils::verify($jwt, $headerDerKey, ec: true)){ + throw new VerifyLoginException("Invalid JWT signature", KnownTranslationFactory::pocketmine_disconnect_invalidSession_badSignature()); + } + }catch(JwtException $e){ + throw new VerifyLoginException($e->getMessage(), null, 0, $e); + } + } +} diff --git a/src/network/mcpe/auth/AuthKeyProvider.php b/src/network/mcpe/auth/AuthKeyProvider.php new file mode 100644 index 000000000..67ffe4908 --- /dev/null +++ b/src/network/mcpe/auth/AuthKeyProvider.php @@ -0,0 +1,164 @@ + */ + private ?PromiseResolver $resolver = null; + + private int $lastFetch = 0; + + public function __construct( + private readonly \Logger $logger, + private readonly AsyncPool $asyncPool, + private readonly int $keyRefreshIntervalSeconds = self::ALLOWED_REFRESH_INTERVAL + ){} + + /** + * Fetches the key for the given key ID. + * The promise will be resolved with an array of [issuer, pemPublicKey]. + * + * @phpstan-return Promise + */ + public function getKey(string $keyId) : Promise{ + /** @phpstan-var PromiseResolver $resolver */ + $resolver = new PromiseResolver(); + + if( + $this->keyring === null || //we haven't fetched keys yet + ($this->keyring->getKey($keyId) === null && $this->lastFetch < time() - $this->keyRefreshIntervalSeconds) //we don't recognise this one & keys might be outdated + ){ + //only refresh keys when we see one we don't recognise + $this->fetchKeys()->onCompletion( + onSuccess: fn(AuthKeyring $newKeyring) => $this->resolveKey($resolver, $newKeyring, $keyId), + onFailure: $resolver->reject(...) + ); + }else{ + $this->resolveKey($resolver, $this->keyring, $keyId); + } + + return $resolver->getPromise(); + } + + /** + * @phpstan-param PromiseResolver $resolver + */ + private function resolveKey(PromiseResolver $resolver, AuthKeyring $keyring, string $keyId) : void{ + $key = $keyring->getKey($keyId); + if($key === null){ + $this->logger->debug("Key $keyId not recognised!"); + $resolver->reject(); + return; + } + + $this->logger->debug("Key $keyId found in keychain"); + $resolver->resolve([$keyring->getIssuer(), $key]); + } + + /** + * @phpstan-param array|null $keys + * @phpstan-param string[]|null $errors + */ + private function onKeysFetched(?array $keys, string $issuer, ?array $errors) : void{ + $resolver = $this->resolver; + if($resolver === null){ + throw new AssumptionFailedError("Not expecting this to be called without a resolver present"); + } + if($errors !== null){ + $this->logger->error("The following errors occurred while fetching new keys:\n\t- " . implode("\n\t-", $errors)); + //we might've still succeeded in fetching keys even if there were errors, so don't return + } + + if($keys === null){ + $this->logger->critical("Failed to fetch authentication keys from Mojang's API. Xbox players may not be able to authenticate!"); + $resolver->reject(); + }else{ + $pemKeys = []; + foreach($keys as $keyModel){ + if($keyModel->use !== "sig" || $keyModel->kty !== "RSA"){ + $this->logger->error("Key ID $keyModel->kid doesn't have the expected properties: expected use=sig, kty=RSA, got use=$keyModel->use, kty=$keyModel->kty"); + continue; + } + $derKey = JwtUtils::rsaPublicKeyModExpToDer($keyModel->n, $keyModel->e); + + //make sure the key is valid + try{ + JwtUtils::parseDerPublicKey($derKey); + }catch(JwtException $e){ + $this->logger->error("Failed to parse RSA public key for key ID $keyModel->kid: " . $e->getMessage()); + $this->logger->logException($e); + continue; + } + + //retain PEM keys instead of OpenSSLAsymmetricKey since these are easier and cheaper to copy between threads + $pemKeys[$keyModel->kid] = $derKey; + } + + if(count($keys) === 0){ + $this->logger->critical("No valid authentication keys returned by Mojang's API. Xbox players may not be able to authenticate!"); + $resolver->reject(); + }else{ + $this->logger->info("Successfully fetched " . count($keys) . " new authentication keys from issuer $issuer, key IDs: " . implode(", ", array_keys($pemKeys))); + $this->keyring = new AuthKeyring($issuer, $pemKeys); + $this->lastFetch = time(); + $resolver->resolve($this->keyring); + } + } + } + + /** + * @phpstan-return Promise + */ + private function fetchKeys() : Promise{ + if($this->resolver !== null){ + $this->logger->debug("Key refresh was requested, but it's already in progress"); + return $this->resolver->getPromise(); + } + + $this->logger->notice("Fetching new authentication keys"); + + /** @phpstan-var PromiseResolver $resolver */ + $resolver = new PromiseResolver(); + $this->resolver = $resolver; + //TODO: extract this so it can be polyfilled for unit testing + $this->asyncPool->submitTask(new FetchAuthKeysTask($this->onKeysFetched(...))); + return $this->resolver->getPromise(); + } +} diff --git a/src/network/mcpe/auth/AuthKeyring.php b/src/network/mcpe/auth/AuthKeyring.php new file mode 100644 index 000000000..cd5d29f6e --- /dev/null +++ b/src/network/mcpe/auth/AuthKeyring.php @@ -0,0 +1,45 @@ + $keys + */ + public function __construct( + private string $issuer, + private array $keys + ){} + + public function getIssuer() : string{ return $this->issuer; } + + /** + * Returns a (raw) DER public key associated with the given key ID + */ + public function getKey(string $keyId) : ?string{ + return $this->keys[$keyId] ?? null; + } +} diff --git a/src/network/mcpe/auth/FetchAuthKeysTask.php b/src/network/mcpe/auth/FetchAuthKeysTask.php new file mode 100644 index 000000000..b159d42af --- /dev/null +++ b/src/network/mcpe/auth/FetchAuthKeysTask.php @@ -0,0 +1,209 @@ +> */ + private ?NonThreadSafeValue $keys = null; + private string $issuer; + + /** @phpstan-var ?NonThreadSafeValue> */ + private ?NonThreadSafeValue $errors = null; + + /** + * @phpstan-param \Closure(?array $keys, string $issuer, ?string[] $errors) : void $onCompletion + */ + public function __construct( + \Closure $onCompletion + ){ + $this->storeLocal(self::KEYS_ON_COMPLETION, $onCompletion); + } + + public function onRun() : void{ + /** @var string[] $errors */ + $errors = []; + + try{ + $authServiceUri = $this->getAuthServiceURI(); + }catch(\RuntimeException $e){ + $errors[] = $e->getMessage(); + $authServiceUri = self::AUTHORIZATION_SERVICE_URI_FALLBACK; + } + + try { + $openIdConfig = $this->getOpenIdConfiguration($authServiceUri); + $jwksUri = $openIdConfig->jwks_uri; + + $this->issuer = $openIdConfig->issuer; + } catch (\RuntimeException $e) { + $errors[] = $e->getMessage(); + $jwksUri = $authServiceUri . self::AUTHORIZATION_SERVICE_KEYS_PATH; + + $this->issuer = $authServiceUri; + } + + try{ + $this->keys = new NonThreadSafeValue($this->getKeys($jwksUri)); + }catch(\RuntimeException $e){ + $errors[] = $e->getMessage(); + } + + $this->errors = $errors === [] ? null : new NonThreadSafeValue($errors); + } + + private function getAuthServiceURI() : string{ + $result = Internet::getURL(self::MINECRAFT_SERVICES_DISCOVERY_URL); + if($result === null || $result->getCode() !== 200){ + throw new \RuntimeException("Failed to fetch Minecraft services discovery document"); + } + + try{ + $json = json_decode($result->getBody(), false, flags: JSON_THROW_ON_ERROR); + }catch(\JsonException $e){ + throw new \RuntimeException($e->getMessage(), 0, $e); + } + if(!is_object($json)){ + throw new \RuntimeException("Unexpected root type of schema file " . gettype($json) . ", expected object"); + } + + $mapper = new \JsonMapper(); + $mapper->bExceptionOnUndefinedProperty = false; //we only care about the properties we're using in this case + $mapper->bExceptionOnMissingData = true; + $mapper->bStrictObjectTypeChecking = true; + $mapper->bEnforceMapType = false; + $mapper->bRemoveUndefinedAttributes = true; + try{ + /** @var MinecraftServicesDiscovery $discovery */ + $discovery = $mapper->map($json, new MinecraftServicesDiscovery()); + }catch(\JsonMapper_Exception $e){ + throw new \RuntimeException("Invalid schema file: " . $e->getMessage(), 0, $e); + } + + return $discovery->result->serviceEnvironments->auth->prod->serviceUri; + } + + private function getOpenIdConfiguration(string $authServiceUri) : AuthServiceOpenIdConfiguration{ + $result = Internet::getURL($authServiceUri . self::AUTHORIZATION_SERVICE_OPENID_CONFIGURATION_PATH); + if($result === null || $result->getCode() !== 200){ + throw new \RuntimeException("Failed to fetch OpenID configuration from authorization service"); + } + + try{ + $json = json_decode($result->getBody(), false, flags: JSON_THROW_ON_ERROR); + }catch(\JsonException $e){ + throw new \RuntimeException($e->getMessage(), 0, $e); + } + if(!is_object($json)){ + throw new \RuntimeException("Unexpected root type of schema file " . gettype($json) . ", expected object"); + } + + $mapper = new \JsonMapper(); + $mapper->bExceptionOnUndefinedProperty = false; //we only care about the properties we're using in this case + $mapper->bExceptionOnMissingData = true; + $mapper->bStrictObjectTypeChecking = true; + $mapper->bEnforceMapType = false; + $mapper->bRemoveUndefinedAttributes = true; + try{ + /** @var AuthServiceOpenIdConfiguration $configuration */ + $configuration = $mapper->map($json, new AuthServiceOpenIdConfiguration()); + }catch(\JsonMapper_Exception $e){ + throw new \RuntimeException("Invalid schema file: " . $e->getMessage(), 0, $e); + } + + return $configuration; + } + + /** + * @return array keys indexed by key ID + */ + private function getKeys(string $jwksUri) : array{ + $result = Internet::getURL($jwksUri); + if($result === null || $result->getCode() !== 200){ + return throw new \RuntimeException("Failed to fetch keys from authorization service"); + } + + try{ + $json = json_decode($result->getBody(), true, flags: JSON_THROW_ON_ERROR); + }catch(\JsonException $e){ + throw new \RuntimeException($e->getMessage(), 0, $e); + } + + if(!is_array($json) || !isset($json["keys"]) || !is_array($keysArray = $json["keys"])){ + throw new \RuntimeException("Unexpected root type of schema file " . gettype($json) . ", expected object"); + } + + $mapper = new \JsonMapper(); + $mapper->bExceptionOnUndefinedProperty = true; + $mapper->bExceptionOnMissingData = true; + $mapper->bStrictObjectTypeChecking = true; + $mapper->bEnforceMapType = false; + $mapper->bRemoveUndefinedAttributes = true; + + $keys = []; + foreach($keysArray as $keyJson){ + if(!is_array($keyJson)){ + throw new \RuntimeException("Unexpected key type in schema file: " . gettype($keyJson) . ", expected object"); + } + + try{ + /** @var AuthServiceKey $key */ + $key = $mapper->map($keyJson, new AuthServiceKey()); + $keys[$key->kid] = $key; + }catch(\JsonMapper_Exception $e){ + throw new \RuntimeException("Invalid schema file: " . $e->getMessage(), 0, $e); + } + } + + return $keys; + } + + public function onCompletion() : void{ + /** + * @var \Closure $callback + * @phpstan-var \Closure(?AuthServiceKey[] $keys, string $issuer, ?string[] $errors) : void $callback + */ + $callback = $this->fetchLocal(self::KEYS_ON_COMPLETION); + $callback($this->keys?->deserialize(), $this->issuer, $this->errors?->deserialize()); + } +} diff --git a/src/network/mcpe/auth/ProcessLegacyLoginTask.php b/src/network/mcpe/auth/ProcessLegacyLoginTask.php new file mode 100644 index 000000000..2be55dab6 --- /dev/null +++ b/src/network/mcpe/auth/ProcessLegacyLoginTask.php @@ -0,0 +1,121 @@ +|string|null + */ + private NonThreadSafeValue|string|null $error = "Unknown"; + /** Whether the player has a certificate chain link signed by the given root public key. */ + private bool $authenticated = false; + private ?string $clientPublicKeyDer = null; + + /** + * @param string[] $chainJwts + * @phpstan-param \Closure(bool $isAuthenticated, bool $authRequired, Translatable|string|null $error, ?string $clientPublicKey) : void $onCompletion + */ + public function __construct( + array $chainJwts, + private string $clientDataJwt, + private ?string $rootAuthKeyDer, + private bool $authRequired, + \Closure $onCompletion + ){ + $this->storeLocal(self::TLS_KEY_ON_COMPLETION, $onCompletion); + $this->chain = igbinary_serialize($chainJwts) ?? throw new AssumptionFailedError("This should never fail"); + } + + public function onRun() : void{ + try{ + $this->clientPublicKeyDer = $this->validateChain(); + AuthJwtHelper::validateSelfSignedToken($this->clientDataJwt, $this->clientPublicKeyDer); + $this->error = null; + }catch(VerifyLoginException $e){ + $disconnectMessage = $e->getDisconnectMessage(); + $this->error = $disconnectMessage instanceof Translatable ? new NonThreadSafeValue($disconnectMessage) : $disconnectMessage; + } + } + + private function validateChain() : string{ + /** @var string[] $chain */ + $chain = igbinary_unserialize($this->chain); + + $identityPublicKeyDer = null; + + foreach($chain as $jwt){ + $claims = AuthJwtHelper::validateLegacyAuthToken($jwt, $identityPublicKeyDer); + if($this->rootAuthKeyDer !== null && $identityPublicKeyDer === $this->rootAuthKeyDer){ + $this->authenticated = true; //we're signed into xbox live, according to this root key + } + if(!isset($claims->identityPublicKey)){ + throw new VerifyLoginException("Missing identityPublicKey in chain link", KnownTranslationFactory::pocketmine_disconnect_invalidSession_missingKey()); + } + $identityPublicKey = base64_decode($claims->identityPublicKey, true); + if($identityPublicKey === false){ + throw new VerifyLoginException("Invalid identityPublicKey: base64 error decoding"); + } + $identityPublicKeyDer = $identityPublicKey; + } + + if($identityPublicKeyDer === null){ + throw new VerifyLoginException("No authentication chain links provided"); + } + + return $identityPublicKeyDer; + } + + public function onCompletion() : void{ + /** + * @var \Closure $callback + * @phpstan-var \Closure(bool, bool, Translatable|string|null, ?string) : void $callback + */ + $callback = $this->fetchLocal(self::TLS_KEY_ON_COMPLETION); + $callback($this->authenticated, $this->authRequired, $this->error instanceof NonThreadSafeValue ? $this->error->deserialize() : $this->error, $this->clientPublicKeyDer); + } +} diff --git a/src/network/mcpe/auth/ProcessLoginTask.php b/src/network/mcpe/auth/ProcessLoginTask.php deleted file mode 100644 index b4c9e6d9c..000000000 --- a/src/network/mcpe/auth/ProcessLoginTask.php +++ /dev/null @@ -1,213 +0,0 @@ -|string|null - */ - private NonThreadSafeValue|string|null $error = "Unknown"; - /** - * Whether the player is logged into Xbox Live. This is true if any link in the keychain is signed with the Mojang - * root public key. - */ - private bool $authenticated = false; - private ?string $clientPublicKey = null; - - /** - * @param string[] $chainJwts - * @phpstan-param \Closure(bool $isAuthenticated, bool $authRequired, Translatable|string|null $error, ?string $clientPublicKey) : void $onCompletion - */ - public function __construct( - array $chainJwts, - private string $clientDataJwt, - private bool $authRequired, - \Closure $onCompletion - ){ - $this->storeLocal(self::TLS_KEY_ON_COMPLETION, $onCompletion); - $this->chain = igbinary_serialize($chainJwts); - } - - public function onRun() : void{ - try{ - $this->clientPublicKey = $this->validateChain(); - $this->error = null; - }catch(VerifyLoginException $e){ - $disconnectMessage = $e->getDisconnectMessage(); - $this->error = $disconnectMessage instanceof Translatable ? new NonThreadSafeValue($disconnectMessage) : $disconnectMessage; - } - } - - private function validateChain() : string{ - /** @var string[] $chain */ - $chain = igbinary_unserialize($this->chain); - - $currentKey = null; - $first = true; - - foreach($chain as $jwt){ - $this->validateToken($jwt, $currentKey, $first); - if($first){ - $first = false; - } - } - - /** @var string $clientKey */ - $clientKey = $currentKey; - - $this->validateToken($this->clientDataJwt, $currentKey); - - return $clientKey; - } - - /** - * @throws VerifyLoginException if errors are encountered - */ - private function validateToken(string $jwt, ?string &$currentPublicKey, bool $first = false) : void{ - try{ - [$headersArray, $claimsArray, ] = JwtUtils::parse($jwt); - }catch(JwtException $e){ - throw new VerifyLoginException("Failed to parse JWT: " . $e->getMessage(), null, 0, $e); - } - - $mapper = new \JsonMapper(); - $mapper->bExceptionOnMissingData = true; - $mapper->bExceptionOnUndefinedProperty = true; - $mapper->bStrictObjectTypeChecking = true; - $mapper->bEnforceMapType = false; - - try{ - /** @var JwtHeader $headers */ - $headers = $mapper->map($headersArray, new JwtHeader()); - }catch(\JsonMapper_Exception $e){ - throw new VerifyLoginException("Invalid JWT header: " . $e->getMessage(), null, 0, $e); - } - - $headerDerKey = base64_decode($headers->x5u, true); - if($headerDerKey === false){ - throw new VerifyLoginException("Invalid JWT public key: base64 decoding error decoding x5u"); - } - - if($currentPublicKey === null){ - if(!$first){ - throw new VerifyLoginException("Missing JWT public key", KnownTranslationFactory::pocketmine_disconnect_invalidSession_missingKey()); - } - }elseif($headerDerKey !== $currentPublicKey){ - //Fast path: if the header key doesn't match what we expected, the signature isn't going to validate anyway - throw new VerifyLoginException("Invalid JWT signature", KnownTranslationFactory::pocketmine_disconnect_invalidSession_badSignature()); - } - - try{ - $signingKeyOpenSSL = JwtUtils::parseDerPublicKey($headerDerKey); - }catch(JwtException $e){ - throw new VerifyLoginException("Invalid JWT public key: " . $e->getMessage(), null, 0, $e); - } - try{ - if(!JwtUtils::verify($jwt, $signingKeyOpenSSL)){ - throw new VerifyLoginException("Invalid JWT signature", KnownTranslationFactory::pocketmine_disconnect_invalidSession_badSignature()); - } - }catch(JwtException $e){ - throw new VerifyLoginException($e->getMessage(), null, 0, $e); - } - - if($headers->x5u === self::MOJANG_ROOT_PUBLIC_KEY){ - $this->authenticated = true; //we're signed into xbox live - } - - $mapper = new \JsonMapper(); - $mapper->bExceptionOnUndefinedProperty = false; //we only care about the properties we're using in this case - $mapper->bExceptionOnMissingData = true; - $mapper->bStrictObjectTypeChecking = true; - $mapper->bEnforceMapType = false; - $mapper->bRemoveUndefinedAttributes = true; - try{ - /** @var JwtChainLinkBody $claims */ - $claims = $mapper->map($claimsArray, new JwtChainLinkBody()); - }catch(\JsonMapper_Exception $e){ - throw new VerifyLoginException("Invalid chain link body: " . $e->getMessage(), null, 0, $e); - } - - $time = time(); - if(isset($claims->nbf) && $claims->nbf > $time + self::CLOCK_DRIFT_MAX){ - throw new VerifyLoginException("JWT not yet valid", KnownTranslationFactory::pocketmine_disconnect_invalidSession_tooEarly()); - } - - if(isset($claims->exp) && $claims->exp < $time - self::CLOCK_DRIFT_MAX){ - throw new VerifyLoginException("JWT expired", KnownTranslationFactory::pocketmine_disconnect_invalidSession_tooLate()); - } - - if(isset($claims->identityPublicKey)){ - $identityPublicKey = base64_decode($claims->identityPublicKey, true); - if($identityPublicKey === false){ - throw new VerifyLoginException("Invalid identityPublicKey: base64 error decoding"); - } - try{ - //verify key format and parameters - JwtUtils::parseDerPublicKey($identityPublicKey); - }catch(JwtException $e){ - throw new VerifyLoginException("Invalid identityPublicKey: " . $e->getMessage(), null, 0, $e); - } - $currentPublicKey = $identityPublicKey; //if there are further links, the next link should be signed with this - } - } - - public function onCompletion() : void{ - /** - * @var \Closure $callback - * @phpstan-var \Closure(bool, bool, Translatable|string|null, ?string) : void $callback - */ - $callback = $this->fetchLocal(self::TLS_KEY_ON_COMPLETION); - $callback($this->authenticated, $this->authRequired, $this->error instanceof NonThreadSafeValue ? $this->error->deserialize() : $this->error, $this->clientPublicKey); - } -} diff --git a/src/network/mcpe/auth/ProcessOpenIdLoginTask.php b/src/network/mcpe/auth/ProcessOpenIdLoginTask.php new file mode 100644 index 000000000..091317a65 --- /dev/null +++ b/src/network/mcpe/auth/ProcessOpenIdLoginTask.php @@ -0,0 +1,98 @@ +|string|null + */ + private NonThreadSafeValue|string|null $error = "Unknown"; + /** + * Whether the player is logged into Xbox Live. This is true if any link in the keychain is signed with the Mojang + * root public key. + */ + private bool $authenticated = false; + private ?string $clientPublicKeyDer = null; + + /** + * @phpstan-param \Closure(bool $isAuthenticated, bool $authRequired, Translatable|string|null $error, ?string $clientPublicKey) : void $onCompletion + */ + public function __construct( + private string $jwt, + private string $issuer, + private string $mojangPublicKeyDer, + private string $clientDataJwt, + private bool $authRequired, + \Closure $onCompletion + ){ + $this->storeLocal(self::TLS_KEY_ON_COMPLETION, $onCompletion); + } + + public function onRun() : void{ + try{ + $this->clientPublicKeyDer = $this->validateChain(); + $this->error = null; + }catch(VerifyLoginException $e){ + $disconnectMessage = $e->getDisconnectMessage(); + $this->error = $disconnectMessage instanceof Translatable ? new NonThreadSafeValue($disconnectMessage) : $disconnectMessage; + } + } + + private function validateChain() : string{ + $claims = AuthJwtHelper::validateOpenIdAuthToken($this->jwt, $this->mojangPublicKeyDer, issuer: $this->issuer, audience: self::MOJANG_AUDIENCE); + //validateToken will throw if the JWT is not valid + $this->authenticated = true; + + $clientDerKey = base64_decode($claims->cpk, strict: true); + if($clientDerKey === false){ + throw new VerifyLoginException("Invalid client public key: base64 error decoding"); + } + //no further validation needed - OpenSSL will bail if the key is invalid + AuthJwtHelper::validateSelfSignedToken($this->clientDataJwt, $clientDerKey); + + return $clientDerKey; + } + + public function onCompletion() : void{ + /** + * @var \Closure $callback + * @phpstan-var \Closure(bool, bool, Translatable|string|null, ?string) : void $callback + */ + $callback = $this->fetchLocal(self::TLS_KEY_ON_COMPLETION); + $callback($this->authenticated, $this->authRequired, $this->error instanceof NonThreadSafeValue ? $this->error->deserialize() : $this->error, $this->clientPublicKeyDer); + } +} diff --git a/src/network/mcpe/cache/CraftingDataCache.php b/src/network/mcpe/cache/CraftingDataCache.php index da0f37c44..d873a53f0 100644 --- a/src/network/mcpe/cache/CraftingDataCache.php +++ b/src/network/mcpe/cache/CraftingDataCache.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace pocketmine\network\mcpe\cache; +use pmmp\encoding\BE; use pocketmine\crafting\CraftingManager; use pocketmine\crafting\FurnaceType; use pocketmine\crafting\ShapedRecipe; @@ -41,7 +42,6 @@ use pocketmine\network\mcpe\protocol\types\recipe\ShapedRecipe as ProtocolShaped use pocketmine\network\mcpe\protocol\types\recipe\ShapelessRecipe as ProtocolShapelessRecipe; use pocketmine\timings\Timings; use pocketmine\utils\AssumptionFailedError; -use pocketmine\utils\Binary; use pocketmine\utils\SingletonTrait; use Ramsey\Uuid\Uuid; use function array_map; @@ -99,7 +99,7 @@ final class CraftingDataCache{ }; $recipesWithTypeIds[] = new ProtocolShapelessRecipe( CraftingDataPacket::ENTRY_SHAPELESS, - Binary::writeInt($recipeNetId), + BE::packUnsignedInt($recipeNetId), //TODO: this should probably be changed to something human-readable array_map($converter->coreRecipeIngredientToNet(...), $recipe->getIngredientList()), array_map($converter->coreItemStackToNet(...), $recipe->getResults()), $nullUUID, @@ -118,7 +118,7 @@ final class CraftingDataCache{ } $recipesWithTypeIds[] = $r = new ProtocolShapedRecipe( CraftingDataPacket::ENTRY_SHAPED, - Binary::writeInt($recipeNetId), + BE::packUnsignedInt($recipeNetId), //TODO: this should probably be changed to something human-readable $inputs, array_map($converter->coreItemStackToNet(...), $recipe->getResults()), $nullUUID, diff --git a/src/network/mcpe/convert/TypeConverter.php b/src/network/mcpe/convert/TypeConverter.php index e886b2b8b..9f946ac33 100644 --- a/src/network/mcpe/convert/TypeConverter.php +++ b/src/network/mcpe/convert/TypeConverter.php @@ -23,6 +23,9 @@ declare(strict_types=1); namespace pocketmine\network\mcpe\convert; +use pmmp\encoding\ByteBufferReader; +use pmmp\encoding\ByteBufferWriter; +use pocketmine\block\tile\Container; use pocketmine\block\VanillaBlocks; use pocketmine\crafting\ExactRecipeIngredient; use pocketmine\crafting\MetaWildcardRecipeIngredient; @@ -31,12 +34,18 @@ use pocketmine\crafting\TagWildcardRecipeIngredient; use pocketmine\data\bedrock\BedrockDataFiles; use pocketmine\data\bedrock\item\BlockItemIdMap; use pocketmine\data\bedrock\item\ItemTypeNames; +use pocketmine\data\SavedDataLoadingException; use pocketmine\item\Item; use pocketmine\item\VanillaItems; +use pocketmine\nbt\LittleEndianNbtSerializer; +use pocketmine\nbt\NBT; use pocketmine\nbt\NbtException; use pocketmine\nbt\tag\CompoundTag; +use pocketmine\nbt\tag\ListTag; +use pocketmine\nbt\tag\Tag; +use pocketmine\nbt\TreeRoot; +use pocketmine\nbt\UnexpectedTagTypeException; use pocketmine\network\mcpe\protocol\serializer\ItemTypeDictionary; -use pocketmine\network\mcpe\protocol\serializer\PacketSerializer; use pocketmine\network\mcpe\protocol\types\GameMode as ProtocolGameMode; use pocketmine\network\mcpe\protocol\types\inventory\ItemStack; use pocketmine\network\mcpe\protocol\types\inventory\ItemStackExtraData; @@ -52,11 +61,13 @@ use pocketmine\utils\SingletonTrait; use pocketmine\world\format\io\GlobalBlockStateHandlers; use pocketmine\world\format\io\GlobalItemDataHandlers; use function get_class; +use function hash; class TypeConverter{ use SingletonTrait; private const PM_ID_TAG = "___Id___"; + private const PM_FULL_NBT_HASH_TAG = "___FullNbtHash___"; private const RECIPE_INPUT_WILDCARD_META = 0x7fff; @@ -197,6 +208,84 @@ class TypeConverter{ return new ExactRecipeIngredient($result); } + /** + * Strips unnecessary block actor NBT from items that have it. + * This tag can potentially be extremely large, and is not read by the client anyway. + */ + protected function stripBlockEntityNBT(CompoundTag $tag) : bool{ + if(($tag->getTag(Item::TAG_BLOCK_ENTITY_TAG)) !== null){ + //client doesn't use this tag, so it's fine to delete completely + $tag->removeTag(Item::TAG_BLOCK_ENTITY_TAG); + return true; + } + return false; + } + + /** + * Strips non-viewable data from shulker boxes and similar blocks + * The lore for shulker boxes only requires knowing the type & count of items and possibly custom name + * We don't need to, and should not allow, sending nested inventories across the network. + */ + protected function stripContainedItemNonVisualNBT(CompoundTag $tag) : bool{ + try{ + $blockEntityInventoryTag = $tag->getListTag(Container::TAG_ITEMS, CompoundTag::class); + }catch(UnexpectedTagTypeException){ + return false; + } + if($blockEntityInventoryTag !== null && $blockEntityInventoryTag->count() > 0){ + $stripped = new ListTag(); + + foreach($blockEntityInventoryTag as $itemTag){ + try{ + $containedItem = Item::nbtDeserialize($itemTag); + $customName = $containedItem->getCustomName(); + $containedItem->clearNamedTag(); + $containedItem->setCustomName($customName); + $stripped->push($containedItem->nbtSerialize()); + }catch(SavedDataLoadingException){ + continue; + } + } + $tag->setTag(Container::TAG_ITEMS, $stripped); + return true; + } + return false; + } + + /** + * Computes a hash of an item's server-side NBT. + * This is baked into an item's network NBT to make sure the client doesn't try to stack items with the same network + * NBT but different server-side NBT. + */ + protected function hashNBT(Tag $tag) : string{ + $encoded = (new LittleEndianNbtSerializer())->write(new TreeRoot($tag)); + return hash('sha256', $encoded, binary: true); + } + + /** + * TODO: HACK! + * Creates a copy of an item's NBT with non-viewable data stripped. + * This is a pretty yucky hack that's mainly needed because of inventories inside blockitems containing blockentity + * data. There isn't really a good way to deal with this due to the way tiles currently require a position, + * otherwise we could just keep a copy of the tile context and ask it for persistent vs network NBT as needed. + * Unfortunately, making this nice will require significant BC breaks, so this will have to do for now. + */ + protected function cleanupUnnecessaryItemNBT(CompoundTag $original) : CompoundTag{ + $tag = clone $original; + $anythingStripped = false; + foreach([ + $this->stripContainedItemNonVisualNBT($tag), + $this->stripBlockEntityNBT($tag) + ] as $stripped){ + $anythingStripped = $anythingStripped || $stripped; + } + + if($anythingStripped){ + $tag->setByteArray(self::PM_FULL_NBT_HASH_TAG, $this->hashNBT($original)); + } + return $tag; + } + public function coreItemStackToNet(Item $itemStack) : ItemStack{ if($itemStack->isNull()){ return ItemStack::null(); @@ -205,7 +294,7 @@ class TypeConverter{ if($nbt->count() === 0){ $nbt = null; }else{ - $nbt = clone $nbt; + $nbt = $this->cleanupUnnecessaryItemNBT($nbt); } $idMeta = $this->itemTranslator->toNetworkIdQuiet($itemStack); @@ -224,7 +313,7 @@ class TypeConverter{ $extraData = $id === $this->shieldRuntimeId ? new ItemStackExtraDataShield($nbt, canPlaceOn: [], canDestroy: [], blockingTick: 0) : new ItemStackExtraData($nbt, canPlaceOn: [], canDestroy: []); - $extraDataSerializer = PacketSerializer::encoder(); + $extraDataSerializer = new ByteBufferWriter(); $extraData->write($extraDataSerializer); return new ItemStack( @@ -232,7 +321,7 @@ class TypeConverter{ $meta, $itemStack->getCount(), $blockRuntimeId ?? ItemTranslator::NO_BLOCK_RUNTIME_ID, - $extraDataSerializer->getBuffer(), + $extraDataSerializer->getData(), ); } @@ -271,7 +360,7 @@ class TypeConverter{ } public function deserializeItemStackExtraData(string $extraData, int $id) : ItemStackExtraData{ - $extraDataDeserializer = PacketSerializer::decoder($extraData, 0); + $extraDataDeserializer = new ByteBufferReader($extraData); return $id === $this->shieldRuntimeId ? ItemStackExtraDataShield::read($extraDataDeserializer) : ItemStackExtraData::read($extraDataDeserializer); diff --git a/src/network/mcpe/encryption/EncryptionContext.php b/src/network/mcpe/encryption/EncryptionContext.php index 8c0b9bf74..6d76a84ad 100644 --- a/src/network/mcpe/encryption/EncryptionContext.php +++ b/src/network/mcpe/encryption/EncryptionContext.php @@ -24,7 +24,7 @@ declare(strict_types=1); namespace pocketmine\network\mcpe\encryption; use Crypto\Cipher; -use pocketmine\utils\Binary; +use pmmp\encoding\LE; use function bin2hex; use function openssl_digest; use function openssl_error_string; @@ -104,7 +104,7 @@ class EncryptionContext{ } private function calculateChecksum(int $counter, string $payload) : string{ - $hash = openssl_digest(Binary::writeLLong($counter) . $payload . $this->key, self::CHECKSUM_ALGO, true); + $hash = openssl_digest(LE::packUnsignedLong($counter) . $payload . $this->key, self::CHECKSUM_ALGO, true); if($hash === false){ throw new \RuntimeException("openssl_digest() error: " . openssl_error_string()); } diff --git a/src/network/mcpe/handler/InGamePacketHandler.php b/src/network/mcpe/handler/InGamePacketHandler.php index 18b3f110f..0a296173a 100644 --- a/src/network/mcpe/handler/InGamePacketHandler.php +++ b/src/network/mcpe/handler/InGamePacketHandler.php @@ -760,6 +760,43 @@ class InGamePacketHandler extends PacketHandler{ return true; //this packet is useless } + /** + * @throws PacketHandlingException + */ + private function updateSignText(CompoundTag $nbt, string $tagName, bool $frontFace, BaseSign $block, Vector3 $pos) : bool{ + $textTag = $nbt->getTag($tagName); + if(!$textTag instanceof CompoundTag){ + throw new PacketHandlingException("Invalid tag type " . get_debug_type($textTag) . " for tag \"$tagName\" in sign update data"); + } + $textBlobTag = $textTag->getTag(Sign::TAG_TEXT_BLOB); + if(!$textBlobTag instanceof StringTag){ + throw new PacketHandlingException("Invalid tag type " . get_debug_type($textBlobTag) . " for tag \"" . Sign::TAG_TEXT_BLOB . "\" in sign update data"); + } + + try{ + $text = SignText::fromBlob($textBlobTag->getValue()); + }catch(\InvalidArgumentException $e){ + throw PacketHandlingException::wrap($e, "Invalid sign text update"); + } + + $oldText = $block->getFaceText($frontFace); + if($text->getLines() === $oldText->getLines()){ + return false; + } + + try{ + if(!$block->updateFaceText($this->player, $frontFace, $text)){ + foreach($this->player->getWorld()->createBlockUpdatePackets([$pos]) as $updatePacket){ + $this->session->sendDataPacket($updatePacket); + } + return false; + } + return true; + }catch(\UnexpectedValueException $e){ + throw PacketHandlingException::wrap($e); + } + } + public function handleBlockActorData(BlockActorDataPacket $packet) : bool{ $pos = new Vector3($packet->blockPosition->getX(), $packet->blockPosition->getY(), $packet->blockPosition->getZ()); if($pos->distanceSquared($this->player->getLocation()) > 10000){ @@ -771,29 +808,9 @@ class InGamePacketHandler extends PacketHandler{ if(!($nbt instanceof CompoundTag)) throw new AssumptionFailedError("PHPStan should ensure this is a CompoundTag"); //for phpstorm's benefit if($block instanceof BaseSign){ - $frontTextTag = $nbt->getTag(Sign::TAG_FRONT_TEXT); - if(!$frontTextTag instanceof CompoundTag){ - throw new PacketHandlingException("Invalid tag type " . get_debug_type($frontTextTag) . " for tag \"" . Sign::TAG_FRONT_TEXT . "\" in sign update data"); - } - $textBlobTag = $frontTextTag->getTag(Sign::TAG_TEXT_BLOB); - if(!$textBlobTag instanceof StringTag){ - throw new PacketHandlingException("Invalid tag type " . get_debug_type($textBlobTag) . " for tag \"" . Sign::TAG_TEXT_BLOB . "\" in sign update data"); - } - - try{ - $text = SignText::fromBlob($textBlobTag->getValue()); - }catch(\InvalidArgumentException $e){ - throw PacketHandlingException::wrap($e, "Invalid sign text update"); - } - - try{ - if(!$block->updateText($this->player, $text)){ - foreach($this->player->getWorld()->createBlockUpdatePackets([$pos]) as $updatePacket){ - $this->session->sendDataPacket($updatePacket); - } - } - }catch(\UnexpectedValueException $e){ - throw PacketHandlingException::wrap($e); + if(!$this->updateSignText($nbt, Sign::TAG_FRONT_TEXT, true, $block, $pos)){ + //only one side can be updated at a time + $this->updateSignText($nbt, Sign::TAG_BACK_TEXT, false, $block, $pos); } return true; diff --git a/src/network/mcpe/handler/LoginPacketHandler.php b/src/network/mcpe/handler/LoginPacketHandler.php index 5c467f2d4..aa7c1da7a 100644 --- a/src/network/mcpe/handler/LoginPacketHandler.php +++ b/src/network/mcpe/handler/LoginPacketHandler.php @@ -27,27 +27,35 @@ use pocketmine\entity\InvalidSkinException; use pocketmine\event\player\PlayerPreLoginEvent; use pocketmine\lang\KnownTranslationFactory; use pocketmine\lang\Translatable; -use pocketmine\network\mcpe\auth\ProcessLoginTask; +use pocketmine\network\mcpe\auth\ProcessLegacyLoginTask; +use pocketmine\network\mcpe\auth\ProcessOpenIdLoginTask; use pocketmine\network\mcpe\JwtException; use pocketmine\network\mcpe\JwtUtils; use pocketmine\network\mcpe\NetworkSession; use pocketmine\network\mcpe\protocol\LoginPacket; -use pocketmine\network\mcpe\protocol\types\login\AuthenticationData; use pocketmine\network\mcpe\protocol\types\login\AuthenticationInfo; use pocketmine\network\mcpe\protocol\types\login\AuthenticationType; -use pocketmine\network\mcpe\protocol\types\login\ClientData; -use pocketmine\network\mcpe\protocol\types\login\ClientDataToSkinDataHelper; -use pocketmine\network\mcpe\protocol\types\login\JwtChain; +use pocketmine\network\mcpe\protocol\types\login\clientdata\ClientData; +use pocketmine\network\mcpe\protocol\types\login\clientdata\ClientDataToSkinDataHelper; +use pocketmine\network\mcpe\protocol\types\login\legacy\LegacyAuthChain; +use pocketmine\network\mcpe\protocol\types\login\legacy\LegacyAuthIdentityData; +use pocketmine\network\mcpe\protocol\types\login\openid\XboxAuthJwtBody; +use pocketmine\network\mcpe\protocol\types\login\openid\XboxAuthJwtHeader; use pocketmine\network\PacketHandlingException; use pocketmine\player\Player; use pocketmine\player\PlayerInfo; use pocketmine\player\XboxLivePlayerInfo; use pocketmine\Server; use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; +use function chr; +use function count; use function gettype; use function is_array; use function is_object; use function json_decode; +use function md5; +use function ord; use const JSON_THROW_ON_ERROR; /** @@ -65,15 +73,95 @@ class LoginPacketHandler extends PacketHandler{ private \Closure $authCallback ){} + private static function calculateUuidFromXuid(string $xuid) : UuidInterface{ + $hash = md5("pocket-auth-1-xuid:" . $xuid, binary: true); + $hash[6] = chr((ord($hash[6]) & 0x0f) | 0x30); // set version to 3 + $hash[8] = chr((ord($hash[8]) & 0x3f) | 0x80); // set variant to RFC 4122 + + return Uuid::fromBytes($hash); + } + public function handleLogin(LoginPacket $packet) : bool{ $authInfo = $this->parseAuthInfo($packet->authInfoJson); - $jwtChain = $this->parseJwtChain($authInfo->Certificate); - $extraData = $this->fetchAuthData($jwtChain); - if(!Player::isValidUserName($extraData->displayName)){ + if($authInfo->AuthenticationType === AuthenticationType::FULL->value){ + try{ + [$headerArray, $claimsArray,] = JwtUtils::parse($authInfo->Token); + }catch(JwtException $e){ + throw PacketHandlingException::wrap($e, "Error parsing authentication token"); + } + $header = $this->mapXboxTokenHeader($headerArray); + $claims = $this->mapXboxTokenBody($claimsArray); + + $legacyUuid = self::calculateUuidFromXuid($claims->xid); + $username = $claims->xname; + $xuid = $claims->xid; + + $authRequired = $this->processLoginCommon($packet, $username, $legacyUuid, $xuid); + if($authRequired === null){ + //plugin cancelled + return true; + } + $this->processOpenIdLogin($authInfo->Token, $header->kid, $packet->clientDataJwt, $authRequired); + + }elseif($authInfo->AuthenticationType === AuthenticationType::SELF_SIGNED->value){ + try{ + $chainData = json_decode($authInfo->Certificate, flags: JSON_THROW_ON_ERROR); + }catch(\JsonException $e){ + throw PacketHandlingException::wrap($e, "Error parsing self-signed certificate chain"); + } + if(!is_object($chainData)){ + throw new PacketHandlingException("Unexpected type for self-signed certificate chain: " . gettype($chainData) . ", expected object"); + } + try{ + $chain = $this->defaultJsonMapper()->map($chainData, new LegacyAuthChain()); + }catch(\JsonMapper_Exception $e){ + throw PacketHandlingException::wrap($e, "Error mapping self-signed certificate chain"); + } + if(count($chain->chain) > 1 || !isset($chain->chain[0])){ + throw new PacketHandlingException("Expected exactly one certificate in self-signed certificate chain, got " . count($chain->chain)); + } + + try{ + [, $claimsArray, ] = JwtUtils::parse($chain->chain[0]); + }catch(JwtException $e){ + throw PacketHandlingException::wrap($e, "Error parsing self-signed certificate"); + } + if(!isset($claimsArray["extraData"]) || !is_array($claimsArray["extraData"])){ + throw new PacketHandlingException("Expected \"extraData\" to be present in self-signed certificate"); + } + + try{ + $claims = $this->defaultJsonMapper()->map($claimsArray["extraData"], new LegacyAuthIdentityData()); + }catch(\JsonMapper_Exception $e){ + throw PacketHandlingException::wrap($e, "Error mapping self-signed certificate extraData"); + } + + if(!Uuid::isValid($claims->identity)){ + throw new PacketHandlingException("Invalid UUID string in self-signed certificate: " . $claims->identity); + } + $legacyUuid = Uuid::fromString($claims->identity); + $username = $claims->displayName; + $xuid = ""; + + $authRequired = $this->processLoginCommon($packet, $username, $legacyUuid, $xuid); + if($authRequired === null){ + //plugin cancelled + return true; + } + $this->processSelfSignedLogin($chain->chain, $packet->clientDataJwt, $authRequired); + }else{ + throw new PacketHandlingException("Unsupported authentication type: $authInfo->AuthenticationType"); + } + + return true; + } + + private function processLoginCommon(LoginPacket $packet, string $username, UuidInterface $legacyUuid, string $xuid) : ?bool{ + if(!Player::isValidUserName($username)){ $this->session->disconnectWithError(KnownTranslationFactory::disconnectionScreen_invalidName()); - return true; + return null; } $clientData = $this->parseClientData($packet->clientDataJwt); @@ -86,32 +174,25 @@ class LoginPacketHandler extends PacketHandler{ disconnectScreenMessage: KnownTranslationFactory::disconnectionScreen_invalidSkin() ); - return true; + return null; } - if(!Uuid::isValid($extraData->identity)){ - throw new PacketHandlingException("Invalid login UUID"); - } - $uuid = Uuid::fromString($extraData->identity); - $arrClientData = (array) $clientData; - $arrClientData["TitleID"] = $extraData->titleId; - - if($extraData->XUID !== ""){ + if($xuid !== ""){ $playerInfo = new XboxLivePlayerInfo( - $extraData->XUID, - $extraData->displayName, - $uuid, + $xuid, + $username, + $legacyUuid, $skin, $clientData->LanguageCode, - $arrClientData + (array) $clientData ); }else{ $playerInfo = new PlayerInfo( - $extraData->displayName, - $uuid, + $username, + $legacyUuid, $skin, $clientData->LanguageCode, - $arrClientData + (array) $clientData ); } ($this->playerInfoConsumer)($playerInfo); @@ -144,12 +225,10 @@ class LoginPacketHandler extends PacketHandler{ $ev->call(); if(!$ev->isAllowed()){ $this->session->disconnect($ev->getFinalDisconnectReason(), $ev->getFinalDisconnectScreenMessage()); - return true; + return null; } - $this->processLogin($authInfo->Token, AuthenticationType::from($authInfo->AuthenticationType), $jwtChain->chain, $packet->clientDataJwt, $ev->isAuthRequired()); - - return true; + return $ev->isAuthRequired(); } /** @@ -162,13 +241,10 @@ class LoginPacketHandler extends PacketHandler{ throw PacketHandlingException::wrap($e); } if(!is_object($authInfoJson)){ - throw new \RuntimeException("Unexpected type for auth info data: " . gettype($authInfoJson) . ", expected object"); + throw new PacketHandlingException("Unexpected type for auth info data: " . gettype($authInfoJson) . ", expected object"); } - $mapper = new \JsonMapper(); - $mapper->bExceptionOnMissingData = true; - $mapper->bExceptionOnUndefinedProperty = true; - $mapper->bStrictObjectTypeChecking = true; + $mapper = $this->defaultJsonMapper(); try{ $clientData = $mapper->map($authInfoJson, new AuthenticationInfo()); }catch(\JsonMapper_Exception $e){ @@ -178,68 +254,31 @@ class LoginPacketHandler extends PacketHandler{ } /** + * @param array $headerArray * @throws PacketHandlingException */ - protected function parseJwtChain(string $chainDataJwt) : JwtChain{ + protected function mapXboxTokenHeader(array $headerArray) : XboxAuthJwtHeader{ + $mapper = $this->defaultJsonMapper(); try{ - $jwtChainJson = json_decode($chainDataJwt, associative: false, flags: JSON_THROW_ON_ERROR); - }catch(\JsonException $e){ - throw PacketHandlingException::wrap($e); - } - if(!is_object($jwtChainJson)){ - throw new \RuntimeException("Unexpected type for JWT chain data: " . gettype($jwtChainJson) . ", expected object"); - } - - $mapper = new \JsonMapper(); - $mapper->bExceptionOnMissingData = true; - $mapper->bExceptionOnUndefinedProperty = true; - $mapper->bStrictObjectTypeChecking = true; - try{ - $clientData = $mapper->map($jwtChainJson, new JwtChain()); + $header = $mapper->map($headerArray, new XboxAuthJwtHeader()); }catch(\JsonMapper_Exception $e){ throw PacketHandlingException::wrap($e); } - return $clientData; + return $header; } /** + * @param array $bodyArray * @throws PacketHandlingException */ - protected function fetchAuthData(JwtChain $chain) : AuthenticationData{ - /** @var AuthenticationData|null $extraData */ - $extraData = null; - foreach($chain->chain as $jwt){ - //validate every chain element - try{ - [, $claims, ] = JwtUtils::parse($jwt); - }catch(JwtException $e){ - throw PacketHandlingException::wrap($e); - } - if(isset($claims["extraData"])){ - if($extraData !== null){ - throw new PacketHandlingException("Found 'extraData' more than once in chainData"); - } - - if(!is_array($claims["extraData"])){ - throw new PacketHandlingException("'extraData' key should be an array"); - } - $mapper = new \JsonMapper(); - $mapper->bEnforceMapType = false; //TODO: we don't really need this as an array, but right now we don't have enough models - $mapper->bExceptionOnMissingData = true; - $mapper->bExceptionOnUndefinedProperty = true; - $mapper->bStrictObjectTypeChecking = true; - try{ - /** @var AuthenticationData $extraData */ - $extraData = $mapper->map($claims["extraData"], new AuthenticationData()); - }catch(\JsonMapper_Exception $e){ - throw PacketHandlingException::wrap($e); - } - } + protected function mapXboxTokenBody(array $bodyArray) : XboxAuthJwtBody{ + $mapper = $this->defaultJsonMapper(); + try{ + $header = $mapper->map($bodyArray, new XboxAuthJwtBody()); + }catch(\JsonMapper_Exception $e){ + throw PacketHandlingException::wrap($e); } - if($extraData === null){ - throw new PacketHandlingException("'extraData' not found in chain data"); - } - return $extraData; + return $header; } /** @@ -252,11 +291,7 @@ class LoginPacketHandler extends PacketHandler{ throw PacketHandlingException::wrap($e); } - $mapper = new \JsonMapper(); - $mapper->bEnforceMapType = false; //TODO: we don't really need this as an array, but right now we don't have enough models - $mapper->bExceptionOnMissingData = true; - $mapper->bExceptionOnUndefinedProperty = true; - $mapper->bStrictObjectTypeChecking = true; + $mapper = $this->defaultJsonMapper(); try{ $clientData = $mapper->map($clientDataClaims, new ClientData()); }catch(\JsonMapper_Exception $e){ @@ -269,15 +304,37 @@ class LoginPacketHandler extends PacketHandler{ * TODO: This is separated for the purposes of allowing plugins (like Specter) to hack it and bypass authentication. * In the future this won't be necessary. * - * @param null|string[] $legacyCertificate - * * @throws \InvalidArgumentException */ - protected function processLogin(string $token, AuthenticationType $authType, ?array $legacyCertificate, string $clientData, bool $authRequired) : void{ - if($legacyCertificate === null){ - throw new PacketHandlingException("Legacy certificate cannot be null"); - } - $this->server->getAsyncPool()->submitTask(new ProcessLoginTask($legacyCertificate, $clientData, $authRequired, $this->authCallback)); + protected function processOpenIdLogin(string $token, string $keyId, string $clientData, bool $authRequired) : void{ $this->session->setHandler(null); //drop packets received during login verification + + $authKeyProvider = $this->server->getAuthKeyProvider(); + + $authKeyProvider->getKey($keyId)->onCompletion( + function(array $issuerAndKey) use ($token, $clientData, $authRequired) : void{ + [$issuer, $mojangPublicKeyPem] = $issuerAndKey; + $this->server->getAsyncPool()->submitTask(new ProcessOpenIdLoginTask($token, $issuer, $mojangPublicKeyPem, $clientData, $authRequired, $this->authCallback)); + }, + fn() => ($this->authCallback)(false, $authRequired, "Unrecognized authentication key ID: $keyId", null) + ); + } + + /** + * @param string[] $legacyCertificate + */ + protected function processSelfSignedLogin(array $legacyCertificate, string $clientDataJwt, bool $authRequired) : void{ + $this->session->setHandler(null); //drop packets received during login verification + + $this->server->getAsyncPool()->submitTask(new ProcessLegacyLoginTask($legacyCertificate, $clientDataJwt, rootAuthKeyDer: null, authRequired: $authRequired, onCompletion: $this->authCallback)); + } + + private function defaultJsonMapper() : \JsonMapper{ + $mapper = new \JsonMapper(); + $mapper->bExceptionOnMissingData = true; + $mapper->bExceptionOnUndefinedProperty = true; + $mapper->bStrictObjectTypeChecking = true; + $mapper->bEnforceMapType = false; + return $mapper; } } diff --git a/src/network/mcpe/serializer/ChunkSerializer.php b/src/network/mcpe/serializer/ChunkSerializer.php index 9120f34a7..230ff4a94 100644 --- a/src/network/mcpe/serializer/ChunkSerializer.php +++ b/src/network/mcpe/serializer/ChunkSerializer.php @@ -23,16 +23,16 @@ declare(strict_types=1); namespace pocketmine\network\mcpe\serializer; +use pmmp\encoding\Byte; +use pmmp\encoding\ByteBufferWriter; +use pmmp\encoding\VarInt; use pocketmine\block\tile\Spawnable; use pocketmine\data\bedrock\BiomeIds; use pocketmine\data\bedrock\LegacyBiomeIdToStringIdMap; use pocketmine\nbt\TreeRoot; use pocketmine\network\mcpe\convert\BlockTranslator; use pocketmine\network\mcpe\protocol\serializer\NetworkNbtSerializer; -use pocketmine\network\mcpe\protocol\serializer\PacketSerializer; use pocketmine\network\mcpe\protocol\types\DimensionIds; -use pocketmine\utils\Binary; -use pocketmine\utils\BinaryStream; use pocketmine\world\format\Chunk; use pocketmine\world\format\PalettedBlockArray; use pocketmine\world\format\SubChunk; @@ -84,7 +84,7 @@ final class ChunkSerializer{ * @phpstan-param DimensionIds::* $dimensionId */ public static function serializeFullChunk(Chunk $chunk, int $dimensionId, BlockTranslator $blockTranslator, ?string $tiles = null) : string{ - $stream = PacketSerializer::encoder(); + $stream = new ByteBufferWriter(); $subChunkCount = self::getSubChunkCount($chunk, $dimensionId); $writtenCount = 0; @@ -100,37 +100,34 @@ final class ChunkSerializer{ self::serializeBiomePalette($chunk->getSubChunk($y)->getBiomeArray(), $biomeIdMap, $stream); } - $stream->putByte(0); //border block array count + Byte::writeUnsigned($stream, 0); //border block array count //Border block entry format: 1 byte (4 bits X, 4 bits Z). These are however useless since they crash the regular client. if($tiles !== null){ - $stream->put($tiles); + $stream->writeByteArray($tiles); }else{ - $stream->put(self::serializeTiles($chunk)); + $stream->writeByteArray(self::serializeTiles($chunk)); } - return $stream->getBuffer(); + return $stream->getData(); } - public static function serializeSubChunk(SubChunk $subChunk, BlockTranslator $blockTranslator, PacketSerializer $stream, bool $persistentBlockStates) : void{ + public static function serializeSubChunk(SubChunk $subChunk, BlockTranslator $blockTranslator, ByteBufferWriter $stream, bool $persistentBlockStates) : void{ $layers = $subChunk->getBlockLayers(); - $stream->putByte(8); //version + Byte::writeUnsigned($stream, 8); //version - $stream->putByte(count($layers)); + Byte::writeUnsigned($stream, count($layers)); $blockStateDictionary = $blockTranslator->getBlockStateDictionary(); foreach($layers as $blocks){ $bitsPerBlock = $blocks->getBitsPerBlock(); $words = $blocks->getWordArray(); - $stream->putByte(($bitsPerBlock << 1) | ($persistentBlockStates ? 0 : 1)); - $stream->put($words); + Byte::writeUnsigned($stream, ($bitsPerBlock << 1) | ($persistentBlockStates ? 0 : 1)); + $stream->writeByteArray($words); $palette = $blocks->getPalette(); if($bitsPerBlock !== 0){ - //these LSHIFT by 1 uvarints are optimizations: the client expects zigzag varints here - //but since we know they are always unsigned, we can avoid the extra fcall overhead of - //zigzag and just shift directly. - $stream->putUnsignedVarInt(count($palette) << 1); //yes, this is intentionally zigzag + VarInt::writeSignedInt($stream, count($palette)); //yes, this is intentionally zigzag } if($persistentBlockStates){ $nbtSerializer = new NetworkNbtSerializer(); @@ -141,46 +138,43 @@ final class ChunkSerializer{ $state = $blockTranslator->getFallbackStateData(); } - $stream->put($nbtSerializer->write(new TreeRoot($state->toNbt()))); + $stream->writeByteArray($nbtSerializer->write(new TreeRoot($state->toNbt()))); } }else{ + //we would use writeSignedIntArray() here, but the gains of writing in batch are negated by the cost of + //allocating a temporary array for the mapped palette IDs, especially for small palettes foreach($palette as $p){ - $stream->put(Binary::writeUnsignedVarInt($blockTranslator->internalIdToNetworkId($p) << 1)); + VarInt::writeSignedInt($stream, $blockTranslator->internalIdToNetworkId($p)); } } } } - private static function serializeBiomePalette(PalettedBlockArray $biomePalette, LegacyBiomeIdToStringIdMap $biomeIdMap, PacketSerializer $stream) : void{ + private static function serializeBiomePalette(PalettedBlockArray $biomePalette, LegacyBiomeIdToStringIdMap $biomeIdMap, ByteBufferWriter $stream) : void{ $biomePaletteBitsPerBlock = $biomePalette->getBitsPerBlock(); - $stream->putByte(($biomePaletteBitsPerBlock << 1) | 1); //the last bit is non-persistence (like for blocks), though it has no effect on biomes since they always use integer IDs - $stream->put($biomePalette->getWordArray()); + Byte::writeUnsigned($stream, ($biomePaletteBitsPerBlock << 1) | 1); //the last bit is non-persistence (like for blocks), though it has no effect on biomes since they always use integer IDs + $stream->writeByteArray($biomePalette->getWordArray()); - //these LSHIFT by 1 uvarints are optimizations: the client expects zigzag varints here - //but since we know they are always unsigned, we can avoid the extra fcall overhead of - //zigzag and just shift directly. $biomePaletteArray = $biomePalette->getPalette(); if($biomePaletteBitsPerBlock !== 0){ - $stream->putUnsignedVarInt(count($biomePaletteArray) << 1); + VarInt::writeSignedInt($stream, count($biomePaletteArray)); } foreach($biomePaletteArray as $p){ - if($biomeIdMap->legacyToString($p) === null){ - //make sure we aren't sending bogus biomes - the 1.18.0 client crashes if we do this - $p = BiomeIds::OCEAN; - } - $stream->put(Binary::writeUnsignedVarInt($p << 1)); + //we would use writeSignedIntArray() here, but the gains of writing in batch are negated by the cost of + //allocating a temporary array for the mapped palette IDs, especially for small palettes + VarInt::writeSignedInt($stream, $biomeIdMap->legacyToString($p) !== null ? $p : BiomeIds::OCEAN); } } public static function serializeTiles(Chunk $chunk) : string{ - $stream = new BinaryStream(); + $stream = new ByteBufferWriter(); foreach($chunk->getTiles() as $tile){ if($tile instanceof Spawnable){ - $stream->put($tile->getSerializedSpawnCompound()->getEncodedNbt()); + $stream->writeByteArray($tile->getSerializedSpawnCompound()->getEncodedNbt()); } } - return $stream->getBuffer(); + return $stream->getData(); } } diff --git a/src/network/query/QueryHandler.php b/src/network/query/QueryHandler.php index 940f64f18..41b633421 100644 --- a/src/network/query/QueryHandler.php +++ b/src/network/query/QueryHandler.php @@ -27,16 +27,16 @@ declare(strict_types=1); */ namespace pocketmine\network\query; +use pmmp\encoding\BE; +use pmmp\encoding\Byte; +use pmmp\encoding\ByteBufferReader; +use pmmp\encoding\ByteBufferWriter; +use pmmp\encoding\DataDecodeException; use pocketmine\network\AdvancedNetworkInterface; use pocketmine\network\RawPacketHandler; use pocketmine\Server; -use pocketmine\utils\Binary; -use pocketmine\utils\BinaryDataException; -use pocketmine\utils\BinaryStream; -use function chr; use function hash; use function random_bytes; -use function strlen; use function substr; class QueryHandler implements RawPacketHandler{ @@ -80,51 +80,53 @@ class QueryHandler implements RawPacketHandler{ } public static function getTokenString(string $token, string $salt) : int{ - return Binary::readInt(substr(hash("sha512", $salt . ":" . $token, true), 7, 4)); + return BE::unpackSignedInt(substr(hash("sha512", $salt . ":" . $token, true), 7, 4)); } public function handle(AdvancedNetworkInterface $interface, string $address, int $port, string $packet) : bool{ try{ - $stream = new BinaryStream($packet); - $header = $stream->get(2); + $stream = new ByteBufferReader($packet); + $header = $stream->readByteArray(2); if($header !== "\xfe\xfd"){ //TODO: have this filtered by the regex filter we installed above return false; } - $packetType = $stream->getByte(); - $sessionID = $stream->getInt(); + $packetType = Byte::readUnsigned($stream); + $sessionID = BE::readUnsignedInt($stream); switch($packetType){ case self::HANDSHAKE: //Handshake - $reply = chr(self::HANDSHAKE); - $reply .= Binary::writeInt($sessionID); - $reply .= self::getTokenString($this->token, $address) . "\x00"; + $writer = new ByteBufferWriter(); + Byte::writeUnsigned($writer, self::HANDSHAKE); + BE::writeUnsignedInt($writer, $sessionID); + $writer->writeByteArray(self::getTokenString($this->token, $address) . "\x00"); - $interface->sendRawPacket($address, $port, $reply); + $interface->sendRawPacket($address, $port, $writer->getData()); return true; case self::STATISTICS: //Stat - $token = $stream->getInt(); + $token = BE::readUnsignedInt($stream); if($token !== ($t1 = self::getTokenString($this->token, $address)) && $token !== ($t2 = self::getTokenString($this->lastToken, $address))){ $this->logger->debug("Bad token $token from $address $port, expected $t1 or $t2"); return true; } - $reply = chr(self::STATISTICS); - $reply .= Binary::writeInt($sessionID); + $writer = new ByteBufferWriter(); + Byte::writeUnsigned($writer, self::STATISTICS); + BE::writeUnsignedInt($writer, $sessionID); - $remaining = $stream->getRemaining(); - if(strlen($remaining) === 4){ //TODO: check this! according to the spec, this should always be here and always be FF FF FF 01 - $reply .= $this->server->getQueryInformation()->getLongQuery(); + $remaining = $stream->getUnreadLength(); + if($remaining === 4){ //TODO: check this! according to the spec, this should always be here and always be FF FF FF 01 + $writer->writeByteArray($this->server->getQueryInformation()->getLongQuery()); }else{ - $reply .= $this->server->getQueryInformation()->getShortQuery(); + $writer->writeByteArray($this->server->getQueryInformation()->getShortQuery()); } - $interface->sendRawPacket($address, $port, $reply); + $interface->sendRawPacket($address, $port, $writer->getData()); return true; default: return false; } - }catch(BinaryDataException $e){ + }catch(DataDecodeException $e){ $this->logger->debug("Bad packet from $address $port: " . $e->getMessage()); return false; } diff --git a/src/network/query/QueryInfo.php b/src/network/query/QueryInfo.php index 0bf5b4f65..30aba5347 100644 --- a/src/network/query/QueryInfo.php +++ b/src/network/query/QueryInfo.php @@ -23,11 +23,11 @@ declare(strict_types=1); namespace pocketmine\network\query; +use pmmp\encoding\LE; use pocketmine\player\GameMode; use pocketmine\player\Player; use pocketmine\plugin\Plugin; use pocketmine\Server; -use pocketmine\utils\Binary; use pocketmine\utils\Utils; use pocketmine\YmlServerProperties; use function array_map; @@ -236,6 +236,6 @@ final class QueryInfo{ } public function getShortQuery() : string{ - return $this->shortQueryCache ?? ($this->shortQueryCache = $this->serverName . "\x00" . $this->gametype . "\x00" . $this->map . "\x00" . $this->numPlayers . "\x00" . $this->maxPlayers . "\x00" . Binary::writeLShort($this->port) . $this->ip . "\x00"); + return $this->shortQueryCache ?? ($this->shortQueryCache = $this->serverName . "\x00" . $this->gametype . "\x00" . $this->map . "\x00" . $this->numPlayers . "\x00" . $this->maxPlayers . "\x00" . LE::packUnsignedShort($this->port) . $this->ip . "\x00"); } } diff --git a/src/player/Player.php b/src/player/Player.php index aa2d2af88..e44f4c97d 100644 --- a/src/player/Player.php +++ b/src/player/Player.php @@ -41,6 +41,7 @@ use pocketmine\entity\Entity; use pocketmine\entity\Human; use pocketmine\entity\Living; use pocketmine\entity\Location; +use pocketmine\entity\NeverSavedWithChunkEntity; use pocketmine\entity\object\ItemEntity; use pocketmine\entity\projectile\Arrow; use pocketmine\entity\Skin; @@ -169,7 +170,7 @@ use const PHP_INT_MAX; /** * Main class that handles networking, recovery, and packet sending to the server part */ -class Player extends Human implements CommandSender, ChunkListener, IPlayer{ +class Player extends Human implements CommandSender, ChunkListener, IPlayer, NeverSavedWithChunkEntity{ use PermissibleDelegateTrait; private const MOVES_PER_TICK = 2; @@ -2838,13 +2839,12 @@ class Player extends Human implements CommandSender, ChunkListener, IPlayer{ /** * Opens the player's sign editor GUI for the sign at the given position. - * TODO: add support for editing the rear side of the sign (not currently supported due to technical limitations) */ - public function openSignEditor(Vector3 $position) : void{ + public function openSignEditor(Vector3 $position, bool $frontFace = true) : void{ $block = $this->getWorld()->getBlock($position); if($block instanceof BaseSign){ $this->getWorld()->setBlock($position, $block->setEditorEntityRuntimeId($this->getId())); - $this->getNetworkSession()->onOpenSignEditor($position, true); + $this->getNetworkSession()->onOpenSignEditor($position, $frontFace); }else{ throw new \InvalidArgumentException("Block at this position is not a sign"); } diff --git a/src/world/World.php b/src/world/World.php index 236fd6e56..7917ea18c 100644 --- a/src/world/World.php +++ b/src/world/World.php @@ -42,6 +42,7 @@ use pocketmine\data\SavedDataLoadingException; use pocketmine\entity\Entity; use pocketmine\entity\EntityFactory; use pocketmine\entity\Location; +use pocketmine\entity\NeverSavedWithChunkEntity; use pocketmine\entity\object\ExperienceOrb; use pocketmine\entity\object\ItemEntity; use pocketmine\event\block\BlockBreakEvent; @@ -2776,7 +2777,7 @@ class World implements ChunkManager{ throw new AssumptionFailedError("Found two different entities sharing entity ID " . $entity->getId()); } } - if(!EntityFactory::getInstance()->isRegistered($entity::class) && !$entity instanceof Player){ + if(!EntityFactory::getInstance()->isRegistered($entity::class) && !$entity instanceof NeverSavedWithChunkEntity){ //canSaveWithChunk is mutable, so that means it could be toggled after adding the entity and cause a crash //later on. Better we just force all entities to have a save ID, even if it might not be needed. throw new \LogicException("Entity " . $entity::class . " is not registered for a save ID in EntityFactory"); diff --git a/src/world/format/io/FastChunkSerializer.php b/src/world/format/io/FastChunkSerializer.php index 35a8ff42f..a186ec07f 100644 --- a/src/world/format/io/FastChunkSerializer.php +++ b/src/world/format/io/FastChunkSerializer.php @@ -23,8 +23,10 @@ declare(strict_types=1); namespace pocketmine\world\format\io; -use pocketmine\utils\Binary; -use pocketmine\utils\BinaryStream; +use pmmp\encoding\BE; +use pmmp\encoding\Byte; +use pmmp\encoding\ByteBufferReader; +use pmmp\encoding\ByteBufferWriter; use pocketmine\world\format\Chunk; use pocketmine\world\format\PalettedBlockArray; use pocketmine\world\format\SubChunk; @@ -45,15 +47,15 @@ final class FastChunkSerializer{ //NOOP } - private static function serializePalettedArray(BinaryStream $stream, PalettedBlockArray $array) : void{ + private static function serializePalettedArray(ByteBufferWriter $stream, PalettedBlockArray $array) : void{ $wordArray = $array->getWordArray(); $palette = $array->getPalette(); - $stream->putByte($array->getBitsPerBlock()); - $stream->put($wordArray); + Byte::writeUnsigned($stream, $array->getBitsPerBlock()); + $stream->writeByteArray($wordArray); $serialPalette = pack("L*", ...$palette); - $stream->putInt(strlen($serialPalette)); - $stream->put($serialPalette); + BE::writeUnsignedInt($stream, strlen($serialPalette)); + $stream->writeByteArray($serialPalette); } /** @@ -61,21 +63,20 @@ final class FastChunkSerializer{ * TODO: tiles and entities */ public static function serializeTerrain(Chunk $chunk) : string{ - $stream = new BinaryStream(); - $stream->putByte( - ($chunk->isPopulated() ? self::FLAG_POPULATED : 0) - ); + $stream = new ByteBufferWriter(); + Byte::writeUnsigned($stream, ($chunk->isPopulated() ? self::FLAG_POPULATED : 0)); //subchunks $subChunks = $chunk->getSubChunks(); $count = count($subChunks); - $stream->putByte($count); + Byte::writeUnsigned($stream, $count); foreach($subChunks as $y => $subChunk){ - $stream->putByte($y); - $stream->putInt($subChunk->getEmptyBlockId()); + Byte::writeSigned($stream, $y); + BE::writeUnsignedInt($stream, $subChunk->getEmptyBlockId()); + $layers = $subChunk->getBlockLayers(); - $stream->putByte(count($layers)); + Byte::writeUnsigned($stream, count($layers)); foreach($layers as $blocks){ self::serializePalettedArray($stream, $blocks); } @@ -83,14 +84,15 @@ final class FastChunkSerializer{ } - return $stream->getBuffer(); + return $stream->getData(); } - private static function deserializePalettedArray(BinaryStream $stream) : PalettedBlockArray{ - $bitsPerBlock = $stream->getByte(); - $words = $stream->get(PalettedBlockArray::getExpectedWordArraySize($bitsPerBlock)); + private static function deserializePalettedArray(ByteBufferReader $stream) : PalettedBlockArray{ + $bitsPerBlock = Byte::readUnsigned($stream); + $words = $stream->readByteArray(PalettedBlockArray::getExpectedWordArraySize($bitsPerBlock)); + $paletteSize = BE::readUnsignedInt($stream); /** @var int[] $unpackedPalette */ - $unpackedPalette = unpack("L*", $stream->get($stream->getInt())); //unpack() will never fail here + $unpackedPalette = unpack("L*", $stream->readByteArray($paletteSize)); //unpack() will never fail here $palette = array_values($unpackedPalette); return PalettedBlockArray::fromData($bitsPerBlock, $words, $palette); @@ -100,20 +102,21 @@ final class FastChunkSerializer{ * Deserializes a fast-serialized chunk */ public static function deserializeTerrain(string $data) : Chunk{ - $stream = new BinaryStream($data); + $stream = new ByteBufferReader($data); - $flags = $stream->getByte(); + $flags = Byte::readUnsigned($stream); $terrainPopulated = (bool) ($flags & self::FLAG_POPULATED); $subChunks = []; - $count = $stream->getByte(); + $count = Byte::readUnsigned($stream); for($subCount = 0; $subCount < $count; ++$subCount){ - $y = Binary::signByte($stream->getByte()); - $airBlockId = $stream->getInt(); + $y = Byte::readSigned($stream); + //TODO: why the heck are we using big-endian here? + $airBlockId = BE::readUnsignedInt($stream); $layers = []; - for($i = 0, $layerCount = $stream->getByte(); $i < $layerCount; ++$i){ + for($i = 0, $layerCount = Byte::readUnsigned($stream); $i < $layerCount; ++$i){ $layers[] = self::deserializePalettedArray($stream); } $biomeArray = self::deserializePalettedArray($stream); diff --git a/src/world/format/io/region/LegacyAnvilChunkTrait.php b/src/world/format/io/region/LegacyAnvilChunkTrait.php index 6e2f4c8f8..05c79ea63 100644 --- a/src/world/format/io/region/LegacyAnvilChunkTrait.php +++ b/src/world/format/io/region/LegacyAnvilChunkTrait.php @@ -87,12 +87,10 @@ trait LegacyAnvilChunkTrait{ } $subChunks = []; - $subChunksTag = $chunk->getListTag("Sections") ?? []; + $subChunksTag = $chunk->getListTag("Sections", CompoundTag::class) ?? []; foreach($subChunksTag as $subChunk){ - if($subChunk instanceof CompoundTag){ - $y = $subChunk->getByte("Y"); - $subChunks[$y] = $this->deserializeSubChunk($subChunk, clone $biomes3d, new \PrefixedLogger($logger, "Subchunk y=$y")); - } + $y = $subChunk->getByte("Y"); + $subChunks[$y] = $this->deserializeSubChunk($subChunk, clone $biomes3d, new \PrefixedLogger($logger, "Subchunk y=$y")); } for($y = Chunk::MIN_SUBCHUNK_INDEX; $y <= Chunk::MAX_SUBCHUNK_INDEX; ++$y){ if(!isset($subChunks[$y])){ diff --git a/src/world/format/io/region/RegionWorldProvider.php b/src/world/format/io/region/RegionWorldProvider.php index 8fe7928b8..9ce0a35de 100644 --- a/src/world/format/io/region/RegionWorldProvider.php +++ b/src/world/format/io/region/RegionWorldProvider.php @@ -23,7 +23,6 @@ declare(strict_types=1); namespace pocketmine\world\format\io\region; -use pocketmine\nbt\NBT; use pocketmine\nbt\tag\ByteArrayTag; use pocketmine\nbt\tag\CompoundTag; use pocketmine\nbt\tag\ListTag; @@ -164,21 +163,11 @@ abstract class RegionWorldProvider extends BaseWorldProvider{ * @throws CorruptedChunkException */ protected static function getCompoundList(string $context, ListTag $list) : array{ - if($list->count() === 0){ //empty lists might have wrong types, we don't care - return []; - } - if($list->getTagType() !== NBT::TAG_Compound){ + $compoundList = $list->cast(CompoundTag::class); + if($compoundList === null){ throw new CorruptedChunkException("Expected TAG_List for '$context'"); } - $result = []; - foreach($list as $tag){ - if(!($tag instanceof CompoundTag)){ - //this should never happen, but it's still possible due to lack of native type safety - throw new CorruptedChunkException("Expected TAG_List for '$context'"); - } - $result[] = $tag; - } - return $result; + return $compoundList->getValue(); } protected static function readFixedSizeByteArray(CompoundTag $chunk, string $tagName, int $length) : string{ diff --git a/src/world/sound/FireworkCrackleSound.php b/src/world/sound/FireworkCrackleSound.php new file mode 100644 index 000000000..c0e897d70 --- /dev/null +++ b/src/world/sound/FireworkCrackleSound.php @@ -0,0 +1,35 @@ +getRawExtraData(); if($rawExtraData !== ""){ - $decoder = PacketSerializer::decoder($rawExtraData, 0); + $decoder = new ByteBufferReader($rawExtraData); $extraData = $itemStringId === ItemTypeNames::SHIELD ? ItemStackExtraDataShield::read($decoder) : ItemStackExtraData::read($decoder); $nbt = $extraData->getNbt(); if($nbt !== null && count($nbt) > 0){ @@ -554,8 +553,8 @@ class ParserPacketHandler extends PacketHandler{ if(!($tag instanceof CompoundTag)){ throw new AssumptionFailedError(); } - $idList = $tag->getTag("idlist"); - if(!($idList instanceof ListTag) || $idList->getTagType() !== NBT::TAG_Compound){ + $generic = $tag->getTag("idlist"); + if(!($generic instanceof ListTag) || ($idList = $generic->cast(CompoundTag::class)) === null){ echo $tag . "\n"; throw new \RuntimeException("expected TAG_List(\"idlist\") tag inside root TAG_Compound"); } @@ -565,9 +564,6 @@ class ParserPacketHandler extends PacketHandler{ } echo "updating legacy => string entity ID mapping table\n"; $map = []; - /** - * @var CompoundTag $thing - */ foreach($idList as $thing){ $map[$thing->getString("id")] = $thing->getInt("rid"); } @@ -649,12 +645,13 @@ function main(array $argv) : int{ fwrite(STDERR, "Unknown packet on line " . ($lineNum + 1) . ": " . $parts[1]); continue; } - $serializer = PacketSerializer::decoder($raw, 0); + $serializer = new ByteBufferReader($raw); $pk->decode($serializer); $pk->handle($handler); - if(!$serializer->feof()){ - echo "Packet on line " . ($lineNum + 1) . ": didn't read all data from " . get_class($pk) . " (stopped at offset " . $serializer->getOffset() . " of " . strlen($serializer->getBuffer()) . " bytes): " . bin2hex($serializer->getRemaining()) . "\n"; + $remaining = strlen($serializer->getData()) - $serializer->getOffset(); + if($remaining > 0){ + echo "Packet on line " . ($lineNum + 1) . ": didn't read all data from " . get_class($pk) . " (stopped at offset " . $serializer->getOffset() . " of " . strlen($serializer->getData()) . " bytes): " . bin2hex($serializer->readByteArray($remaining)) . "\n"; } } return 0;