From 2864bc788337c4ee7fb9f90086e76d40472aa8cb Mon Sep 17 00:00:00 2001 From: Term4 Date: Wed, 15 Jul 2026 12:00:16 -0500 Subject: [PATCH 1/2] Replay legacy drop actions as throw-clicks so the slot resyncs 1.17.1+ servers skip the hotbar slot resync after a drop action, assuming the client predicted it (ServerPlayer#drop marks the remote slot as already known) - clients below 1.17.1 don't predict drops, so the dropped stack ghosts in their hotbar. Convert drop item/stack actions into the equivalent throw-click on the selected hotbar slot at the 1.17.1 boundary: the click path does get resynced, and every affected client passes through this hop. --- .../v1_17_1to1_17/Protocol1_17_1To1_17.java | 41 ++++++++++++++++++ .../storage/InventoryStateIds.java | 4 ++ .../storage/PlayerInventoryState.java | 42 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/PlayerInventoryState.java diff --git a/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/Protocol1_17_1To1_17.java b/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/Protocol1_17_1To1_17.java index b7a1dfbcb..4e23e824f 100644 --- a/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/Protocol1_17_1To1_17.java +++ b/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/Protocol1_17_1To1_17.java @@ -22,6 +22,7 @@ import com.viaversion.nbt.tag.StringTag; import com.viaversion.viabackwards.api.BackwardsProtocol; import com.viaversion.viabackwards.protocol.v1_17_1to1_17.storage.InventoryStateIds; +import com.viaversion.viabackwards.protocol.v1_17_1to1_17.storage.PlayerInventoryState; import com.viaversion.viabackwards.protocol.v1_17to1_16_4.storage.PlayerLastCursorItem; import com.viaversion.viaversion.api.connection.UserConnection; import com.viaversion.viaversion.api.minecraft.item.Item; @@ -57,6 +58,13 @@ protected void registerPackets() { registerClientbound(ClientboundPackets1_17_1.CONTAINER_CLOSE, wrapper -> { short containerId = wrapper.passthrough(Types.UNSIGNED_BYTE); wrapper.user().get(InventoryStateIds.class).removeStateId(containerId); + wrapper.user().get(PlayerInventoryState.class).setOpenContainerId(0); + }); + registerClientbound(ClientboundPackets1_17_1.OPEN_SCREEN, wrapper -> { + wrapper.user().get(PlayerInventoryState.class).setOpenContainerId(wrapper.passthrough(Types.VAR_INT)); + }); + registerClientbound(ClientboundPackets1_17_1.SET_CARRIED_ITEM, wrapper -> { + wrapper.user().get(PlayerInventoryState.class).setSelectedHotbarSlot(wrapper.passthrough(Types.BYTE)); }); registerClientbound(ClientboundPackets1_17_1.CONTAINER_SET_SLOT, wrapper -> { byte containerId = wrapper.passthrough(Types.BYTE); @@ -98,6 +106,38 @@ protected void registerPackets() { registerServerbound(ServerboundPackets1_17.CONTAINER_CLOSE, wrapper -> { byte containerId = wrapper.passthrough(Types.BYTE); wrapper.user().get(InventoryStateIds.class).removeStateId(containerId); + wrapper.user().get(PlayerInventoryState.class).setOpenContainerId(0); + }); + registerServerbound(ServerboundPackets1_17.SET_CARRIED_ITEM, wrapper -> { + wrapper.user().get(PlayerInventoryState.class).setSelectedHotbarSlot(wrapper.passthrough(Types.SHORT)); + }); + registerServerbound(ServerboundPackets1_17.PLAYER_ACTION, wrapper -> { + final int status = wrapper.passthrough(Types.VAR_INT); + if (status != 3 && status != 4) { // Drop stack / drop item + return; + } + + // 1.17.1+ servers skip the slot resync after a drop action, assuming the client predicted it + // (ServerPlayer#drop marks the remote slot as already known) - clients this far back don't predict + // drops, so the dropped stack ghosts in their hotbar. Replay the drop as a throw-click instead: + // the click path does get resynced. Clients only send drop actions with no screen open; if the + // server still has one open, the click would miss - leave the vanilla action alone there. + final PlayerInventoryState state = wrapper.user().get(PlayerInventoryState.class); + if (state.openContainerId() != 0) { + return; + } + + wrapper.cancel(); + final int stateId = wrapper.user().get(InventoryStateIds.class).getStateId(0); + final PacketWrapper click = wrapper.create(ServerboundPackets1_17.CONTAINER_CLICK); + click.write(Types.BYTE, (byte) 0); // Player inventory + click.write(Types.VAR_INT, stateId == Integer.MAX_VALUE ? 0 : stateId); + click.write(Types.SHORT, (short) (36 + state.selectedHotbarSlot())); + click.write(Types.BYTE, (byte) (status == 3 ? 1 : 0)); // Throw button: 1 = full stack, 0 = single + click.write(Types.VAR_INT, 4); // Throw + click.write(Types.VAR_INT, 0); // No claimed slot changes; the server resyncs the difference + click.write(Types.ITEM1_13_2, null); // Carried item stays empty + click.sendToServer(Protocol1_17_1To1_17.class); }); registerServerbound(ServerboundPackets1_17.CONTAINER_CLICK, wrapper -> { byte containerId = wrapper.passthrough(Types.BYTE); @@ -160,5 +200,6 @@ protected void registerPackets() { @Override public void init(UserConnection connection) { connection.put(new InventoryStateIds()); + connection.put(new PlayerInventoryState()); } } diff --git a/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/InventoryStateIds.java b/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/InventoryStateIds.java index 3ad30ac0f..2c7a05066 100644 --- a/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/InventoryStateIds.java +++ b/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/InventoryStateIds.java @@ -32,6 +32,10 @@ public void setStateId(int containerId, int id) { ids.put(containerId, id); } + public int getStateId(int containerId) { + return ids.get(containerId); + } + public int removeStateId(int containerId) { return ids.remove(containerId); } diff --git a/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/PlayerInventoryState.java b/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/PlayerInventoryState.java new file mode 100644 index 000000000..dfa8255ce --- /dev/null +++ b/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/PlayerInventoryState.java @@ -0,0 +1,42 @@ +/* + * This file is part of ViaBackwards - https://github.com/ViaVersion/ViaBackwards + * Copyright (C) 2016-2026 ViaVersion and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.viaversion.viabackwards.protocol.v1_17_1to1_17.storage; + +import com.viaversion.viaversion.api.connection.StorableObject; + +public final class PlayerInventoryState implements StorableObject { + + private short selectedHotbarSlot = 0; + private int openContainerId = 0; // 0 = none open (the server never assigns 0 to a screen) + + public short selectedHotbarSlot() { + return selectedHotbarSlot; + } + + public void setSelectedHotbarSlot(final short slot) { + this.selectedHotbarSlot = slot; + } + + public int openContainerId() { + return openContainerId; + } + + public void setOpenContainerId(final int containerId) { + this.openContainerId = containerId; + } +} From 2b0eecdc7ad2c61a9f9cb9263bee612a26103f6a Mon Sep 17 00:00:00 2001 From: Term4 Date: Wed, 15 Jul 2026 12:27:14 -0500 Subject: [PATCH 2/2] Move the drop replay to the 1.13.1 boundary Empirical bisect (stock plugins, 26.2 vanilla): the drop ghost affects clients up to 1.13, not 1.17 - client-side drop prediction was added in 1.13.1, so every affected client passes through this hop instead. Gate on a 1.17.1+ server (older servers resync drops themselves); the upper hops convert the legacy click onward, including the 1.17.1 state id. --- .../v1_13_1to1_13/Protocol1_13_1To1_13.java | 48 +++++++++++++++++++ .../storage/PlayerInventoryState.java | 2 +- .../v1_17_1to1_17/Protocol1_17_1To1_17.java | 41 ---------------- .../storage/InventoryStateIds.java | 4 -- 4 files changed, 49 insertions(+), 46 deletions(-) rename common/src/main/java/com/viaversion/viabackwards/protocol/{v1_17_1to1_17 => v1_13_1to1_13}/storage/PlayerInventoryState.java (95%) diff --git a/common/src/main/java/com/viaversion/viabackwards/protocol/v1_13_1to1_13/Protocol1_13_1To1_13.java b/common/src/main/java/com/viaversion/viabackwards/protocol/v1_13_1to1_13/Protocol1_13_1To1_13.java index 51b7a3c2a..9477e9ebc 100644 --- a/common/src/main/java/com/viaversion/viabackwards/protocol/v1_13_1to1_13/Protocol1_13_1To1_13.java +++ b/common/src/main/java/com/viaversion/viabackwards/protocol/v1_13_1to1_13/Protocol1_13_1To1_13.java @@ -25,6 +25,7 @@ import com.viaversion.viabackwards.protocol.v1_13_1to1_13.rewriter.EntityPacketRewriter1_13_1; import com.viaversion.viabackwards.protocol.v1_13_1to1_13.rewriter.ItemPacketRewriter1_13_1; import com.viaversion.viabackwards.protocol.v1_13_1to1_13.rewriter.WorldPacketRewriter1_13_1; +import com.viaversion.viabackwards.protocol.v1_13_1to1_13.storage.PlayerInventoryState; import com.viaversion.viaversion.api.connection.UserConnection; import com.viaversion.viaversion.api.minecraft.ClientWorld; import com.viaversion.viaversion.api.minecraft.RegistryType; @@ -33,6 +34,7 @@ import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; import com.viaversion.viaversion.api.protocol.remapper.PacketHandlers; import com.viaversion.viaversion.api.protocol.remapper.ValueTransformer; +import com.viaversion.viaversion.api.protocol.version.ProtocolVersion; import com.viaversion.viaversion.api.type.Types; import com.viaversion.viaversion.data.entity.EntityTrackerBase; import com.viaversion.viaversion.libs.gson.JsonElement; @@ -92,6 +94,7 @@ public String transform(PacketWrapper wrapper, String inputValue) { public void register() { map(Types.UNSIGNED_BYTE); // Id map(Types.STRING); // Window Type + handler(wrapper -> wrapper.user().get(PlayerInventoryState.class).setOpenContainerId(wrapper.get(Types.UNSIGNED_BYTE, 0))); handler(wrapper -> { JsonElement title = wrapper.passthrough(Types.COMPONENT); translatableRewriter.processText(wrapper.user(), title); @@ -112,6 +115,50 @@ public void register() { } }); + registerClientbound(ClientboundPackets1_13.CONTAINER_CLOSE, wrapper -> { + wrapper.passthrough(Types.UNSIGNED_BYTE); // Id + wrapper.user().get(PlayerInventoryState.class).setOpenContainerId(0); + }); + registerServerbound(ServerboundPackets1_13.CONTAINER_CLOSE, wrapper -> { + wrapper.passthrough(Types.BYTE); // Id + wrapper.user().get(PlayerInventoryState.class).setOpenContainerId(0); + }); + registerClientbound(ClientboundPackets1_13.SET_CARRIED_ITEM, wrapper -> { + wrapper.user().get(PlayerInventoryState.class).setSelectedHotbarSlot(wrapper.passthrough(Types.BYTE)); + }); + registerServerbound(ServerboundPackets1_13.SET_CARRIED_ITEM, wrapper -> { + wrapper.user().get(PlayerInventoryState.class).setSelectedHotbarSlot(wrapper.passthrough(Types.SHORT)); + }); + registerServerbound(ServerboundPackets1_13.PLAYER_ACTION, wrapper -> { + final int status = wrapper.passthrough(Types.VAR_INT); + if (status != 3 && status != 4) { // Drop stack / drop item + return; + } + + // 1.17.1+ servers skip the hotbar slot resync after a drop action, assuming the client predicted + // it (ServerPlayer#drop marks the remote slot as already known) - clients only predict drops since + // 1.13.1, so on older clients the dropped stack ghosts in the hotbar. Replay the drop as the + // equivalent throw-click instead: the click path does get resynced. Clients only send drop actions + // with no screen open; if the server still has one open, the click would miss - leave the vanilla + // action alone there, as on older servers, which resync drops themselves. + final boolean suppressingServer = wrapper.user().getProtocolInfo().serverProtocolVersion() + .newerThanOrEqualTo(ProtocolVersion.v1_17_1); + final PlayerInventoryState state = wrapper.user().get(PlayerInventoryState.class); + if (!suppressingServer || state.openContainerId() != 0) { + return; + } + + wrapper.cancel(); + final PacketWrapper click = wrapper.create(ServerboundPackets1_13.CONTAINER_CLICK); + click.write(Types.BYTE, (byte) 0); // Player inventory + click.write(Types.SHORT, (short) (36 + state.selectedHotbarSlot())); + click.write(Types.BYTE, (byte) (status == 3 ? 1 : 0)); // Throw button: 1 = full stack, 0 = single + click.write(Types.SHORT, (short) 0); // Action id the client never used; its confirmation is ignored + click.write(Types.VAR_INT, 4); // Throw + click.write(Types.ITEM1_13, null); // Unknown content; a mismatch only makes the server resync + click.sendToServer(Protocol1_13_1To1_13.class); + }); + registerClientbound(ClientboundPackets1_13.COMMAND_SUGGESTIONS, new PacketHandlers() { @Override public void register() { @@ -161,6 +208,7 @@ public void register() { public void init(UserConnection user) { user.addEntityTracker(getClass(), new EntityTrackerBase(user, EntityTypes1_13.EntityType.PLAYER)); user.addClientWorld(getClass(), new ClientWorld()); + user.put(new PlayerInventoryState()); } @Override diff --git a/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/PlayerInventoryState.java b/common/src/main/java/com/viaversion/viabackwards/protocol/v1_13_1to1_13/storage/PlayerInventoryState.java similarity index 95% rename from common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/PlayerInventoryState.java rename to common/src/main/java/com/viaversion/viabackwards/protocol/v1_13_1to1_13/storage/PlayerInventoryState.java index dfa8255ce..8a322c16e 100644 --- a/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/PlayerInventoryState.java +++ b/common/src/main/java/com/viaversion/viabackwards/protocol/v1_13_1to1_13/storage/PlayerInventoryState.java @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.viaversion.viabackwards.protocol.v1_17_1to1_17.storage; +package com.viaversion.viabackwards.protocol.v1_13_1to1_13.storage; import com.viaversion.viaversion.api.connection.StorableObject; diff --git a/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/Protocol1_17_1To1_17.java b/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/Protocol1_17_1To1_17.java index 4e23e824f..b7a1dfbcb 100644 --- a/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/Protocol1_17_1To1_17.java +++ b/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/Protocol1_17_1To1_17.java @@ -22,7 +22,6 @@ import com.viaversion.nbt.tag.StringTag; import com.viaversion.viabackwards.api.BackwardsProtocol; import com.viaversion.viabackwards.protocol.v1_17_1to1_17.storage.InventoryStateIds; -import com.viaversion.viabackwards.protocol.v1_17_1to1_17.storage.PlayerInventoryState; import com.viaversion.viabackwards.protocol.v1_17to1_16_4.storage.PlayerLastCursorItem; import com.viaversion.viaversion.api.connection.UserConnection; import com.viaversion.viaversion.api.minecraft.item.Item; @@ -58,13 +57,6 @@ protected void registerPackets() { registerClientbound(ClientboundPackets1_17_1.CONTAINER_CLOSE, wrapper -> { short containerId = wrapper.passthrough(Types.UNSIGNED_BYTE); wrapper.user().get(InventoryStateIds.class).removeStateId(containerId); - wrapper.user().get(PlayerInventoryState.class).setOpenContainerId(0); - }); - registerClientbound(ClientboundPackets1_17_1.OPEN_SCREEN, wrapper -> { - wrapper.user().get(PlayerInventoryState.class).setOpenContainerId(wrapper.passthrough(Types.VAR_INT)); - }); - registerClientbound(ClientboundPackets1_17_1.SET_CARRIED_ITEM, wrapper -> { - wrapper.user().get(PlayerInventoryState.class).setSelectedHotbarSlot(wrapper.passthrough(Types.BYTE)); }); registerClientbound(ClientboundPackets1_17_1.CONTAINER_SET_SLOT, wrapper -> { byte containerId = wrapper.passthrough(Types.BYTE); @@ -106,38 +98,6 @@ protected void registerPackets() { registerServerbound(ServerboundPackets1_17.CONTAINER_CLOSE, wrapper -> { byte containerId = wrapper.passthrough(Types.BYTE); wrapper.user().get(InventoryStateIds.class).removeStateId(containerId); - wrapper.user().get(PlayerInventoryState.class).setOpenContainerId(0); - }); - registerServerbound(ServerboundPackets1_17.SET_CARRIED_ITEM, wrapper -> { - wrapper.user().get(PlayerInventoryState.class).setSelectedHotbarSlot(wrapper.passthrough(Types.SHORT)); - }); - registerServerbound(ServerboundPackets1_17.PLAYER_ACTION, wrapper -> { - final int status = wrapper.passthrough(Types.VAR_INT); - if (status != 3 && status != 4) { // Drop stack / drop item - return; - } - - // 1.17.1+ servers skip the slot resync after a drop action, assuming the client predicted it - // (ServerPlayer#drop marks the remote slot as already known) - clients this far back don't predict - // drops, so the dropped stack ghosts in their hotbar. Replay the drop as a throw-click instead: - // the click path does get resynced. Clients only send drop actions with no screen open; if the - // server still has one open, the click would miss - leave the vanilla action alone there. - final PlayerInventoryState state = wrapper.user().get(PlayerInventoryState.class); - if (state.openContainerId() != 0) { - return; - } - - wrapper.cancel(); - final int stateId = wrapper.user().get(InventoryStateIds.class).getStateId(0); - final PacketWrapper click = wrapper.create(ServerboundPackets1_17.CONTAINER_CLICK); - click.write(Types.BYTE, (byte) 0); // Player inventory - click.write(Types.VAR_INT, stateId == Integer.MAX_VALUE ? 0 : stateId); - click.write(Types.SHORT, (short) (36 + state.selectedHotbarSlot())); - click.write(Types.BYTE, (byte) (status == 3 ? 1 : 0)); // Throw button: 1 = full stack, 0 = single - click.write(Types.VAR_INT, 4); // Throw - click.write(Types.VAR_INT, 0); // No claimed slot changes; the server resyncs the difference - click.write(Types.ITEM1_13_2, null); // Carried item stays empty - click.sendToServer(Protocol1_17_1To1_17.class); }); registerServerbound(ServerboundPackets1_17.CONTAINER_CLICK, wrapper -> { byte containerId = wrapper.passthrough(Types.BYTE); @@ -200,6 +160,5 @@ protected void registerPackets() { @Override public void init(UserConnection connection) { connection.put(new InventoryStateIds()); - connection.put(new PlayerInventoryState()); } } diff --git a/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/InventoryStateIds.java b/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/InventoryStateIds.java index 2c7a05066..3ad30ac0f 100644 --- a/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/InventoryStateIds.java +++ b/common/src/main/java/com/viaversion/viabackwards/protocol/v1_17_1to1_17/storage/InventoryStateIds.java @@ -32,10 +32,6 @@ public void setStateId(int containerId, int id) { ids.put(containerId, id); } - public int getStateId(int containerId) { - return ids.get(containerId); - } - public int removeStateId(int containerId) { return ids.remove(containerId); }