From c931437a303de526be3dc28858f7ed5dbefc7503 Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Sun, 31 Aug 2025 21:45:55 +0100 Subject: [PATCH 01/17] InventoryTransaction: Remove action shuffling we used to have this to prevent dependence on client ordering, and make ordering consistently not work. However, since the introduction of the ItemStackRequest protocol, we don't expect to see client actions in the wrong order anymore, so this shouldn't be needed anymore. closes #6701 --- .../transaction/InventoryTransaction.php | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/src/inventory/transaction/InventoryTransaction.php b/src/inventory/transaction/InventoryTransaction.php index 6e010c7b8..99fa6a097 100644 --- a/src/inventory/transaction/InventoryTransaction.php +++ b/src/inventory/transaction/InventoryTransaction.php @@ -95,10 +95,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 +122,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 +298,6 @@ class InventoryTransaction{ throw new TransactionValidationException("Transaction has already been executed"); } - $this->shuffleActions(); - $this->validate(); if(!$this->callExecuteEvent()){ From e569cc3275a88a454ce5b01412df50a03dd6c7bd Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Sun, 31 Aug 2025 21:47:49 +0100 Subject: [PATCH 02/17] stfu --- src/inventory/transaction/InventoryTransaction.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/inventory/transaction/InventoryTransaction.php b/src/inventory/transaction/InventoryTransaction.php index 99fa6a097..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; From b2d0be5b75050082988d5b106af799256673c784 Mon Sep 17 00:00:00 2001 From: "Dylan T." Date: Mon, 1 Sep 2025 17:15:29 +0100 Subject: [PATCH 03/17] Support editing the back side of signs (#6774) * Deprecate BaseSign get/set/updateText(), add get/set/updateFaceText() which accepts true/false for front/back * add isFrontFace() to SignChangeEvent * add optional frontFace to Player::openSignEditor() * add BaseSign::getFacingDegrees() and getHitboxCenter() which need to be implemented by subclasses --- src/block/BaseSign.php | 92 ++++++++++++++++--- src/block/CeilingCenterHangingSign.php | 4 + src/block/CeilingEdgesHangingSign.php | 11 +++ src/block/FloorSign.php | 4 + src/block/WallHangingSign.php | 11 +++ src/block/WallSign.php | 22 +++++ src/block/tile/Sign.php | 53 +++++------ src/event/block/SignChangeEvent.php | 10 +- .../mcpe/handler/InGamePacketHandler.php | 63 ++++++++----- src/player/Player.php | 5 +- 10 files changed, 203 insertions(+), 72 deletions(-) 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/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/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/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/network/mcpe/handler/InGamePacketHandler.php b/src/network/mcpe/handler/InGamePacketHandler.php index 6aae60745..dc8c3a0ba 100644 --- a/src/network/mcpe/handler/InGamePacketHandler.php +++ b/src/network/mcpe/handler/InGamePacketHandler.php @@ -756,6 +756,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){ @@ -767,29 +804,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/player/Player.php b/src/player/Player.php index aa2d2af88..4468d929e 100644 --- a/src/player/Player.php +++ b/src/player/Player.php @@ -2838,13 +2838,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"); } From fa5cc3301c8bf46f4d5b1bcb2d8944d517a3b880 Mon Sep 17 00:00:00 2001 From: "Dylan T." Date: Tue, 2 Sep 2025 18:36:00 +0100 Subject: [PATCH 04/17] Strip unnecessary NBT from network items (#6790) TypeConverter: Strip unnecessary NBT from clientbound items Item containers like shulker boxes, or chests with block entity data obtained with ctrl+middle click, will often have extremely large NBT payloads. This problem gets exponentially worse in cases where it's possible to nest inventories, as in #4665. We can't easily address this at the core level, because tiles are not able to exist within items (due to position requirement) so we don't have a good way to avoid this useless NBT in the first place. However, we can strip it before the item is sent to the client, which dramatically reduces the network costs of such items, as well as removing any reason the client could have to send such enormous items to the server. A fully loaded shulker box with written books now takes only 5-6 KB on the wire instead of ~1 MB, which is a substantial improvement. Dealing with this in a less hacky way is currently blocked on #6147. --- src/network/mcpe/convert/TypeConverter.php | 90 +++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/src/network/mcpe/convert/TypeConverter.php b/src/network/mcpe/convert/TypeConverter.php index e886b2b8b..a6520ce12 100644 --- a/src/network/mcpe/convert/TypeConverter.php +++ b/src/network/mcpe/convert/TypeConverter.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace pocketmine\network\mcpe\convert; +use pocketmine\block\tile\Container; use pocketmine\block\VanillaBlocks; use pocketmine\crafting\ExactRecipeIngredient; use pocketmine\crafting\MetaWildcardRecipeIngredient; @@ -31,10 +32,16 @@ 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\network\mcpe\protocol\serializer\ItemTypeDictionary; use pocketmine\network\mcpe\protocol\serializer\PacketSerializer; use pocketmine\network\mcpe\protocol\types\GameMode as ProtocolGameMode; @@ -52,11 +59,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 +206,85 @@ 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{ + if( + ($blockEntityInventoryTag = $tag->getTag(Container::TAG_ITEMS)) !== null && + $blockEntityInventoryTag instanceof ListTag && + $blockEntityInventoryTag->getTagType() === NBT::TAG_Compound && + $blockEntityInventoryTag->count() > 0 + ){ + $stripped = new ListTag(); + + /** @var CompoundTag $itemTag */ + 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 +293,7 @@ class TypeConverter{ if($nbt->count() === 0){ $nbt = null; }else{ - $nbt = clone $nbt; + $nbt = $this->cleanupUnnecessaryItemNBT($nbt); } $idMeta = $this->itemTranslator->toNetworkIdQuiet($itemStack); From 3999a1f9f439c16a81685ae461f20b1cf501461f Mon Sep 17 00:00:00 2001 From: Darya Markova <122279000+Dasciam@users.noreply.github.com> Date: Wed, 3 Sep 2025 09:53:32 +0300 Subject: [PATCH 05/17] Fix sneaking hitbox height (#6771) --- src/entity/Human.php | 4 ++++ src/entity/Living.php | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/entity/Human.php b/src/entity/Human.php index c94b76097..d2637a3f9 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. */ diff --git a/src/entity/Living.php b/src/entity/Living.php index 6d62c85d2..5ae1fd4e6 100644 --- a/src/entity/Living.php +++ b/src/entity/Living.php @@ -242,6 +242,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 +296,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())); } From 54e8ad2a9c397492b97a618f6af726a157123160 Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Wed, 10 Sep 2025 16:21:29 +0100 Subject: [PATCH 06/17] Update BedrockProtocol --- composer.json | 2 +- composer.lock | 14 ++++++------- src/network/mcpe/auth/ProcessLoginTask.php | 12 +++++------ .../mcpe/handler/LoginPacketHandler.php | 20 +++++++++---------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/composer.json b/composer.json index 17271955e..b4ca8de6b 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,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": "~41.0.0+bedrock-1.21.100", "pocketmine/binaryutils": "^0.2.1", "pocketmine/callback-validator": "^1.0.2", "pocketmine/color": "^0.3.0", diff --git a/composer.lock b/composer.lock index 330f002d7..9af788d2b 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": "27fee330bdcb6ea2373c57cdfb3bc22f", + "content-hash": "7bf7cd54642c2d65ecdfdcb28f3a64a8", "packages": [ { "name": "adhocore/json-comment", @@ -256,16 +256,16 @@ }, { "name": "pocketmine/bedrock-protocol", - "version": "40.0.0+bedrock-1.21.100", + "version": "41.0.0+bedrock-1.21.100", "source": { "type": "git", "url": "https://github.com/pmmp/BedrockProtocol.git", - "reference": "5e95cab3a6e6c24920e0c25ca4aaf887ed4b70ca" + "reference": "920ac291fe1b0143b2ebc90b3374ddab0b8531bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pmmp/BedrockProtocol/zipball/5e95cab3a6e6c24920e0c25ca4aaf887ed4b70ca", - "reference": "5e95cab3a6e6c24920e0c25ca4aaf887ed4b70ca", + "url": "https://api.github.com/repos/pmmp/BedrockProtocol/zipball/920ac291fe1b0143b2ebc90b3374ddab0b8531bf", + "reference": "920ac291fe1b0143b2ebc90b3374ddab0b8531bf", "shasum": "" }, "require": { @@ -296,9 +296,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/41.0.0+bedrock-1.21.100" }, - "time": "2025-08-06T15:13:45+00:00" + "time": "2025-09-09T20:52:18+00:00" }, { "name": "pocketmine/binaryutils", diff --git a/src/network/mcpe/auth/ProcessLoginTask.php b/src/network/mcpe/auth/ProcessLoginTask.php index b4c9e6d9c..218edc7a5 100644 --- a/src/network/mcpe/auth/ProcessLoginTask.php +++ b/src/network/mcpe/auth/ProcessLoginTask.php @@ -27,8 +27,8 @@ use pocketmine\lang\KnownTranslationFactory; use pocketmine\lang\Translatable; use pocketmine\network\mcpe\JwtException; use pocketmine\network\mcpe\JwtUtils; -use pocketmine\network\mcpe\protocol\types\login\JwtChainLinkBody; -use pocketmine\network\mcpe\protocol\types\login\JwtHeader; +use pocketmine\network\mcpe\protocol\types\login\legacy\LegacyAuthJwtBody; +use pocketmine\network\mcpe\protocol\types\login\SelfSignedJwtHeader; use pocketmine\scheduler\AsyncTask; use pocketmine\thread\NonThreadSafeValue; use function base64_decode; @@ -128,8 +128,8 @@ class ProcessLoginTask extends AsyncTask{ $mapper->bEnforceMapType = false; try{ - /** @var JwtHeader $headers */ - $headers = $mapper->map($headersArray, new JwtHeader()); + /** @var SelfSignedJwtHeader $headers */ + $headers = $mapper->map($headersArray, new SelfSignedJwtHeader()); }catch(\JsonMapper_Exception $e){ throw new VerifyLoginException("Invalid JWT header: " . $e->getMessage(), null, 0, $e); } @@ -172,8 +172,8 @@ class ProcessLoginTask extends AsyncTask{ $mapper->bEnforceMapType = false; $mapper->bRemoveUndefinedAttributes = true; try{ - /** @var JwtChainLinkBody $claims */ - $claims = $mapper->map($claimsArray, new JwtChainLinkBody()); + /** @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); } diff --git a/src/network/mcpe/handler/LoginPacketHandler.php b/src/network/mcpe/handler/LoginPacketHandler.php index 5c467f2d4..c664c4b9f 100644 --- a/src/network/mcpe/handler/LoginPacketHandler.php +++ b/src/network/mcpe/handler/LoginPacketHandler.php @@ -32,12 +32,12 @@ 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\PacketHandlingException; use pocketmine\player\Player; use pocketmine\player\PlayerInfo; @@ -180,7 +180,7 @@ class LoginPacketHandler extends PacketHandler{ /** * @throws PacketHandlingException */ - protected function parseJwtChain(string $chainDataJwt) : JwtChain{ + protected function parseJwtChain(string $chainDataJwt) : LegacyAuthChain{ try{ $jwtChainJson = json_decode($chainDataJwt, associative: false, flags: JSON_THROW_ON_ERROR); }catch(\JsonException $e){ @@ -195,7 +195,7 @@ class LoginPacketHandler extends PacketHandler{ $mapper->bExceptionOnUndefinedProperty = true; $mapper->bStrictObjectTypeChecking = true; try{ - $clientData = $mapper->map($jwtChainJson, new JwtChain()); + $clientData = $mapper->map($jwtChainJson, new LegacyAuthChain()); }catch(\JsonMapper_Exception $e){ throw PacketHandlingException::wrap($e); } @@ -205,8 +205,8 @@ class LoginPacketHandler extends PacketHandler{ /** * @throws PacketHandlingException */ - protected function fetchAuthData(JwtChain $chain) : AuthenticationData{ - /** @var AuthenticationData|null $extraData */ + protected function fetchAuthData(LegacyAuthChain $chain) : LegacyAuthIdentityData{ + /** @var LegacyAuthIdentityData|null $extraData */ $extraData = null; foreach($chain->chain as $jwt){ //validate every chain element @@ -229,8 +229,8 @@ class LoginPacketHandler extends PacketHandler{ $mapper->bExceptionOnUndefinedProperty = true; $mapper->bStrictObjectTypeChecking = true; try{ - /** @var AuthenticationData $extraData */ - $extraData = $mapper->map($claims["extraData"], new AuthenticationData()); + /** @var LegacyAuthIdentityData $extraData */ + $extraData = $mapper->map($claims["extraData"], new LegacyAuthIdentityData()); }catch(\JsonMapper_Exception $e){ throw PacketHandlingException::wrap($e); } From c854f2c76567d43b9a104f643c0d88b1e23d0033 Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Wed, 10 Sep 2025 20:06:35 +0100 Subject: [PATCH 07/17] ItemSerializer: avoid slow NBT check hasNamedTag() rebuilds the NBT from item properties to decide if there's any NBT. This is a waste of resources when we then fetch the tag to encode anyway. --- src/data/bedrock/item/ItemSerializer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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); From e47c189cb68d3c9b4014e0ed05c773458313fa2d Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Sun, 14 Sep 2025 18:01:33 +0100 Subject: [PATCH 08/17] Add support for private timings pasting --- src/command/defaults/TimingsCommand.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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()); From 6d2d23a210fd39fe8855318bfb504a834464a318 Mon Sep 17 00:00:00 2001 From: IvanCraft623 <57236932+IvanCraft623@users.noreply.github.com> Date: Thu, 18 Sep 2025 12:00:55 -0600 Subject: [PATCH 09/17] Implement Trident (#4547) Co-authored-by: Dylan T. --- .../ItemSerializerDeserializerRegistrar.php | 1 + src/entity/EntityFactory.php | 19 ++ src/entity/projectile/Projectile.php | 16 +- src/entity/projectile/Trident.php | 183 ++++++++++++++++++ src/event/player/PlayerDeathEvent.php | 11 +- src/item/ItemTypeIds.php | 3 +- src/item/StringToItemParser.php | 1 + src/item/Trident.php | 93 +++++++++ src/item/VanillaItems.php | 2 + src/world/sound/TridentHitEntitySound.php | 35 ++++ src/world/sound/TridentHitGroundSound.php | 35 ++++ src/world/sound/TridentThrowSound.php | 35 ++++ 12 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 src/entity/projectile/Trident.php create mode 100644 src/item/Trident.php create mode 100644 src/world/sound/TridentHitEntitySound.php create mode 100644 src/world/sound/TridentHitGroundSound.php create mode 100644 src/world/sound/TridentThrowSound.php diff --git a/src/data/bedrock/item/ItemSerializerDeserializerRegistrar.php b/src/data/bedrock/item/ItemSerializerDeserializerRegistrar.php index f5c03dbeb..c7e5d7020 100644 --- a/src/data/bedrock/item/ItemSerializerDeserializerRegistrar.php +++ b/src/data/bedrock/item/ItemSerializerDeserializerRegistrar.php @@ -403,6 +403,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()); 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/projectile/Projectile.php b/src/entity/projectile/Projectile.php index 68b6c4763..f8c8f45a5 100644 --- a/src/entity/projectile/Projectile.php +++ b/src/entity/projectile/Projectile.php @@ -227,12 +227,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 +298,9 @@ abstract class Projectile extends Entity{ } } - $this->flagForDespawn(); + if($this->despawnsOnEntityHit()){ + $this->flagForDespawn(); + } } /** @@ -305,4 +310,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..73b3880ac --- /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 TridentHitGroundSound()); + } + + 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/player/PlayerDeathEvent.php b/src/event/player/PlayerDeathEvent.php index aacff3438..ca4b46564 100644 --- a/src/event/player/PlayerDeathEvent.php +++ b/src/event/player/PlayerDeathEvent.php @@ -26,7 +26,9 @@ namespace pocketmine\event\player; use pocketmine\block\BlockTypeIds; use pocketmine\entity\Living; use pocketmine\entity\object\FallingBlock; +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 +115,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; diff --git a/src/item/ItemTypeIds.php b/src/item/ItemTypeIds.php index af32cbcc2..36fc2c65f 100644 --- a/src/item/ItemTypeIds.php +++ b/src/item/ItemTypeIds.php @@ -346,8 +346,9 @@ 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 FIRST_UNUSED_ITEM_ID = 20310; + public const FIRST_UNUSED_ITEM_ID = 20311; private static int $nextDynamicId = self::FIRST_UNUSED_ITEM_ID; diff --git a/src/item/StringToItemParser.php b/src/item/StringToItemParser.php index 2f316f66b..5e45ea25d 100644 --- a/src/item/StringToItemParser.php +++ b/src/item/StringToItemParser.php @@ -1560,6 +1560,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..48ae95c32 100644 --- a/src/item/VanillaItems.php +++ b/src/item/VanillaItems.php @@ -335,6 +335,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() @@ -630,6 +631,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/world/sound/TridentHitEntitySound.php b/src/world/sound/TridentHitEntitySound.php new file mode 100644 index 000000000..ea77a7404 --- /dev/null +++ b/src/world/sound/TridentHitEntitySound.php @@ -0,0 +1,35 @@ + Date: Fri, 19 Sep 2025 23:40:04 +0100 Subject: [PATCH 10/17] Updated NBT library to get new ListTag handling features --- composer.json | 2 +- composer.lock | 17 +++++----- src/block/tile/Banner.php | 3 +- src/block/tile/ChiseledBookshelf.php | 10 ++++-- src/block/tile/ContainerTrait.php | 10 ++++-- .../bedrock/item/upgrade/ItemDataUpgrader.php | 32 +++---------------- src/entity/EntityDataHelper.php | 13 +++----- src/entity/Human.php | 6 ++-- src/entity/Living.php | 3 +- src/entity/projectile/Projectile.php | 6 ++-- src/item/Banner.php | 6 ++-- src/item/Item.php | 20 +++++------- src/item/WritableBookBase.php | 11 +++---- src/network/mcpe/convert/TypeConverter.php | 14 ++++---- .../io/region/LegacyAnvilChunkTrait.php | 8 ++--- .../format/io/region/RegionWorldProvider.php | 17 ++-------- tools/generate-bedrock-data-from-packets.php | 9 ++---- 17 files changed, 72 insertions(+), 115 deletions(-) diff --git a/composer.json b/composer.json index d77203389..0f2ffe95f 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,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 fec0d3a29..ece3c5070 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": "9a4fa406f66a2f57be87c9ac955191b2", + "content-hash": "1e7545f6cc226b31d54238602143ba78", "packages": [ { "name": "adhocore/json-comment", @@ -576,16 +576,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 +595,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 +613,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", 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/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/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/Human.php b/src/entity/Human.php index d2637a3f9..97ebdefca 100644 --- a/src/entity/Human.php +++ b/src/entity/Human.php @@ -299,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 @@ -328,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 5ae1fd4e6..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)); diff --git a/src/entity/projectile/Projectile.php b/src/entity/projectile/Projectile.php index f8c8f45a5..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()); 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/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/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/convert/TypeConverter.php b/src/network/mcpe/convert/TypeConverter.php index a6520ce12..2a3a4e8f3 100644 --- a/src/network/mcpe/convert/TypeConverter.php +++ b/src/network/mcpe/convert/TypeConverter.php @@ -42,6 +42,7 @@ 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; @@ -225,15 +226,14 @@ class TypeConverter{ * We don't need to, and should not allow, sending nested inventories across the network. */ protected function stripContainedItemNonVisualNBT(CompoundTag $tag) : bool{ - if( - ($blockEntityInventoryTag = $tag->getTag(Container::TAG_ITEMS)) !== null && - $blockEntityInventoryTag instanceof ListTag && - $blockEntityInventoryTag->getTagType() === NBT::TAG_Compound && - $blockEntityInventoryTag->count() > 0 - ){ + try{ + $blockEntityInventoryTag = $tag->getListTag(Container::TAG_ITEMS, CompoundTag::class); + }catch(UnexpectedTagTypeException){ + return false; + } + if($blockEntityInventoryTag !== null && $blockEntityInventoryTag->count() > 0){ $stripped = new ListTag(); - /** @var CompoundTag $itemTag */ foreach($blockEntityInventoryTag as $itemTag){ try{ $containedItem = Item::nbtDeserialize($itemTag); 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/tools/generate-bedrock-data-from-packets.php b/tools/generate-bedrock-data-from-packets.php index f40029365..11b73e86d 100644 --- a/tools/generate-bedrock-data-from-packets.php +++ b/tools/generate-bedrock-data-from-packets.php @@ -37,10 +37,10 @@ use pocketmine\data\bedrock\item\BlockItemIdMap; use pocketmine\data\bedrock\item\ItemTypeNames; use pocketmine\inventory\json\CreativeGroupData; use pocketmine\nbt\LittleEndianNbtSerializer; -use pocketmine\nbt\NBT; use pocketmine\nbt\tag\CompoundTag; use pocketmine\nbt\tag\ListTag; use pocketmine\nbt\TreeRoot; +use pocketmine\nbt\UnexpectedTagTypeException; use pocketmine\network\mcpe\convert\BlockStateDictionary; use pocketmine\network\mcpe\convert\BlockTranslator; use pocketmine\network\mcpe\convert\ItemTranslator; @@ -554,8 +554,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 +565,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"); } From 1dea35026110f00248284997d21fd36ad8a40ff5 Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Fri, 19 Sep 2025 23:43:04 +0100 Subject: [PATCH 11/17] shut --- tools/generate-bedrock-data-from-packets.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/generate-bedrock-data-from-packets.php b/tools/generate-bedrock-data-from-packets.php index 11b73e86d..01ff368ab 100644 --- a/tools/generate-bedrock-data-from-packets.php +++ b/tools/generate-bedrock-data-from-packets.php @@ -40,7 +40,6 @@ use pocketmine\nbt\LittleEndianNbtSerializer; use pocketmine\nbt\tag\CompoundTag; use pocketmine\nbt\tag\ListTag; use pocketmine\nbt\TreeRoot; -use pocketmine\nbt\UnexpectedTagTypeException; use pocketmine\network\mcpe\convert\BlockStateDictionary; use pocketmine\network\mcpe\convert\BlockTranslator; use pocketmine\network\mcpe\convert\ItemTranslator; From dd9cbb74f0bfeac9203e11f681db535cee55a0ef Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Sat, 20 Sep 2025 00:01:16 +0100 Subject: [PATCH 12/17] Added NeverSavedWithChunkEntity interface closes #6809 turns out we can actually use this for players too. we need this also for fireworks and probably lightning in the future. --- src/entity/Entity.php | 2 +- src/entity/NeverSavedWithChunkEntity.php | 36 ++++++++++++++++++++++++ src/player/Player.php | 3 +- src/world/World.php | 3 +- 4 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 src/entity/NeverSavedWithChunkEntity.php 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/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 @@ +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"); From ebeee29a8842fdfdc5499e219a0ef395c0fa99ef Mon Sep 17 00:00:00 2001 From: IvanCraft623 <57236932+IvanCraft623@users.noreply.github.com> Date: Sat, 20 Sep 2025 11:04:05 -0600 Subject: [PATCH 13/17] Implement firework rocket & firework star (#5455) Co-authored-by: Dylan T Co-authored-by: ipad54 <63200545+ipad54@users.noreply.github.com> --- src/data/bedrock/FireworkRocketTypeIdMap.php | 45 ++++ src/data/bedrock/FireworkRocketTypeIds.php | 32 +++ .../ItemSerializerDeserializerRegistrar.php | 10 + .../animation/FireworkParticlesAnimation.php | 41 ++++ src/entity/object/FireworkRocket.php | 204 ++++++++++++++++++ src/event/player/PlayerDeathEvent.php | 5 +- src/item/FireworkRocket.php | 141 ++++++++++++ src/item/FireworkRocketExplosion.php | 190 ++++++++++++++++ src/item/FireworkRocketType.php | 46 ++++ src/item/FireworkStar.php | 112 ++++++++++ src/item/ItemTypeIds.php | 4 +- src/item/StringToItemParser.php | 3 + src/item/VanillaItems.php | 4 + src/world/sound/FireworkCrackleSound.php | 35 +++ src/world/sound/FireworkExplosionSound.php | 35 +++ .../sound/FireworkLargeExplosionSound.php | 35 +++ src/world/sound/FireworkLaunchSound.php | 35 +++ 17 files changed, 975 insertions(+), 2 deletions(-) create mode 100644 src/data/bedrock/FireworkRocketTypeIdMap.php create mode 100644 src/data/bedrock/FireworkRocketTypeIds.php create mode 100644 src/entity/animation/FireworkParticlesAnimation.php create mode 100644 src/entity/object/FireworkRocket.php create mode 100644 src/item/FireworkRocket.php create mode 100644 src/item/FireworkRocketExplosion.php create mode 100644 src/item/FireworkRocketType.php create mode 100644 src/item/FireworkStar.php create mode 100644 src/world/sound/FireworkCrackleSound.php create mode 100644 src/world/sound/FireworkExplosionSound.php create mode 100644 src/world/sound/FireworkLargeExplosionSound.php create mode 100644 src/world/sound/FireworkLaunchSound.php 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 @@ +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()); @@ -501,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/entity/animation/FireworkParticlesAnimation.php b/src/entity/animation/FireworkParticlesAnimation.php new file mode 100644 index 000000000..cdeb44f03 --- /dev/null +++ b/src/entity/animation/FireworkParticlesAnimation.php @@ -0,0 +1,41 @@ +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/event/player/PlayerDeathEvent.php b/src/event/player/PlayerDeathEvent.php index ca4b46564..4b02b4811 100644 --- a/src/event/player/PlayerDeathEvent.php +++ b/src/event/player/PlayerDeathEvent.php @@ -26,6 +26,7 @@ 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; @@ -164,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/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/ItemTypeIds.php b/src/item/ItemTypeIds.php index 36fc2c65f..3595d3afc 100644 --- a/src/item/ItemTypeIds.php +++ b/src/item/ItemTypeIds.php @@ -347,8 +347,10 @@ final class ItemTypeIds{ 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 = 20311; + 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 5e45ea25d..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()); diff --git a/src/item/VanillaItems.php b/src/item/VanillaItems.php index 48ae95c32..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() @@ -511,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")); 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 @@ + Date: Sat, 20 Sep 2025 22:40:38 +0100 Subject: [PATCH 14/17] Implement new OpenID authentication system (#6798) Co-authored-by: Dries C <15795262+dries-c@users.noreply.github.com> --- src/Server.php | 8 + src/network/mcpe/JwtUtils.php | 69 +++-- src/network/mcpe/auth/AuthJwtHelper.php | 165 ++++++++++++ src/network/mcpe/auth/AuthKeyProvider.php | 164 ++++++++++++ src/network/mcpe/auth/AuthKeyring.php | 45 ++++ src/network/mcpe/auth/FetchAuthKeysTask.php | 209 +++++++++++++++ .../mcpe/auth/ProcessLegacyLoginTask.php | 121 +++++++++ src/network/mcpe/auth/ProcessLoginTask.php | 213 ---------------- .../mcpe/auth/ProcessOpenIdLoginTask.php | 98 +++++++ .../mcpe/handler/LoginPacketHandler.php | 241 +++++++++++------- tests/phpstan/configs/actual-problems.neon | 6 - 11 files changed, 1012 insertions(+), 327 deletions(-) create mode 100644 src/network/mcpe/auth/AuthJwtHelper.php create mode 100644 src/network/mcpe/auth/AuthKeyProvider.php create mode 100644 src/network/mcpe/auth/AuthKeyring.php create mode 100644 src/network/mcpe/auth/FetchAuthKeysTask.php create mode 100644 src/network/mcpe/auth/ProcessLegacyLoginTask.php delete mode 100644 src/network/mcpe/auth/ProcessLoginTask.php create mode 100644 src/network/mcpe/auth/ProcessOpenIdLoginTask.php diff --git a/src/Server.php b/src/Server.php index d6f0a8415..4f0fa4ce1 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,10 @@ class Server{ return $this->forceLanguage; } + public function getAuthKeyProvider() : AuthKeyProvider{ + return $this->authKeyProvider; + } + public function getNetwork() : Network{ return $this->network; } diff --git a/src/network/mcpe/JwtUtils.php b/src/network/mcpe/JwtUtils.php index 987ed6e61..dfdfada83 100644 --- a/src/network/mcpe/JwtUtils.php +++ b/src/network/mcpe/JwtUtils.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace pocketmine\network\mcpe; use pocketmine\utils\AssumptionFailedError; +use pocketmine\utils\Binary; use pocketmine\utils\BinaryStream; use pocketmine\utils\Utils; use function base64_decode; @@ -32,6 +33,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 +56,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; @@ -170,17 +173,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 +241,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(Binary::writeInt($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/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 218edc7a5..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 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($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 LegacyAuthJwtBody $claims */ - $claims = $mapper->map($claimsArray, new LegacyAuthJwtBody()); - }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/handler/LoginPacketHandler.php b/src/network/mcpe/handler/LoginPacketHandler.php index c664c4b9f..aa7c1da7a 100644 --- a/src/network/mcpe/handler/LoginPacketHandler.php +++ b/src/network/mcpe/handler/LoginPacketHandler.php @@ -27,7 +27,8 @@ 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; @@ -38,16 +39,23 @@ 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) : LegacyAuthChain{ + 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 LegacyAuthChain()); + $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(LegacyAuthChain $chain) : LegacyAuthIdentityData{ - /** @var LegacyAuthIdentityData|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 LegacyAuthIdentityData $extraData */ - $extraData = $mapper->map($claims["extraData"], new LegacyAuthIdentityData()); - }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/tests/phpstan/configs/actual-problems.neon b/tests/phpstan/configs/actual-problems.neon index 76cebf283..fa513de78 100644 --- a/tests/phpstan/configs/actual-problems.neon +++ b/tests/phpstan/configs/actual-problems.neon @@ -870,12 +870,6 @@ parameters: count: 1 path: ../../../src/network/mcpe/NetworkSession.php - - - message: '#^Property pocketmine\\network\\mcpe\\auth\\ProcessLoginTask\:\:\$chain \(string\) does not accept string\|null\.$#' - identifier: assign.propertyType - count: 1 - path: ../../../src/network/mcpe/auth/ProcessLoginTask.php - - message: '#^Parameter \#1 \$result of method pocketmine\\network\\mcpe\\compression\\CompressBatchPromise\:\:resolve\(\) expects string, mixed given\.$#' identifier: argument.type From 3336cda34ad7f26af1d4511043d6b3badfbc8053 Mon Sep 17 00:00:00 2001 From: "Dylan T." Date: Thu, 25 Sep 2025 17:32:38 +0100 Subject: [PATCH 15/17] First pass ext-encoding support (high-level network I/O and read-only data) (#6799) This implements ext-encoding only in high-level network I/O (only BedrockProtocol and stuff implemented in PM) and read-only data. This should net a significant performance advantage while being low-risk in the case of critical issues with the extension. Any problems affecting protocol won't do permanent damage while being fairly easy to debug. Next passes will integrate ext-encoding versions of RakLib, RakLibIpc and NBT, as well as generally using ext-encoding for writeable data. --- composer.json | 3 +- composer.lock | 16 ++--- src/PocketMine.php | 7 +++ src/block/Block.php | 6 +- src/crafting/CraftingManager.php | 13 ++-- .../block/upgrade/BlockIdMetaUpgrader.php | 21 ++++--- src/network/mcpe/ChunkRequestTask.php | 6 +- src/network/mcpe/JwtUtils.php | 19 +++--- src/network/mcpe/NetworkSession.php | 32 +++++----- .../mcpe/StandardPacketBroadcaster.php | 11 ++-- src/network/mcpe/cache/CraftingDataCache.php | 6 +- src/network/mcpe/convert/TypeConverter.php | 9 +-- .../mcpe/encryption/EncryptionContext.php | 4 +- .../mcpe/serializer/ChunkSerializer.php | 62 +++++++++---------- src/network/query/QueryHandler.php | 48 +++++++------- src/network/query/QueryInfo.php | 4 +- src/world/format/io/FastChunkSerializer.php | 55 ++++++++-------- tools/generate-bedrock-data-from-packets.php | 11 ++-- 18 files changed, 177 insertions(+), 156 deletions(-) diff --git a/composer.json b/composer.json index 0f2ffe95f..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": "~41.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", diff --git a/composer.lock b/composer.lock index ece3c5070..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": "1e7545f6cc226b31d54238602143ba78", + "content-hash": "0d71d3fba23ba8c4734cac59b9e10129", "packages": [ { "name": "adhocore/json-comment", @@ -256,19 +256,20 @@ }, { "name": "pocketmine/bedrock-protocol", - "version": "41.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": "920ac291fe1b0143b2ebc90b3374ddab0b8531bf" + "reference": "2d7aa27a5537ae593fb1c39158648ea462fef72a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pmmp/BedrockProtocol/zipball/920ac291fe1b0143b2ebc90b3374ddab0b8531bf", - "reference": "920ac291fe1b0143b2ebc90b3374ddab0b8531bf", + "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/41.0.0+bedrock-1.21.100" + "source": "https://github.com/pmmp/BedrockProtocol/tree/50.0.0+bedrock-1.21.100" }, - "time": "2025-09-09T20:52:18+00:00" + "time": "2025-09-20T23:09:19+00:00" }, { "name": "pocketmine/binaryutils", @@ -2797,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/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/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/block/upgrade/BlockIdMetaUpgrader.php b/src/data/bedrock/block/upgrade/BlockIdMetaUpgrader.php index 1c339bd46..379784afb 100644 --- a/src/data/bedrock/block/upgrade/BlockIdMetaUpgrader.php +++ b/src/data/bedrock/block/upgrade/BlockIdMetaUpgrader.php @@ -23,11 +23,12 @@ declare(strict_types=1); namespace pocketmine\data\bedrock\block\upgrade; +use pmmp\encoding\ByteBufferReader; +use pmmp\encoding\DataDecodeException; +use pmmp\encoding\VarInt; use pocketmine\data\bedrock\block\BlockStateData; use pocketmine\data\bedrock\block\BlockStateDeserializeException; use pocketmine\nbt\LittleEndianNbtSerializer; -use pocketmine\utils\BinaryDataException; -use pocketmine\utils\BinaryStream; /** * Handles translating legacy 1.12 block ID/meta into modern blockstates. @@ -84,25 +85,25 @@ final class BlockIdMetaUpgrader{ public static function loadFromString(string $data, LegacyBlockIdToStringIdMap $idMap, BlockStateUpgrader $blockStateUpgrader) : self{ $mappingTable = []; - $legacyStateMapReader = new BinaryStream($data); + $legacyStateMapReader = new ByteBufferReader($data); $nbtReader = new LittleEndianNbtSerializer(); - $idCount = $legacyStateMapReader->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/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 dfdfada83..8c25ee042 100644 --- a/src/network/mcpe/JwtUtils.php +++ b/src/network/mcpe/JwtUtils.php @@ -23,9 +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\Binary; -use pocketmine\utils\BinaryStream; use pocketmine\utils\Utils; use function base64_decode; use function base64_encode; @@ -133,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); } @@ -159,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"); } @@ -250,7 +251,7 @@ final class JwtUtils{ return chr($length); } - $lengthBytes = ltrim(Binary::writeInt($length), "\x00"); + $lengthBytes = ltrim(BE::packUnsignedInt($length), "\x00"); return chr(0x80 | strlen($lengthBytes)) . $lengthBytes; } 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/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 2a3a4e8f3..9f946ac33 100644 --- a/src/network/mcpe/convert/TypeConverter.php +++ b/src/network/mcpe/convert/TypeConverter.php @@ -23,6 +23,8 @@ 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; @@ -44,7 +46,6 @@ 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; @@ -312,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( @@ -320,7 +321,7 @@ class TypeConverter{ $meta, $itemStack->getCount(), $blockRuntimeId ?? ItemTranslator::NO_BLOCK_RUNTIME_ID, - $extraDataSerializer->getBuffer(), + $extraDataSerializer->getData(), ); } @@ -359,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/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/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/tools/generate-bedrock-data-from-packets.php b/tools/generate-bedrock-data-from-packets.php index 01ff368ab..47411701a 100644 --- a/tools/generate-bedrock-data-from-packets.php +++ b/tools/generate-bedrock-data-from-packets.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace pocketmine\tools\generate_bedrock_data_from_packets; +use pmmp\encoding\ByteBufferReader; use pocketmine\crafting\json\FurnaceRecipeData; use pocketmine\crafting\json\ItemStackData; use pocketmine\crafting\json\PotionContainerChangeRecipeData; @@ -51,7 +52,6 @@ use pocketmine\network\mcpe\protocol\CreativeContentPacket; use pocketmine\network\mcpe\protocol\ItemRegistryPacket; use pocketmine\network\mcpe\protocol\PacketPool; use pocketmine\network\mcpe\protocol\serializer\ItemTypeDictionary; -use pocketmine\network\mcpe\protocol\serializer\PacketSerializer; use pocketmine\network\mcpe\protocol\StartGamePacket; use pocketmine\network\mcpe\protocol\types\inventory\CreativeGroupEntry; use pocketmine\network\mcpe\protocol\types\inventory\ItemStack; @@ -190,7 +190,7 @@ class ParserPacketHandler extends PacketHandler{ $rawExtraData = $itemStack->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){ @@ -645,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; From 5ca8e1502720c8d71bf837688ad0fcb9e773a0b4 Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Thu, 25 Sep 2025 18:18:10 +0100 Subject: [PATCH 16/17] tidy --- src/Server.php | 3 +++ src/entity/projectile/Trident.php | 4 ++-- .../{TridentHitGroundSound.php => TridentHitBlockSound.php} | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) rename src/world/sound/{TridentHitGroundSound.php => TridentHitBlockSound.php} (95%) diff --git a/src/Server.php b/src/Server.php index 4f0fa4ce1..af9cbeda7 100644 --- a/src/Server.php +++ b/src/Server.php @@ -1804,6 +1804,9 @@ class Server{ return $this->forceLanguage; } + /** + * @internal + */ public function getAuthKeyProvider() : AuthKeyProvider{ return $this->authKeyProvider; } diff --git a/src/entity/projectile/Trident.php b/src/entity/projectile/Trident.php index 73b3880ac..20a494e8d 100644 --- a/src/entity/projectile/Trident.php +++ b/src/entity/projectile/Trident.php @@ -38,8 +38,8 @@ use pocketmine\network\mcpe\protocol\types\entity\EntityIds; use pocketmine\network\mcpe\protocol\types\entity\EntityMetadataCollection; use pocketmine\network\mcpe\protocol\types\entity\EntityMetadataFlags; use pocketmine\player\Player; +use pocketmine\world\sound\TridentHitBlockSound; use pocketmine\world\sound\TridentHitEntitySound; -use pocketmine\world\sound\TridentHitGroundSound; class Trident extends Projectile{ @@ -119,7 +119,7 @@ class Trident extends Projectile{ protected function onHitBlock(Block $blockHit, RayTraceResult $hitResult) : void{ parent::onHitBlock($blockHit, $hitResult); $this->canCollide = true; - $this->broadcastSound(new TridentHitGroundSound()); + $this->broadcastSound(new TridentHitBlockSound()); } public function getItem() : Item{ diff --git a/src/world/sound/TridentHitGroundSound.php b/src/world/sound/TridentHitBlockSound.php similarity index 95% rename from src/world/sound/TridentHitGroundSound.php rename to src/world/sound/TridentHitBlockSound.php index 361381786..05cd34248 100644 --- a/src/world/sound/TridentHitGroundSound.php +++ b/src/world/sound/TridentHitBlockSound.php @@ -27,7 +27,7 @@ use pocketmine\math\Vector3; use pocketmine\network\mcpe\protocol\LevelSoundEventPacket; use pocketmine\network\mcpe\protocol\types\LevelSoundEvent; -class TridentHitGroundSound implements Sound{ +class TridentHitBlockSound implements Sound{ public function encode(Vector3 $pos) : array{ return [LevelSoundEventPacket::nonActorSound(LevelSoundEvent::ITEM_TRIDENT_HIT_GROUND, $pos, false)]; From 236fa062f04cfac3a54a9af5032dfd1a7fd2a454 Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Thu, 25 Sep 2025 18:20:23 +0100 Subject: [PATCH 17/17] Prepare 5.34.0 release --- changelogs/5.34.md | 104 ++++++++++++++++++++++++++++++++++++++++++++ src/VersionInfo.php | 4 +- 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 changelogs/5.34.md 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/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"; /**