diff --git a/src/crafting/CraftingManager.php b/src/crafting/CraftingManager.php index 21abac424..4f34b291b 100644 --- a/src/crafting/CraftingManager.php +++ b/src/crafting/CraftingManager.php @@ -46,6 +46,12 @@ class CraftingManager{ */ protected array $shapelessRecipes = []; + /** + * @var CraftingRecipe[] + * @phpstan-var array + */ + private array $craftingRecipeIndex = []; + /** * @var FurnaceRecipeManager[] * @phpstan-var array @@ -159,6 +165,14 @@ class CraftingManager{ return $this->shapedRecipes; } + /** + * @return CraftingRecipe[] + * @phpstan-return array + */ + public function getCraftingRecipeIndex() : array{ + return $this->craftingRecipeIndex; + } + public function getFurnaceRecipeManager(FurnaceType $furnaceType) : FurnaceRecipeManager{ return $this->furnaceRecipeManagers[$furnaceType->id()]; } @@ -181,6 +195,7 @@ class CraftingManager{ public function registerShapedRecipe(ShapedRecipe $recipe) : void{ $this->shapedRecipes[self::hashOutputs($recipe->getResults())][] = $recipe; + $this->craftingRecipeIndex[] = $recipe; foreach($this->recipeRegisteredCallbacks as $callback){ $callback(); @@ -189,6 +204,7 @@ class CraftingManager{ public function registerShapelessRecipe(ShapelessRecipe $recipe) : void{ $this->shapelessRecipes[self::hashOutputs($recipe->getResults())][] = $recipe; + $this->craftingRecipeIndex[] = $recipe; foreach($this->recipeRegisteredCallbacks as $callback){ $callback(); diff --git a/src/inventory/BaseInventory.php b/src/inventory/BaseInventory.php index 1b13935bc..764659353 100644 --- a/src/inventory/BaseInventory.php +++ b/src/inventory/BaseInventory.php @@ -343,7 +343,7 @@ abstract class BaseInventory implements Inventory{ if($invManager === null){ continue; } - $invManager->syncSlot($this, $index); + $invManager->onSlotChange($this, $index); } } diff --git a/src/inventory/transaction/CraftingTransaction.php b/src/inventory/transaction/CraftingTransaction.php index a5b60a12f..2ae231b6e 100644 --- a/src/inventory/transaction/CraftingTransaction.php +++ b/src/inventory/transaction/CraftingTransaction.php @@ -64,9 +64,11 @@ class CraftingTransaction extends InventoryTransaction{ private CraftingManager $craftingManager; - public function __construct(Player $source, CraftingManager $craftingManager, array $actions = []){ + public function __construct(Player $source, CraftingManager $craftingManager, array $actions = [], ?CraftingRecipe $recipe = null, ?int $repetitions = null){ parent::__construct($source, $actions); $this->craftingManager = $craftingManager; + $this->recipe = $recipe; + $this->repetitions = $repetitions; } /** @@ -225,6 +227,18 @@ class CraftingTransaction extends InventoryTransaction{ return $iterations; } + private function validateRecipe(CraftingRecipe $recipe, ?int $expectedRepetitions) : int{ + //compute number of times recipe was crafted + $repetitions = $this->matchOutputs($this->outputs, $recipe->getResultsFor($this->source->getCraftingGrid())); + if($expectedRepetitions !== null && $repetitions !== $expectedRepetitions){ + throw new TransactionValidationException("Expected $expectedRepetitions repetitions, got $repetitions"); + } + //assert that $repetitions x recipe ingredients should be consumed + self::matchIngredients($this->inputs, $recipe->getIngredientList(), $repetitions); + + return $repetitions; + } + public function validate() : void{ $this->squashDuplicateSlotChanges(); if(count($this->actions) < 1){ @@ -233,25 +247,29 @@ class CraftingTransaction extends InventoryTransaction{ $this->matchItems($this->outputs, $this->inputs); - $failed = 0; - foreach($this->craftingManager->matchRecipeByOutputs($this->outputs) as $recipe){ - try{ - //compute number of times recipe was crafted - $this->repetitions = $this->matchOutputs($this->outputs, $recipe->getResultsFor($this->source->getCraftingGrid())); - //assert that $repetitions x recipe ingredients should be consumed - self::matchIngredients($this->inputs, $recipe->getIngredientList(), $this->repetitions); - - //Success! - $this->recipe = $recipe; - break; - }catch(TransactionValidationException $e){ - //failed - ++$failed; - } - } - if($this->recipe === null){ - throw new TransactionValidationException("Unable to match a recipe to transaction (tried to match against $failed recipes)"); + $failed = 0; + foreach($this->craftingManager->matchRecipeByOutputs($this->outputs) as $recipe){ + try{ + //compute number of times recipe was crafted + $this->repetitions = $this->matchOutputs($this->outputs, $recipe->getResultsFor($this->source->getCraftingGrid())); + //assert that $repetitions x recipe ingredients should be consumed + self::matchIngredients($this->inputs, $recipe->getIngredientList(), $this->repetitions); + + //Success! + $this->recipe = $recipe; + break; + }catch(TransactionValidationException $e){ + //failed + ++$failed; + } + } + + if($this->recipe === null){ + throw new TransactionValidationException("Unable to match a recipe to transaction (tried to match against $failed recipes)"); + } + }else{ + $this->repetitions = $this->validateRecipe($this->recipe, $this->repetitions); } } diff --git a/src/network/mcpe/InventoryManager.php b/src/network/mcpe/InventoryManager.php index d6515c091..f4fa76482 100644 --- a/src/network/mcpe/InventoryManager.php +++ b/src/network/mcpe/InventoryManager.php @@ -40,7 +40,6 @@ use pocketmine\inventory\Inventory; use pocketmine\inventory\transaction\action\SlotChangeAction; use pocketmine\inventory\transaction\InventoryTransaction; use pocketmine\item\Item; -use pocketmine\network\mcpe\convert\TypeConversionException; use pocketmine\network\mcpe\convert\TypeConverter; use pocketmine\network\mcpe\protocol\ClientboundPacket; use pocketmine\network\mcpe\protocol\ContainerClosePacket; @@ -53,6 +52,7 @@ use pocketmine\network\mcpe\protocol\MobEquipmentPacket; use pocketmine\network\mcpe\protocol\types\BlockPosition; use pocketmine\network\mcpe\protocol\types\inventory\ContainerIds; use pocketmine\network\mcpe\protocol\types\inventory\CreativeContentEntry; +use pocketmine\network\mcpe\protocol\types\inventory\ItemStack; use pocketmine\network\mcpe\protocol\types\inventory\ItemStackWrapper; use pocketmine\network\mcpe\protocol\types\inventory\NetworkInventoryAction; use pocketmine\network\mcpe\protocol\types\inventory\UIInventorySlotOffset; @@ -61,7 +61,6 @@ use pocketmine\network\PacketHandlingException; use pocketmine\player\Player; use pocketmine\utils\AssumptionFailedError; use pocketmine\utils\ObjectSet; -use function array_map; use function array_search; use function get_class; use function is_int; @@ -89,7 +88,7 @@ class InventoryManager{ /** * @var Item[][] - * @phpstan-var array> + * @phpstan-var array */ private array $initiatedSlotChanges = []; private int $clientSelectedHotbarSlot = -1; @@ -101,6 +100,15 @@ class InventoryManager{ /** @phpstan-var \Closure() : void */ private ?\Closure $pendingOpenWindowCallback = null; + private int $nextItemStackId = 1; + private ?int $currentItemStackRequestId = null; + + /** + * @var int[][] + * @phpstan-var array> + */ + private array $itemStackInfos = []; + public function __construct( private Player $player, private NetworkSession $session @@ -143,11 +151,14 @@ class InventoryManager{ private function remove(int $id) : void{ $inventory = $this->windowMap[$id]; - $splObjectId = spl_object_id($inventory); - unset($this->windowMap[$id], $this->initiatedSlotChanges[$id], $this->complexWindows[$splObjectId]); - foreach($this->complexSlotToWindowMap as $netSlot => $entry){ - if($entry->getInventory() === $inventory){ - unset($this->complexSlotToWindowMap[$netSlot]); + unset($this->windowMap[$id]); + if($this->getWindowId($inventory) === null){ + $splObjectId = spl_object_id($inventory); + unset($this->initiatedSlotChanges[$splObjectId], $this->itemStackInfos[$splObjectId], $this->complexWindows[$splObjectId]); + foreach($this->complexSlotToWindowMap as $netSlot => $entry){ + if($entry->getInventory() === $inventory){ + unset($this->complexSlotToWindowMap[$netSlot]); + } } } } @@ -161,7 +172,7 @@ class InventoryManager{ } /** - * @phpstan-return array{Inventory, int} + * @phpstan-return array{Inventory, int}|null */ public function locateWindowAndSlot(int $windowId, int $netSlotId) : ?array{ if($windowId === ContainerIds::UI){ @@ -178,11 +189,17 @@ class InventoryManager{ return null; } - public function onTransactionStart(InventoryTransaction $tx) : void{ + private function addPredictedSlotChange(Inventory $inventory, int $slot, ItemStack $item) : void{ + $predictions = ($this->initiatedSlotChanges[spl_object_id($inventory)] ??= new InventoryManagerPredictedChanges($inventory)); + $predictions->add($slot, $item); + } + + public function addTransactionPredictedSlotChanges(InventoryTransaction $tx) : void{ foreach($tx->getActions() as $action){ - if($action instanceof SlotChangeAction && ($windowId = $this->getWindowId($action->getInventory())) !== null){ - //in some cases the inventory might not have a window ID, but still be referenced by a transaction (e.g. crafting grid changes), so we can't unconditionally record the change here or we might leak things - $this->initiatedSlotChanges[$windowId][$action->getSlot()] = $action->getTargetItem(); + if($action instanceof SlotChangeAction){ + //TODO: ItemStackRequestExecutor can probably build these predictions with much lower overhead + $itemStack = TypeConverter::getInstance()->coreItemStackToNet($action->getTargetItem()); + $this->addPredictedSlotChange($action->getInventory(), $action->getSlot(), $itemStack); } } } @@ -191,22 +208,34 @@ class InventoryManager{ * @param NetworkInventoryAction[] $networkInventoryActions * @throws PacketHandlingException */ - public function addPredictedSlotChanges(array $networkInventoryActions) : void{ + public function addRawPredictedSlotChanges(array $networkInventoryActions) : void{ foreach($networkInventoryActions as $action){ - if($action->sourceType === NetworkInventoryAction::SOURCE_CONTAINER && ( - isset($this->windowMap[$action->windowId]) || - ($action->windowId === ContainerIds::UI && isset($this->complexSlotToWindowMap[$action->inventorySlot])) - )){ - try{ - $item = TypeConverter::getInstance()->netItemStackToCore($action->newItem->getItemStack()); - }catch(TypeConversionException $e){ - throw new PacketHandlingException($e->getMessage(), 0, $e); - } - $this->initiatedSlotChanges[$action->windowId][$action->inventorySlot] = $item; + if($action->sourceType !== NetworkInventoryAction::SOURCE_CONTAINER){ + continue; } + + //legacy transactions should not modify or predict anything other than these inventories, since these are + //the only ones accessible when not in-game (ItemStackRequest is used for everything else) + if(match($action->windowId){ + ContainerIds::INVENTORY, ContainerIds::OFFHAND, ContainerIds::ARMOR => false, + default => true + }){ + throw new PacketHandlingException("Legacy transactions cannot predict changes to inventory with ID " . $action->windowId); + } + $info = $this->locateWindowAndSlot($action->windowId, $action->inventorySlot); + if($info === null){ + continue; + } + + [$inventory, $slot] = $info; + $this->addPredictedSlotChange($inventory, $slot, $action->newItem->getItemStack()); } } + public function setCurrentItemStackRequestId(?int $id) : void{ + $this->currentItemStackRequestId = $id; + } + /** * When the server initiates a window close, it does so by sending a ContainerClose to the client, which causes the * client to behave as if it initiated the close itself. It responds by sending a ContainerClose back to the server, @@ -355,33 +384,63 @@ class InventoryManager{ } } + public function onSlotChange(Inventory $inventory, int $slot) : void{ + $currentItem = TypeConverter::getInstance()->coreItemStackToNet($inventory->getItem($slot)); + $predictions = $this->initiatedSlotChanges[spl_object_id($inventory)] ?? null; + $clientSideItem = $predictions?->getSlot($slot); + if($clientSideItem === null || !$clientSideItem->equals($currentItem)){ + //no prediction or incorrect - do not associate this with the currently active itemstack request + $this->trackItemStack($inventory, $slot, $currentItem, null); + $this->syncSlot($inventory, $slot); + }else{ + //correctly predicted - associate the change with the currently active itemstack request + $this->trackItemStack($inventory, $slot, $currentItem, $this->currentItemStackRequestId); + } + $predictions?->remove($slot); + } + public function syncSlot(Inventory $inventory, int $slot) : void{ + $itemStackInfo = $this->getItemStackInfo($inventory, $slot); + if($itemStackInfo === null){ + throw new \LogicException("Cannot sync an untracked inventory slot"); + } $slotMap = $this->complexWindows[spl_object_id($inventory)] ?? null; if($slotMap !== null){ $windowId = ContainerIds::UI; - $netSlot = $slotMap->mapCoreToNet($slot) ?? null; + $netSlot = $slotMap->mapCoreToNet($slot) ?? throw new AssumptionFailedError("We already have an ItemStackInfo, so this should not be null"); }else{ - $windowId = $this->getWindowId($inventory); + $windowId = $this->getWindowId($inventory) ?? throw new AssumptionFailedError("We already have an ItemStackInfo, so this should not be null"); $netSlot = $slot; } - if($windowId !== null && $netSlot !== null){ - $currentItem = $inventory->getItem($slot); - $clientSideItem = $this->initiatedSlotChanges[$windowId][$netSlot] ?? null; - if($clientSideItem === null || !$clientSideItem->equalsExact($currentItem)){ - $itemStackWrapper = ItemStackWrapper::legacy(TypeConverter::getInstance()->coreItemStackToNet($currentItem)); - if($windowId === ContainerIds::OFFHAND){ - //TODO: HACK! - //The client may sometimes ignore the InventorySlotPacket for the offhand slot. - //This can cause a lot of problems (totems, arrows, and more...). - //The workaround is to send an InventoryContentPacket instead - //BDS (Bedrock Dedicated Server) also seems to work this way. - $this->session->sendDataPacket(InventoryContentPacket::create($windowId, [$itemStackWrapper])); - }else{ - $this->session->sendDataPacket(InventorySlotPacket::create($windowId, $netSlot, $itemStackWrapper)); - } + + $itemStackWrapper = new ItemStackWrapper($itemStackInfo->getStackId(), $itemStackInfo->getItemStack()); + if($windowId === ContainerIds::OFFHAND){ + //TODO: HACK! + //The client may sometimes ignore the InventorySlotPacket for the offhand slot. + //This can cause a lot of problems (totems, arrows, and more...). + //The workaround is to send an InventoryContentPacket instead + //BDS (Bedrock Dedicated Server) also seems to work this way. + $this->session->sendDataPacket(InventoryContentPacket::create($windowId, [$itemStackWrapper])); + }else{ + if($this->currentItemStackRequestId !== null){ + //TODO: HACK! + //When right-clicking to equip armour, the client predicts the content of the armour slot, but + //doesn't report it in the transaction packet. The server then sends an InventorySlotPacket to + //the client, assuming the slot changed for some other reason, since there is no prediction for + //the slot. + //However, later requests involving that itemstack will refer to the request ID in which the + //armour was equipped, instead of the stack ID provided by the server in the outgoing + //InventorySlotPacket. (Perhaps because the item is already the same as the client actually + //predicted, but didn't tell us?) + //We work around this bug by setting the slot to air and then back to the correct item. In + //theory, setting a different count and then back again (or changing any other property) would + //also work, but this is simpler. + $this->session->sendDataPacket(InventorySlotPacket::create($windowId, $netSlot, new ItemStackWrapper(0, ItemStack::null()))); } - unset($this->initiatedSlotChanges[$windowId][$netSlot]); + $this->session->sendDataPacket(InventorySlotPacket::create($windowId, $netSlot, $itemStackWrapper)); } + $predictions = $this->initiatedSlotChanges[spl_object_id($inventory)] ?? null; + $predictions?->remove($slot); } public function syncContents(Inventory $inventory) : void{ @@ -391,26 +450,28 @@ class InventoryManager{ }else{ $windowId = $this->getWindowId($inventory); } - $typeConverter = TypeConverter::getInstance(); if($windowId !== null){ + unset($this->initiatedSlotChanges[spl_object_id($inventory)]); + $contents = []; + foreach($inventory->getContents(true) as $slot => $item){ + $itemStack = TypeConverter::getInstance()->coreItemStackToNet($item); + $info = $this->trackItemStack($inventory, $slot, $itemStack, null); + $contents[] = new ItemStackWrapper($info->getStackId(), $info->getItemStack()); + } if($slotMap !== null){ - foreach($inventory->getContents(true) as $slotId => $item){ + foreach($contents as $slotId => $info){ $packetSlot = $slotMap->mapCoreToNet($slotId) ?? null; if($packetSlot === null){ continue; } - unset($this->initiatedSlotChanges[$windowId][$packetSlot]); $this->session->sendDataPacket(InventorySlotPacket::create( $windowId, $packetSlot, - ItemStackWrapper::legacy($typeConverter->coreItemStackToNet($inventory->getItem($slotId))) + $info )); } }else{ - unset($this->initiatedSlotChanges[$windowId]); - $this->session->sendDataPacket(InventoryContentPacket::create($windowId, array_map(function(Item $itemStack) use ($typeConverter) : ItemStackWrapper{ - return ItemStackWrapper::legacy($typeConverter->coreItemStackToNet($itemStack)); - }, $inventory->getContents(true)))); + $this->session->sendDataPacket(InventoryContentPacket::create($windowId, $contents)); } } } @@ -425,22 +486,16 @@ class InventoryManager{ } public function syncMismatchedPredictedSlotChanges() : void{ - foreach($this->initiatedSlotChanges as $windowId => $slots){ - foreach($slots as $netSlot => $expectedItem){ - $located = $this->locateWindowAndSlot($windowId, $netSlot); - if($located === null){ - continue; - } - [$inventory, $slot] = $located; - - if(!$inventory->slotExists($slot)){ + foreach($this->initiatedSlotChanges as $predictions){ + $inventory = $predictions->getInventory(); + foreach($predictions->getSlots() as $slot => $expectedItem){ + if(!$inventory->slotExists($slot) || $this->getItemStackInfo($inventory, $slot) === null){ continue; //TODO: size desync ??? } - $actualItem = $inventory->getItem($slot); - if(!$actualItem->equalsExact($expectedItem)){ - $this->session->getLogger()->debug("Detected prediction mismatch in inventory " . get_class($inventory) . "#" . spl_object_id($inventory) . " slot $slot"); - $this->syncSlot($inventory, $slot); - } + + //any prediction that still exists at this point is a slot that was predicted to change but didn't + $this->session->getLogger()->debug("Detected prediction mismatch in inventory " . get_class($inventory) . "#" . spl_object_id($inventory) . " slot $slot"); + $this->syncSlot($inventory, $slot); } } @@ -459,11 +514,14 @@ class InventoryManager{ } public function syncSelectedHotbarSlot() : void{ - $selected = $this->player->getInventory()->getHeldItemIndex(); + $playerInventory = $this->player->getInventory(); + $selected = $playerInventory->getHeldItemIndex(); if($selected !== $this->clientSelectedHotbarSlot){ + $itemStackInfo = $this->itemStackInfos[spl_object_id($playerInventory)][$selected]; + $this->session->sendDataPacket(MobEquipmentPacket::create( $this->player->getId(), - ItemStackWrapper::legacy(TypeConverter::getInstance()->coreItemStackToNet($this->player->getInventory()->getItemInHand())), + new ItemStackWrapper($itemStackInfo->getStackId(), $itemStackInfo->getItemStack()), $selected, $selected, ContainerIds::INVENTORY @@ -475,9 +533,55 @@ class InventoryManager{ public function syncCreative() : void{ $typeConverter = TypeConverter::getInstance(); - $nextEntryId = 1; - $this->session->sendDataPacket(CreativeContentPacket::create(array_map(function(Item $item) use($typeConverter, &$nextEntryId) : CreativeContentEntry{ - return new CreativeContentEntry($nextEntryId++, $typeConverter->coreItemStackToNet($item)); - }, $this->player->isSpectator() ? [] : CreativeInventory::getInstance()->getAll()))); + $entries = []; + if(!$this->player->isSpectator()){ + //creative inventory may have holes if items were unregistered - ensure network IDs used are always consistent + foreach(CreativeInventory::getInstance()->getAll() as $k => $item){ + $entries[] = new CreativeContentEntry($k, $typeConverter->coreItemStackToNet($item)); + } + } + $this->session->sendDataPacket(CreativeContentPacket::create($entries)); + } + + private function newItemStackId() : int{ + return $this->nextItemStackId++; + } + + public function getItemStackInfo(Inventory $inventory, int $slot) : ?ItemStackInfo{ + return $this->itemStackInfos[spl_object_id($inventory)][$slot] ?? null; + } + + private function trackItemStack(Inventory $inventory, int $slotId, ItemStack $itemStack, ?int $itemStackRequestId) : ItemStackInfo{ + $existing = $this->itemStackInfos[spl_object_id($inventory)][$slotId] ?? null; + if($existing !== null && $existing->getItemStack()->equals($itemStack) && $existing->getRequestId() === $itemStackRequestId){ + return $existing; + } + + //TODO: ItemStack->isNull() would be nice to have here + $info = new ItemStackInfo($itemStackRequestId, $itemStack->getId() === 0 ? 0 : $this->newItemStackId(), $itemStack); + return $this->itemStackInfos[spl_object_id($inventory)][$slotId] = $info; + } + + public function matchItemStack(Inventory $inventory, int $slotId, int $clientItemStackId) : bool{ + $inventoryObjectId = spl_object_id($inventory); + if(!isset($this->itemStackInfos[$inventoryObjectId])){ + $this->session->getLogger()->debug("Attempted to match item preimage unsynced inventory " . get_class($inventory) . "#" . $inventoryObjectId); + return false; + } + $info = $this->itemStackInfos[$inventoryObjectId][$slotId] ?? null; + if($info === null){ + $this->session->getLogger()->debug("Attempted to match item preimage for unsynced slot $slotId in " . get_class($inventory) . "#$inventoryObjectId that isn't synced"); + return false; + } + + if(!($clientItemStackId < 0 ? $info->getRequestId() === $clientItemStackId : $info->getStackId() === $clientItemStackId)){ + $this->session->getLogger()->debug( + "Mismatched expected itemstack: " . get_class($inventory) . "#" . $inventoryObjectId . ", " . + "slot: $slotId, client expected: $clientItemStackId, server actual: " . $info->getStackId() . ", last modified by request: " . ($info->getRequestId() ?? "none") + ); + return false; + } + + return true; } } diff --git a/src/network/mcpe/InventoryManagerPredictedChanges.php b/src/network/mcpe/InventoryManagerPredictedChanges.php new file mode 100644 index 000000000..8264e83fb --- /dev/null +++ b/src/network/mcpe/InventoryManagerPredictedChanges.php @@ -0,0 +1,61 @@ + + */ + private array $slots = []; + + public function __construct( + private Inventory $inventory + ){} + + public function getInventory() : Inventory{ return $this->inventory; } + + /** + * @return ItemStack[] + * @phpstan-return array + */ + public function getSlots() : array{ + return $this->slots; + } + + public function getSlot(int $slot) : ?ItemStack{ + return $this->slots[$slot] ?? null; + } + + public function add(int $slot, ItemStack $item) : void{ + $this->slots[$slot] = $item; + } + + public function remove(int $slot) : void{ + unset($this->slots[$slot]); + } +} diff --git a/src/network/mcpe/ItemStackInfo.php b/src/network/mcpe/ItemStackInfo.php new file mode 100644 index 000000000..630765bfa --- /dev/null +++ b/src/network/mcpe/ItemStackInfo.php @@ -0,0 +1,41 @@ +requestId; } + + public function getStackId() : int{ return $this->stackId; } + + public function getItemStack() : ItemStack{ return $this->itemStack; } +} diff --git a/src/network/mcpe/NetworkSession.php b/src/network/mcpe/NetworkSession.php index 938f76398..355c5c6bd 100644 --- a/src/network/mcpe/NetworkSession.php +++ b/src/network/mcpe/NetworkSession.php @@ -449,18 +449,18 @@ class NetworkSession{ $decodeTimings->stopTiming(); } - $handlerTimings = Timings::getHandleDataPacketTimings($packet); - $handlerTimings->startTiming(); - try{ - //TODO: I'm not sure DataPacketReceiveEvent should be included in the handler timings, but it needs to be - //included for now to ensure the receivePacket timings are counted the way they were before - $ev = new DataPacketReceiveEvent($this, $packet); - $ev->call(); - if(!$ev->isCancelled() && ($this->handler === null || !$packet->handle($this->handler))){ - $this->logger->debug("Unhandled " . $packet->getName() . ": " . base64_encode($stream->getBuffer())); + $ev = new DataPacketReceiveEvent($this, $packet); + $ev->call(); + if(!$ev->isCancelled()){ + $handlerTimings = Timings::getHandleDataPacketTimings($packet); + $handlerTimings->startTiming(); + try{ + if($this->handler === null || !$packet->handle($this->handler)){ + $this->logger->debug("Unhandled " . $packet->getName() . ": " . base64_encode($stream->getBuffer())); + } + }finally{ + $handlerTimings->stopTiming(); } - }finally{ - $handlerTimings->stopTiming(); } }finally{ $timings->stopTiming(); diff --git a/src/network/mcpe/cache/CraftingDataCache.php b/src/network/mcpe/cache/CraftingDataCache.php index 436c3ccf0..9b70d00c9 100644 --- a/src/network/mcpe/cache/CraftingDataCache.php +++ b/src/network/mcpe/cache/CraftingDataCache.php @@ -26,6 +26,8 @@ namespace pocketmine\network\mcpe\cache; use pocketmine\crafting\CraftingManager; use pocketmine\crafting\FurnaceType; use pocketmine\crafting\RecipeIngredient; +use pocketmine\crafting\ShapedRecipe; +use pocketmine\crafting\ShapelessRecipe; use pocketmine\crafting\ShapelessRecipeType; use pocketmine\item\Item; use pocketmine\network\mcpe\convert\GlobalItemTypeDictionary; @@ -78,12 +80,12 @@ final class CraftingDataCache{ private function buildCraftingDataCache(CraftingManager $manager) : CraftingDataPacket{ Timings::$craftingDataCacheRebuild->startTiming(); - $counter = 0; $nullUUID = Uuid::fromString(Uuid::NIL); $converter = TypeConverter::getInstance(); $recipesWithTypeIds = []; - foreach($manager->getShapelessRecipes() as $list){ - foreach($list as $recipe){ + + foreach($manager->getCraftingRecipeIndex() as $index => $recipe){ + if($recipe instanceof ShapelessRecipe){ $typeTag = match($recipe->getType()->id()){ ShapelessRecipeType::CRAFTING()->id() => CraftingRecipeBlockName::CRAFTING_TABLE, ShapelessRecipeType::STONECUTTER()->id() => CraftingRecipeBlockName::STONECUTTER, @@ -93,7 +95,7 @@ final class CraftingDataCache{ }; $recipesWithTypeIds[] = new ProtocolShapelessRecipe( CraftingDataPacket::ENTRY_SHAPELESS, - Binary::writeInt(++$counter), + Binary::writeInt($index), array_map(function(RecipeIngredient $item) use ($converter) : ProtocolRecipeIngredient{ return $converter->coreRecipeIngredientToNet($item); }, $recipe->getIngredientList()), @@ -103,12 +105,9 @@ final class CraftingDataCache{ $nullUUID, $typeTag, 50, - $counter + $index ); - } - } - foreach($manager->getShapedRecipes() as $list){ - foreach($list as $recipe){ + }elseif($recipe instanceof ShapedRecipe){ $inputs = []; for($row = 0, $height = $recipe->getHeight(); $row < $height; ++$row){ @@ -118,7 +117,7 @@ final class CraftingDataCache{ } $recipesWithTypeIds[] = $r = new ProtocolShapedRecipe( CraftingDataPacket::ENTRY_SHAPED, - Binary::writeInt(++$counter), + Binary::writeInt($index), $inputs, array_map(function(Item $item) use ($converter) : ItemStack{ return $converter->coreItemStackToNet($item); @@ -126,8 +125,10 @@ final class CraftingDataCache{ $nullUUID, CraftingRecipeBlockName::CRAFTING_TABLE, 50, - $counter + $index ); + }else{ + //TODO: probably special recipe types } } diff --git a/src/network/mcpe/convert/TypeConverter.php b/src/network/mcpe/convert/TypeConverter.php index 27836a3c7..ac155aa39 100644 --- a/src/network/mcpe/convert/TypeConverter.php +++ b/src/network/mcpe/convert/TypeConverter.php @@ -29,27 +29,17 @@ use pocketmine\crafting\MetaWildcardRecipeIngredient; use pocketmine\crafting\RecipeIngredient; use pocketmine\crafting\TagWildcardRecipeIngredient; use pocketmine\data\bedrock\item\BlockItemIdMap; -use pocketmine\inventory\transaction\action\CreateItemAction; -use pocketmine\inventory\transaction\action\DestroyItemAction; -use pocketmine\inventory\transaction\action\DropItemAction; -use pocketmine\inventory\transaction\action\InventoryAction; -use pocketmine\inventory\transaction\action\SlotChangeAction; use pocketmine\item\Item; use pocketmine\item\VanillaItems; use pocketmine\nbt\NbtException; use pocketmine\nbt\tag\CompoundTag; -use pocketmine\network\mcpe\InventoryManager; use pocketmine\network\mcpe\protocol\types\GameMode as ProtocolGameMode; -use pocketmine\network\mcpe\protocol\types\inventory\ContainerIds; use pocketmine\network\mcpe\protocol\types\inventory\ItemStack; -use pocketmine\network\mcpe\protocol\types\inventory\NetworkInventoryAction; -use pocketmine\network\mcpe\protocol\types\inventory\UIInventorySlotOffset; use pocketmine\network\mcpe\protocol\types\recipe\IntIdMetaItemDescriptor; use pocketmine\network\mcpe\protocol\types\recipe\RecipeIngredient as ProtocolRecipeIngredient; use pocketmine\network\mcpe\protocol\types\recipe\StringIdMetaItemDescriptor; use pocketmine\network\mcpe\protocol\types\recipe\TagItemDescriptor; use pocketmine\player\GameMode; -use pocketmine\player\Player; use pocketmine\utils\AssumptionFailedError; use pocketmine\utils\SingletonTrait; use function get_class; @@ -239,60 +229,4 @@ class TypeConverter{ return $itemResult; } - - /** - * @throws TypeConversionException - */ - public function createInventoryAction(NetworkInventoryAction $action, Player $player, InventoryManager $inventoryManager) : ?InventoryAction{ - if($action->oldItem->getItemStack()->equals($action->newItem->getItemStack())){ - //filter out useless noise in 1.13 - return null; - } - try{ - $old = $this->netItemStackToCore($action->oldItem->getItemStack()); - }catch(TypeConversionException $e){ - throw TypeConversionException::wrap($e, "Inventory action: oldItem"); - } - try{ - $new = $this->netItemStackToCore($action->newItem->getItemStack()); - }catch(TypeConversionException $e){ - throw TypeConversionException::wrap($e, "Inventory action: newItem"); - } - switch($action->sourceType){ - case NetworkInventoryAction::SOURCE_CONTAINER: - if($action->windowId === ContainerIds::UI && $action->inventorySlot === UIInventorySlotOffset::CREATED_ITEM_OUTPUT){ - return null; //useless noise - } - $located = $inventoryManager->locateWindowAndSlot($action->windowId, $action->inventorySlot); - if($located !== null){ - [$window, $slot] = $located; - return new SlotChangeAction($window, $slot, $old, $new); - } - - throw new TypeConversionException("No open container with window ID $action->windowId"); - case NetworkInventoryAction::SOURCE_WORLD: - if($action->inventorySlot !== NetworkInventoryAction::ACTION_MAGIC_SLOT_DROP_ITEM){ - throw new TypeConversionException("Only expecting drop-item world actions from the client!"); - } - - return new DropItemAction($new); - case NetworkInventoryAction::SOURCE_CREATIVE: - switch($action->inventorySlot){ - case NetworkInventoryAction::ACTION_MAGIC_SLOT_CREATIVE_DELETE_ITEM: - return new DestroyItemAction($new); - case NetworkInventoryAction::ACTION_MAGIC_SLOT_CREATIVE_CREATE_ITEM: - return new CreateItemAction($old); - default: - throw new TypeConversionException("Unexpected creative action type $action->inventorySlot"); - - } - case NetworkInventoryAction::SOURCE_TODO: - //These are used to balance a transaction that involves special actions, like crafting, enchanting, etc. - //The vanilla server just accepted these without verifying them. We don't need to care about them since - //we verify crafting by checking for imbalances anyway. - return null; - default: - throw new TypeConversionException("Unknown inventory source type $action->sourceType"); - } - } } diff --git a/src/network/mcpe/handler/InGamePacketHandler.php b/src/network/mcpe/handler/InGamePacketHandler.php index 4d286e653..cac7dc500 100644 --- a/src/network/mcpe/handler/InGamePacketHandler.php +++ b/src/network/mcpe/handler/InGamePacketHandler.php @@ -32,11 +32,11 @@ use pocketmine\entity\animation\ConsumingItemAnimation; use pocketmine\entity\Attribute; use pocketmine\entity\InvalidSkinException; use pocketmine\event\player\PlayerEditBookEvent; -use pocketmine\inventory\transaction\action\InventoryAction; +use pocketmine\inventory\transaction\action\DropItemAction; use pocketmine\inventory\transaction\CraftingTransaction; use pocketmine\inventory\transaction\InventoryTransaction; +use pocketmine\inventory\transaction\TransactionBuilder; use pocketmine\inventory\transaction\TransactionException; -use pocketmine\inventory\transaction\TransactionValidationException; use pocketmine\item\VanillaItems; use pocketmine\item\WritableBook; use pocketmine\item\WritableBookPage; @@ -65,6 +65,8 @@ use pocketmine\network\mcpe\protocol\EmotePacket; use pocketmine\network\mcpe\protocol\InteractPacket; use pocketmine\network\mcpe\protocol\InventoryTransactionPacket; use pocketmine\network\mcpe\protocol\ItemFrameDropItemPacket; +use pocketmine\network\mcpe\protocol\ItemStackRequestPacket; +use pocketmine\network\mcpe\protocol\ItemStackResponsePacket; use pocketmine\network\mcpe\protocol\LabTablePacket; use pocketmine\network\mcpe\protocol\LecternUpdatePacket; use pocketmine\network\mcpe\protocol\LevelSoundEventPacket; @@ -96,7 +98,8 @@ use pocketmine\network\mcpe\protocol\types\inventory\MismatchTransactionData; use pocketmine\network\mcpe\protocol\types\inventory\NetworkInventoryAction; use pocketmine\network\mcpe\protocol\types\inventory\NormalTransactionData; use pocketmine\network\mcpe\protocol\types\inventory\ReleaseItemTransactionData; -use pocketmine\network\mcpe\protocol\types\inventory\UIInventorySlotOffset; +use pocketmine\network\mcpe\protocol\types\inventory\stackrequest\ItemStackRequest; +use pocketmine\network\mcpe\protocol\types\inventory\stackresponse\ItemStackResponse; use pocketmine\network\mcpe\protocol\types\inventory\UseItemOnEntityTransactionData; use pocketmine\network\mcpe\protocol\types\inventory\UseItemTransactionData; use pocketmine\network\mcpe\protocol\types\PlayerAction; @@ -118,7 +121,6 @@ use function is_bool; use function is_infinite; use function is_nan; use function json_decode; -use function json_encode; use function max; use function mb_strlen; use function microtime; @@ -272,13 +274,22 @@ class InGamePacketHandler extends PacketHandler{ if(count($useItemTransaction->getTransactionData()->getActions()) > 100){ throw new PacketHandlingException("Too many actions in item use transaction"); } - $this->inventoryManager->addPredictedSlotChanges($useItemTransaction->getTransactionData()->getActions()); + + $this->inventoryManager->setCurrentItemStackRequestId($useItemTransaction->getRequestId()); + $this->inventoryManager->addRawPredictedSlotChanges($useItemTransaction->getTransactionData()->getActions()); if(!$this->handleUseItemTransaction($useItemTransaction->getTransactionData())){ $packetHandled = false; $this->session->getLogger()->debug("Unhandled transaction in PlayerAuthInputPacket (type " . $useItemTransaction->getTransactionData()->getActionType() . ")"); }else{ $this->inventoryManager->syncMismatchedPredictedSlotChanges(); } + $this->inventoryManager->setCurrentItemStackRequestId(null); + } + + $itemStackRequest = $packet->getItemStackRequest(); + if($itemStackRequest !== null){ + $result = $this->handleSingleItemStackRequest($itemStackRequest); + $this->session->sendDataPacket(ItemStackResponsePacket::create([$result])); } return $packetHandled; @@ -312,14 +323,15 @@ class InGamePacketHandler extends PacketHandler{ public function handleInventoryTransaction(InventoryTransactionPacket $packet) : bool{ $result = true; - if(count($packet->trData->getActions()) > 100){ + if(count($packet->trData->getActions()) > 50){ throw new PacketHandlingException("Too many actions in inventory transaction"); } - $this->inventoryManager->addPredictedSlotChanges($packet->trData->getActions()); + $this->inventoryManager->setCurrentItemStackRequestId($packet->requestId); + $this->inventoryManager->addRawPredictedSlotChanges($packet->trData->getActions()); if($packet->trData instanceof NormalTransactionData){ - $result = $this->handleNormalTransaction($packet->trData); + $result = $this->handleNormalTransaction($packet->trData, $packet->requestId); }elseif($packet->trData instanceof MismatchTransactionData){ $this->session->getLogger()->debug("Mismatch transaction received"); $this->inventoryManager->syncAll(); @@ -335,93 +347,76 @@ class InGamePacketHandler extends PacketHandler{ if($this->craftingTransaction === null){ //don't sync if we're waiting to complete a crafting transaction $this->inventoryManager->syncMismatchedPredictedSlotChanges(); } + $this->inventoryManager->setCurrentItemStackRequestId(null); return $result; } - private function handleNormalTransaction(NormalTransactionData $data) : bool{ - /** @var InventoryAction[] $actions */ - $actions = []; + private function executeInventoryTransaction(InventoryTransaction $transaction, int $requestId) : bool{ + $this->player->setUsingItem(false); - $isCraftingPart = false; - $converter = TypeConverter::getInstance(); - foreach($data->getActions() as $networkInventoryAction){ - if( - $networkInventoryAction->sourceType === NetworkInventoryAction::SOURCE_TODO || ( - $this->craftingTransaction !== null && - !$networkInventoryAction->oldItem->getItemStack()->equals($networkInventoryAction->newItem->getItemStack()) && - $networkInventoryAction->sourceType === NetworkInventoryAction::SOURCE_CONTAINER && - $networkInventoryAction->windowId === ContainerIds::UI && - $networkInventoryAction->inventorySlot === UIInventorySlotOffset::CREATED_ITEM_OUTPUT - ) - ){ - $isCraftingPart = true; - } + $this->inventoryManager->setCurrentItemStackRequestId($requestId); + $this->inventoryManager->addTransactionPredictedSlotChanges($transaction); + try{ + $transaction->execute(); + }catch(TransactionException $e){ + $logger = $this->session->getLogger(); + $logger->debug("Failed to execute inventory transaction: " . $e->getMessage()); - try{ - $action = $converter->createInventoryAction($networkInventoryAction, $this->player, $this->inventoryManager); - if($action !== null){ - $actions[] = $action; - } - }catch(TypeConversionException $e){ - $this->session->getLogger()->debug("Error unpacking inventory action: " . $e->getMessage()); - return false; - } - } - - if($isCraftingPart){ - if($this->craftingTransaction === null){ - //TODO: this might not be crafting if there is a special inventory open (anvil, enchanting, loom etc) - $this->craftingTransaction = new CraftingTransaction($this->player, $this->player->getServer()->getCraftingManager(), $actions); - }else{ - foreach($actions as $action){ - $this->craftingTransaction->addAction($action); - } - } - - try{ - $this->craftingTransaction->validate(); - }catch(TransactionValidationException $e){ - //transaction is incomplete - crafting transaction comes in lots of little bits, so we have to collect - //all of the parts before we can execute it - return true; - } - $this->player->setUsingItem(false); - try{ - $this->craftingTransaction->execute(); - }catch(TransactionException $e){ - $this->session->getLogger()->debug("Failed to execute crafting transaction: " . $e->getMessage()); - return false; - }finally{ - $this->craftingTransaction = null; - } - }else{ - //normal transaction fallthru - if($this->craftingTransaction !== null){ - $this->session->getLogger()->debug("Got unexpected normal inventory action with incomplete crafting transaction, refusing to execute crafting"); - $this->craftingTransaction = null; - return false; - } - - if(count($actions) === 0){ - //TODO: 1.13+ often sends transactions with nothing but useless crap in them, no need for the debug noise - return true; - } - - $this->player->setUsingItem(false); - $transaction = new InventoryTransaction($this->player, $actions); - try{ - $transaction->execute(); - }catch(TransactionException $e){ - $logger = $this->session->getLogger(); - $logger->debug("Failed to execute inventory transaction: " . $e->getMessage()); - $logger->debug("Actions: " . json_encode($data->getActions())); - return false; - } + return false; + }finally{ + $this->inventoryManager->syncMismatchedPredictedSlotChanges(); + $this->inventoryManager->setCurrentItemStackRequestId(null); } return true; } + private function handleNormalTransaction(NormalTransactionData $data, int $itemStackRequestId) : bool{ + //When the ItemStackRequest system is used, this transaction type is only used for dropping items by pressing Q. + //I don't know why they don't just use ItemStackRequest for that too, which already supports dropping items by + //clicking them outside an open inventory menu, but for now it is what it is. + //Fortunately, this means we can be extremely strict about the validation criteria. + + if(count($data->getActions()) > 2){ + throw new PacketHandlingException("Expected exactly 2 actions for dropping an item"); + } + + foreach($data->getActions() as $networkInventoryAction){ + if($networkInventoryAction->sourceType === NetworkInventoryAction::SOURCE_WORLD){ + //drop item - we don't need to validate this, we only care about the count + //if the resulting actions don't match the client for some reason, it will trigger an automatic + //prediction rollback anyway. + //it's technically possible to see this more than once, but a normal client should never do that. + $inventory = $this->player->getInventory(); + $heldItem = $inventory->getItemInHand(); + + try{ + $droppedItem = TypeConverter::getInstance()->netItemStackToCore($networkInventoryAction->newItem->getItemStack()); + }catch(TypeConversionException $e){ + throw PacketHandlingException::wrap($e); + } + + //TODO: if we can avoid decoding incoming item NBT, it will be faster to compare network ItemStacks + //rather than converting to internal itemstacks and using canStackWith() here. + if(!$heldItem->canStackWith($droppedItem) || $heldItem->getCount() < $droppedItem->getCount()){ + return false; + } + + //purposely overwritten here - this allows any immutable internal references to be shared + $droppedItem = $heldItem->pop($droppedItem->getCount()); + + $builder = new TransactionBuilder(); + $builder->getInventory($inventory)->setItem($inventory->getHeldItemIndex(), $heldItem); + $builder->addAction(new DropItemAction($droppedItem)); + + $transaction = new InventoryTransaction($this->player, $builder->generateActions()); + return $this->executeInventoryTransaction($transaction, $itemStackRequestId); + } + } + + throw new PacketHandlingException("Legacy 'normal' transactions should only be used for dropping items"); + } + private function handleUseItemTransaction(UseItemTransactionData $data) : bool{ $this->player->selectHotbarSlot($data->getHotbarSlot()); @@ -533,6 +528,26 @@ class InGamePacketHandler extends PacketHandler{ return false; } + private function handleSingleItemStackRequest(ItemStackRequest $request) : ItemStackResponse{ + $executor = new ItemStackRequestExecutor($this->player, $this->inventoryManager, $request); + $transaction = $executor->generateInventoryTransaction(); + $result = $this->executeInventoryTransaction($transaction, $request->getRequestId()); + $this->session->getLogger()->debug("Item stack request " . $request->getRequestId() . " result: " . ($result ? "success" : "failure")); + + return $executor->buildItemStackResponse($result); + } + + public function handleItemStackRequest(ItemStackRequestPacket $packet) : bool{ + $responses = []; + foreach($packet->getRequests() as $request){ + $responses[] = $this->handleSingleItemStackRequest($request); + } + + $this->session->sendDataPacket(ItemStackResponsePacket::create($responses)); + + return true; + } + public function handleMobEquipment(MobEquipmentPacket $packet) : bool{ if($packet->windowId === ContainerIds::OFFHAND){ return true; //this happens when we put an item into the offhand diff --git a/src/network/mcpe/handler/ItemStackContainerIdTranslator.php b/src/network/mcpe/handler/ItemStackContainerIdTranslator.php new file mode 100644 index 000000000..a0ad3b26c --- /dev/null +++ b/src/network/mcpe/handler/ItemStackContainerIdTranslator.php @@ -0,0 +1,90 @@ + ContainerIds::ARMOR, + + ContainerUIIds::HOTBAR, + ContainerUIIds::INVENTORY, + ContainerUIIds::COMBINED_HOTBAR_AND_INVENTORY => ContainerIds::INVENTORY, + + ContainerUIIds::OFFHAND => ContainerIds::OFFHAND, + + ContainerUIIds::ANVIL_INPUT, + ContainerUIIds::ANVIL_MATERIAL, + ContainerUIIds::BEACON_PAYMENT, + ContainerUIIds::CARTOGRAPHY_ADDITIONAL, + ContainerUIIds::CARTOGRAPHY_INPUT, + ContainerUIIds::COMPOUND_CREATOR_INPUT, + ContainerUIIds::CRAFTING_INPUT, + ContainerUIIds::CREATED_OUTPUT, + ContainerUIIds::CURSOR, + ContainerUIIds::ENCHANTING_INPUT, + ContainerUIIds::ENCHANTING_MATERIAL, + ContainerUIIds::GRINDSTONE_ADDITIONAL, + ContainerUIIds::GRINDSTONE_INPUT, + ContainerUIIds::LAB_TABLE_INPUT, + ContainerUIIds::LOOM_DYE, + ContainerUIIds::LOOM_INPUT, + ContainerUIIds::LOOM_MATERIAL, + ContainerUIIds::MATERIAL_REDUCER_INPUT, + ContainerUIIds::MATERIAL_REDUCER_OUTPUT, + ContainerUIIds::SMITHING_TABLE_INPUT, + ContainerUIIds::SMITHING_TABLE_MATERIAL, + ContainerUIIds::STONECUTTER_INPUT, + ContainerUIIds::TRADE2_INGREDIENT1, + ContainerUIIds::TRADE2_INGREDIENT2, + ContainerUIIds::TRADE_INGREDIENT1, + ContainerUIIds::TRADE_INGREDIENT2 => ContainerIds::UI, + + ContainerUIIds::BARREL, + ContainerUIIds::BLAST_FURNACE_INGREDIENT, + ContainerUIIds::BREWING_STAND_FUEL, + ContainerUIIds::BREWING_STAND_INPUT, + ContainerUIIds::BREWING_STAND_RESULT, + ContainerUIIds::FURNACE_FUEL, + ContainerUIIds::FURNACE_INGREDIENT, + ContainerUIIds::FURNACE_RESULT, + ContainerUIIds::LEVEL_ENTITY, //chest + ContainerUIIds::SHULKER_BOX, + ContainerUIIds::SMOKER_INGREDIENT => $currentWindowId, + + //all preview slots are ignored, since the client shouldn't be modifying those directly + + default => throw new PacketHandlingException("Unexpected container UI ID $containerInterfaceId") + }; + } +} diff --git a/src/network/mcpe/handler/ItemStackRequestExecutor.php b/src/network/mcpe/handler/ItemStackRequestExecutor.php new file mode 100644 index 000000000..be7e472ac --- /dev/null +++ b/src/network/mcpe/handler/ItemStackRequestExecutor.php @@ -0,0 +1,313 @@ +builder = new TransactionBuilder(); + } + + /** + * @phpstan-return array{TransactionBuilderInventory, int} + */ + private function getBuilderInventoryAndSlot(ItemStackRequestSlotInfo $info) : array{ + $windowId = ItemStackContainerIdTranslator::translate($info->getContainerId(), $this->inventoryManager->getCurrentWindowId()); + $windowAndSlot = $this->inventoryManager->locateWindowAndSlot($windowId, $info->getSlotId()); + if($windowAndSlot === null){ + throw new PacketHandlingException("Stack request action cannot target an inventory that is not open"); + } + [$inventory, $slot] = $windowAndSlot; + if(!$inventory->slotExists($slot)){ + throw new PacketHandlingException("Stack request action cannot target an inventory slot that does not exist"); + } + + if( + $info->getStackId() !== $this->request->getRequestId() && //using TransactionBuilderInventory enables this to work + !$this->inventoryManager->matchItemStack($inventory, $slot, $info->getStackId()) + ){ + throw new PacketHandlingException("Inventory " . $info->getContainerId() . ", slot " . $slot . ": server-side item does not match expected"); + } + + return [$this->builder->getInventory($inventory), $slot]; + } + + private function transferItems(ItemStackRequestSlotInfo $source, ItemStackRequestSlotInfo $destination, int $count) : void{ + $removed = $this->removeItemFromSlot($source, $count); + $this->addItemToSlot($destination, $removed, $count); + } + + /** + * Deducts items from an inventory slot, returning a stack containing the removed items. + */ + private function removeItemFromSlot(ItemStackRequestSlotInfo $slotInfo, int $count) : Item{ + $this->requestSlotInfos[] = $slotInfo; + [$inventory, $slot] = $this->getBuilderInventoryAndSlot($slotInfo); + + $existingItem = $inventory->getItem($slot); + if($existingItem->getCount() < $count){ + throw new PacketHandlingException("Cannot take $count items from a stack of " . $existingItem->getCount()); + } + + $removed = $existingItem->pop($count); + $inventory->setItem($slot, $existingItem); + + return $removed; + } + + /** + * Adds items to the target slot, if they are stackable. + */ + private function addItemToSlot(ItemStackRequestSlotInfo $slotInfo, Item $item, int $count) : void{ + $this->requestSlotInfos[] = $slotInfo; + [$inventory, $slot] = $this->getBuilderInventoryAndSlot($slotInfo); + + $existingItem = $inventory->getItem($slot); + if(!$existingItem->isNull() && !$existingItem->canStackWith($item)){ + throw new PacketHandlingException("Can only add items to an empty slot, or a slot containing the same item"); + } + + //we can't use the existing item here; it may be an empty stack + $newItem = clone $item; + $newItem->setCount($existingItem->getCount() + $count); + $inventory->setItem($slot, $newItem); + } + + private function setNextCreatedItem(?Item $item, bool $creative = false) : void{ + if($item !== null && $item->isNull()){ + $item = null; + } + if($this->nextCreatedItem !== null){ + //while this is more complicated than simply adding the action when the item is taken, this ensures that + //plugins can tell the difference between 1 item that got split into 2 slots, vs 2 separate items. + if($this->createdItemFromCreativeInventory && $this->createdItemsTakenCount > 0){ + $this->nextCreatedItem->setCount($this->createdItemsTakenCount); + $this->builder->addAction(new CreateItemAction($this->nextCreatedItem)); + }elseif($this->createdItemsTakenCount < $this->nextCreatedItem->getCount()){ + throw new PacketHandlingException("Not all of the previous created item was taken"); + } + } + $this->nextCreatedItem = $item; + $this->createdItemFromCreativeInventory = $creative; + $this->createdItemsTakenCount = 0; + } + + private function beginCrafting(int $recipeId, int $repetitions) : void{ + if($this->specialTransaction !== null){ + throw new PacketHandlingException("Cannot perform more than 1 special action per request"); + } + if($repetitions < 1){ //TODO: upper bound? + throw new PacketHandlingException("Cannot craft a recipe less than 1 time"); + } + $craftingManager = $this->player->getServer()->getCraftingManager(); + $recipe = $craftingManager->getCraftingRecipeIndex()[$recipeId] ?? null; + if($recipe === null){ + throw new PacketHandlingException("Unknown crafting recipe ID $recipeId"); + } + + $this->specialTransaction = new CraftingTransaction($this->player, $craftingManager, [], $recipe, $repetitions); + + $currentWindow = $this->player->getCurrentWindow(); + if($currentWindow !== null && !($currentWindow instanceof CraftingGrid)){ + throw new PacketHandlingException("Cannot complete crafting when the player's current window is not a crafting grid"); + } + $craftingGrid = $currentWindow ?? $this->player->getCraftingGrid(); + + $craftingResults = $recipe->getResultsFor($craftingGrid); + foreach($craftingResults as $k => $craftingResult){ + $craftingResult->setCount($craftingResult->getCount() * $repetitions); + $this->craftingResults[$k] = $craftingResult; + } + if(count($this->craftingResults) === 1){ + //for multi-output recipes, later actions will tell us which result to create and when + $this->setNextCreatedItem($this->craftingResults[array_key_first($this->craftingResults)]); + } + } + + private function takeCreatedItem(ItemStackRequestSlotInfo $destination, int $count) : void{ + $createdItem = $this->nextCreatedItem; + if($createdItem === null){ + throw new PacketHandlingException("No created item is waiting to be taken"); + } + + if(!$this->createdItemFromCreativeInventory){ + $availableCount = $createdItem->getCount() - $this->createdItemsTakenCount; + if($count > $availableCount){ + throw new PacketHandlingException("Not enough created items available to be taken (have $availableCount, tried to take $count)"); + } + } + + $this->createdItemsTakenCount += $count; + $this->addItemToSlot($destination, $createdItem, $count); + if(!$this->createdItemFromCreativeInventory && $this->createdItemsTakenCount >= $createdItem->getCount()){ + $this->setNextCreatedItem(null); + } + } + + private function processItemStackRequestAction(ItemStackRequestAction $action) : void{ + if( + $action instanceof TakeStackRequestAction || + $action instanceof PlaceStackRequestAction + ){ + $source = $action->getSource(); + $destination = $action->getDestination(); + + if($source->getContainerId() === ContainerUIIds::CREATED_OUTPUT && $source->getSlotId() === UIInventorySlotOffset::CREATED_ITEM_OUTPUT){ + $this->takeCreatedItem($destination, $action->getCount()); + }else{ + $this->transferItems($source, $destination, $action->getCount()); + } + }elseif($action instanceof SwapStackRequestAction){ + $this->requestSlotInfos[] = $action->getSlot1(); + $this->requestSlotInfos[] = $action->getSlot2(); + + [$inventory1, $slot1] = $this->getBuilderInventoryAndSlot($action->getSlot1()); + [$inventory2, $slot2] = $this->getBuilderInventoryAndSlot($action->getSlot2()); + + $item1 = $inventory1->getItem($slot1); + $item2 = $inventory2->getItem($slot2); + $inventory1->setItem($slot1, $item2); + $inventory2->setItem($slot2, $item1); + }elseif($action instanceof DropStackRequestAction){ + //TODO: this action has a "randomly" field, I have no idea what it's used for + $dropped = $this->removeItemFromSlot($action->getSource(), $action->getCount()); + $this->builder->addAction(new DropItemAction($dropped)); + + }elseif($action instanceof DestroyStackRequestAction){ + $destroyed = $this->removeItemFromSlot($action->getSource(), $action->getCount()); + $this->builder->addAction(new DestroyItemAction($destroyed)); + + }elseif($action instanceof CreativeCreateStackRequestAction){ + $item = CreativeInventory::getInstance()->getItem($action->getCreativeItemId()); + if($item === null){ + //TODO: the item may have been unregistered after the client was sent the creative contents, leaving a + //gap in the creative item list. This probably shouldn't be a violation, but I'm not sure how else to + //handle it right now. + throw new PacketHandlingException("Tried to create nonexisting creative item " . $action->getCreativeItemId()); + } + + $this->setNextCreatedItem($item, true); + }elseif($action instanceof CraftRecipeStackRequestAction){ + $this->beginCrafting($action->getRecipeId(), 1); + }elseif($action instanceof CraftRecipeAutoStackRequestAction){ + $this->beginCrafting($action->getRecipeId(), $action->getRepetitions()); + }elseif($action instanceof CraftingConsumeInputStackRequestAction){ + if(!$this->specialTransaction instanceof CraftingTransaction){ + throw new PacketHandlingException("Cannot consume crafting input when no crafting transaction is in progress"); + } + $this->removeItemFromSlot($action->getSource(), $action->getCount()); //output discarded - we allow CraftingTransaction to verify the balance + + }elseif($action instanceof CraftingCreateSpecificResultStackRequestAction){ + if(!$this->specialTransaction instanceof CraftingTransaction){ + throw new AssumptionFailedError("Cannot mark crafting result index when no crafting transaction is in progress"); + } + + $nextResultItem = $this->craftingResults[$action->getResultIndex()] ?? null; + if($nextResultItem === null){ + throw new PacketHandlingException("No such crafting result index " . $action->getResultIndex()); + } + $this->setNextCreatedItem($nextResultItem); + }elseif($action instanceof DeprecatedCraftingResultsStackRequestAction){ + //no obvious use + }else{ + throw new PacketHandlingException("Unhandled item stack request action: " . get_class($action)); + } + } + + public function generateInventoryTransaction() : InventoryTransaction{ + foreach($this->request->getActions() as $action){ + $this->processItemStackRequestAction($action); + } + $this->setNextCreatedItem(null); + $inventoryActions = $this->builder->generateActions(); + + $transaction = $this->specialTransaction ?? new InventoryTransaction($this->player); + foreach($inventoryActions as $action){ + $transaction->addAction($action); + } + + return $transaction; + } + + public function buildItemStackResponse(bool $success) : ItemStackResponse{ + $builder = new ItemStackResponseBuilder($this->request->getRequestId(), $this->inventoryManager); + foreach($this->requestSlotInfos as $requestInfo){ + $builder->addSlot($requestInfo->getContainerId(), $requestInfo->getSlotId()); + } + + return $builder->build($success); + } +} diff --git a/src/network/mcpe/handler/ItemStackResponseBuilder.php b/src/network/mcpe/handler/ItemStackResponseBuilder.php new file mode 100644 index 000000000..782404ebe --- /dev/null +++ b/src/network/mcpe/handler/ItemStackResponseBuilder.php @@ -0,0 +1,107 @@ +> + */ + private array $changedSlots = []; + + public function __construct( + private int $requestId, + private InventoryManager $inventoryManager + ){} + + public function addSlot(int $containerInterfaceId, int $slotId) : void{ + $this->changedSlots[$containerInterfaceId][$slotId] = $slotId; + } + + /** + * @phpstan-return array{Inventory, int} + */ + private function getInventoryAndSlot(int $containerInterfaceId, int $slotId) : array{ + $windowId = ItemStackContainerIdTranslator::translate($containerInterfaceId, $this->inventoryManager->getCurrentWindowId()); + $windowAndSlot = $this->inventoryManager->locateWindowAndSlot($windowId, $slotId); + if($windowAndSlot === null){ + throw new PacketHandlingException("Stack request action cannot target an inventory that is not open"); + } + [$inventory, $slot] = $windowAndSlot; + if(!$inventory->slotExists($slot)){ + throw new PacketHandlingException("Stack request action cannot target an inventory slot that does not exist"); + } + + return [$inventory, $slot]; + } + + public function build(bool $success) : ItemStackResponse{ + $responseInfosByContainer = []; + foreach($this->changedSlots as $containerInterfaceId => $slotIds){ + if($containerInterfaceId === ContainerUIIds::CREATED_OUTPUT){ + continue; + } + foreach($slotIds as $slotId){ + [$inventory, $slot] = $this->getInventoryAndSlot($containerInterfaceId, $slotId); + + $itemStackInfo = $this->inventoryManager->getItemStackInfo($inventory, $slot); + if($itemStackInfo === null){ + //TODO: what if a plugin closes the inventory while the transaction is ongoing? + throw new \LogicException("ItemStackInfo should never be null for an open inventory"); + } + if($itemStackInfo->getRequestId() !== $this->requestId){ + //the itemstack may have been synced due to transaction producing results that the client did not + //predict correctly, which will wipe out the tracked request ID (intentionally) + continue; + } + $item = $inventory->getItem($slot); + + $responseInfosByContainer[$containerInterfaceId][] = new ItemStackResponseSlotInfo( + $slotId, + $slotId, + $item->getCount(), + $itemStackInfo->getStackId(), + $item->getCustomName(), + 0 + ); + } + } + + $responseContainerInfos = []; + foreach($responseInfosByContainer as $containerInterfaceId => $responseInfos){ + $responseContainerInfos[] = new ItemStackResponseContainerInfo($containerInterfaceId, $responseInfos); + } + + return new ItemStackResponse($success ? ItemStackResponse::RESULT_OK : ItemStackResponse::RESULT_ERROR, $this->requestId, $responseContainerInfos); + } +} diff --git a/src/network/mcpe/handler/PreSpawnPacketHandler.php b/src/network/mcpe/handler/PreSpawnPacketHandler.php index 04c51d3fb..a451f59a9 100644 --- a/src/network/mcpe/handler/PreSpawnPacketHandler.php +++ b/src/network/mcpe/handler/PreSpawnPacketHandler.php @@ -98,7 +98,7 @@ class PreSpawnPacketHandler extends PacketHandler{ 0, 0, "", - false, + true, sprintf("%s %s", VersionInfo::NAME, VersionInfo::VERSION()->getFullVersion(true)), Uuid::fromString(Uuid::NIL), false,