diff --git a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java index ecdf47fb147..7f29c989dea 100644 --- a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java +++ b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java @@ -192,6 +192,7 @@ import org.geysermc.geyser.translator.text.MessageTranslator; import org.geysermc.geyser.util.ChunkUtils; import org.geysermc.geyser.util.EntityUtils; +import org.geysermc.geyser.util.InterruptibleFuture; import org.geysermc.geyser.util.InventoryUtils; import org.geysermc.geyser.util.LoginEncryptionUtils; import org.geysermc.geyser.util.MathUtils; @@ -372,6 +373,12 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource { @Setter private ScheduledFuture containerOutputFuture; + /** + * Used to delay specific inventory transactions until we know that the + * client isn't actually about to close their inventory + */ + private final InterruptibleFuture inventoryTransactionFuture; + /** * Stores session collision */ @@ -794,6 +801,8 @@ public GeyserSession(GeyserImpl geyser, BedrockServerSession bedrockServerSessio this.collisionManager = new CollisionManager(this); this.blockBreakHandler = new BlockBreakHandler(this); + this.inventoryTransactionFuture = new InterruptibleFuture(this); + this.playerEntity = new SessionPlayerEntity(this); collisionManager.updatePlayerBoundingBox(this.playerEntity.getPosition()); diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java index 4b3642d03ab..3889f0c7426 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java @@ -47,6 +47,7 @@ import org.cloudburstmc.protocol.bedrock.data.inventory.itemstack.request.action.CraftResultsDeprecatedAction; import org.cloudburstmc.protocol.bedrock.data.inventory.itemstack.request.action.DropAction; import org.cloudburstmc.protocol.bedrock.data.inventory.itemstack.request.action.ItemStackRequestAction; +import org.cloudburstmc.protocol.bedrock.data.inventory.itemstack.request.action.PlaceAction; import org.cloudburstmc.protocol.bedrock.data.inventory.itemstack.request.action.SwapAction; import org.cloudburstmc.protocol.bedrock.data.inventory.itemstack.request.action.TransferItemStackRequestAction; import org.cloudburstmc.protocol.bedrock.data.inventory.itemstack.response.ItemStackResponse; @@ -88,6 +89,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.TimeUnit; import static org.geysermc.geyser.translator.inventory.BundleInventoryTranslator.isBundle; @@ -237,7 +239,7 @@ protected boolean shouldRejectItemPlace(GeyserSession session, Type inventory, C } /** - * Should be overrided if this request matches a certain criteria and shouldn't be treated normally. + * Should be overridden if this request matches a certain criteria and shouldn't be treated normally. * E.G. anvil renaming or enchanting */ protected boolean shouldHandleRequestFirst(ItemStackRequestAction action, Type inventory) { @@ -252,7 +254,55 @@ protected ItemStackResponse translateSpecialRequest(GeyserSession session, Type } public final void translateRequests(GeyserSession session, Type inventory, List requests) { + this.translateRequests(session, inventory, requests, true); + } + + private void translateRequests(GeyserSession session, Type inventory, List requests, boolean firstTime) { boolean refresh = false; + + // If we get another request, we're not closing the inventory and should run the queued request asap + // to "not fall behind" on transactions + session.getInventoryTransactionFuture().runCurrentIfPresent(); + + // Fixes https://github.com/GeyserMC/Geyser/issues/5258 + // Bedrock edition has the quirk where it'll remove all items from the crafting inventory + // before closing it, which breaks some Java plugins... + boolean mustDelay = firstTime && requests.stream().allMatch(request -> { + if (request.getActions().length > 0) { + if (request.getActions()[0] instanceof PlaceAction action) { + return action.getSource().getContainerName().getContainer() == ContainerSlotType.CRAFTING_INPUT && + action.getDestination().getContainerName().getContainer() == ContainerSlotType.HOTBAR_AND_INVENTORY; + } + } + return false; + }); + + if (mustDelay) { + // We cannot clearly differentiate shift-clicking a single stack... + // So we try to guess and delay the processing (at least 10 ticks) until we either get a new inventory transaction, + // or until we know the inventory wasn't closed + session.getInventoryTransactionFuture().schedule(() -> { + if (session.getPendingOrCurrentBedrockInventoryId() == inventory.getBedrockId()) { + translateRequests(session, inventory, requests, false); + } else { + ItemStackResponsePacket responsePacket = new ItemStackResponsePacket(); + requests.forEach(request -> responsePacket.getEntries().add(rejectRequest(request, false))); + session.sendUpstreamPacket(responsePacket); + + // Now update current inventory + if (session.getInventoryHolder() != null) { + session.getInventoryHolder().updateInventory(); + } else { + session.getPlayerInventoryHolder().updateInventory(); + } + } + }, + 750, + TimeUnit.MILLISECONDS + ); + return; + } + ItemStackResponsePacket responsePacket = new ItemStackResponsePacket(); for (ItemStackRequest request : requests) { ItemStackResponse response; diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockContainerCloseTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockContainerCloseTranslator.java index b3fa987a5c1..84b9f8f2934 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockContainerCloseTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockContainerCloseTranslator.java @@ -86,6 +86,7 @@ public void translate(GeyserSession session, ContainerClosePacket packet) { } session.setPendingOrCurrentBedrockInventoryId(-1); + session.getInventoryTransactionFuture().runCurrentIfPresent(); if (holder != null) { // Send close confirmation to Java edition if container closing is client-initiated diff --git a/core/src/main/java/org/geysermc/geyser/util/InterruptibleFuture.java b/core/src/main/java/org/geysermc/geyser/util/InterruptibleFuture.java new file mode 100644 index 00000000000..abdd47ab5b9 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/util/InterruptibleFuture.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.util; + +import org.checkerframework.checker.nullness.qual.Nullable; +import org.geysermc.geyser.session.GeyserSession; + +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +public class InterruptibleFuture { + + private final GeyserSession session; + + private @Nullable ScheduledFuture future = null; + private @Nullable Runnable runnable = null; + + public InterruptibleFuture(GeyserSession session) { + this.session = session; + } + + public void runCurrentIfPresent() { + if (future != null && future.cancel(false) && runnable != null) { + runnable.run(); + } + + future = null; + runnable = null; + } + + public void schedule(Runnable runnable, long delay, TimeUnit unit) { + runCurrentIfPresent(); + this.future = session.scheduleInEventLoop(this.runnable = runnable, delay, unit); + } +}