From eb7ae75cc038edda7bcb06e890f0d0f6cff7eaee Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Mon, 25 May 2026 23:33:47 +0200 Subject: [PATCH 01/50] Add internal packet GUI backend --- ...0006-add-internal-packet-gui-backend.patch | 2383 +++++++++++++++++ 1 file changed, 2383 insertions(+) create mode 100644 patches/0006-add-internal-packet-gui-backend.patch diff --git a/patches/0006-add-internal-packet-gui-backend.patch b/patches/0006-add-internal-packet-gui-backend.patch new file mode 100644 index 0000000..0050c01 --- /dev/null +++ b/patches/0006-add-internal-packet-gui-backend.patch @@ -0,0 +1,2383 @@ +From 19839bbfac4bcfb77c789015346b65f8a65cb3b2 Mon Sep 17 00:00:00 2001 +From: Keviro +Date: Mon, 25 May 2026 23:04:19 +0200 +Subject: [PATCH] Add internal packet GUI backend + +--- + gradle/libs.versions.toml | 7 +- + .../build.gradle.kts | 4 +- + .../inventoryframework/BukkitViewer.java | 7 +- + .../IFInventoryListener.java | 18 + + .../inventoryframework/ViewFrame.java | 10 +- + .../context/BukkitSlotClickOrigin.java | 89 +++ + .../context/CloseContext.java | 1 - + .../context/PacketSlotClickOrigin.java | 122 +++++ + .../context/RenderContext.java | 8 +- + .../context/SlotClickContext.java | 61 ++- + .../context/SlotClickOrigin.java | 42 ++ + .../internal/BukkitElementFactory.java | 18 +- + .../internal/BukkitGuiBackend.java | 20 + + .../internal/GuiBackend.java | 34 ++ + .../internal/GuiBackendFactory.java | 42 ++ + .../internal/packet/PacketGuiBackend.java | 511 ++++++++++++++++++ + .../internal/packet/PacketGuiClick.java | 73 +++ + .../packet/PacketGuiPacketListener.java | 122 +++++ + .../internal/packet/PacketGuiRender.java | 99 ++++ + .../internal/packet/PacketGuiSession.java | 116 ++++ + .../packet/PacketInventoryConstants.java | 39 ++ + .../internal/packet/PacketItemConverter.java | 37 ++ + .../internal/packet/PacketViewContainer.java | 229 ++++++++ + .../packet/PacketViewerInventory.java | 162 ++++++ + .../pipeline/GlobalClickInterceptor.java | 4 +- + .../pipeline/ItemClickInterceptor.java | 5 +- + .../pipeline/ItemCloseOnClickInterceptor.java | 5 +- + settings.gradle.kts | 2 + + 28 files changed, 1842 insertions(+), 45 deletions(-) + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/BukkitGuiBackend.java + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackend.java + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackendFactory.java + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java + create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java + +diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml +index 89850fa8..daec84d3 100644 +--- a/gradle/libs.versions.toml ++++ b/gradle/libs.versions.toml +@@ -14,6 +14,7 @@ plugin-spotless = "7.2.1" + plugin-bukkit = "0.7.1" + minestom = "b39badc77b" + folialib = "0.5.1" ++packetevents = "2.12.1" + + [libraries.spigot] + module = "org.spigotmc:spigot-api" +@@ -57,10 +58,14 @@ version.ref = "minestom" + module = "com.tcoded:FoliaLib" + version.ref = "folialib" + ++[libraries.packetevents-spigot] ++module = "com.github.retrooper:packetevents-spigot" ++version.ref = "packetevents" ++ + [plugins] + shadowjar = { id = "com.gradleup.shadow", version.ref = "plugin-shadowjar" } + spotless = { id = "com.diffplug.spotless", version.ref = "plugin-spotless" } + kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } + bukkit = { id = "de.eldoria.plugin-yml.bukkit", version.ref = "plugin-bukkit" } + runPaper = { id = "xyz.jpenilla.run-paper", version = "3.0.2" } +-publish = { id = "com.vanniktech.maven.publish.base", version = "0.34.0" } +\ No newline at end of file ++publish = { id = "com.vanniktech.maven.publish.base", version = "0.34.0" } +diff --git a/inventory-framework-platform-bukkit/build.gradle.kts b/inventory-framework-platform-bukkit/build.gradle.kts +index 6a29127f..237bf07e 100644 +--- a/inventory-framework-platform-bukkit/build.gradle.kts ++++ b/inventory-framework-platform-bukkit/build.gradle.kts +@@ -13,6 +13,7 @@ dependencies { + api(projects.inventoryFrameworkPlatform) + runtimeOnly(projects.inventoryFrameworkAnvilInput) + compileOnly(libs.spigot) ++ compileOnly(libs.packetevents.spigot) + testCompileOnly(libs.spigot) + testRuntimeOnly(libs.spigot) + testImplementation(projects.inventoryFrameworkApi) +@@ -39,5 +40,6 @@ bukkit { + website = "https://github.com/DevNatan/inventory-framework" + apiVersion = "1.13" + authors = listOf("SaiintBrisson", "DevNatan", "sasuked") ++ softDepend = listOf("packetevents") + foliaSupported = true +-} +\ No newline at end of file ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/BukkitViewer.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/BukkitViewer.java +index 16e47919..78c704d7 100644 +--- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/BukkitViewer.java ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/BukkitViewer.java +@@ -52,7 +52,12 @@ public final class BukkitViewer implements Viewer { + + @Override + public void open(@NotNull final ViewContainer container) { +- getPlayer().openInventory(((BukkitViewContainer) container).getInventory()); ++ if (container instanceof BukkitViewContainer) { ++ getPlayer().openInventory(((BukkitViewContainer) container).getInventory()); ++ return; ++ } ++ ++ container.open(this); + } + + @Override +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java +index e7d2c7a0..199757af 100644 +--- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java +@@ -5,6 +5,8 @@ import me.devnatan.inventoryframework.context.IFCloseContext; + import me.devnatan.inventoryframework.context.IFContext; + import me.devnatan.inventoryframework.context.IFRenderContext; + import me.devnatan.inventoryframework.context.IFSlotClickContext; ++import me.devnatan.inventoryframework.internal.BukkitGuiBackend; ++import me.devnatan.inventoryframework.internal.GuiBackend; + import me.devnatan.inventoryframework.pipeline.StandardPipelinePhases; + import org.bukkit.entity.Player; + import org.bukkit.event.EventHandler; +@@ -13,6 +15,7 @@ import org.bukkit.event.Listener; + import org.bukkit.event.inventory.InventoryClickEvent; + import org.bukkit.event.inventory.InventoryCloseEvent; + import org.bukkit.event.inventory.InventoryDragEvent; ++import org.bukkit.event.inventory.InventoryOpenEvent; + import org.bukkit.event.inventory.InventoryType; + import org.bukkit.event.player.PlayerDropItemEvent; + import org.bukkit.event.player.PlayerPickupItemEvent; +@@ -24,9 +27,15 @@ import org.bukkit.inventory.PlayerInventory; + final class IFInventoryListener implements Listener { + + private final ViewFrame viewFrame; ++ private final GuiBackend guiBackend; + + public IFInventoryListener(ViewFrame viewFrame) { ++ this(viewFrame, new BukkitGuiBackend()); ++ } ++ ++ public IFInventoryListener(ViewFrame viewFrame, GuiBackend guiBackend) { + this.viewFrame = viewFrame; ++ this.guiBackend = guiBackend; + } + + @EventHandler +@@ -39,6 +48,8 @@ final class IFInventoryListener implements Listener { + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + final Player player = (Player) event.getPlayer(); ++ if (guiBackend.handlePlayerQuit(player)) return; ++ + final Viewer viewer = viewFrame.getViewer(player); + if (viewer == null) return; + +@@ -49,6 +60,13 @@ final class IFInventoryListener implements Listener { + root.getPipeline().execute(StandardPipelinePhases.CLOSE, closeContext); + } + ++ @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) ++ public void onInventoryOpen(final InventoryOpenEvent event) { ++ if (!(event.getPlayer() instanceof Player)) return; ++ ++ guiBackend.handleExternalInventoryOpen((Player) event.getPlayer()); ++ } ++ + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onInventoryClick(final InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player)) return; +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/ViewFrame.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/ViewFrame.java +index 2d97a162..4dc75f81 100644 +--- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/ViewFrame.java ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/ViewFrame.java +@@ -14,6 +14,8 @@ import me.devnatan.inventoryframework.feature.DefaultFeatureInstaller; + import me.devnatan.inventoryframework.feature.Feature; + import me.devnatan.inventoryframework.feature.FeatureInstaller; + import me.devnatan.inventoryframework.internal.BukkitElementFactory; ++import me.devnatan.inventoryframework.internal.GuiBackend; ++import me.devnatan.inventoryframework.internal.GuiBackendFactory; + import me.devnatan.inventoryframework.internal.PlatformUtils; + import me.devnatan.inventoryframework.runtime.thirdparty.Metrics; + import org.bukkit.entity.Player; +@@ -35,10 +37,12 @@ public class ViewFrame extends IFViewFrame { + + "https://github.com/DevNatan/inventory-framework/wiki/Installation#preventing-library-conflicts"; + + private final Plugin owner; ++ private final GuiBackend guiBackend; + private final FeatureInstaller featureInstaller = new DefaultFeatureInstaller<>(this); + + private ViewFrame(Plugin owner) { + this.owner = owner; ++ this.guiBackend = GuiBackendFactory.create(owner); + } + + @NotNull +@@ -186,13 +190,14 @@ public class ViewFrame extends IFViewFrame { + public final ViewFrame register() { + if (isRegistered()) throw new IllegalStateException("This view frame is already registered"); + +- PlatformUtils.setFactory(new BukkitElementFactory(getOwner())); ++ PlatformUtils.setFactory(new BukkitElementFactory(getOwner(), guiBackend)); ++ guiBackend.register(); + tryEnableMetrics(); + checkRelocationIssues(); + setRegistered(true); + getPipeline().execute(IFViewFrame.FRAME_REGISTERED, this); + initializeViews(); +- getOwner().getServer().getPluginManager().registerEvents(new IFInventoryListener(this), getOwner()); ++ getOwner().getServer().getPluginManager().registerEvents(new IFInventoryListener(this, guiBackend), getOwner()); + return this; + } + +@@ -213,6 +218,7 @@ public class ViewFrame extends IFViewFrame { + iterator.remove(); + } + getPipeline().execute(IFViewFrame.FRAME_UNREGISTERED, this); ++ guiBackend.unregister(); + } + + // region Internals +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java +new file mode 100644 +index 00000000..07639c5a +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java +@@ -0,0 +1,89 @@ ++package me.devnatan.inventoryframework.context; ++ ++import org.bukkit.entity.Player; ++import org.bukkit.event.inventory.ClickType; ++import org.bukkit.event.inventory.InventoryClickEvent; ++import org.bukkit.event.inventory.InventoryType; ++import org.bukkit.inventory.ItemStack; ++import org.bukkit.inventory.PlayerInventory; ++import org.jetbrains.annotations.NotNull; ++import org.jetbrains.annotations.Nullable; ++ ++final class BukkitSlotClickOrigin implements SlotClickOrigin { ++ ++ private final InventoryClickEvent event; ++ ++ BukkitSlotClickOrigin(@NotNull InventoryClickEvent event) { ++ this.event = event; ++ } ++ ++ @Override ++ public @NotNull Player getPlayer() { ++ return (Player) event.getWhoClicked(); ++ } ++ ++ @Override ++ public @Nullable ItemStack getCurrentItem() { ++ return event.getCurrentItem(); ++ } ++ ++ @Override ++ public Object getPlatformEvent() { ++ return event; ++ } ++ ++ @Override ++ public int getRawSlot() { ++ return event.getRawSlot(); ++ } ++ ++ @Override ++ public boolean isLeftClick() { ++ return event.isLeftClick(); ++ } ++ ++ @Override ++ public boolean isRightClick() { ++ return event.isRightClick(); ++ } ++ ++ @Override ++ public boolean isMiddleClick() { ++ return event.getClick() == ClickType.MIDDLE; ++ } ++ ++ @Override ++ public boolean isShiftClick() { ++ return event.isShiftClick(); ++ } ++ ++ @Override ++ public boolean isKeyboardClick() { ++ return event.getClick().isKeyboardClick(); ++ } ++ ++ @Override ++ public boolean isOutsideClick() { ++ return event.getSlotType() == InventoryType.SlotType.OUTSIDE; ++ } ++ ++ @Override ++ public boolean isOnEntityContainer() { ++ return event.getClickedInventory() instanceof PlayerInventory; ++ } ++ ++ @Override ++ public @NotNull String getClickIdentifier() { ++ return event.getClick().name(); ++ } ++ ++ @Override ++ public boolean isCancelled() { ++ return event.isCancelled(); ++ } ++ ++ @Override ++ public void setCancelled(boolean cancelled) { ++ event.setCancelled(cancelled); ++ } ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/CloseContext.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/CloseContext.java +index d73ddb29..686664b4 100644 +--- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/CloseContext.java ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/CloseContext.java +@@ -12,7 +12,6 @@ import me.devnatan.inventoryframework.state.State; + import me.devnatan.inventoryframework.state.StateValue; + import me.devnatan.inventoryframework.state.StateWatcher; + import org.bukkit.entity.Player; +-import org.bukkit.event.inventory.InventoryCloseEvent; + import org.jetbrains.annotations.ApiStatus; + import org.jetbrains.annotations.NotNull; + import org.jetbrains.annotations.UnmodifiableView; +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java +new file mode 100644 +index 00000000..74af4430 +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java +@@ -0,0 +1,122 @@ ++package me.devnatan.inventoryframework.context; ++ ++import org.bukkit.entity.Player; ++import org.bukkit.inventory.ItemStack; ++import org.jetbrains.annotations.ApiStatus; ++import org.jetbrains.annotations.NotNull; ++import org.jetbrains.annotations.Nullable; ++ ++@ApiStatus.Internal ++public final class PacketSlotClickOrigin implements SlotClickOrigin { ++ ++ private final Player player; ++ private final ItemStack currentItem; ++ private final Object platformEvent; ++ private final int rawSlot; ++ private final String clickIdentifier; ++ private final boolean leftClick; ++ private final boolean rightClick; ++ private final boolean middleClick; ++ private final boolean shiftClick; ++ private final boolean keyboardClick; ++ private final boolean outsideClick; ++ private final boolean onEntityContainer; ++ private boolean cancelled = true; ++ ++ public PacketSlotClickOrigin( ++ @NotNull Player player, ++ @Nullable ItemStack currentItem, ++ Object platformEvent, ++ int rawSlot, ++ @NotNull String clickIdentifier, ++ boolean leftClick, ++ boolean rightClick, ++ boolean middleClick, ++ boolean shiftClick, ++ boolean keyboardClick, ++ boolean outsideClick, ++ boolean onEntityContainer) { ++ this.player = player; ++ this.currentItem = currentItem == null ? null : currentItem.clone(); ++ this.platformEvent = platformEvent; ++ this.rawSlot = rawSlot; ++ this.clickIdentifier = clickIdentifier; ++ this.leftClick = leftClick; ++ this.rightClick = rightClick; ++ this.middleClick = middleClick; ++ this.shiftClick = shiftClick; ++ this.keyboardClick = keyboardClick; ++ this.outsideClick = outsideClick; ++ this.onEntityContainer = onEntityContainer; ++ } ++ ++ @Override ++ public @NotNull Player getPlayer() { ++ return player; ++ } ++ ++ @Override ++ public @Nullable ItemStack getCurrentItem() { ++ return currentItem == null ? null : currentItem.clone(); ++ } ++ ++ @Override ++ public Object getPlatformEvent() { ++ return platformEvent; ++ } ++ ++ @Override ++ public int getRawSlot() { ++ return rawSlot; ++ } ++ ++ @Override ++ public boolean isLeftClick() { ++ return leftClick; ++ } ++ ++ @Override ++ public boolean isRightClick() { ++ return rightClick; ++ } ++ ++ @Override ++ public boolean isMiddleClick() { ++ return middleClick; ++ } ++ ++ @Override ++ public boolean isShiftClick() { ++ return shiftClick; ++ } ++ ++ @Override ++ public boolean isKeyboardClick() { ++ return keyboardClick; ++ } ++ ++ @Override ++ public boolean isOutsideClick() { ++ return outsideClick; ++ } ++ ++ @Override ++ public boolean isOnEntityContainer() { ++ return onEntityContainer; ++ } ++ ++ @Override ++ public @NotNull String getClickIdentifier() { ++ return clickIdentifier; ++ } ++ ++ @Override ++ public boolean isCancelled() { ++ return cancelled; ++ } ++ ++ @Override ++ public void setCancelled(boolean cancelled) { ++ this.cancelled = cancelled; ++ } ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java +index b89eae09..e1ccb7b9 100644 +--- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java +@@ -169,6 +169,12 @@ public final class RenderContext extends PlatformRenderContext This is an internal inventory-framework API that should not be used from outside of ++ * this library. No compatibility guarantees are provided. ++ */ ++ @ApiStatus.Internal ++ public SlotClickContext( ++ int slot, ++ @NotNull IFRenderContext parent, ++ @NotNull Viewer whoClicked, ++ @NotNull ViewContainer clickedContainer, ++ @Nullable Component clickedComponent, ++ @NotNull Object clickOrigin, ++ boolean combined) { + super(slot, parent); + this.whoClicked = whoClicked; + this.clickedContainer = clickedContainer; + this.clickedComponent = clickedComponent; +- this.clickOrigin = clickOrigin; ++ this.clickOrigin = normalizeOrigin(clickOrigin); ++ this.inventoryClickOrigin = clickOrigin instanceof InventoryClickEvent ? (InventoryClickEvent) clickOrigin : null; + this.combined = combined; ++ this.cancelled = this.clickOrigin.isCancelled(); + } + + /** + * The player who clicked on the slot. + */ + public final @NotNull Player getPlayer() { +- return (Player) clickOrigin.getWhoClicked(); ++ return clickOrigin.getPlayer(); + } + + /** +@@ -55,7 +71,11 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext + */ + @NotNull + public InventoryClickEvent getClickOrigin() { +- return clickOrigin; ++ if (inventoryClickOrigin == null) ++ throw new UnsupportedOperationException( ++ "InventoryClickEvent is not available when using the packet GUI backend."); ++ ++ return inventoryClickOrigin; + } + + /** +@@ -84,12 +104,12 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext + @Override + public final void setCancelled(boolean cancelled) { + this.cancelled = cancelled; +- getClickOrigin().setCancelled(cancelled); ++ clickOrigin.setCancelled(cancelled); + } + + @Override + public final Object getPlatformEvent() { +- return clickOrigin; ++ return clickOrigin.getPlatformEvent(); + } + + @Override +@@ -99,42 +119,42 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext + + @Override + public final boolean isLeftClick() { +- return getClickOrigin().isLeftClick(); ++ return clickOrigin.isLeftClick(); + } + + @Override + public final boolean isRightClick() { +- return getClickOrigin().isRightClick(); ++ return clickOrigin.isRightClick(); + } + + @Override + public final boolean isMiddleClick() { +- return getClickOrigin().getClick() == ClickType.MIDDLE; ++ return clickOrigin.isMiddleClick(); + } + + @Override + public final boolean isShiftClick() { +- return getClickOrigin().isShiftClick(); ++ return clickOrigin.isShiftClick(); + } + + @Override + public final boolean isKeyboardClick() { +- return getClickOrigin().getClick().isKeyboardClick(); ++ return clickOrigin.isKeyboardClick(); + } + + @Override + public final boolean isOutsideClick() { +- return getClickOrigin().getSlotType() == InventoryType.SlotType.OUTSIDE; ++ return clickOrigin.isOutsideClick(); + } + + @Override + public final String getClickIdentifier() { +- return getClickOrigin().getClick().name(); ++ return clickOrigin.getClickIdentifier(); + } + + @Override + public final boolean isOnEntityContainer() { +- return getClickOrigin().getClickedInventory() instanceof PlayerInventory; ++ return clickOrigin.isOnEntityContainer(); + } + + @Override +@@ -176,4 +196,11 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext + public final boolean isCombined() { + return combined; + } ++ ++ private SlotClickOrigin normalizeOrigin(Object origin) { ++ if (origin instanceof SlotClickOrigin) return (SlotClickOrigin) origin; ++ if (origin instanceof InventoryClickEvent) return new BukkitSlotClickOrigin((InventoryClickEvent) origin); ++ ++ throw new IllegalArgumentException("Unsupported click origin: " + origin.getClass().getName()); ++ } + } +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java +new file mode 100644 +index 00000000..7da19162 +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java +@@ -0,0 +1,42 @@ ++package me.devnatan.inventoryframework.context; ++ ++import org.bukkit.entity.Player; ++import org.bukkit.inventory.ItemStack; ++import org.jetbrains.annotations.ApiStatus; ++import org.jetbrains.annotations.NotNull; ++import org.jetbrains.annotations.Nullable; ++ ++@ApiStatus.Internal ++public interface SlotClickOrigin { ++ ++ @NotNull ++ Player getPlayer(); ++ ++ @Nullable ++ ItemStack getCurrentItem(); ++ ++ Object getPlatformEvent(); ++ ++ int getRawSlot(); ++ ++ boolean isLeftClick(); ++ ++ boolean isRightClick(); ++ ++ boolean isMiddleClick(); ++ ++ boolean isShiftClick(); ++ ++ boolean isKeyboardClick(); ++ ++ boolean isOutsideClick(); ++ ++ boolean isOnEntityContainer(); ++ ++ @NotNull ++ String getClickIdentifier(); ++ ++ boolean isCancelled(); ++ ++ void setCancelled(boolean cancelled); ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/BukkitElementFactory.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/BukkitElementFactory.java +index 50f57dbc..f6dec24a 100644 +--- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/BukkitElementFactory.java ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/BukkitElementFactory.java +@@ -16,10 +16,6 @@ import me.devnatan.inventoryframework.context.*; + import me.devnatan.inventoryframework.logging.Logger; + import me.devnatan.inventoryframework.logging.NoopLogger; + import org.bukkit.entity.Player; +-import org.bukkit.event.inventory.InventoryClickEvent; +-import org.bukkit.event.inventory.InventoryCloseEvent; +-import org.bukkit.inventory.Inventory; +-import org.bukkit.inventory.InventoryHolder; + import org.bukkit.plugin.Plugin; + import org.jetbrains.annotations.NotNull; + import org.jetbrains.annotations.Nullable; +@@ -30,9 +26,15 @@ public class BukkitElementFactory extends ElementFactory { + private Boolean worksInCurrentPlatform = null; + + private final FoliaLib foliaLib; ++ private final GuiBackend guiBackend; + + public BukkitElementFactory(Plugin plugin) { ++ this(plugin, new BukkitGuiBackend()); ++ } ++ ++ public BukkitElementFactory(Plugin plugin, GuiBackend guiBackend) { + this.foliaLib = new FoliaLib(plugin); ++ this.guiBackend = guiBackend; + } + + @Override +@@ -57,11 +59,7 @@ public class BukkitElementFactory extends ElementFactory { + finalType.getMaxSize(), + context.getRoot().getClass().getName())); + +- final InventoryHolder holder = context instanceof InventoryHolder ? (InventoryHolder) context : null; +- final Inventory inventory = +- InventoryFactory.current().createInventory(holder, finalType, size, config.getTitle()); +- +- return new BukkitViewContainer(inventory, false, finalType, false); ++ return guiBackend.createContainer(context, finalType, size, config.getTitle()); + } + + @Override +@@ -109,7 +107,7 @@ public class BukkitElementFactory extends ElementFactory { + whoClicked, + interactionContainer, + componentClicked, +- (InventoryClickEvent) origin, ++ origin, + combined); + } + +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/BukkitGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/BukkitGuiBackend.java +new file mode 100644 +index 00000000..d4e57da2 +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/BukkitGuiBackend.java +@@ -0,0 +1,20 @@ ++package me.devnatan.inventoryframework.internal; ++ ++import me.devnatan.inventoryframework.BukkitViewContainer; ++import me.devnatan.inventoryframework.ViewContainer; ++import me.devnatan.inventoryframework.ViewType; ++import me.devnatan.inventoryframework.context.IFContext; ++import org.bukkit.inventory.Inventory; ++import org.bukkit.inventory.InventoryHolder; ++import org.jetbrains.annotations.NotNull; ++ ++public final class BukkitGuiBackend implements GuiBackend { ++ ++ @Override ++ public @NotNull ViewContainer createContainer( ++ @NotNull IFContext context, @NotNull ViewType type, int size, Object title) { ++ final InventoryHolder holder = context instanceof InventoryHolder ? (InventoryHolder) context : null; ++ final Inventory inventory = InventoryFactory.current().createInventory(holder, type, size, title); ++ return new BukkitViewContainer(inventory, false, type, false); ++ } ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackend.java +new file mode 100644 +index 00000000..b7efdbc7 +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackend.java +@@ -0,0 +1,34 @@ ++package me.devnatan.inventoryframework.internal; ++ ++import me.devnatan.inventoryframework.ViewContainer; ++import me.devnatan.inventoryframework.ViewType; ++import me.devnatan.inventoryframework.context.IFContext; ++import org.bukkit.entity.Player; ++import org.jetbrains.annotations.ApiStatus; ++import org.jetbrains.annotations.NotNull; ++ ++/** ++ * Internal backend abstraction for platform inventory rendering. ++ */ ++@ApiStatus.Internal ++public interface GuiBackend { ++ ++ @NotNull ++ ViewContainer createContainer(@NotNull IFContext context, @NotNull ViewType type, int size, Object title); ++ ++ default void register() {} ++ ++ default void unregister() {} ++ ++ default boolean handlePlayerQuit(@NotNull Player player) { ++ return false; ++ } ++ ++ default boolean handleExternalInventoryOpen(@NotNull Player player) { ++ return false; ++ } ++ ++ default boolean isPacketBackend() { ++ return false; ++ } ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackendFactory.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackendFactory.java +new file mode 100644 +index 00000000..a490dc1a +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackendFactory.java +@@ -0,0 +1,42 @@ ++package me.devnatan.inventoryframework.internal; ++ ++import me.devnatan.inventoryframework.internal.packet.PacketGuiBackend; ++import org.bukkit.plugin.Plugin; ++import org.jetbrains.annotations.ApiStatus; ++import org.jetbrains.annotations.NotNull; ++ ++@ApiStatus.Internal ++public final class GuiBackendFactory { ++ ++ private static final String BACKEND_PROPERTY = "inventory-framework.gui-backend"; ++ private static final String PACKET_BACKEND = "packet"; ++ private static final String PACKET_EVENTS_FQN = "com.github.retrooper.packetevents.PacketEvents"; ++ ++ private GuiBackendFactory() {} ++ ++ public static @NotNull GuiBackend create(@NotNull Plugin owner) { ++ final BukkitGuiBackend bukkitBackend = new BukkitGuiBackend(); ++ final String configuredBackend = System.getProperty(BACKEND_PROPERTY, "bukkit"); ++ if (!PACKET_BACKEND.equalsIgnoreCase(configuredBackend)) { ++ return bukkitBackend; ++ } ++ ++ if (!isPacketEventsPresent()) { ++ owner.getLogger() ++ .warning("Packet GUI backend requested, but PacketEvents is not available. " ++ + "Falling back to the Bukkit inventory backend."); ++ return bukkitBackend; ++ } ++ ++ return new PacketGuiBackend(owner, bukkitBackend); ++ } ++ ++ private static boolean isPacketEventsPresent() { ++ try { ++ Class.forName(PACKET_EVENTS_FQN, false, GuiBackendFactory.class.getClassLoader()); ++ return true; ++ } catch (final ClassNotFoundException ignored) { ++ return false; ++ } ++ } ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java +new file mode 100644 +index 00000000..1d617f3a +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java +@@ -0,0 +1,511 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import com.github.retrooper.packetevents.PacketEvents; ++import com.github.retrooper.packetevents.event.PacketListenerCommon; ++import com.github.retrooper.packetevents.manager.server.ServerVersion; ++import com.github.retrooper.packetevents.protocol.player.User; ++import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerCloseWindow; ++import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerOpenWindow; ++import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetCursorItem; ++import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerWindowItems; ++import java.util.ArrayList; ++import java.util.List; ++import java.util.UUID; ++import java.util.concurrent.ConcurrentHashMap; ++import java.util.concurrent.ConcurrentMap; ++import java.util.concurrent.atomic.AtomicInteger; ++import java.util.logging.Level; ++import me.devnatan.inventoryframework.BukkitViewer; ++import me.devnatan.inventoryframework.RootView; ++import me.devnatan.inventoryframework.ViewContainer; ++import me.devnatan.inventoryframework.ViewType; ++import me.devnatan.inventoryframework.Viewer; ++import me.devnatan.inventoryframework.component.Component; ++import me.devnatan.inventoryframework.context.IFCloseContext; ++import me.devnatan.inventoryframework.context.IFRenderContext; ++import me.devnatan.inventoryframework.context.IFSlotClickContext; ++import me.devnatan.inventoryframework.context.PacketSlotClickOrigin; ++import me.devnatan.inventoryframework.internal.BukkitGuiBackend; ++import me.devnatan.inventoryframework.internal.GuiBackend; ++import me.devnatan.inventoryframework.pipeline.StandardPipelinePhases; ++import org.bukkit.Bukkit; ++import org.bukkit.entity.Player; ++import org.bukkit.plugin.Plugin; ++import org.jetbrains.annotations.NotNull; ++ ++public final class PacketGuiBackend implements GuiBackend { ++ ++ private static final int MAX_WINDOW_ID = 127; ++ private static final String CLOSE_ORIGIN_CLIENT = "packet-gui-client-close"; ++ private static final String CLOSE_ORIGIN_SERVER = "packet-gui-server-close"; ++ private static final String CLOSE_ORIGIN_QUIT = "packet-gui-player-quit"; ++ private static final String CLOSE_ORIGIN_EXTERNAL_OPEN = "packet-gui-external-inventory-open"; ++ private static final String CLOSE_ORIGIN_SHUTDOWN = "packet-gui-shutdown"; ++ ++ private final Plugin owner; ++ private final BukkitGuiBackend fallbackBackend; ++ private final ConcurrentMap sessions = new ConcurrentHashMap<>(); ++ private final ConcurrentMap viewerInventories = new ConcurrentHashMap<>(); ++ private final AtomicInteger nextWindowId = new AtomicInteger(1); ++ private PacketListenerCommon listener; ++ private volatile boolean available = true; ++ ++ public PacketGuiBackend(@NotNull Plugin owner, @NotNull BukkitGuiBackend fallbackBackend) { ++ this.owner = owner; ++ this.fallbackBackend = fallbackBackend; ++ } ++ ++ @Override ++ public @NotNull ViewContainer createContainer( ++ @NotNull me.devnatan.inventoryframework.context.IFContext context, ++ @NotNull ViewType type, ++ int size, ++ Object title) { ++ if (!available || !(context instanceof IFRenderContext)) { ++ return fallbackBackend.createContainer(context, type, size, title); ++ } ++ ++ if (!ViewType.CHEST.equals(type) || (size != 0 && size % type.getColumns() != 0)) { ++ owner.getLogger() ++ .fine("Packet GUI backend currently supports chest-style containers only. " ++ + "Using Bukkit backend for " + type.getIdentifier() + "."); ++ return fallbackBackend.createContainer(context, type, size, title); ++ } ++ ++ final int effectiveSize = size == 0 ? 27 : size; ++ return new PacketViewContainer(this, (IFRenderContext) context, type, effectiveSize, title); ++ } ++ ++ @Override ++ public void register() { ++ try { ++ if (PacketEvents.getAPI() == null || !PacketEvents.getAPI().isInitialized()) { ++ available = false; ++ owner.getLogger() ++ .warning("Packet GUI backend requested, but PacketEvents is not initialized. " ++ + "Falling back to the Bukkit inventory backend."); ++ return; ++ } ++ ++ listener = PacketEvents.getAPI().getEventManager().registerListener(new PacketGuiPacketListener(this)); ++ } catch (final RuntimeException exception) { ++ available = false; ++ owner.getLogger().log(Level.WARNING, "Failed to register packet GUI backend. Falling back to Bukkit.", exception); ++ } ++ } ++ ++ @Override ++ public void unregister() { ++ for (final PacketGuiSession session : List.copyOf(sessions.values())) { ++ closeSession(session, false, CLOSE_ORIGIN_SHUTDOWN, false); ++ } ++ sessions.clear(); ++ viewerInventories.clear(); ++ ++ if (listener == null) { ++ return; ++ } ++ ++ try { ++ PacketEvents.getAPI().getEventManager().unregisterListener(listener); ++ } catch (final RuntimeException exception) { ++ owner.getLogger().log(Level.WARNING, "Failed to unregister packet GUI listener", exception); ++ } finally { ++ listener = null; ++ } ++ } ++ ++ @Override ++ public boolean handlePlayerQuit(@NotNull Player player) { ++ final PacketGuiSession session = sessions.get(player.getUniqueId()); ++ if (session == null) return false; ++ ++ closeSession(session, false, CLOSE_ORIGIN_QUIT, true); ++ viewerInventories.remove(player.getUniqueId()); ++ return true; ++ } ++ ++ @Override ++ public boolean handleExternalInventoryOpen(@NotNull Player player) { ++ final PacketGuiSession session = sessions.get(player.getUniqueId()); ++ if (session == null) return false; ++ ++ closeSession(session, false, CLOSE_ORIGIN_EXTERNAL_OPEN, true); ++ return true; ++ } ++ ++ @Override ++ public boolean isPacketBackend() { ++ return available; ++ } ++ ++ void open(@NotNull BukkitViewer viewer, @NotNull PacketViewContainer container) { ++ if (!Bukkit.isPrimaryThread()) { ++ runMain(() -> open(viewer, container)); ++ return; ++ } ++ ++ final Player player = viewer.getPlayer(); ++ final User user = user(player.getUniqueId()); ++ if (user == null) { ++ owner.getLogger() ++ .warning("Unable to open packet GUI for " + player.getName() ++ + ": PacketEvents has no user for this player."); ++ return; ++ } ++ ++ final PacketGuiSession previous = sessions.get(player.getUniqueId()); ++ if (previous != null) { ++ closeSession(previous, false, CLOSE_ORIGIN_SERVER, true); ++ } ++ ++ final PacketViewerInventory viewerInventory = inventoryFor(player.getUniqueId()); ++ viewerInventory.snapshotFrom(player); ++ ++ final PacketGuiSession session = ++ new PacketGuiSession(viewer, user, allocateWindowId(), container, viewerInventory); ++ sessions.put(player.getUniqueId(), session); ++ fullResync(session, true); ++ } ++ ++ void close(@NotNull PacketViewContainer container, boolean sendClosePacket) { ++ for (final PacketGuiSession session : List.copyOf(sessions.values())) { ++ if (session.container() == container) { ++ closeSession(session, sendClosePacket, CLOSE_ORIGIN_SERVER, true); ++ } ++ } ++ } ++ ++ void close(@NotNull PacketViewContainer container, @NotNull BukkitViewer viewer, boolean sendClosePacket) { ++ final PacketGuiSession session = sessions.get(viewer.getPlayer().getUniqueId()); ++ if (session == null || session.container() != container) { ++ return; ++ } ++ ++ closeSession(session, sendClosePacket, CLOSE_ORIGIN_SERVER, true); ++ } ++ ++ void requestResync(@NotNull PacketViewContainer container) { ++ for (final PacketGuiSession session : sessions.values()) { ++ if (session.container() != container || !session.markResyncScheduled()) { ++ continue; ++ } ++ ++ runMain(() -> { ++ session.clearResyncScheduled(); ++ fullResync(session, false); ++ }); ++ } ++ } ++ ++ void requestReopen(@NotNull PacketViewContainer container, @NotNull BukkitViewer viewer) { ++ final PacketGuiSession session = sessions.get(viewer.getPlayer().getUniqueId()); ++ if (session == null || session.container() != container) { ++ return; ++ } ++ ++ runMain(() -> fullResync(session, true)); ++ } ++ ++ boolean isGuiWindow(User user, int windowId) { ++ if (user == null || user.getUUID() == null) { ++ return false; ++ } ++ ++ final PacketGuiSession session = sessions.get(user.getUUID()); ++ return isTracked(session) && session.windowId() == windowId; ++ } ++ ++ void handleWindowClick(User user, PacketGuiClick click) { ++ if (user == null || user.getUUID() == null) { ++ return; ++ } ++ ++ final PacketGuiSession session = sessions.get(user.getUUID()); ++ if (!isTracked(session) || session.windowId() != click.windowId()) { ++ return; ++ } ++ ++ if (!click.isSafeTopPickup(session.container().getSize())) { ++ runMain(() -> fullResync(session, false)); ++ return; ++ } ++ ++ runMain(() -> handleSafeTopClick(session, click)); ++ } ++ ++ void handleWindowClose(User user, int windowId) { ++ if (user == null || user.getUUID() == null) { ++ return; ++ } ++ ++ final PacketGuiSession session = sessions.get(user.getUUID()); ++ if (!isTracked(session) || session.windowId() != windowId) { ++ return; ++ } ++ ++ runMain(() -> closeSession(session, false, CLOSE_ORIGIN_CLIENT, true)); ++ } ++ ++ void handleExternalInventoryOpen(User user, int windowId, int topSize) { ++ if (user == null || user.getUUID() == null) { ++ return; ++ } ++ ++ if (!isGuiWindow(user, windowId)) { ++ inventoryFor(user.getUUID()).setOpenWindow(windowId, topSize); ++ } ++ ++ final PacketGuiSession session = sessions.get(user.getUUID()); ++ if (session != null && !isGuiWindow(user, windowId)) { ++ runMain(() -> closeSession(session, false, CLOSE_ORIGIN_EXTERNAL_OPEN, true)); ++ } ++ } ++ ++ void handleInventoryClosePacket(User user, int windowId) { ++ if (user == null || user.getUUID() == null) { ++ return; ++ } ++ ++ inventoryFor(user.getUUID()).closeWindow(windowId); ++ } ++ ++ void handleDisconnect(UUID viewerId) { ++ if (viewerId == null) { ++ return; ++ } ++ ++ final PacketGuiSession session = sessions.get(viewerId); ++ if (session != null) { ++ closeSession(session, false, CLOSE_ORIGIN_QUIT, false); ++ } ++ viewerInventories.remove(viewerId); ++ } ++ ++ void trackWindowItems( ++ User user, ++ int windowId, ++ List items, ++ com.github.retrooper.packetevents.protocol.item.ItemStack carried) { ++ if (user == null || user.getUUID() == null) { ++ return; ++ } ++ ++ final PacketViewerInventory inventory = inventoryFor(user.getUUID()); ++ if (windowId == PacketInventoryConstants.PLAYER_WINDOW_ID) { ++ inventory.applyPlayerWindowItems(items, carried); ++ return; ++ } ++ ++ inventory.applyContainerWindowItems(windowId, items, carried); ++ } ++ ++ void trackPlayerInventorySlot( ++ User user, int slot, com.github.retrooper.packetevents.protocol.item.ItemStack item) { ++ if (user == null || user.getUUID() == null) { ++ return; ++ } ++ ++ inventoryFor(user.getUUID()).applySlot(PacketInventoryConstants.playerInventorySlotToContainerSlot(slot), item); ++ } ++ ++ void trackWindowSlot( ++ User user, int windowId, int slot, com.github.retrooper.packetevents.protocol.item.ItemStack item) { ++ if (user == null || user.getUUID() == null || slot < 0) { ++ return; ++ } ++ ++ final PacketViewerInventory inventory = inventoryFor(user.getUUID()); ++ if (windowId == PacketInventoryConstants.PLAYER_WINDOW_ID) { ++ inventory.applySlot(slot, item); ++ return; ++ } ++ ++ final PacketGuiSession session = sessions.get(user.getUUID()); ++ final int mappedSlot = isTracked(session) && session.windowId() == windowId ++ ? PacketViewerInventory.mapGuiContainerSlotToPlayerSlot(session.container().getSize(), slot) ++ : inventory.mapContainerSlotToPlayerSlot(windowId, slot); ++ if (mappedSlot >= 0) { ++ inventory.applySlot(mappedSlot, item); ++ } ++ } ++ ++ void trackCursor(User user, com.github.retrooper.packetevents.protocol.item.ItemStack item) { ++ if (user == null || user.getUUID() == null) { ++ return; ++ } ++ ++ inventoryFor(user.getUUID()).applyCursor(item); ++ } ++ ++ private void handleSafeTopClick(PacketGuiSession session, PacketGuiClick click) { ++ if (!isTracked(session)) { ++ return; ++ } ++ ++ final IFRenderContext context = session.context(); ++ final Component clickedComponent = context.getComponentsAt(click.slot()).stream() ++ .filter(Component::isVisible) ++ .findFirst() ++ .orElse(null); ++ ++ final PacketSlotClickOrigin origin = new PacketSlotClickOrigin( ++ session.player(), ++ session.container().item(click.slot()), ++ click, ++ click.slot(), ++ click.clickIdentifier(), ++ click.isLeftClick(), ++ click.isRightClick(), ++ false, ++ false, ++ false, ++ false, ++ false); ++ ++ try { ++ final IFSlotClickContext clickContext = context.getRoot() ++ .getElementFactory() ++ .createSlotClickContext( ++ click.slot(), ++ session.viewer(), ++ context.getContainer(), ++ clickedComponent, ++ origin, ++ false); ++ ++ context.getRoot().getPipeline().execute(StandardPipelinePhases.CLICK, clickContext); ++ } catch (final RuntimeException exception) { ++ owner.getLogger().log(Level.SEVERE, "An error occurred while processing a packet GUI click", exception); ++ closeSession(session, true, CLOSE_ORIGIN_SERVER, true); ++ return; ++ } ++ ++ fullResync(session, false); ++ } ++ ++ private void fullResync(PacketGuiSession session, boolean forceReopen) { ++ if (!Bukkit.isPrimaryThread()) { ++ runMain(() -> fullResync(session, forceReopen)); ++ return; ++ } ++ ++ if (!isTracked(session)) { ++ return; ++ } ++ ++ session.viewerInventory().snapshotFrom(session.player()); ++ final PacketGuiRender render = PacketGuiRender.from(session.container(), session.viewer().getId()); ++ final PacketGuiRender previous = session.appliedRender(); ++ session.currentRender(render); ++ ++ try { ++ if (forceReopen || !render.sameWindow(previous)) { ++ sendOpenWindow(session, render); ++ } ++ ++ sendWindowItems(session, render); ++ sendCursor(session); ++ session.appliedRender(render); ++ } catch (final RuntimeException exception) { ++ owner.getLogger().log(Level.WARNING, "Failed to send packet GUI render", exception); ++ closeSession(session, false, CLOSE_ORIGIN_SERVER, true); ++ } ++ } ++ ++ private void sendOpenWindow(PacketGuiSession session, PacketGuiRender render) { ++ final ServerVersion version = PacketEvents.getAPI().getServerManager().getVersion(); ++ final WrapperPlayServerOpenWindow packet; ++ if (version.isNewerThanOrEquals(ServerVersion.V_1_14)) { ++ packet = new WrapperPlayServerOpenWindow(session.windowId(), render.rows() - 1, render.title()); ++ } else { ++ packet = new WrapperPlayServerOpenWindow( ++ session.windowId(), "minecraft:chest", render.title(), render.size(), 0); ++ } ++ ++ session.user().sendPacket(packet); ++ } ++ ++ private void sendWindowItems(PacketGuiSession session, PacketGuiRender render) { ++ final List items = ++ new ArrayList<>(render.size() + 36); ++ items.addAll(render.packetTopItems()); ++ items.addAll(session.viewerInventory().mainAndHotbarItems()); ++ ++ session.user() ++ .sendPacket(new WrapperPlayServerWindowItems( ++ session.windowId(), ++ session.nextStateId(), ++ items, ++ session.viewerInventory().cursor())); ++ } ++ ++ private void sendCursor(PacketGuiSession session) { ++ session.user().sendPacket(new WrapperPlayServerSetCursorItem(session.viewerInventory().cursor())); ++ } ++ ++ private boolean closeSession(PacketGuiSession session, boolean sendClosePacket, Object origin, boolean callClose) { ++ if (session == null) { ++ return false; ++ } ++ ++ synchronized (session) { ++ if (session.closed()) { ++ return false; ++ } ++ session.closeRequested(true); ++ session.closed(true); ++ } ++ ++ sessions.remove(session.viewerId(), session); ++ ++ if (sendClosePacket) { ++ try { ++ sendCursor(session); ++ session.user().sendPacket(new WrapperPlayServerCloseWindow(session.windowId())); ++ } catch (final RuntimeException exception) { ++ owner.getLogger().log(Level.WARNING, "Failed to send packet GUI close packet", exception); ++ } ++ } ++ ++ if (callClose) { ++ executeClosePipeline(session, origin); ++ } ++ ++ return true; ++ } ++ ++ private void executeClosePipeline(PacketGuiSession session, Object origin) { ++ final IFRenderContext context = session.context(); ++ final RootView root = context.getRoot(); ++ final Viewer viewer = session.viewer(); ++ final IFCloseContext closeContext = root.getElementFactory().createCloseContext(viewer, context, origin); ++ root.getPipeline().execute(StandardPipelinePhases.CLOSE, closeContext); ++ } ++ ++ private PacketViewerInventory inventoryFor(UUID viewerId) { ++ return viewerInventories.computeIfAbsent(viewerId, ignored -> new PacketViewerInventory()); ++ } ++ ++ private User user(UUID viewerId) { ++ final Object channel = PacketEvents.getAPI().getProtocolManager().getChannel(viewerId); ++ return channel == null ? null : PacketEvents.getAPI().getProtocolManager().getUser(channel); ++ } ++ ++ private boolean isTracked(PacketGuiSession session) { ++ return session != null && !session.closed() && sessions.get(session.viewerId()) == session; ++ } ++ ++ private int allocateWindowId() { ++ return Math.max(1, nextWindowId.getAndUpdate(previous -> previous >= MAX_WINDOW_ID ? 1 : previous + 1)); ++ } ++ ++ private void runMain(Runnable task) { ++ if (Bukkit.isPrimaryThread()) { ++ task.run(); ++ return; ++ } ++ ++ Bukkit.getScheduler().runTask(owner, task); ++ } ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java +new file mode 100644 +index 00000000..ba07a12d +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java +@@ -0,0 +1,73 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientClickWindow; ++ ++final class PacketGuiClick { ++ ++ private final int windowId; ++ private final int slot; ++ private final int button; ++ private final WrapperPlayClientClickWindow.WindowClickType clickType; ++ ++ private PacketGuiClick( ++ int windowId, int slot, int button, WrapperPlayClientClickWindow.WindowClickType clickType) { ++ this.windowId = windowId; ++ this.slot = slot; ++ this.button = button; ++ this.clickType = clickType; ++ } ++ ++ static PacketGuiClick from(WrapperPlayClientClickWindow packet) { ++ return new PacketGuiClick( ++ packet.getWindowId(), ++ packet.getSlot(), ++ packet.getButton(), ++ packet.getWindowClickType()); ++ } ++ ++ int windowId() { ++ return windowId; ++ } ++ ++ int slot() { ++ return slot; ++ } ++ ++ int button() { ++ return button; ++ } ++ ++ boolean isSafeTopPickup(int topSize) { ++ return clickType == WrapperPlayClientClickWindow.WindowClickType.PICKUP ++ && slot >= 0 ++ && slot < topSize ++ && (button == 0 || button == 1); ++ } ++ ++ boolean isLeftClick() { ++ return clickType == WrapperPlayClientClickWindow.WindowClickType.PICKUP && button == 0; ++ } ++ ++ boolean isRightClick() { ++ return clickType == WrapperPlayClientClickWindow.WindowClickType.PICKUP && button == 1; ++ } ++ ++ String clickIdentifier() { ++ if (clickType == WrapperPlayClientClickWindow.WindowClickType.PICKUP) { ++ if (button == 0) return "LEFT"; ++ if (button == 1) return "RIGHT"; ++ } ++ ++ return clickType.name(); ++ } ++ ++ @Override ++ public String toString() { ++ return "PacketGuiClick{" ++ + "windowId=" + windowId ++ + ", slot=" + slot ++ + ", button=" + button ++ + ", clickType=" + clickType ++ + '}'; ++ } ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java +new file mode 100644 +index 00000000..b10d5a0e +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java +@@ -0,0 +1,122 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import com.github.retrooper.packetevents.event.PacketListenerAbstract; ++import com.github.retrooper.packetevents.event.PacketListenerPriority; ++import com.github.retrooper.packetevents.event.PacketReceiveEvent; ++import com.github.retrooper.packetevents.event.PacketSendEvent; ++import com.github.retrooper.packetevents.event.UserDisconnectEvent; ++import com.github.retrooper.packetevents.protocol.ConnectionState; ++import com.github.retrooper.packetevents.protocol.item.ItemStack; ++import com.github.retrooper.packetevents.protocol.packettype.PacketType; ++import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; ++import com.github.retrooper.packetevents.protocol.player.User; ++import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientClickWindow; ++import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientCloseWindow; ++import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerCloseWindow; ++import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerOpenHorseWindow; ++import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerOpenWindow; ++import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetCursorItem; ++import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetPlayerInventory; ++import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetSlot; ++import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerWindowItems; ++ ++final class PacketGuiPacketListener extends PacketListenerAbstract { ++ ++ private final PacketGuiBackend backend; ++ ++ PacketGuiPacketListener(PacketGuiBackend backend) { ++ super(PacketListenerPriority.HIGH); ++ this.backend = backend; ++ } ++ ++ @Override ++ public void onPacketReceive(PacketReceiveEvent event) { ++ if (event.getConnectionState() != ConnectionState.PLAY) { ++ return; ++ } ++ ++ final PacketTypeCommon packetType = event.getPacketType(); ++ if (packetType == PacketType.Play.Client.CLICK_WINDOW) { ++ final WrapperPlayClientClickWindow packet = new WrapperPlayClientClickWindow(event); ++ if (!backend.isGuiWindow(event.getUser(), packet.getWindowId())) { ++ return; ++ } ++ ++ event.setCancelled(true); ++ backend.handleWindowClick(event.getUser(), PacketGuiClick.from(packet)); ++ return; ++ } ++ ++ if (packetType == PacketType.Play.Client.CLOSE_WINDOW) { ++ final WrapperPlayClientCloseWindow packet = new WrapperPlayClientCloseWindow(event); ++ if (!backend.isGuiWindow(event.getUser(), packet.getWindowId())) { ++ return; ++ } ++ ++ event.setCancelled(true); ++ backend.handleWindowClose(event.getUser(), packet.getWindowId()); ++ } ++ } ++ ++ @Override ++ public void onPacketSend(PacketSendEvent event) { ++ if (event.getConnectionState() != ConnectionState.PLAY) { ++ return; ++ } ++ ++ final User user = event.getUser(); ++ final PacketTypeCommon packetType = event.getPacketType(); ++ if (packetType == PacketType.Play.Server.WINDOW_ITEMS) { ++ final WrapperPlayServerWindowItems packet = new WrapperPlayServerWindowItems(event); ++ final ItemStack carried = packet.getCarriedItem().orElse(ItemStack.EMPTY); ++ backend.trackWindowItems(user, packet.getWindowId(), packet.getItems(), carried); ++ return; ++ } ++ ++ if (packetType == PacketType.Play.Server.SET_PLAYER_INVENTORY) { ++ final WrapperPlayServerSetPlayerInventory packet = new WrapperPlayServerSetPlayerInventory(event); ++ backend.trackPlayerInventorySlot(user, packet.getSlot(), packet.getStack()); ++ return; ++ } ++ ++ if (packetType == PacketType.Play.Server.SET_SLOT) { ++ final WrapperPlayServerSetSlot packet = new WrapperPlayServerSetSlot(event); ++ backend.trackWindowSlot(user, packet.getWindowId(), packet.getSlot(), packet.getItem()); ++ return; ++ } ++ ++ if (packetType == PacketType.Play.Server.SET_CURSOR_ITEM) { ++ final WrapperPlayServerSetCursorItem packet = new WrapperPlayServerSetCursorItem(event); ++ backend.trackCursor(user, packet.getStack()); ++ return; ++ } ++ ++ if (packetType == PacketType.Play.Server.OPEN_WINDOW) { ++ final WrapperPlayServerOpenWindow packet = new WrapperPlayServerOpenWindow(event); ++ if (!backend.isGuiWindow(user, packet.getContainerId())) { ++ backend.handleExternalInventoryOpen(user, packet.getContainerId(), -1); ++ } ++ return; ++ } ++ ++ if (packetType == PacketType.Play.Server.OPEN_HORSE_WINDOW) { ++ final WrapperPlayServerOpenHorseWindow packet = new WrapperPlayServerOpenHorseWindow(event); ++ backend.handleExternalInventoryOpen(user, packet.getWindowId(), packet.getSlotCount()); ++ return; ++ } ++ ++ if (packetType == PacketType.Play.Server.CLOSE_WINDOW) { ++ final WrapperPlayServerCloseWindow packet = new WrapperPlayServerCloseWindow(event); ++ backend.handleInventoryClosePacket(user, packet.getWindowId()); ++ } ++ } ++ ++ @Override ++ public void onUserDisconnect(UserDisconnectEvent event) { ++ if (event.getUser() == null) { ++ return; ++ } ++ ++ backend.handleDisconnect(event.getUser().getUUID()); ++ } ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java +new file mode 100644 +index 00000000..8d6fd461 +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java +@@ -0,0 +1,99 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import java.util.ArrayList; ++import java.util.Arrays; ++import java.util.List; ++import java.util.Objects; ++import net.kyori.adventure.text.Component; ++import org.bukkit.inventory.ItemStack; ++ ++final class PacketGuiRender { ++ ++ private final Object rawTitle; ++ private final Component title; ++ private final int rows; ++ private final ItemStack[] topItems; ++ ++ private PacketGuiRender(Object rawTitle, Component title, int rows, ItemStack[] topItems) { ++ this.rawTitle = rawTitle; ++ this.title = title; ++ this.rows = rows; ++ this.topItems = topItems; ++ } ++ ++ static PacketGuiRender from(PacketViewContainer container) { ++ final ItemStack[] topItems = container.snapshotItems(); ++ return new PacketGuiRender( ++ container.getRawTitle(null), ++ titleComponent(container.getRawTitle(null)), ++ Math.max(1, container.getRowsCount()), ++ topItems); ++ } ++ ++ static PacketGuiRender from(PacketViewContainer container, String viewerId) { ++ final ItemStack[] topItems = container.snapshotItems(); ++ return new PacketGuiRender( ++ container.getRawTitle(viewerId), ++ titleComponent(container.getRawTitle(viewerId)), ++ Math.max(1, container.getRowsCount()), ++ topItems); ++ } ++ ++ Component title() { ++ return title; ++ } ++ ++ int rows() { ++ return rows; ++ } ++ ++ int size() { ++ return topItems.length; ++ } ++ ++ ItemStack bukkitItem(int slot) { ++ final ItemStack item = topItems[slot]; ++ return item == null ? null : item.clone(); ++ } ++ ++ List packetTopItems() { ++ final List items = new ArrayList<>(topItems.length); ++ for (final ItemStack item : topItems) { ++ items.add(PacketItemConverter.toPacket(item)); ++ } ++ return items; ++ } ++ ++ boolean sameWindow(PacketGuiRender other) { ++ return other != null && rows == other.rows && Objects.equals(rawTitle, other.rawTitle); ++ } ++ ++ @Override ++ public boolean equals(Object o) { ++ if (this == o) return true; ++ if (!(o instanceof PacketGuiRender)) return false; ++ final PacketGuiRender that = (PacketGuiRender) o; ++ return rows == that.rows ++ && Objects.equals(rawTitle, that.rawTitle) ++ && Arrays.equals(topItems, that.topItems); ++ } ++ ++ @Override ++ public int hashCode() { ++ int result = Objects.hash(rawTitle, rows); ++ result = 31 * result + Arrays.hashCode(topItems); ++ return result; ++ } ++ ++ private static Component titleComponent(Object title) { ++ if (title instanceof Component) { ++ return (Component) title; ++ } ++ ++ if (title == null) { ++ return Component.empty(); ++ } ++ ++ return Component.text(String.valueOf(title)); ++ } ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java +new file mode 100644 +index 00000000..c70570ac +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java +@@ -0,0 +1,116 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import com.github.retrooper.packetevents.protocol.player.User; ++import java.util.UUID; ++import java.util.concurrent.atomic.AtomicBoolean; ++import me.devnatan.inventoryframework.BukkitViewer; ++import me.devnatan.inventoryframework.context.IFRenderContext; ++import org.bukkit.entity.Player; ++ ++final class PacketGuiSession { ++ ++ private final UUID viewerId; ++ private final BukkitViewer viewer; ++ private final Player player; ++ private final User user; ++ private final int windowId; ++ private final PacketViewContainer container; ++ private final PacketViewerInventory viewerInventory; ++ private final AtomicBoolean resyncScheduled = new AtomicBoolean(); ++ private PacketGuiRender currentRender; ++ private PacketGuiRender appliedRender; ++ private boolean closed; ++ private boolean closeRequested; ++ private int stateId = 1; ++ ++ PacketGuiSession( ++ BukkitViewer viewer, ++ User user, ++ int windowId, ++ PacketViewContainer container, ++ PacketViewerInventory viewerInventory) { ++ this.viewer = viewer; ++ this.player = viewer.getPlayer(); ++ this.viewerId = player.getUniqueId(); ++ this.user = user; ++ this.windowId = windowId; ++ this.container = container; ++ this.viewerInventory = viewerInventory; ++ } ++ ++ synchronized UUID viewerId() { ++ return viewerId; ++ } ++ ++ synchronized BukkitViewer viewer() { ++ return viewer; ++ } ++ ++ synchronized Player player() { ++ return player; ++ } ++ ++ synchronized User user() { ++ return user; ++ } ++ ++ synchronized int windowId() { ++ return windowId; ++ } ++ ++ synchronized PacketViewContainer container() { ++ return container; ++ } ++ ++ synchronized IFRenderContext context() { ++ return container.getContext(); ++ } ++ ++ synchronized PacketViewerInventory viewerInventory() { ++ return viewerInventory; ++ } ++ ++ synchronized PacketGuiRender currentRender() { ++ return currentRender; ++ } ++ ++ synchronized void currentRender(PacketGuiRender currentRender) { ++ this.currentRender = currentRender; ++ } ++ ++ synchronized PacketGuiRender appliedRender() { ++ return appliedRender; ++ } ++ ++ synchronized void appliedRender(PacketGuiRender appliedRender) { ++ this.appliedRender = appliedRender; ++ } ++ ++ synchronized boolean closed() { ++ return closed; ++ } ++ ++ synchronized void closed(boolean closed) { ++ this.closed = closed; ++ } ++ ++ synchronized boolean closeRequested() { ++ return closeRequested; ++ } ++ ++ synchronized void closeRequested(boolean closeRequested) { ++ this.closeRequested = closeRequested; ++ } ++ ++ synchronized int nextStateId() { ++ return stateId++; ++ } ++ ++ boolean markResyncScheduled() { ++ return resyncScheduled.compareAndSet(false, true); ++ } ++ ++ void clearResyncScheduled() { ++ resyncScheduled.set(false); ++ } ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java +new file mode 100644 +index 00000000..aa6ab4ab +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java +@@ -0,0 +1,39 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++final class PacketInventoryConstants { ++ ++ static final int PLAYER_WINDOW_ID = 0; ++ static final int INVENTORY_SIZE = 46; ++ ++ static final int SLOT_HELMET = 5; ++ static final int SLOT_CHESTPLATE = 6; ++ static final int SLOT_LEGGINGS = 7; ++ static final int SLOT_BOOTS = 8; ++ ++ static final int ITEMS_START = 9; ++ static final int HOTBAR_START = 36; ++ static final int SLOT_OFFHAND = 45; ++ ++ private PacketInventoryConstants() {} ++ ++ static int playerInventorySlotToContainerSlot(int playerInventorySlot) { ++ if (playerInventorySlot < 0) return -1; ++ if (playerInventorySlot <= 8) return HOTBAR_START + playerInventorySlot; ++ if (playerInventorySlot <= 35) return playerInventorySlot; ++ ++ switch (playerInventorySlot) { ++ case 36: ++ return SLOT_BOOTS; ++ case 37: ++ return SLOT_LEGGINGS; ++ case 38: ++ return SLOT_CHESTPLATE; ++ case 39: ++ return SLOT_HELMET; ++ case 40: ++ return SLOT_OFFHAND; ++ default: ++ return -1; ++ } ++ } ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java +new file mode 100644 +index 00000000..bccc4cfc +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java +@@ -0,0 +1,37 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import io.github.retrooper.packetevents.util.SpigotConversionUtil; ++import org.bukkit.Material; ++import org.bukkit.inventory.ItemStack; ++ ++final class PacketItemConverter { ++ ++ private PacketItemConverter() {} ++ ++ static com.github.retrooper.packetevents.protocol.item.ItemStack toPacket(ItemStack item) { ++ if (item == null || item.getType() == Material.AIR) { ++ return com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY; ++ } ++ ++ final com.github.retrooper.packetevents.protocol.item.ItemStack converted = ++ SpigotConversionUtil.fromBukkitItemStack(item); ++ return converted == null || converted.isEmpty() ++ ? com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY ++ : converted.copy(); ++ } ++ ++ static ItemStack toBukkit(com.github.retrooper.packetevents.protocol.item.ItemStack item) { ++ if (item == null || item.isEmpty()) { ++ return null; ++ } ++ ++ return SpigotConversionUtil.toBukkitItemStack(item.copy()); ++ } ++ ++ static com.github.retrooper.packetevents.protocol.item.ItemStack copy( ++ com.github.retrooper.packetevents.protocol.item.ItemStack item) { ++ return item == null || item.isEmpty() ++ ? com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY ++ : item.copy(); ++ } ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java +new file mode 100644 +index 00000000..8e578b11 +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java +@@ -0,0 +1,229 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import java.util.Arrays; ++import java.util.Map; ++import java.util.Objects; ++import java.util.concurrent.ConcurrentHashMap; ++import me.devnatan.inventoryframework.BukkitViewer; ++import me.devnatan.inventoryframework.ViewContainer; ++import me.devnatan.inventoryframework.ViewType; ++import me.devnatan.inventoryframework.Viewer; ++import me.devnatan.inventoryframework.context.IFRenderContext; ++import org.bukkit.inventory.ItemStack; ++import org.jetbrains.annotations.NotNull; ++import org.jetbrains.annotations.Nullable; ++ ++public final class PacketViewContainer implements ViewContainer { ++ ++ private final PacketGuiBackend backend; ++ private final IFRenderContext context; ++ private final ViewType type; ++ private final int size; ++ private final ItemStack[] topItems; ++ private final Map viewerTitles = new ConcurrentHashMap<>(); ++ private Object title; ++ ++ PacketViewContainer( ++ @NotNull PacketGuiBackend backend, ++ @NotNull IFRenderContext context, ++ @NotNull ViewType type, ++ int size, ++ Object title) { ++ this.backend = backend; ++ this.context = context; ++ this.type = type; ++ this.size = size == 0 ? type.getMaxSize() : size; ++ this.title = title; ++ this.topItems = new ItemStack[this.size]; ++ } ++ ++ IFRenderContext getContext() { ++ return context; ++ } ++ ++ Object getRawTitle(@Nullable String viewerId) { ++ if (viewerId != null && viewerTitles.containsKey(viewerId)) { ++ return viewerTitles.get(viewerId); ++ } ++ ++ return title; ++ } ++ ++ ItemStack[] snapshotItems() { ++ synchronized (topItems) { ++ final ItemStack[] snapshot = new ItemStack[topItems.length]; ++ for (int slot = 0; slot < topItems.length; slot++) { ++ snapshot[slot] = topItems[slot] == null ? null : topItems[slot].clone(); ++ } ++ return snapshot; ++ } ++ } ++ ++ ItemStack item(int slot) { ++ synchronized (topItems) { ++ if (slot < 0 || slot >= topItems.length) return null; ++ return topItems[slot] == null ? null : topItems[slot].clone(); ++ } ++ } ++ ++ @Override ++ public String getTitle() { ++ return titleAsString(title); ++ } ++ ++ @Override ++ public String getTitle(@NotNull Viewer viewer) { ++ return titleAsString(getRawTitle(viewer.getId())); ++ } ++ ++ @Override ++ public @NotNull ViewType getType() { ++ return type; ++ } ++ ++ @Override ++ public int getFirstSlot() { ++ return 0; ++ } ++ ++ @Override ++ public int getLastSlot() { ++ return getSlotsCount(); ++ } ++ ++ @Override ++ public boolean hasItem(int slot) { ++ synchronized (topItems) { ++ return slot >= 0 && slot < topItems.length && topItems[slot] != null; ++ } ++ } ++ ++ @Override ++ public void renderItem(int slot, Object item) { ++ requireSupportedItem(item); ++ synchronized (topItems) { ++ topItems[slot] = item == null ? null : ((ItemStack) item).clone(); ++ } ++ backend.requestResync(this); ++ } ++ ++ @Override ++ public void removeItem(int slot) { ++ synchronized (topItems) { ++ topItems[slot] = null; ++ } ++ backend.requestResync(this); ++ } ++ ++ @Override ++ public boolean matchesItem(int slot, Object item, boolean exactly) { ++ requireSupportedItem(item); ++ final ItemStack target = item(slot); ++ if (target == null) return item == null; ++ if (item instanceof ItemStack) return exactly ? target.equals(item) : target.isSimilar((ItemStack) item); ++ return false; ++ } ++ ++ @Override ++ public boolean isSupportedItem(Object item) { ++ return item == null || item instanceof ItemStack; ++ } ++ ++ @Override ++ public int getSize() { ++ return size; ++ } ++ ++ @Override ++ public int getSlotsCount() { ++ return size - 1; ++ } ++ ++ @Override ++ public int getRowsCount() { ++ return getSize() / getColumnsCount(); ++ } ++ ++ @Override ++ public int getColumnsCount() { ++ return type.getColumns(); ++ } ++ ++ @Override ++ public void open(@NotNull Viewer viewer) { ++ backend.open((BukkitViewer) viewer, this); ++ } ++ ++ @Override ++ public void close() { ++ backend.close(this, true); ++ } ++ ++ @Override ++ public void close(@NotNull Viewer viewer) { ++ backend.close(this, (BukkitViewer) viewer, true); ++ } ++ ++ @Override ++ public void changeTitle(@Nullable Object title, @NotNull Viewer target) { ++ if (title == null) { ++ viewerTitles.remove(target.getId()); ++ } else { ++ viewerTitles.put(target.getId(), title); ++ } ++ ++ backend.requestReopen(this, (BukkitViewer) target); ++ } ++ ++ void changeBaseTitle(@Nullable Object title) { ++ this.title = title; ++ backend.requestResync(this); ++ } ++ ++ @Override ++ public boolean isEntityContainer() { ++ return false; ++ } ++ ++ @Override ++ public boolean isProxied() { ++ return false; ++ } ++ ++ private void requireSupportedItem(Object item) { ++ if (isSupportedItem(item)) return; ++ ++ throw new IllegalStateException("Unsupported item type: " + item.getClass().getName()); ++ } ++ ++ private static String titleAsString(Object title) { ++ return title == null ? "" : String.valueOf(title); ++ } ++ ++ @Override ++ public boolean equals(Object o) { ++ if (this == o) return true; ++ if (!(o instanceof PacketViewContainer)) return false; ++ final PacketViewContainer that = (PacketViewContainer) o; ++ return size == that.size ++ && Objects.equals(context, that.context) ++ && Objects.equals(type, that.type) ++ && Arrays.equals(snapshotItems(), that.snapshotItems()); ++ } ++ ++ @Override ++ public int hashCode() { ++ int result = Objects.hash(context, type, size); ++ result = 31 * result + Arrays.hashCode(snapshotItems()); ++ return result; ++ } ++ ++ @Override ++ public String toString() { ++ return "PacketViewContainer{" ++ + "context=" + context ++ + ", type=" + type ++ + ", size=" + size ++ + '}'; ++ } ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java +new file mode 100644 +index 00000000..19a018d5 +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java +@@ -0,0 +1,162 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import java.util.ArrayList; ++import java.util.Arrays; ++import java.util.List; ++import org.bukkit.entity.Player; ++import org.bukkit.inventory.PlayerInventory; ++ ++final class PacketViewerInventory { ++ ++ private final com.github.retrooper.packetevents.protocol.item.ItemStack[] slots = ++ new com.github.retrooper.packetevents.protocol.item.ItemStack[PacketInventoryConstants.INVENTORY_SIZE]; ++ private final boolean[] knownSlots = new boolean[PacketInventoryConstants.INVENTORY_SIZE]; ++ private com.github.retrooper.packetevents.protocol.item.ItemStack cursor = ++ com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY; ++ private boolean cursorKnown; ++ private int openWindowId = PacketInventoryConstants.PLAYER_WINDOW_ID; ++ private int openWindowTopSize = PacketInventoryConstants.INVENTORY_SIZE; ++ ++ PacketViewerInventory() { ++ Arrays.fill(slots, com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY); ++ } ++ ++ synchronized void snapshotFrom(Player player) { ++ final PlayerInventory inventory = player.getInventory(); ++ ++ for (int slot = 0; slot <= 35; slot++) { ++ applySlot(PacketInventoryConstants.playerInventorySlotToContainerSlot(slot), PacketItemConverter.toPacket(inventory.getItem(slot))); ++ } ++ ++ applySlot(PacketInventoryConstants.SLOT_HELMET, PacketItemConverter.toPacket(inventory.getHelmet())); ++ applySlot(PacketInventoryConstants.SLOT_CHESTPLATE, PacketItemConverter.toPacket(inventory.getChestplate())); ++ applySlot(PacketInventoryConstants.SLOT_LEGGINGS, PacketItemConverter.toPacket(inventory.getLeggings())); ++ applySlot(PacketInventoryConstants.SLOT_BOOTS, PacketItemConverter.toPacket(inventory.getBoots())); ++ applySlot(PacketInventoryConstants.SLOT_OFFHAND, PacketItemConverter.toPacket(inventory.getItemInOffHand())); ++ applyCursor(PacketItemConverter.toPacket(player.getItemOnCursor())); ++ } ++ ++ synchronized void applyPlayerWindowItems( ++ List items, ++ com.github.retrooper.packetevents.protocol.item.ItemStack carried) { ++ Arrays.fill(slots, com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY); ++ Arrays.fill(knownSlots, false); ++ ++ final int limit = Math.min(items.size(), PacketInventoryConstants.INVENTORY_SIZE); ++ for (int slot = 0; slot < limit; slot++) { ++ slots[slot] = PacketItemConverter.copy(items.get(slot)); ++ knownSlots[slot] = true; ++ } ++ ++ applyCursor(carried); ++ resetOpenWindow(); ++ } ++ ++ synchronized void applyContainerWindowItems( ++ int windowId, ++ List items, ++ com.github.retrooper.packetevents.protocol.item.ItemStack carried) { ++ if (items.size() >= 36) { ++ setOpenWindow(windowId, items.size() - 36); ++ final int playerSectionStart = items.size() - 36; ++ ++ for (int index = 0; index < 27; index++) { ++ applySlot(PacketInventoryConstants.ITEMS_START + index, items.get(playerSectionStart + index)); ++ } ++ ++ for (int index = 0; index < 9; index++) { ++ applySlot(PacketInventoryConstants.HOTBAR_START + index, items.get(playerSectionStart + 27 + index)); ++ } ++ } ++ ++ applyCursor(carried); ++ } ++ ++ synchronized void applySlot(int slot, com.github.retrooper.packetevents.protocol.item.ItemStack item) { ++ if (slot < 0 || slot >= PacketInventoryConstants.INVENTORY_SIZE) { ++ return; ++ } ++ ++ slots[slot] = PacketItemConverter.copy(item); ++ knownSlots[slot] = true; ++ } ++ ++ synchronized void applyCursor(com.github.retrooper.packetevents.protocol.item.ItemStack item) { ++ cursor = PacketItemConverter.copy(item); ++ cursorKnown = true; ++ } ++ ++ synchronized boolean isKnown(int slot) { ++ return slot >= 0 && slot < PacketInventoryConstants.INVENTORY_SIZE && knownSlots[slot]; ++ } ++ ++ synchronized com.github.retrooper.packetevents.protocol.item.ItemStack item(int slot) { ++ if (!isKnown(slot)) { ++ return com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY; ++ } ++ ++ return PacketItemConverter.copy(slots[slot]); ++ } ++ ++ synchronized com.github.retrooper.packetevents.protocol.item.ItemStack cursor() { ++ return cursorKnown ? PacketItemConverter.copy(cursor) : com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY; ++ } ++ ++ synchronized List mainAndHotbarItems() { ++ final List items = new ArrayList<>(36); ++ appendRange(items, PacketInventoryConstants.ITEMS_START, 27); ++ appendRange(items, PacketInventoryConstants.HOTBAR_START, 9); ++ return items; ++ } ++ ++ synchronized void setOpenWindow(int windowId, int topSize) { ++ openWindowId = windowId; ++ openWindowTopSize = topSize; ++ } ++ ++ synchronized void closeWindow(int windowId) { ++ if (openWindowId == windowId) { ++ resetOpenWindow(); ++ } ++ } ++ ++ synchronized int mapContainerSlotToPlayerSlot(int windowId, int containerSlot) { ++ if (windowId == PacketInventoryConstants.PLAYER_WINDOW_ID) { ++ return containerSlot; ++ } ++ ++ if (windowId != openWindowId || openWindowTopSize < 0) { ++ return -1; ++ } ++ ++ return mapGuiContainerSlotToPlayerSlot(openWindowTopSize, containerSlot); ++ } ++ ++ static int mapGuiContainerSlotToPlayerSlot(int topSize, int containerSlot) { ++ final int relativeSlot = containerSlot - topSize; ++ if (relativeSlot < 0) { ++ return -1; ++ } ++ ++ if (relativeSlot < 27) { ++ return PacketInventoryConstants.ITEMS_START + relativeSlot; ++ } ++ ++ if (relativeSlot < 36) { ++ return PacketInventoryConstants.HOTBAR_START + (relativeSlot - 27); ++ } ++ ++ return -1; ++ } ++ ++ private void appendRange(List items, int sourceStart, int amount) { ++ for (int index = 0; index < amount; index++) { ++ items.add(item(sourceStart + index)); ++ } ++ } ++ ++ private void resetOpenWindow() { ++ openWindowId = PacketInventoryConstants.PLAYER_WINDOW_ID; ++ openWindowTopSize = PacketInventoryConstants.INVENTORY_SIZE; ++ } ++} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/GlobalClickInterceptor.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/GlobalClickInterceptor.java +index ec3bd553..b5888370 100644 +--- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/GlobalClickInterceptor.java ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/GlobalClickInterceptor.java +@@ -4,7 +4,6 @@ import static me.devnatan.inventoryframework.ViewConfig.CANCEL_ON_CLICK; + + import me.devnatan.inventoryframework.VirtualView; + import me.devnatan.inventoryframework.context.SlotClickContext; +-import org.bukkit.event.inventory.InventoryClickEvent; + import org.jetbrains.annotations.NotNull; + + /** +@@ -18,10 +17,9 @@ public final class GlobalClickInterceptor implements PipelineInterceptor Date: Mon, 25 May 2026 23:46:25 +0200 Subject: [PATCH 02/50] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ...0006-add-internal-packet-gui-backend.patch | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/patches/0006-add-internal-packet-gui-backend.patch b/patches/0006-add-internal-packet-gui-backend.patch index 0050c01..ce8b6be 100644 --- a/patches/0006-add-internal-packet-gui-backend.patch +++ b/patches/0006-add-internal-packet-gui-backend.patch @@ -1857,7 +1857,7 @@ index 00000000..bccc4cfc @@ -0,0 +1,37 @@ +package me.devnatan.inventoryframework.internal.packet; + -+import io.github.retrooper.packetevents.util.SpigotConversionUtil; +import com.github.retrooper.packetevents.util.SpigotConversionUtil; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + @@ -1998,22 +1998,30 @@ index 00000000..8e578b11 + } + } + -+ @Override -+ public void renderItem(int slot, Object item) { -+ requireSupportedItem(item); -+ synchronized (topItems) { -+ topItems[slot] = item == null ? null : ((ItemStack) item).clone(); -+ } -+ backend.requestResync(this); -+ } -+ -+ @Override -+ public void removeItem(int slot) { -+ synchronized (topItems) { -+ topItems[slot] = null; -+ } -+ backend.requestResync(this); -+ } + @Override + public void renderItem(int slot, Object item) { + requireSupportedItem(item); + synchronized (topItems) { + if (slot < 0 || slot >= topItems.length) { + throw new IndexOutOfBoundsException( + "Slot out of bounds: " + slot + " (size=" + topItems.length + ")"); + } + topItems[slot] = item == null ? null : ((ItemStack) item).clone(); + } + backend.requestResync(this); + } + + @Override + public void removeItem(int slot) { + synchronized (topItems) { + if (slot < 0 || slot >= topItems.length) { + throw new IndexOutOfBoundsException( + "Slot out of bounds: " + slot + " (size=" + topItems.length + ")"); + } + topItems[slot] = null; + } + backend.requestResync(this); + } + + @Override + public boolean matchesItem(int slot, Object item, boolean exactly) { @@ -2100,23 +2108,15 @@ index 00000000..8e578b11 + return title == null ? "" : String.valueOf(title); + } + -+ @Override -+ public boolean equals(Object o) { -+ if (this == o) return true; -+ if (!(o instanceof PacketViewContainer)) return false; -+ final PacketViewContainer that = (PacketViewContainer) o; -+ return size == that.size -+ && Objects.equals(context, that.context) -+ && Objects.equals(type, that.type) -+ && Arrays.equals(snapshotItems(), that.snapshotItems()); -+ } -+ -+ @Override -+ public int hashCode() { -+ int result = Objects.hash(context, type, size); -+ result = 31 * result + Arrays.hashCode(snapshotItems()); -+ return result; -+ } + @Override + public boolean equals(Object o) { + return this == o; + } + + @Override + public int hashCode() { + return System.identityHashCode(this); + } + + @Override + public String toString() { From 974b0b434a0ed44ed39a731e8a014a1ff962e515 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 26 May 2026 00:20:37 +0200 Subject: [PATCH 03/50] fix broken patch - thanks @copilot --- ...0006-add-internal-packet-gui-backend.patch | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/patches/0006-add-internal-packet-gui-backend.patch b/patches/0006-add-internal-packet-gui-backend.patch index ce8b6be..e9360f7 100644 --- a/patches/0006-add-internal-packet-gui-backend.patch +++ b/patches/0006-add-internal-packet-gui-backend.patch @@ -1857,7 +1857,7 @@ index 00000000..bccc4cfc @@ -0,0 +1,37 @@ +package me.devnatan.inventoryframework.internal.packet; + -import com.github.retrooper.packetevents.util.SpigotConversionUtil; ++import com.github.retrooper.packetevents.util.SpigotConversionUtil; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + @@ -1998,30 +1998,30 @@ index 00000000..8e578b11 + } + } + - @Override - public void renderItem(int slot, Object item) { - requireSupportedItem(item); - synchronized (topItems) { - if (slot < 0 || slot >= topItems.length) { - throw new IndexOutOfBoundsException( - "Slot out of bounds: " + slot + " (size=" + topItems.length + ")"); - } - topItems[slot] = item == null ? null : ((ItemStack) item).clone(); - } - backend.requestResync(this); - } - - @Override - public void removeItem(int slot) { - synchronized (topItems) { - if (slot < 0 || slot >= topItems.length) { - throw new IndexOutOfBoundsException( - "Slot out of bounds: " + slot + " (size=" + topItems.length + ")"); - } - topItems[slot] = null; - } - backend.requestResync(this); - } ++ @Override ++ public void renderItem(int slot, Object item) { ++ requireSupportedItem(item); ++ synchronized (topItems) { ++ if (slot < 0 || slot >= topItems.length) { ++ throw new IndexOutOfBoundsException( ++ "Slot out of bounds: " + slot + " (size=" + topItems.length + ")"); ++ } ++ topItems[slot] = item == null ? null : ((ItemStack) item).clone(); ++ } ++ backend.requestResync(this); ++ } ++ ++ @Override ++ public void removeItem(int slot) { ++ synchronized (topItems) { ++ if (slot < 0 || slot >= topItems.length) { ++ throw new IndexOutOfBoundsException( ++ "Slot out of bounds: " + slot + " (size=" + topItems.length + ")"); ++ } ++ topItems[slot] = null; ++ } ++ backend.requestResync(this); ++ } + + @Override + public boolean matchesItem(int slot, Object item, boolean exactly) { @@ -2108,15 +2108,15 @@ index 00000000..8e578b11 + return title == null ? "" : String.valueOf(title); + } + - @Override - public boolean equals(Object o) { - return this == o; - } - - @Override - public int hashCode() { - return System.identityHashCode(this); - } ++ @Override ++ public boolean equals(Object o) { ++ return this == o; ++ } ++ ++ @Override ++ public int hashCode() { ++ return System.identityHashCode(this); ++ } + + @Override + public String toString() { From 29bc745d864e135508d316be1c3958581a8e51d9 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 26 May 2026 00:23:40 +0200 Subject: [PATCH 04/50] update import path for SpigotConversionUtil --- patches/0006-add-internal-packet-gui-backend.patch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/patches/0006-add-internal-packet-gui-backend.patch b/patches/0006-add-internal-packet-gui-backend.patch index e9360f7..5aa6fc8 100644 --- a/patches/0006-add-internal-packet-gui-backend.patch +++ b/patches/0006-add-internal-packet-gui-backend.patch @@ -1857,7 +1857,7 @@ index 00000000..bccc4cfc @@ -0,0 +1,37 @@ +package me.devnatan.inventoryframework.internal.packet; + -+import com.github.retrooper.packetevents.util.SpigotConversionUtil; ++import io.github.retrooper.packetevents.util.SpigotConversionUtil; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + From 1707b80ddae05af34008a554204df9adb1a6c7de Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 26 May 2026 01:04:31 +0200 Subject: [PATCH 05/50] fix folia issues --- ...0006-add-internal-packet-gui-backend.patch | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/patches/0006-add-internal-packet-gui-backend.patch b/patches/0006-add-internal-packet-gui-backend.patch index 5aa6fc8..980b15a 100644 --- a/patches/0006-add-internal-packet-gui-backend.patch +++ b/patches/0006-add-internal-packet-gui-backend.patch @@ -19,7 +19,7 @@ Subject: [PATCH] Add internal packet GUI backend .../internal/BukkitGuiBackend.java | 20 + .../internal/GuiBackend.java | 34 ++ .../internal/GuiBackendFactory.java | 42 ++ - .../internal/packet/PacketGuiBackend.java | 511 ++++++++++++++++++ + .../internal/packet/PacketGuiBackend.java | 532 ++++++++++++++++++ .../internal/packet/PacketGuiClick.java | 73 +++ .../packet/PacketGuiPacketListener.java | 122 +++++ .../internal/packet/PacketGuiRender.java | 99 ++++ @@ -32,7 +32,7 @@ Subject: [PATCH] Add internal packet GUI backend .../pipeline/ItemClickInterceptor.java | 5 +- .../pipeline/ItemCloseOnClickInterceptor.java | 5 +- settings.gradle.kts | 2 + - 28 files changed, 1842 insertions(+), 45 deletions(-) + 28 files changed, 1863 insertions(+), 45 deletions(-) create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java @@ -405,7 +405,7 @@ index 00000000..74af4430 + + @Override + public Object getPlatformEvent() { -+ return platformEvent; ++ return this; + } + + @Override @@ -858,7 +858,7 @@ new file mode 100644 index 00000000..1d617f3a --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,511 @@ +@@ -0,0 +1,532 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -869,6 +869,7 @@ index 00000000..1d617f3a +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerOpenWindow; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetCursorItem; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerWindowItems; ++import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; @@ -876,6 +877,7 @@ index 00000000..1d617f3a +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; ++import java.util.function.Consumer; +import me.devnatan.inventoryframework.BukkitViewer; +import me.devnatan.inventoryframework.RootView; +import me.devnatan.inventoryframework.ViewContainer; @@ -1002,7 +1004,7 @@ index 00000000..1d617f3a + + void open(@NotNull BukkitViewer viewer, @NotNull PacketViewContainer container) { + if (!Bukkit.isPrimaryThread()) { -+ runMain(() -> open(viewer, container)); ++ runOnPlayer(viewer.getPlayer(), () -> open(viewer, container)); + return; + } + @@ -1052,7 +1054,7 @@ index 00000000..1d617f3a + continue; + } + -+ runMain(() -> { ++ runOnPlayer(session.player(), () -> { + session.clearResyncScheduled(); + fullResync(session, false); + }); @@ -1065,7 +1067,7 @@ index 00000000..1d617f3a + return; + } + -+ runMain(() -> fullResync(session, true)); ++ runOnPlayer(session.player(), () -> fullResync(session, true)); + } + + boolean isGuiWindow(User user, int windowId) { @@ -1088,11 +1090,11 @@ index 00000000..1d617f3a + } + + if (!click.isSafeTopPickup(session.container().getSize())) { -+ runMain(() -> fullResync(session, false)); ++ runOnPlayer(session.player(), () -> fullResync(session, false)); + return; + } + -+ runMain(() -> handleSafeTopClick(session, click)); ++ runOnPlayer(session.player(), () -> handleSafeTopClick(session, click)); + } + + void handleWindowClose(User user, int windowId) { @@ -1105,7 +1107,7 @@ index 00000000..1d617f3a + return; + } + -+ runMain(() -> closeSession(session, false, CLOSE_ORIGIN_CLIENT, true)); ++ runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_CLIENT, true)); + } + + void handleExternalInventoryOpen(User user, int windowId, int topSize) { @@ -1119,7 +1121,7 @@ index 00000000..1d617f3a + + final PacketGuiSession session = sessions.get(user.getUUID()); + if (session != null && !isGuiWindow(user, windowId)) { -+ runMain(() -> closeSession(session, false, CLOSE_ORIGIN_EXTERNAL_OPEN, true)); ++ runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_EXTERNAL_OPEN, true)); + } + } + @@ -1247,7 +1249,7 @@ index 00000000..1d617f3a + + private void fullResync(PacketGuiSession session, boolean forceReopen) { + if (!Bukkit.isPrimaryThread()) { -+ runMain(() -> fullResync(session, forceReopen)); ++ runOnPlayer(session.player(), () -> fullResync(session, forceReopen)); + return; + } + @@ -1361,14 +1363,33 @@ index 00000000..1d617f3a + return Math.max(1, nextWindowId.getAndUpdate(previous -> previous >= MAX_WINDOW_ID ? 1 : previous + 1)); + } + -+ private void runMain(Runnable task) { ++ private void runOnPlayer(Player player, Runnable task) { + if (Bukkit.isPrimaryThread()) { + task.run(); + return; + } + ++ if (tryRunEntityScheduler(player, task)) { ++ return; ++ } ++ + Bukkit.getScheduler().runTask(owner, task); + } ++ ++ private boolean tryRunEntityScheduler(Player player, Runnable task) { ++ try { ++ final Method getScheduler = player.getClass().getMethod("getScheduler"); ++ final Object scheduler = getScheduler.invoke(player); ++ final Method run = scheduler.getClass().getMethod("run", Plugin.class, Consumer.class, Runnable.class); ++ final Consumer scheduledTaskConsumer = ignored -> task.run(); ++ run.invoke(scheduler, owner, scheduledTaskConsumer, null); ++ return true; ++ } catch (final NoSuchMethodException ignored) { ++ return false; ++ } catch (final ReflectiveOperationException exception) { ++ throw new IllegalStateException("Failed to schedule packet GUI task on the player scheduler", exception); ++ } ++ } +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java new file mode 100644 From 8ca49fe2242ffee84a063273a8871ec170ccadfd Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 26 May 2026 01:23:41 +0200 Subject: [PATCH 06/50] enhance PacketGuiBackend with inventory close handling - add handling for closing inventory sessions based on user actions - improve session management by ensuring proper windowId checks - update session inventory state after closing to reflect changes --- .../0006-add-internal-packet-gui-backend.patch | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/patches/0006-add-internal-packet-gui-backend.patch b/patches/0006-add-internal-packet-gui-backend.patch index 980b15a..4c356ab 100644 --- a/patches/0006-add-internal-packet-gui-backend.patch +++ b/patches/0006-add-internal-packet-gui-backend.patch @@ -858,7 +858,7 @@ new file mode 100644 index 00000000..1d617f3a --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,532 @@ +@@ -0,0 +1,541 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1106,7 +1106,7 @@ index 00000000..1d617f3a + if (!isTracked(session) || session.windowId() != windowId) { + return; + } -+ ++ session.viewerInventory().closeWindow(windowId); + runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_CLIENT, true)); + } + @@ -1130,6 +1130,14 @@ index 00000000..1d617f3a + return; + } + ++ final PacketGuiSession session = sessions.get(user.getUUID()); ++ if (isTracked(session)) { ++ if (windowId != session.windowId()) { ++ closeSession(session, true, CLOSE_ORIGIN_SERVER, true); ++ } ++ return; ++ } ++ + inventoryFor(user.getUUID()).closeWindow(windowId); + } + @@ -1321,7 +1329,7 @@ index 00000000..1d617f3a + } + + sessions.remove(session.viewerId(), session); -+ ++ session.viewerInventory().closeWindow(session.windowId()); + if (sendClosePacket) { + try { + sendCursor(session); @@ -1331,6 +1339,7 @@ index 00000000..1d617f3a + } + } + ++ session.player().updateInventory(); + if (callClose) { + executeClosePipeline(session, origin); + } @@ -1531,7 +1540,7 @@ index 00000000..b10d5a0e + return; + } + -+ event.setCancelled(true); ++ event.setCancelled(false); + backend.handleWindowClose(event.getUser(), packet.getWindowId()); + } + } From 4a605a8572fb9d89c4d6b74930a6bc604235f8a1 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 26 May 2026 02:38:07 +0200 Subject: [PATCH 07/50] add PacketItemConverter for item normalization - implement normalization for item display names and lore - utilize reflection to access ItemMeta properties - ensure non-italic display names for better consistency --- ...0006-add-internal-packet-gui-backend.patch | 121 +++++++++++++++++- 1 file changed, 118 insertions(+), 3 deletions(-) diff --git a/patches/0006-add-internal-packet-gui-backend.patch b/patches/0006-add-internal-packet-gui-backend.patch index 4c356ab..d23ea42 100644 --- a/patches/0006-add-internal-packet-gui-backend.patch +++ b/patches/0006-add-internal-packet-gui-backend.patch @@ -25,7 +25,7 @@ Subject: [PATCH] Add internal packet GUI backend .../internal/packet/PacketGuiRender.java | 99 ++++ .../internal/packet/PacketGuiSession.java | 116 ++++ .../packet/PacketInventoryConstants.java | 39 ++ - .../internal/packet/PacketItemConverter.java | 37 ++ + .../internal/packet/PacketItemConverter.java | 151 +++++ .../internal/packet/PacketViewContainer.java | 229 ++++++++ .../packet/PacketViewerInventory.java | 162 ++++++ .../pipeline/GlobalClickInterceptor.java | 4 +- @@ -1884,12 +1884,18 @@ new file mode 100644 index 00000000..bccc4cfc --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java -@@ -0,0 +1,37 @@ +@@ -0,0 +1,151 @@ +package me.devnatan.inventoryframework.internal.packet; + +import io.github.retrooper.packetevents.util.SpigotConversionUtil; ++import java.lang.reflect.Method; ++import java.util.ArrayList; ++import java.util.List; ++import net.kyori.adventure.text.Component; ++import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; ++import org.bukkit.inventory.meta.ItemMeta; + +final class PacketItemConverter { + @@ -1901,7 +1907,7 @@ index 00000000..bccc4cfc + } + + final com.github.retrooper.packetevents.protocol.item.ItemStack converted = -+ SpigotConversionUtil.fromBukkitItemStack(item); ++ SpigotConversionUtil.fromBukkitItemStack(normalizeItem(item)); + return converted == null || converted.isEmpty() + ? com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY + : converted.copy(); @@ -1921,7 +1927,116 @@ index 00000000..bccc4cfc + ? com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY + : item.copy(); + } ++ ++ private static ItemStack normalizeItem(ItemStack item) { ++ final ItemStack copy = item.clone(); ++ final ItemMeta meta = copy.getItemMeta(); ++ if (meta == null) { ++ return copy; ++ } ++ ++ final boolean changed = normalizeDisplayName(meta) | normalizeLore(meta); ++ if (changed) { ++ copy.setItemMeta(meta); ++ } ++ return copy; ++ } ++ ++ private static boolean normalizeDisplayName(ItemMeta meta) { ++ final Component displayName = readAdventureDisplayName(meta); ++ if (displayName == null) { ++ return false; ++ } ++ ++ final Component normalized = forceNonItalic(displayName); ++ if (normalized.equals(displayName)) { ++ return false; ++ } ++ ++ return invokeItemMetaSetter(meta, "displayName", Component.class, normalized); ++ } ++ ++ private static boolean normalizeLore(ItemMeta meta) { ++ final List lore = readAdventureLore(meta); ++ if (lore == null) { ++ return false; ++ } ++ ++ final List normalizedLore = new ArrayList<>(lore.size()); ++ boolean changed = false; ++ for (final Component component : lore) { ++ final Component normalized = forceNonItalic(component); ++ normalizedLore.add(normalized); ++ changed |= !normalized.equals(component); ++ } ++ ++ if (!changed) { ++ return false; ++ } ++ ++ return invokeItemMetaSetter(meta, "lore", List.class, normalizedLore); ++ } ++ ++ private static Component readAdventureDisplayName(ItemMeta meta) { ++ final Object value = invokeItemMetaGetter(meta, "displayName"); ++ return value instanceof Component ? (Component) value : null; ++ } ++ ++ private static List readAdventureLore(ItemMeta meta) { ++ final Object value = invokeItemMetaGetter(meta, "lore"); ++ if (!(value instanceof List)) { ++ return null; ++ } ++ ++ final List rawLore = (List) value; ++ final List lore = new ArrayList<>(rawLore.size()); ++ for (final Object line : rawLore) { ++ if (!(line instanceof Component)) { ++ return null; ++ } ++ lore.add((Component) line); ++ } ++ return lore; ++ } ++ ++ private static Object invokeItemMetaGetter(ItemMeta meta, String methodName) { ++ try { ++ final Method method = ItemMeta.class.getMethod(methodName); ++ return method.invoke(meta); ++ } catch (final ReflectiveOperationException ignored) { ++ return null; ++ } ++ } ++ ++ private static boolean invokeItemMetaSetter( ++ ItemMeta meta, ++ String methodName, ++ Class parameterType, ++ Object value) { ++ try { ++ final Method method = ItemMeta.class.getMethod(methodName, parameterType); ++ method.invoke(meta, value); ++ return true; ++ } catch (final ReflectiveOperationException ignored) { ++ return false; ++ } ++ } ++ ++ private static Component forceNonItalic(Component component) { ++ Component normalized = component.decoration(TextDecoration.ITALIC, TextDecoration.State.FALSE); ++ if (normalized.children().isEmpty()) { ++ return normalized; ++ } ++ ++ final List children = normalized.children(); ++ final List normalizedChildren = new ArrayList<>(children.size()); ++ for (final Component child : children) { ++ normalizedChildren.add(forceNonItalic(child)); ++ } ++ return normalized.children(normalizedChildren); ++ } +} + diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java new file mode 100644 index 00000000..8e578b11 From 876fd47fc7fddbb5e20e913b5eb457f35b3a5396 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 26 May 2026 12:29:19 +0200 Subject: [PATCH 08/50] Improve packet GUI performance with dirty-slot rendering Adds coalesced SetSlot-based updates and per-session item conversion caching so normal GUI interactions no longer rebuild and resend the full window. Keeps full WindowItems only for open/reopen/hard repair paths, preserves Folia-safe scheduling, and maintains PacketEvents as the packet API. This massively reduces repeated ItemStack conversion and fullResync overhead during clicks, pagination, and dynamic shop GUI updates. --- ...006-Add-internal-packet-GUI-backend.patch} | 523 +++++++++++++----- 1 file changed, 377 insertions(+), 146 deletions(-) rename patches/{0006-add-internal-packet-gui-backend.patch => 0006-Add-internal-packet-GUI-backend.patch} (85%) diff --git a/patches/0006-add-internal-packet-gui-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch similarity index 85% rename from patches/0006-add-internal-packet-gui-backend.patch rename to patches/0006-Add-internal-packet-GUI-backend.patch index d23ea42..cf02023 100644 --- a/patches/0006-add-internal-packet-gui-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1,56 +1,11 @@ -From 19839bbfac4bcfb77c789015346b65f8a65cb3b2 Mon Sep 17 00:00:00 2001 +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Keviro Date: Mon, 25 May 2026 23:04:19 +0200 Subject: [PATCH] Add internal packet GUI backend ---- - gradle/libs.versions.toml | 7 +- - .../build.gradle.kts | 4 +- - .../inventoryframework/BukkitViewer.java | 7 +- - .../IFInventoryListener.java | 18 + - .../inventoryframework/ViewFrame.java | 10 +- - .../context/BukkitSlotClickOrigin.java | 89 +++ - .../context/CloseContext.java | 1 - - .../context/PacketSlotClickOrigin.java | 122 +++++ - .../context/RenderContext.java | 8 +- - .../context/SlotClickContext.java | 61 ++- - .../context/SlotClickOrigin.java | 42 ++ - .../internal/BukkitElementFactory.java | 18 +- - .../internal/BukkitGuiBackend.java | 20 + - .../internal/GuiBackend.java | 34 ++ - .../internal/GuiBackendFactory.java | 42 ++ - .../internal/packet/PacketGuiBackend.java | 532 ++++++++++++++++++ - .../internal/packet/PacketGuiClick.java | 73 +++ - .../packet/PacketGuiPacketListener.java | 122 +++++ - .../internal/packet/PacketGuiRender.java | 99 ++++ - .../internal/packet/PacketGuiSession.java | 116 ++++ - .../packet/PacketInventoryConstants.java | 39 ++ - .../internal/packet/PacketItemConverter.java | 151 +++++ - .../internal/packet/PacketViewContainer.java | 229 ++++++++ - .../packet/PacketViewerInventory.java | 162 ++++++ - .../pipeline/GlobalClickInterceptor.java | 4 +- - .../pipeline/ItemClickInterceptor.java | 5 +- - .../pipeline/ItemCloseOnClickInterceptor.java | 5 +- - settings.gradle.kts | 2 + - 28 files changed, 1863 insertions(+), 45 deletions(-) - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/BukkitGuiBackend.java - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackend.java - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackendFactory.java - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java - create mode 100644 inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml -index 89850fa8..daec84d3 100644 +index 89850fa8d67a4ff08fbe5997ab94625e50c0cf19..daec84d3a7722c8390ae6624b01b84d52c596df9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ plugin-spotless = "7.2.1" @@ -79,7 +34,7 @@ index 89850fa8..daec84d3 100644 \ No newline at end of file +publish = { id = "com.vanniktech.maven.publish.base", version = "0.34.0" } diff --git a/inventory-framework-platform-bukkit/build.gradle.kts b/inventory-framework-platform-bukkit/build.gradle.kts -index 6a29127f..237bf07e 100644 +index 6a29127fffd904f31720719a40c8f61bf4c1ae05..237bf07ee139b580f0148a28061fb36166787247 100644 --- a/inventory-framework-platform-bukkit/build.gradle.kts +++ b/inventory-framework-platform-bukkit/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { @@ -100,7 +55,7 @@ index 6a29127f..237bf07e 100644 \ No newline at end of file +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/BukkitViewer.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/BukkitViewer.java -index 16e47919..78c704d7 100644 +index 16e4791981a4fd159659190b2ac3e1a961e28e90..78c704d7457a6fe653346e5d100372922759105b 100644 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/BukkitViewer.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/BukkitViewer.java @@ -52,7 +52,12 @@ public final class BukkitViewer implements Viewer { @@ -118,7 +73,7 @@ index 16e47919..78c704d7 100644 @Override diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java -index e7d2c7a0..199757af 100644 +index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..199757af2e76e5ffecbc8a1d2c2ca954f050938e 100644 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java @@ -5,6 +5,8 @@ import me.devnatan.inventoryframework.context.IFCloseContext; @@ -178,7 +133,7 @@ index e7d2c7a0..199757af 100644 public void onInventoryClick(final InventoryClickEvent event) { if (!(event.getWhoClicked() instanceof Player)) return; diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/ViewFrame.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/ViewFrame.java -index 2d97a162..4dc75f81 100644 +index 2d97a162afacb6aa9652d9d83079088761bbd35b..4dc75f811d1a888cee0851a70377ac76390d85a9 100644 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/ViewFrame.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/ViewFrame.java @@ -14,6 +14,8 @@ import me.devnatan.inventoryframework.feature.DefaultFeatureInstaller; @@ -230,7 +185,7 @@ index 2d97a162..4dc75f81 100644 // region Internals diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java new file mode 100644 -index 00000000..07639c5a +index 0000000000000000000000000000000000000000..07639c5a46b064d0292e9f90ea9deb77559bdb38 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java @@ -0,0 +1,89 @@ @@ -324,7 +279,7 @@ index 00000000..07639c5a + } +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/CloseContext.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/CloseContext.java -index d73ddb29..686664b4 100644 +index d73ddb29859761e6505b001fb4d303f953cb99e3..686664b4211880e0dc7651cebda4e66cfef24245 100644 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/CloseContext.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/CloseContext.java @@ -12,7 +12,6 @@ import me.devnatan.inventoryframework.state.State; @@ -337,7 +292,7 @@ index d73ddb29..686664b4 100644 import org.jetbrains.annotations.UnmodifiableView; diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java new file mode 100644 -index 00000000..74af4430 +index 0000000000000000000000000000000000000000..ad0d7f2d02291a259c44d206fd483b560e88e865 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java @@ -0,0 +1,122 @@ @@ -464,7 +419,7 @@ index 00000000..74af4430 + } +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java -index b89eae09..e1ccb7b9 100644 +index b89eae09cdfbbc5c01ef378071801f9add48af63..e1ccb7b9309017dbf69102f1511518247a66f85f 100644 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java @@ -169,6 +169,12 @@ public final class RenderContext extends PlatformRenderContext open(viewer, container)); + return; + } @@ -1023,12 +980,11 @@ index 00000000..1d617f3a + } + + final PacketViewerInventory viewerInventory = inventoryFor(player.getUniqueId()); -+ viewerInventory.snapshotFrom(player); + + final PacketGuiSession session = + new PacketGuiSession(viewer, user, allocateWindowId(), container, viewerInventory); + sessions.put(player.getUniqueId(), session); -+ fullResync(session, true); ++ renderSession(session, true, true); + } + + void close(@NotNull PacketViewContainer container, boolean sendClosePacket) { @@ -1050,14 +1006,11 @@ index 00000000..1d617f3a + + void requestResync(@NotNull PacketViewContainer container) { + for (final PacketGuiSession session : sessions.values()) { -+ if (session.container() != container || !session.markResyncScheduled()) { ++ if (session.container() != container) { + continue; + } + -+ runOnPlayer(session.player(), () -> { -+ session.clearResyncScheduled(); -+ fullResync(session, false); -+ }); ++ requestRender(session, false, false); + } + } + @@ -1067,7 +1020,7 @@ index 00000000..1d617f3a + return; + } + -+ runOnPlayer(session.player(), () -> fullResync(session, true)); ++ requestRender(session, true, true); + } + + boolean isGuiWindow(User user, int windowId) { @@ -1090,7 +1043,7 @@ index 00000000..1d617f3a + } + + if (!click.isSafeTopPickup(session.container().getSize())) { -+ runOnPlayer(session.player(), () -> fullResync(session, false)); ++ requestRender(session, false, true); + return; + } + @@ -1132,9 +1085,9 @@ index 00000000..1d617f3a + + final PacketGuiSession session = sessions.get(user.getUUID()); + if (isTracked(session)) { -+ if (windowId != session.windowId()) { -+ closeSession(session, true, CLOSE_ORIGIN_SERVER, true); -+ } ++ runOnPlayer( ++ session.player(), ++ () -> closeSession(session, windowId != session.windowId(), CLOSE_ORIGIN_SERVER, true)); + return; + } + @@ -1252,30 +1205,55 @@ index 00000000..1d617f3a + return; + } + -+ fullResync(session, false); ++ requestRender(session, false, false); + } + -+ private void fullResync(PacketGuiSession session, boolean forceReopen) { -+ if (!Bukkit.isPrimaryThread()) { -+ runOnPlayer(session.player(), () -> fullResync(session, forceReopen)); ++ private void requestRender(PacketGuiSession session, boolean forceReopen, boolean hardResync) { ++ if (!isTracked(session) || session.closeRequested()) { + return; + } + -+ if (!isTracked(session)) { ++ if (!session.scheduleRender(forceReopen, hardResync)) { ++ return; ++ } ++ ++ runOnPlayerNextTick(session.player(), () -> { ++ final PacketGuiSession.RenderRequest request = session.consumeRenderRequest(); ++ renderSession(session, request.forceReopen(), request.hardResync()); ++ }); ++ } ++ ++ private void renderSession(PacketGuiSession session, boolean forceReopen, boolean hardResync) { ++ if (!isOnPlayerThread(session.player())) { ++ runOnPlayer(session.player(), () -> renderSession(session, forceReopen, hardResync)); ++ return; ++ } ++ ++ if (!isTracked(session) || session.closeRequested()) { + return; + } + -+ session.viewerInventory().snapshotFrom(session.player()); + final PacketGuiRender render = PacketGuiRender.from(session.container(), session.viewer().getId()); + final PacketGuiRender previous = session.appliedRender(); + session.currentRender(render); ++ final boolean reopen = forceReopen || !render.sameWindow(previous); ++ final boolean sendFullWindow = hardResync || reopen || previous == null; + + try { -+ if (forceReopen || !render.sameWindow(previous)) { ++ if (sendFullWindow) { ++ session.viewerInventory().snapshotFrom(session.player()); ++ } ++ ++ if (reopen) { + sendOpenWindow(session, render); + } + -+ sendWindowItems(session, render); ++ if (sendFullWindow) { ++ sendWindowItems(session, render); ++ } else { ++ sendChangedTopSlots(session, previous, render); ++ } ++ + sendCursor(session); + session.appliedRender(render); + } catch (final RuntimeException exception) { @@ -1300,7 +1278,9 @@ index 00000000..1d617f3a + private void sendWindowItems(PacketGuiSession session, PacketGuiRender render) { + final List items = + new ArrayList<>(render.size() + 36); -+ items.addAll(render.packetTopItems()); ++ for (int slot = 0; slot < render.size(); slot++) { ++ items.add(session.packetItem(render, slot)); ++ } + items.addAll(session.viewerInventory().mainAndHotbarItems()); + + session.user() @@ -1311,6 +1291,26 @@ index 00000000..1d617f3a + session.viewerInventory().cursor())); + } + ++ private void sendChangedTopSlots(PacketGuiSession session, PacketGuiRender previous, PacketGuiRender render) { ++ int stateId = -1; ++ for (int slot = 0; slot < render.size(); slot++) { ++ if (render.sameTopItem(previous, slot)) { ++ continue; ++ } ++ ++ if (stateId < 0) { ++ stateId = session.nextStateId(); ++ } ++ ++ session.user() ++ .sendPacket(new WrapperPlayServerSetSlot( ++ session.windowId(), ++ stateId, ++ slot, ++ session.packetItem(render, slot))); ++ } ++ } ++ + private void sendCursor(PacketGuiSession session) { + session.user().sendPacket(new WrapperPlayServerSetCursorItem(session.viewerInventory().cursor())); + } @@ -1320,6 +1320,11 @@ index 00000000..1d617f3a + return false; + } + ++ if (!isOnPlayerThread(session.player()) && (sendClosePacket || callClose)) { ++ runOnPlayer(session.player(), () -> closeSession(session, sendClosePacket, origin, callClose)); ++ return true; ++ } ++ + synchronized (session) { + if (session.closed()) { + return false; @@ -1329,7 +1334,8 @@ index 00000000..1d617f3a + } + + sessions.remove(session.viewerId(), session); -+ session.viewerInventory().closeWindow(session.windowId()); ++ session.clearPacketItemCache(); ++ session.viewerInventory().resetOpenWindow(); + if (sendClosePacket) { + try { + sendCursor(session); @@ -1339,7 +1345,9 @@ index 00000000..1d617f3a + } + } + -+ session.player().updateInventory(); ++ if (sendClosePacket || callClose) { ++ session.player().updateInventory(); ++ } + if (callClose) { + executeClosePipeline(session, origin); + } @@ -1373,7 +1381,7 @@ index 00000000..1d617f3a + } + + private void runOnPlayer(Player player, Runnable task) { -+ if (Bukkit.isPrimaryThread()) { ++ if (isOnPlayerThread(player)) { + task.run(); + return; + } @@ -1385,24 +1393,83 @@ index 00000000..1d617f3a + Bukkit.getScheduler().runTask(owner, task); + } + ++ private void runOnPlayerNextTick(Player player, Runnable task) { ++ if (tryRunEntitySchedulerDelayed(player, task)) { ++ return; ++ } ++ ++ Bukkit.getScheduler().runTask(owner, task); ++ } ++ + private boolean tryRunEntityScheduler(Player player, Runnable task) { ++ final Object scheduler; + try { + final Method getScheduler = player.getClass().getMethod("getScheduler"); -+ final Object scheduler = getScheduler.invoke(player); ++ scheduler = getScheduler.invoke(player); ++ } catch (final NoSuchMethodException ignored) { ++ return false; ++ } catch (final ReflectiveOperationException exception) { ++ throw new IllegalStateException("Failed to access packet GUI player scheduler", exception); ++ } ++ ++ try { + final Method run = scheduler.getClass().getMethod("run", Plugin.class, Consumer.class, Runnable.class); + final Consumer scheduledTaskConsumer = ignored -> task.run(); + run.invoke(scheduler, owner, scheduledTaskConsumer, null); + return true; + } catch (final NoSuchMethodException ignored) { ++ throw new IllegalStateException("Player scheduler does not expose a run method"); ++ } catch (final ReflectiveOperationException exception) { ++ throw new IllegalStateException("Failed to schedule packet GUI task on the player scheduler", exception); ++ } ++ } ++ ++ private boolean tryRunEntitySchedulerDelayed(Player player, Runnable task) { ++ final Object scheduler; ++ try { ++ final Method getScheduler = player.getClass().getMethod("getScheduler"); ++ scheduler = getScheduler.invoke(player); ++ } catch (final NoSuchMethodException ignored) { + return false; + } catch (final ReflectiveOperationException exception) { ++ throw new IllegalStateException("Failed to access packet GUI player scheduler", exception); ++ } ++ ++ try { ++ final Method runDelayed = ++ scheduler.getClass().getMethod("runDelayed", Plugin.class, Consumer.class, Runnable.class, long.class); ++ final Consumer scheduledTaskConsumer = ignored -> task.run(); ++ runDelayed.invoke(scheduler, owner, scheduledTaskConsumer, null, 1L); ++ return true; ++ } catch (final NoSuchMethodException ignored) { ++ return tryRunEntityScheduler(player, task); ++ } catch (final ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to schedule packet GUI task on the player scheduler", exception); + } + } ++ ++ private boolean isOnPlayerThread(Player player) { ++ if (isOwnedByCurrentRegion(player)) { ++ return true; ++ } ++ ++ return Bukkit.isPrimaryThread(); ++ } ++ ++ private boolean isOwnedByCurrentRegion(Player player) { ++ try { ++ final Method method = Bukkit.class.getMethod("isOwnedByCurrentRegion", Entity.class); ++ return Boolean.TRUE.equals(method.invoke(null, player)); ++ } catch (final NoSuchMethodException ignored) { ++ return false; ++ } catch (final ReflectiveOperationException ignored) { ++ return false; ++ } ++ } +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java new file mode 100644 -index 00000000..ba07a12d +index 0000000000000000000000000000000000000000..ba07a12d669675a549169f91e5c53db1a321b609 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java @@ -0,0 +1,73 @@ @@ -1481,7 +1548,7 @@ index 00000000..ba07a12d +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java new file mode 100644 -index 00000000..b10d5a0e +index 0000000000000000000000000000000000000000..89cf420ff84c58826d06cbe9d92c4f9b3f074d03 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java @@ -0,0 +1,122 @@ @@ -1609,15 +1676,13 @@ index 00000000..b10d5a0e +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java new file mode 100644 -index 00000000..8d6fd461 +index 0000000000000000000000000000000000000000..1e4d3441f44b118c75ab62bf24e846c68a30d955 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java -@@ -0,0 +1,99 @@ +@@ -0,0 +1,103 @@ +package me.devnatan.inventoryframework.internal.packet; + -+import java.util.ArrayList; +import java.util.Arrays; -+import java.util.List; +import java.util.Objects; +import net.kyori.adventure.text.Component; +import org.bukkit.inventory.ItemStack; @@ -1638,18 +1703,20 @@ index 00000000..8d6fd461 + + static PacketGuiRender from(PacketViewContainer container) { + final ItemStack[] topItems = container.snapshotItems(); ++ final Object rawTitle = container.getRawTitle(null); + return new PacketGuiRender( -+ container.getRawTitle(null), -+ titleComponent(container.getRawTitle(null)), ++ rawTitle, ++ titleComponent(rawTitle), + Math.max(1, container.getRowsCount()), + topItems); + } + + static PacketGuiRender from(PacketViewContainer container, String viewerId) { + final ItemStack[] topItems = container.snapshotItems(); ++ final Object rawTitle = container.getRawTitle(viewerId); + return new PacketGuiRender( -+ container.getRawTitle(viewerId), -+ titleComponent(container.getRawTitle(viewerId)), ++ rawTitle, ++ titleComponent(rawTitle), + Math.max(1, container.getRowsCount()), + topItems); + } @@ -1671,18 +1738,22 @@ index 00000000..8d6fd461 + return item == null ? null : item.clone(); + } + -+ List packetTopItems() { -+ final List items = new ArrayList<>(topItems.length); -+ for (final ItemStack item : topItems) { -+ items.add(PacketItemConverter.toPacket(item)); -+ } -+ return items; ++ ItemStack rawBukkitItem(int slot) { ++ return topItems[slot]; + } + + boolean sameWindow(PacketGuiRender other) { + return other != null && rows == other.rows && Objects.equals(rawTitle, other.rawTitle); + } + ++ boolean sameTopItem(PacketGuiRender other, int slot) { ++ return other != null ++ && slot >= 0 ++ && slot < topItems.length ++ && slot < other.topItems.length ++ && PacketItemConverter.sameDisplayItem(topItems[slot], other.topItems[slot]); ++ } ++ + @Override + public boolean equals(Object o) { + if (this == o) return true; @@ -1714,18 +1785,18 @@ index 00000000..8d6fd461 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java new file mode 100644 -index 00000000..c70570ac +index 0000000000000000000000000000000000000000..8994528f01d429b9959282dbed029caabb2a02a4 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java -@@ -0,0 +1,116 @@ +@@ -0,0 +1,195 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.protocol.player.User; +import java.util.UUID; -+import java.util.concurrent.atomic.AtomicBoolean; +import me.devnatan.inventoryframework.BukkitViewer; +import me.devnatan.inventoryframework.context.IFRenderContext; +import org.bukkit.entity.Player; ++import org.bukkit.inventory.ItemStack; + +final class PacketGuiSession { + @@ -1736,9 +1807,12 @@ index 00000000..c70570ac + private final int windowId; + private final PacketViewContainer container; + private final PacketViewerInventory viewerInventory; -+ private final AtomicBoolean resyncScheduled = new AtomicBoolean(); ++ private CachedPacketItem[] topItemCache = new CachedPacketItem[0]; + private PacketGuiRender currentRender; + private PacketGuiRender appliedRender; ++ private boolean renderScheduled; ++ private boolean scheduledForceReopen; ++ private boolean scheduledHardResync; + private boolean closed; + private boolean closeRequested; + private int stateId = 1; @@ -1826,17 +1900,93 @@ index 00000000..c70570ac + return stateId++; + } + -+ boolean markResyncScheduled() { -+ return resyncScheduled.compareAndSet(false, true); ++ synchronized boolean scheduleRender(boolean forceReopen, boolean hardResync) { ++ scheduledForceReopen |= forceReopen; ++ scheduledHardResync |= hardResync; ++ if (renderScheduled) { ++ return false; ++ } ++ ++ renderScheduled = true; ++ return true; ++ } ++ ++ synchronized RenderRequest consumeRenderRequest() { ++ final RenderRequest request = new RenderRequest(scheduledForceReopen, scheduledHardResync); ++ renderScheduled = false; ++ scheduledForceReopen = false; ++ scheduledHardResync = false; ++ return request; ++ } ++ ++ synchronized com.github.retrooper.packetevents.protocol.item.ItemStack packetItem(PacketGuiRender render, int slot) { ++ ensureTopItemCache(render.size()); ++ ++ final ItemStack item = render.rawBukkitItem(slot); ++ final int fingerprint = PacketItemConverter.displayFingerprint(item); ++ final CachedPacketItem cached = topItemCache[slot]; ++ if (cached != null && cached.matches(item, fingerprint)) { ++ return PacketItemConverter.copy(cached.packetItem); ++ } ++ ++ final com.github.retrooper.packetevents.protocol.item.ItemStack packetItem = ++ PacketItemConverter.toPacket(item); ++ topItemCache[slot] = new CachedPacketItem(item, fingerprint, packetItem); ++ return PacketItemConverter.copy(packetItem); ++ } ++ ++ synchronized void clearPacketItemCache() { ++ topItemCache = new CachedPacketItem[0]; ++ } ++ ++ private void ensureTopItemCache(int size) { ++ if (topItemCache.length != size) { ++ topItemCache = new CachedPacketItem[size]; ++ } ++ } ++ ++ static final class RenderRequest { ++ ++ private final boolean forceReopen; ++ private final boolean hardResync; ++ ++ private RenderRequest(boolean forceReopen, boolean hardResync) { ++ this.forceReopen = forceReopen; ++ this.hardResync = hardResync; ++ } ++ ++ boolean forceReopen() { ++ return forceReopen; ++ } ++ ++ boolean hardResync() { ++ return hardResync; ++ } + } + -+ void clearResyncScheduled() { -+ resyncScheduled.set(false); ++ private static final class CachedPacketItem { ++ ++ private final ItemStack bukkitItem; ++ private final int fingerprint; ++ private final com.github.retrooper.packetevents.protocol.item.ItemStack packetItem; ++ ++ private CachedPacketItem( ++ ItemStack bukkitItem, ++ int fingerprint, ++ com.github.retrooper.packetevents.protocol.item.ItemStack packetItem) { ++ this.bukkitItem = bukkitItem == null ? null : bukkitItem.clone(); ++ this.fingerprint = fingerprint; ++ this.packetItem = PacketItemConverter.copy(packetItem); ++ } ++ ++ private boolean matches(ItemStack item, int fingerprint) { ++ return this.fingerprint == fingerprint && PacketItemConverter.sameDisplayItem(bukkitItem, item); ++ } + } +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java new file mode 100644 -index 00000000..aa6ab4ab +index 0000000000000000000000000000000000000000..aa6ab4aba33324a21bab6f7707532550c222af1a --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java @@ -0,0 +1,39 @@ @@ -1881,10 +2031,10 @@ index 00000000..aa6ab4ab +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java new file mode 100644 -index 00000000..bccc4cfc +index 0000000000000000000000000000000000000000..03db18d4012fb02b485f06b365778835861a43e4 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java -@@ -0,0 +1,151 @@ +@@ -0,0 +1,216 @@ +package me.devnatan.inventoryframework.internal.packet; + +import io.github.retrooper.packetevents.util.SpigotConversionUtil; @@ -1899,10 +2049,15 @@ index 00000000..bccc4cfc + +final class PacketItemConverter { + ++ private static final Method DISPLAY_NAME_GETTER = itemMetaMethod("displayName"); ++ private static final Method DISPLAY_NAME_SETTER = itemMetaMethod("displayName", Component.class); ++ private static final Method LORE_GETTER = itemMetaMethod("lore"); ++ private static final Method LORE_SETTER = itemMetaMethod("lore", List.class); ++ + private PacketItemConverter() {} + + static com.github.retrooper.packetevents.protocol.item.ItemStack toPacket(ItemStack item) { -+ if (item == null || item.getType() == Material.AIR) { ++ if (isEmpty(item)) { + return com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY; + } + @@ -1928,6 +2083,26 @@ index 00000000..bccc4cfc + : item.copy(); + } + ++ static boolean sameDisplayItem(ItemStack first, ItemStack second) { ++ if (isEmpty(first) && isEmpty(second)) { ++ return true; ++ } ++ ++ if (isEmpty(first) || isEmpty(second)) { ++ return false; ++ } ++ ++ return first.equals(second); ++ } ++ ++ static int displayFingerprint(ItemStack item) { ++ return isEmpty(item) ? 0 : item.hashCode(); ++ } ++ ++ static boolean isEmpty(ItemStack item) { ++ return item == null || item.getType() == Material.AIR || item.getAmount() <= 0; ++ } ++ + private static ItemStack normalizeItem(ItemStack item) { + final ItemStack copy = item.clone(); + final ItemMeta meta = copy.getItemMeta(); @@ -2000,12 +2175,8 @@ index 00000000..bccc4cfc + } + + private static Object invokeItemMetaGetter(ItemMeta meta, String methodName) { -+ try { -+ final Method method = ItemMeta.class.getMethod(methodName); -+ return method.invoke(meta); -+ } catch (final ReflectiveOperationException ignored) { -+ return null; -+ } ++ final Method method = getter(methodName); ++ return method == null ? null : invoke(method, meta); + } + + private static boolean invokeItemMetaSetter( @@ -2013,8 +2184,12 @@ index 00000000..bccc4cfc + String methodName, + Class parameterType, + Object value) { ++ final Method method = setter(methodName, parameterType); ++ if (method == null) { ++ return false; ++ } ++ + try { -+ final Method method = ItemMeta.class.getMethod(methodName, parameterType); + method.invoke(meta, value); + return true; + } catch (final ReflectiveOperationException ignored) { @@ -2022,6 +2197,46 @@ index 00000000..bccc4cfc + } + } + ++ private static Method getter(String methodName) { ++ if ("displayName".equals(methodName)) { ++ return DISPLAY_NAME_GETTER; ++ } ++ ++ if ("lore".equals(methodName)) { ++ return LORE_GETTER; ++ } ++ ++ return null; ++ } ++ ++ private static Method setter(String methodName, Class parameterType) { ++ if ("displayName".equals(methodName) && Component.class.equals(parameterType)) { ++ return DISPLAY_NAME_SETTER; ++ } ++ ++ if ("lore".equals(methodName) && List.class.equals(parameterType)) { ++ return LORE_SETTER; ++ } ++ ++ return null; ++ } ++ ++ private static Object invoke(Method method, ItemMeta meta, Object... arguments) { ++ try { ++ return method.invoke(meta, arguments); ++ } catch (final ReflectiveOperationException ignored) { ++ return null; ++ } ++ } ++ ++ private static Method itemMetaMethod(String methodName, Class... parameterTypes) { ++ try { ++ return ItemMeta.class.getMethod(methodName, parameterTypes); ++ } catch (final NoSuchMethodException ignored) { ++ return null; ++ } ++ } ++ + private static Component forceNonItalic(Component component) { + Component normalized = component.decoration(TextDecoration.ITALIC, TextDecoration.State.FALSE); + if (normalized.children().isEmpty()) { @@ -2036,16 +2251,14 @@ index 00000000..bccc4cfc + return normalized.children(normalizedChildren); + } +} - diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java new file mode 100644 -index 00000000..8e578b11 +index 0000000000000000000000000000000000000000..c67b05644fd4a7e5063780d6f2cbcaef65fb29ca --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java -@@ -0,0 +1,229 @@ +@@ -0,0 +1,248 @@ +package me.devnatan.inventoryframework.internal.packet; + -+import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; @@ -2146,26 +2359,39 @@ index 00000000..8e578b11 + @Override + public void renderItem(int slot, Object item) { + requireSupportedItem(item); ++ final boolean changed; + synchronized (topItems) { + if (slot < 0 || slot >= topItems.length) { + throw new IndexOutOfBoundsException( + "Slot out of bounds: " + slot + " (size=" + topItems.length + ")"); + } -+ topItems[slot] = item == null ? null : ((ItemStack) item).clone(); ++ final ItemStack nextItem = item == null ? null : ((ItemStack) item).clone(); ++ changed = !PacketItemConverter.sameDisplayItem(topItems[slot], nextItem); ++ if (changed) { ++ topItems[slot] = nextItem; ++ } ++ } ++ if (changed) { ++ backend.requestResync(this); + } -+ backend.requestResync(this); + } + + @Override + public void removeItem(int slot) { ++ final boolean changed; + synchronized (topItems) { + if (slot < 0 || slot >= topItems.length) { + throw new IndexOutOfBoundsException( + "Slot out of bounds: " + slot + " (size=" + topItems.length + ")"); + } -+ topItems[slot] = null; ++ changed = !PacketItemConverter.isEmpty(topItems[slot]); ++ if (changed) { ++ topItems[slot] = null; ++ } ++ } ++ if (changed) { ++ backend.requestResync(this); + } -+ backend.requestResync(this); + } + + @Override @@ -2219,16 +2445,23 @@ index 00000000..8e578b11 + + @Override + public void changeTitle(@Nullable Object title, @NotNull Viewer target) { ++ final Object previous = getRawTitle(target.getId()); + if (title == null) { + viewerTitles.remove(target.getId()); + } else { + viewerTitles.put(target.getId(), title); + } + -+ backend.requestReopen(this, (BukkitViewer) target); ++ if (!Objects.equals(previous, getRawTitle(target.getId()))) { ++ backend.requestReopen(this, (BukkitViewer) target); ++ } + } + + void changeBaseTitle(@Nullable Object title) { ++ if (Objects.equals(this.title, title)) { ++ return; ++ } ++ + this.title = title; + backend.requestResync(this); + } @@ -2274,10 +2507,10 @@ index 00000000..8e578b11 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java new file mode 100644 -index 00000000..19a018d5 +index 0000000000000000000000000000000000000000..fc2ec585cbb708f5c6ebff72c7c1b0000b987a31 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java -@@ -0,0 +1,162 @@ +@@ -0,0 +1,163 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.util.ArrayList; @@ -2400,6 +2633,11 @@ index 00000000..19a018d5 + } + } + ++ synchronized void resetOpenWindow() { ++ openWindowId = PacketInventoryConstants.PLAYER_WINDOW_ID; ++ openWindowTopSize = PacketInventoryConstants.INVENTORY_SIZE; ++ } ++ + synchronized int mapContainerSlotToPlayerSlot(int windowId, int containerSlot) { + if (windowId == PacketInventoryConstants.PLAYER_WINDOW_ID) { + return containerSlot; @@ -2435,13 +2673,9 @@ index 00000000..19a018d5 + } + } + -+ private void resetOpenWindow() { -+ openWindowId = PacketInventoryConstants.PLAYER_WINDOW_ID; -+ openWindowTopSize = PacketInventoryConstants.INVENTORY_SIZE; -+ } +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/GlobalClickInterceptor.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/GlobalClickInterceptor.java -index ec3bd553..b5888370 100644 +index ec3bd553181923362bece51aff5fec6983a8a52f..b5888370d5c9703c7487d4312df7e9c989e3940d 100644 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/GlobalClickInterceptor.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/GlobalClickInterceptor.java @@ -4,7 +4,6 @@ import static me.devnatan.inventoryframework.ViewConfig.CANCEL_ON_CLICK; @@ -2465,7 +2699,7 @@ index ec3bd553..b5888370 100644 } } diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/ItemClickInterceptor.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/ItemClickInterceptor.java -index db6b8c48..4047e6e7 100644 +index db6b8c48c4c406e78aa84d3af4a15d0421ff53b0..4047e6e71b9e3086810236355f64e7354124358c 100644 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/ItemClickInterceptor.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/ItemClickInterceptor.java @@ -4,8 +4,6 @@ import me.devnatan.inventoryframework.VirtualView; @@ -2488,7 +2722,7 @@ index db6b8c48..4047e6e7 100644 final Component component = context.getComponent(); if (component == null) return; diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/ItemCloseOnClickInterceptor.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/ItemCloseOnClickInterceptor.java -index 6499f339..3f89ed26 100644 +index 6499f33961d5fbf084ca15f00a65b38d22e4fd0b..3f89ed26c49e0c94da9ea5bc277826751542281e 100644 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/ItemCloseOnClickInterceptor.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/ItemCloseOnClickInterceptor.java @@ -4,8 +4,6 @@ import me.devnatan.inventoryframework.VirtualView; @@ -2511,7 +2745,7 @@ index 6499f339..3f89ed26 100644 final Component component = context.getComponent(); if (!(component instanceof ItemComponent) || !component.isVisible()) return; diff --git a/settings.gradle.kts b/settings.gradle.kts -index 05585a2b..f26ceb55 100644 +index 05585a2bd587532365bfe98563ecc18047386144..f26ceb55f94d93d4eaf30706bf82998bab48f278 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,8 @@ dependencyResolutionManagement { @@ -2523,6 +2757,3 @@ index 05585a2b..f26ceb55 100644 } } --- -2.50.1.windows.1 - From c0642c201f2f9e37b8b19dc48df70a1bd6a24a7c Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 26 May 2026 14:51:31 +0200 Subject: [PATCH 09/50] Support packet GUI shift and keyboard clicks Handle QUICK_MOVE, SWAP, and CLONE click actions in the packet GUI backend while keeping unsafe drag/drop/double-click actions cancelled and repaired. Shift and keyboard-style clicks now enter the normal click pipeline and trigger a hard repair to avoid client-side ghost item movement. --- ...0006-Add-internal-packet-GUI-backend.patch | 77 +++++++++++++++---- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index cf02023..2af610e 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1042,7 +1042,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + return; + } + -+ if (!click.isSafeTopPickup(session.container().getSize())) { ++ if (!click.isSafeTopClick(session.container().getSize())) { + requestRender(session, false, true); + return; + } @@ -1181,9 +1181,9 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + click.clickIdentifier(), + click.isLeftClick(), + click.isRightClick(), -+ false, -+ false, -+ false, ++ click.isMiddleClick(), ++ click.isShiftClick(), ++ click.isKeyboardClick(), + false, + false); + @@ -1205,7 +1205,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + return; + } + -+ requestRender(session, false, false); ++ requestRender(session, false, click.needsHardRepair()); + } + + private void requestRender(PacketGuiSession session, boolean forceReopen, boolean hardResync) { @@ -1472,13 +1472,15 @@ new file mode 100644 index 0000000000000000000000000000000000000000..ba07a12d669675a549169f91e5c53db1a321b609 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java -@@ -0,0 +1,73 @@ +@@ -0,0 +1,122 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientClickWindow; + +final class PacketGuiClick { + ++ private static final int OFFHAND_SWAP_BUTTON = 40; ++ + private final int windowId; + private final int slot; + private final int button; @@ -1512,30 +1514,77 @@ index 0000000000000000000000000000000000000000..ba07a12d669675a549169f91e5c53db1 + return button; + } + -+ boolean isSafeTopPickup(int topSize) { -+ return clickType == WrapperPlayClientClickWindow.WindowClickType.PICKUP -+ && slot >= 0 -+ && slot < topSize -+ && (button == 0 || button == 1); ++ boolean isSafeTopClick(int topSize) { ++ if (slot < 0 || slot >= topSize) { ++ return false; ++ } ++ ++ return isPickupClick() || isQuickMoveClick() || isSwapClick() || isCloneClick(); ++ } ++ ++ boolean needsHardRepair() { ++ return isQuickMoveClick() || isSwapClick() || isCloneClick(); + } + + boolean isLeftClick() { -+ return clickType == WrapperPlayClientClickWindow.WindowClickType.PICKUP && button == 0; ++ return (isPickupClick() || isQuickMoveClick()) && button == 0; + } + + boolean isRightClick() { -+ return clickType == WrapperPlayClientClickWindow.WindowClickType.PICKUP && button == 1; ++ return (isPickupClick() || isQuickMoveClick()) && button == 1; ++ } ++ ++ boolean isMiddleClick() { ++ return isCloneClick(); ++ } ++ ++ boolean isShiftClick() { ++ return isQuickMoveClick(); ++ } ++ ++ boolean isKeyboardClick() { ++ return isSwapClick(); + } + + String clickIdentifier() { -+ if (clickType == WrapperPlayClientClickWindow.WindowClickType.PICKUP) { ++ if (isPickupClick()) { + if (button == 0) return "LEFT"; + if (button == 1) return "RIGHT"; + } + ++ if (isQuickMoveClick()) { ++ if (button == 0) return "SHIFT_LEFT"; ++ if (button == 1) return "SHIFT_RIGHT"; ++ } ++ ++ if (isSwapClick()) { ++ return button == OFFHAND_SWAP_BUTTON ? "SWAP_OFFHAND" : "NUMBER_KEY"; ++ } ++ ++ if (isCloneClick()) { ++ return "MIDDLE"; ++ } ++ + return clickType.name(); + } + ++ private boolean isPickupClick() { ++ return clickType == WrapperPlayClientClickWindow.WindowClickType.PICKUP && (button == 0 || button == 1); ++ } ++ ++ private boolean isQuickMoveClick() { ++ return clickType == WrapperPlayClientClickWindow.WindowClickType.QUICK_MOVE && (button == 0 || button == 1); ++ } ++ ++ private boolean isSwapClick() { ++ return clickType == WrapperPlayClientClickWindow.WindowClickType.SWAP ++ && ((button >= 0 && button <= 8) || button == OFFHAND_SWAP_BUTTON); ++ } ++ ++ private boolean isCloneClick() { ++ return clickType == WrapperPlayClientClickWindow.WindowClickType.CLONE; ++ } ++ + @Override + public String toString() { + return "PacketGuiClick{" From 46df20c8b94cbd4b6c53065ff78a6accb7fa9726 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 26 May 2026 15:12:37 +0200 Subject: [PATCH 10/50] Fix packet GUI close handling for dialogs Handle server-side close packets without sending an extra close packet or forcing updateInventory, preventing packet GUI cleanup from immediately closing dialogs opened from click handlers. --- ...0006-Add-internal-packet-GUI-backend.patch | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 2af610e..07c27bf 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -813,7 +813,7 @@ new file mode 100644 index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f124a55707c --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,653 @@ +@@ -0,0 +1,660 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -917,7 +917,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + @Override + public void unregister() { + for (final PacketGuiSession session : List.copyOf(sessions.values())) { -+ closeSession(session, false, CLOSE_ORIGIN_SHUTDOWN, false); ++ closeSession(session, false, CLOSE_ORIGIN_SHUTDOWN, false, false); + } + sessions.clear(); + viewerInventories.clear(); @@ -940,7 +940,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + final PacketGuiSession session = sessions.get(player.getUniqueId()); + if (session == null) return false; + -+ closeSession(session, false, CLOSE_ORIGIN_QUIT, true); ++ closeSession(session, false, CLOSE_ORIGIN_QUIT, true, true); + viewerInventories.remove(player.getUniqueId()); + return true; + } @@ -950,7 +950,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + final PacketGuiSession session = sessions.get(player.getUniqueId()); + if (session == null) return false; + -+ closeSession(session, false, CLOSE_ORIGIN_EXTERNAL_OPEN, true); ++ closeSession(session, false, CLOSE_ORIGIN_EXTERNAL_OPEN, true, true); + return true; + } + @@ -976,7 +976,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + + final PacketGuiSession previous = sessions.get(player.getUniqueId()); + if (previous != null) { -+ closeSession(previous, false, CLOSE_ORIGIN_SERVER, true); ++ closeSession(previous, false, CLOSE_ORIGIN_SERVER, true, true); + } + + final PacketViewerInventory viewerInventory = inventoryFor(player.getUniqueId()); @@ -990,7 +990,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + void close(@NotNull PacketViewContainer container, boolean sendClosePacket) { + for (final PacketGuiSession session : List.copyOf(sessions.values())) { + if (session.container() == container) { -+ closeSession(session, sendClosePacket, CLOSE_ORIGIN_SERVER, true); ++ closeSession(session, sendClosePacket, CLOSE_ORIGIN_SERVER, true, true); + } + } + } @@ -1001,7 +1001,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + return; + } + -+ closeSession(session, sendClosePacket, CLOSE_ORIGIN_SERVER, true); ++ closeSession(session, sendClosePacket, CLOSE_ORIGIN_SERVER, true, true); + } + + void requestResync(@NotNull PacketViewContainer container) { @@ -1060,7 +1060,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + return; + } + session.viewerInventory().closeWindow(windowId); -+ runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_CLIENT, true)); ++ runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_CLIENT, true, true)); + } + + void handleExternalInventoryOpen(User user, int windowId, int topSize) { @@ -1074,7 +1074,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + + final PacketGuiSession session = sessions.get(user.getUUID()); + if (session != null && !isGuiWindow(user, windowId)) { -+ runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_EXTERNAL_OPEN, true)); ++ runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_EXTERNAL_OPEN, true, true)); + } + } + @@ -1085,9 +1085,15 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + + final PacketGuiSession session = sessions.get(user.getUUID()); + if (isTracked(session)) { ++ session.closeRequested(true); + runOnPlayer( + session.player(), -+ () -> closeSession(session, windowId != session.windowId(), CLOSE_ORIGIN_SERVER, true)); ++ () -> closeSession( ++ session, ++ false, ++ CLOSE_ORIGIN_SERVER, ++ true, ++ false)); + return; + } + @@ -1101,7 +1107,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + + final PacketGuiSession session = sessions.get(viewerId); + if (session != null) { -+ closeSession(session, false, CLOSE_ORIGIN_QUIT, false); ++ closeSession(session, false, CLOSE_ORIGIN_QUIT, false, false); + } + viewerInventories.remove(viewerId); + } @@ -1201,7 +1207,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + context.getRoot().getPipeline().execute(StandardPipelinePhases.CLICK, clickContext); + } catch (final RuntimeException exception) { + owner.getLogger().log(Level.SEVERE, "An error occurred while processing a packet GUI click", exception); -+ closeSession(session, true, CLOSE_ORIGIN_SERVER, true); ++ closeSession(session, true, CLOSE_ORIGIN_SERVER, true, true); + return; + } + @@ -1258,7 +1264,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + session.appliedRender(render); + } catch (final RuntimeException exception) { + owner.getLogger().log(Level.WARNING, "Failed to send packet GUI render", exception); -+ closeSession(session, false, CLOSE_ORIGIN_SERVER, true); ++ closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true); + } + } + @@ -1315,13 +1321,14 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + session.user().sendPacket(new WrapperPlayServerSetCursorItem(session.viewerInventory().cursor())); + } + -+ private boolean closeSession(PacketGuiSession session, boolean sendClosePacket, Object origin, boolean callClose) { ++ private boolean closeSession( ++ PacketGuiSession session, boolean sendClosePacket, Object origin, boolean callClose, boolean syncInventory) { + if (session == null) { + return false; + } + + if (!isOnPlayerThread(session.player()) && (sendClosePacket || callClose)) { -+ runOnPlayer(session.player(), () -> closeSession(session, sendClosePacket, origin, callClose)); ++ runOnPlayer(session.player(), () -> closeSession(session, sendClosePacket, origin, callClose, syncInventory)); + return true; + } + @@ -1345,7 +1352,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + } + } + -+ if (sendClosePacket || callClose) { ++ if (syncInventory) { + session.player().updateInventory(); + } + if (callClose) { From 60209d140934ca5f02b03f37c8e718e2ac920971 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 26 May 2026 15:50:43 +0200 Subject: [PATCH 11/50] fix packet GUI pickup clicks Force a post-click repair for normal left/right PICKUP clicks so client-side prediction cannot leave fake GUI items missing or stuck on the cursor. Keeps shift, swap, clone, and unsafe click repair behavior intact. --- ...0006-Add-internal-packet-GUI-backend.patch | 88 ++++++++++++++++++- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 07c27bf..d3bb0c7 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -813,7 +813,7 @@ new file mode 100644 index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f124a55707c --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,660 @@ +@@ -0,0 +1,742 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1124,6 +1124,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + final PacketViewerInventory inventory = inventoryFor(user.getUUID()); + if (windowId == PacketInventoryConstants.PLAYER_WINDOW_ID) { + inventory.applyPlayerWindowItems(items, carried); ++ mirrorPlayerInventoryWindow(user.getUUID(), inventory); + return; + } + @@ -1136,7 +1137,9 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + return; + } + -+ inventoryFor(user.getUUID()).applySlot(PacketInventoryConstants.playerInventorySlotToContainerSlot(slot), item); ++ final int mappedSlot = PacketInventoryConstants.playerInventorySlotToContainerSlot(slot); ++ inventoryFor(user.getUUID()).applySlot(mappedSlot, item); ++ mirrorPlayerInventorySlot(user.getUUID(), mappedSlot, item); + } + + void trackWindowSlot( @@ -1148,6 +1151,7 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + final PacketViewerInventory inventory = inventoryFor(user.getUUID()); + if (windowId == PacketInventoryConstants.PLAYER_WINDOW_ID) { + inventory.applySlot(slot, item); ++ mirrorPlayerInventorySlot(user.getUUID(), slot, item); + return; + } + @@ -1160,6 +1164,84 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + } + } + ++ private void mirrorPlayerInventoryWindow(UUID viewerId, PacketViewerInventory inventory) { ++ final PacketGuiSession session = sessions.get(viewerId); ++ if (!isTracked(session) || session.closeRequested()) { ++ return; ++ } ++ ++ final List guiSlots = new ArrayList<>(36); ++ final List items = new ArrayList<>(36); ++ for (int slot = PacketInventoryConstants.ITEMS_START; ++ slot < PacketInventoryConstants.HOTBAR_START + 9; ++ slot++) { ++ final int guiSlot = mapPlayerWindowSlotToOpenGuiSlot(session.container().getSize(), slot); ++ if (guiSlot < 0) { ++ continue; ++ } ++ ++ guiSlots.add(guiSlot); ++ items.add(inventory.item(slot)); ++ } ++ ++ runOnPlayer(session.player(), () -> { ++ if (!isTracked(session) || session.closeRequested()) { ++ return; ++ } ++ ++ final int stateId = session.nextStateId(); ++ for (int index = 0; index < guiSlots.size(); index++) { ++ session.user() ++ .sendPacket(new WrapperPlayServerSetSlot( ++ session.windowId(), ++ stateId, ++ guiSlots.get(index), ++ items.get(index))); ++ } ++ }); ++ } ++ ++ private void mirrorPlayerInventorySlot( ++ UUID viewerId, int playerWindowSlot, com.github.retrooper.packetevents.protocol.item.ItemStack item) { ++ final PacketGuiSession session = sessions.get(viewerId); ++ if (!isTracked(session) || session.closeRequested()) { ++ return; ++ } ++ ++ final int guiSlot = mapPlayerWindowSlotToOpenGuiSlot(session.container().getSize(), playerWindowSlot); ++ if (guiSlot < 0) { ++ return; ++ } ++ ++ final com.github.retrooper.packetevents.protocol.item.ItemStack mirrorItem = PacketItemConverter.copy(item); ++ runOnPlayer(session.player(), () -> { ++ if (!isTracked(session) || session.closeRequested()) { ++ return; ++ } ++ ++ session.user() ++ .sendPacket(new WrapperPlayServerSetSlot( ++ session.windowId(), ++ session.nextStateId(), ++ guiSlot, ++ mirrorItem)); ++ }); ++ } ++ ++ private static int mapPlayerWindowSlotToOpenGuiSlot(int topSize, int playerWindowSlot) { ++ if (playerWindowSlot >= PacketInventoryConstants.ITEMS_START ++ && playerWindowSlot < PacketInventoryConstants.HOTBAR_START) { ++ return topSize + (playerWindowSlot - PacketInventoryConstants.ITEMS_START); ++ } ++ ++ if (playerWindowSlot >= PacketInventoryConstants.HOTBAR_START ++ && playerWindowSlot < PacketInventoryConstants.HOTBAR_START + 9) { ++ return topSize + 27 + (playerWindowSlot - PacketInventoryConstants.HOTBAR_START); ++ } ++ ++ return -1; ++ } ++ + void trackCursor(User user, com.github.retrooper.packetevents.protocol.item.ItemStack item) { + if (user == null || user.getUUID() == null) { + return; @@ -1530,7 +1612,7 @@ index 0000000000000000000000000000000000000000..ba07a12d669675a549169f91e5c53db1 + } + + boolean needsHardRepair() { -+ return isQuickMoveClick() || isSwapClick() || isCloneClick(); ++ return isPickupClick() || isQuickMoveClick() || isSwapClick() || isCloneClick(); + } + + boolean isLeftClick() { From a0b4e013922bfb95d7e517f31e7ddf9583c93667 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 26 May 2026 16:19:17 +0200 Subject: [PATCH 12/50] fix offhand swap in packet GUIs Ensure offhand swap clicks repair the client-side offhand slot and cursor so fake GUI items cannot appear as ghost items in the player's hand. --- ...0006-Add-internal-packet-GUI-backend.patch | 160 ++++++++++++------ 1 file changed, 105 insertions(+), 55 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index d3bb0c7..e5ddc1e 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -13,13 +13,13 @@ index 89850fa8d67a4ff08fbe5997ab94625e50c0cf19..daec84d3a7722c8390ae6624b01b84d5 minestom = "b39badc77b" folialib = "0.5.1" +packetevents = "2.12.1" - + [libraries.spigot] module = "org.spigotmc:spigot-api" @@ -57,10 +58,14 @@ version.ref = "minestom" module = "com.tcoded:FoliaLib" version.ref = "folialib" - + +[libraries.packetevents-spigot] +module = "com.github.retrooper:packetevents-spigot" +version.ref = "packetevents" @@ -59,7 +59,7 @@ index 16e4791981a4fd159659190b2ac3e1a961e28e90..78c704d7457a6fe653346e5d10037292 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/BukkitViewer.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/BukkitViewer.java @@ -52,7 +52,12 @@ public final class BukkitViewer implements Viewer { - + @Override public void open(@NotNull final ViewContainer container) { - getPlayer().openInventory(((BukkitViewContainer) container).getInventory()); @@ -70,7 +70,7 @@ index 16e4791981a4fd159659190b2ac3e1a961e28e90..78c704d7457a6fe653346e5d10037292 + + container.open(this); } - + @Override diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..199757af2e76e5ffecbc8a1d2c2ca954f050938e 100644 @@ -95,10 +95,10 @@ index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..199757af2e76e5ffecbc8a1d2c2ca954 import org.bukkit.event.player.PlayerPickupItemEvent; @@ -24,9 +27,15 @@ import org.bukkit.inventory.PlayerInventory; final class IFInventoryListener implements Listener { - + private final ViewFrame viewFrame; + private final GuiBackend guiBackend; - + public IFInventoryListener(ViewFrame viewFrame) { + this(viewFrame, new BukkitGuiBackend()); + } @@ -107,7 +107,7 @@ index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..199757af2e76e5ffecbc8a1d2c2ca954 this.viewFrame = viewFrame; + this.guiBackend = guiBackend; } - + @EventHandler @@ -39,6 +48,8 @@ final class IFInventoryListener implements Listener { @EventHandler @@ -117,11 +117,11 @@ index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..199757af2e76e5ffecbc8a1d2c2ca954 + final Viewer viewer = viewFrame.getViewer(player); if (viewer == null) return; - + @@ -49,6 +60,13 @@ final class IFInventoryListener implements Listener { root.getPipeline().execute(StandardPipelinePhases.CLOSE, closeContext); } - + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onInventoryOpen(final InventoryOpenEvent event) { + if (!(event.getPlayer() instanceof Player)) return; @@ -147,21 +147,21 @@ index 2d97a162afacb6aa9652d9d83079088761bbd35b..4dc75f811d1a888cee0851a70377ac76 import org.bukkit.entity.Player; @@ -35,10 +37,12 @@ public class ViewFrame extends IFViewFrame { + "https://github.com/DevNatan/inventory-framework/wiki/Installation#preventing-library-conflicts"; - + private final Plugin owner; + private final GuiBackend guiBackend; private final FeatureInstaller featureInstaller = new DefaultFeatureInstaller<>(this); - + private ViewFrame(Plugin owner) { this.owner = owner; + this.guiBackend = GuiBackendFactory.create(owner); } - + @NotNull @@ -186,13 +190,14 @@ public class ViewFrame extends IFViewFrame { public final ViewFrame register() { if (isRegistered()) throw new IllegalStateException("This view frame is already registered"); - + - PlatformUtils.setFactory(new BukkitElementFactory(getOwner())); + PlatformUtils.setFactory(new BukkitElementFactory(getOwner(), guiBackend)); + guiBackend.register(); @@ -174,14 +174,14 @@ index 2d97a162afacb6aa9652d9d83079088761bbd35b..4dc75f811d1a888cee0851a70377ac76 + getOwner().getServer().getPluginManager().registerEvents(new IFInventoryListener(this, guiBackend), getOwner()); return this; } - + @@ -213,6 +218,7 @@ public class ViewFrame extends IFViewFrame { iterator.remove(); } getPipeline().execute(IFViewFrame.FRAME_UNREGISTERED, this); + guiBackend.unregister(); } - + // region Internals diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java new file mode 100644 @@ -423,7 +423,7 @@ index b89eae09cdfbbc5c01ef378071801f9add48af63..e1ccb7b9309017dbf69102f151151824 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java @@ -169,6 +169,12 @@ public final class RenderContext extends PlatformRenderContext Date: Tue, 26 May 2026 19:38:52 +0200 Subject: [PATCH 13/50] optimize packet GUI targeted repairs Replace normal-click full window repairs with targeted slot/cursor repair scopes, reduce full WindowItems usage, and simplify packet item caching to avoid expensive render hashing. --- ...0006-Add-internal-packet-GUI-backend.patch | 578 +++++++++++++----- 1 file changed, 432 insertions(+), 146 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index e5ddc1e..98ef5c0 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -13,13 +13,13 @@ index 89850fa8d67a4ff08fbe5997ab94625e50c0cf19..daec84d3a7722c8390ae6624b01b84d5 minestom = "b39badc77b" folialib = "0.5.1" +packetevents = "2.12.1" - + [libraries.spigot] module = "org.spigotmc:spigot-api" @@ -57,10 +58,14 @@ version.ref = "minestom" module = "com.tcoded:FoliaLib" version.ref = "folialib" - + +[libraries.packetevents-spigot] +module = "com.github.retrooper:packetevents-spigot" +version.ref = "packetevents" @@ -59,7 +59,7 @@ index 16e4791981a4fd159659190b2ac3e1a961e28e90..78c704d7457a6fe653346e5d10037292 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/BukkitViewer.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/BukkitViewer.java @@ -52,7 +52,12 @@ public final class BukkitViewer implements Viewer { - + @Override public void open(@NotNull final ViewContainer container) { - getPlayer().openInventory(((BukkitViewContainer) container).getInventory()); @@ -70,7 +70,7 @@ index 16e4791981a4fd159659190b2ac3e1a961e28e90..78c704d7457a6fe653346e5d10037292 + + container.open(this); } - + @Override diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..199757af2e76e5ffecbc8a1d2c2ca954f050938e 100644 @@ -95,10 +95,10 @@ index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..199757af2e76e5ffecbc8a1d2c2ca954 import org.bukkit.event.player.PlayerPickupItemEvent; @@ -24,9 +27,15 @@ import org.bukkit.inventory.PlayerInventory; final class IFInventoryListener implements Listener { - + private final ViewFrame viewFrame; + private final GuiBackend guiBackend; - + public IFInventoryListener(ViewFrame viewFrame) { + this(viewFrame, new BukkitGuiBackend()); + } @@ -107,7 +107,7 @@ index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..199757af2e76e5ffecbc8a1d2c2ca954 this.viewFrame = viewFrame; + this.guiBackend = guiBackend; } - + @EventHandler @@ -39,6 +48,8 @@ final class IFInventoryListener implements Listener { @EventHandler @@ -117,11 +117,11 @@ index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..199757af2e76e5ffecbc8a1d2c2ca954 + final Viewer viewer = viewFrame.getViewer(player); if (viewer == null) return; - + @@ -49,6 +60,13 @@ final class IFInventoryListener implements Listener { root.getPipeline().execute(StandardPipelinePhases.CLOSE, closeContext); } - + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onInventoryOpen(final InventoryOpenEvent event) { + if (!(event.getPlayer() instanceof Player)) return; @@ -147,21 +147,21 @@ index 2d97a162afacb6aa9652d9d83079088761bbd35b..4dc75f811d1a888cee0851a70377ac76 import org.bukkit.entity.Player; @@ -35,10 +37,12 @@ public class ViewFrame extends IFViewFrame { + "https://github.com/DevNatan/inventory-framework/wiki/Installation#preventing-library-conflicts"; - + private final Plugin owner; + private final GuiBackend guiBackend; private final FeatureInstaller featureInstaller = new DefaultFeatureInstaller<>(this); - + private ViewFrame(Plugin owner) { this.owner = owner; + this.guiBackend = GuiBackendFactory.create(owner); } - + @NotNull @@ -186,13 +190,14 @@ public class ViewFrame extends IFViewFrame { public final ViewFrame register() { if (isRegistered()) throw new IllegalStateException("This view frame is already registered"); - + - PlatformUtils.setFactory(new BukkitElementFactory(getOwner())); + PlatformUtils.setFactory(new BukkitElementFactory(getOwner(), guiBackend)); + guiBackend.register(); @@ -174,14 +174,14 @@ index 2d97a162afacb6aa9652d9d83079088761bbd35b..4dc75f811d1a888cee0851a70377ac76 + getOwner().getServer().getPluginManager().registerEvents(new IFInventoryListener(this, guiBackend), getOwner()); return this; } - + @@ -213,6 +218,7 @@ public class ViewFrame extends IFViewFrame { iterator.remove(); } getPipeline().execute(IFViewFrame.FRAME_UNREGISTERED, this); + guiBackend.unregister(); } - + // region Internals diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java new file mode 100644 @@ -423,7 +423,7 @@ index b89eae09cdfbbc5c01ef378071801f9add48af63..e1ccb7b9309017dbf69102f151151824 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java @@ -169,6 +169,12 @@ public final class RenderContext extends PlatformRenderContext handleSafeTopClick(session, click)); ++ runOnPlayer(session.player(), () -> handleSafeTopClick(session, click, repairScope)); + } + + void handleWindowClose(User user, int windowId) { @@ -1276,7 +1279,8 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + inventoryFor(user.getUUID()).applyCursor(item); + } + -+ private void handleSafeTopClick(PacketGuiSession session, PacketGuiClick click) { ++ private void handleSafeTopClick( ++ PacketGuiSession session, PacketGuiClick click, PacketGuiRepairScope repairScope) { + if (!isTracked(session)) { + return; + } @@ -1319,47 +1323,61 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + return; + } + -+ if (click.isOffhandSwapClick()) { -+ repairOffhandSlot(session); -+ } -+ requestRender(session, false, click.needsHardRepair()); ++ requestRender(session, false, false, repairScope, click); + } + -+ private void repairOffhandSlot(PacketGuiSession session) { -+ if (!isTracked(session) || session.closeRequested()) { -+ return; -+ } -+ -+ final com.github.retrooper.packetevents.protocol.item.ItemStack offhandItem = -+ PacketItemConverter.toPacket(session.player().getInventory().getItemInOffHand()); -+ session.viewerInventory().applySlot(PacketInventoryConstants.SLOT_OFFHAND, offhandItem); -+ session.user() -+ .sendPacket(new WrapperPlayServerSetSlot( -+ PacketInventoryConstants.PLAYER_WINDOW_ID, -+ session.nextStateId(), -+ PacketInventoryConstants.SLOT_OFFHAND, -+ offhandItem)); -+ sendCursor(session); ++ private void requestRender(PacketGuiSession session, boolean forceReopen, boolean hardResync) { ++ requestRender(session, forceReopen, hardResync, PacketGuiRepairScope.NONE, null); + } + -+ private void requestRender(PacketGuiSession session, boolean forceReopen, boolean hardResync) { ++ private void requestRender( ++ PacketGuiSession session, ++ boolean forceReopen, ++ boolean hardResync, ++ PacketGuiRepairScope repairScope, ++ PacketGuiClick click) { + if (!isTracked(session) || session.closeRequested()) { + return; + } + -+ if (!session.scheduleRender(forceReopen, hardResync)) { ++ if (!session.scheduleRender(forceReopen, hardResync, repairScope, click)) { + return; + } + + runOnPlayerNextTick(session.player(), () -> { + final PacketGuiSession.RenderRequest request = session.consumeRenderRequest(); -+ renderSession(session, request.forceReopen(), request.hardResync()); ++ renderSession(session, request); + }); + } + + private void renderSession(PacketGuiSession session, boolean forceReopen, boolean hardResync) { ++ renderSession(session, forceReopen, hardResync, new boolean[0], new int[0]); ++ } ++ ++ private void renderSession(PacketGuiSession session, PacketGuiSession.RenderRequest request) { ++ renderSession( ++ session, ++ request.forceReopen(), ++ request.hardResync(), ++ request.forcedTopSlotRepairs(), ++ request.playerSlotRepairs()); ++ } ++ ++ private void renderSession( ++ PacketGuiSession session, ++ boolean forceReopen, ++ boolean hardResync, ++ boolean[] forcedTopSlotRepairs, ++ int[] playerSlotRepairs) { + if (!isOnPlayerThread(session.player())) { -+ runOnPlayer(session.player(), () -> renderSession(session, forceReopen, hardResync)); ++ runOnPlayer( ++ session.player(), ++ () -> renderSession( ++ session, ++ forceReopen, ++ hardResync, ++ forcedTopSlotRepairs, ++ playerSlotRepairs)); + return; + } + @@ -1385,7 +1403,8 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + if (sendFullWindow) { + sendWindowItems(session, render); + } else { -+ sendChangedTopSlots(session, previous, render); ++ sendChangedTopSlots(session, previous, render, forcedTopSlotRepairs); ++ sendPlayerSlotRepairs(session, render, playerSlotRepairs); + } + + sendCursor(session); @@ -1425,10 +1444,15 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + session.viewerInventory().cursor())); + } + -+ private void sendChangedTopSlots(PacketGuiSession session, PacketGuiRender previous, PacketGuiRender render) { ++ private void sendChangedTopSlots( ++ PacketGuiSession session, ++ PacketGuiRender previous, ++ PacketGuiRender render, ++ boolean[] forcedTopSlotRepairs) { + int stateId = -1; + for (int slot = 0; slot < render.size(); slot++) { -+ if (render.sameTopItem(previous, slot)) { ++ final boolean forcedRepair = slot < forcedTopSlotRepairs.length && forcedTopSlotRepairs[slot]; ++ if (!forcedRepair && render.sameTopItem(previous, slot)) { + continue; + } + @@ -1445,6 +1469,75 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + } + } + ++ private void sendPlayerSlotRepairs(PacketGuiSession session, PacketGuiRender render, int[] playerSlotRepairs) { ++ if (playerSlotRepairs.length == 0) { ++ return; ++ } ++ ++ int stateId = -1; ++ for (final int playerSlot : playerSlotRepairs) { ++ final com.github.retrooper.packetevents.protocol.item.ItemStack item = ++ snapshotPlayerInventorySlot(session, playerSlot); ++ final int openGuiSlot = mapPlayerWindowSlotToOpenGuiSlot(render.size(), playerSlot); ++ if (openGuiSlot >= 0) { ++ if (stateId < 0) { ++ stateId = session.nextStateId(); ++ } ++ ++ session.user() ++ .sendPacket(new WrapperPlayServerSetSlot( ++ session.windowId(), ++ stateId, ++ openGuiSlot, ++ item)); ++ continue; ++ } ++ ++ session.user() ++ .sendPacket(new WrapperPlayServerSetSlot( ++ PacketInventoryConstants.PLAYER_WINDOW_ID, ++ session.nextStateId(), ++ playerSlot, ++ item)); ++ } ++ } ++ ++ private com.github.retrooper.packetevents.protocol.item.ItemStack snapshotPlayerInventorySlot( ++ PacketGuiSession session, int playerWindowSlot) { ++ final com.github.retrooper.packetevents.protocol.item.ItemStack item = ++ PacketItemConverter.toPacket(playerInventoryItem(session.player(), playerWindowSlot)); ++ session.viewerInventory().applySlot(playerWindowSlot, item); ++ return item; ++ } ++ ++ private static ItemStack playerInventoryItem(Player player, int playerWindowSlot) { ++ final PlayerInventory inventory = player.getInventory(); ++ if (playerWindowSlot >= PacketInventoryConstants.ITEMS_START ++ && playerWindowSlot < PacketInventoryConstants.HOTBAR_START) { ++ return inventory.getItem(playerWindowSlot); ++ } ++ ++ if (playerWindowSlot >= PacketInventoryConstants.HOTBAR_START ++ && playerWindowSlot < PacketInventoryConstants.HOTBAR_START + 9) { ++ return inventory.getItem(playerWindowSlot - PacketInventoryConstants.HOTBAR_START); ++ } ++ ++ switch (playerWindowSlot) { ++ case PacketInventoryConstants.SLOT_HELMET: ++ return inventory.getHelmet(); ++ case PacketInventoryConstants.SLOT_CHESTPLATE: ++ return inventory.getChestplate(); ++ case PacketInventoryConstants.SLOT_LEGGINGS: ++ return inventory.getLeggings(); ++ case PacketInventoryConstants.SLOT_BOOTS: ++ return inventory.getBoots(); ++ case PacketInventoryConstants.SLOT_OFFHAND: ++ return inventory.getItemInOffHand(); ++ default: ++ return null; ++ } ++ } ++ + private void sendCursor(PacketGuiSession session) { + session.user().sendPacket(new WrapperPlayServerSetCursorItem(session.viewerInventory().cursor())); + } @@ -1584,49 +1677,58 @@ index 0000000000000000000000000000000000000000..f4b2cf8806175f9dc964a2ac1db90f12 + } + + private boolean isOnPlayerThread(Player player) { -+ if (isOwnedByCurrentRegion(player)) { -+ return true; ++ final Boolean ownedByCurrentRegion = isOwnedByCurrentRegion(player); ++ if (ownedByCurrentRegion != null) { ++ return ownedByCurrentRegion; + } + + return Bukkit.isPrimaryThread(); + } + -+ private boolean isOwnedByCurrentRegion(Player player) { ++ private Boolean isOwnedByCurrentRegion(Player player) { + try { + final Method method = Bukkit.class.getMethod("isOwnedByCurrentRegion", Entity.class); + return Boolean.TRUE.equals(method.invoke(null, player)); + } catch (final NoSuchMethodException ignored) { -+ return false; ++ return null; + } catch (final ReflectiveOperationException ignored) { -+ return false; ++ return Boolean.FALSE; + } + } +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java new file mode 100644 -index 0000000000000000000000000000000000000000..ba07a12d669675a549169f91e5c53db1a321b609 +index 0000000000000000000000000000000000000000..bc0bf258f523442060af97569fe76518dd51364e --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java -@@ -0,0 +1,126 @@ +@@ -0,0 +1,188 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientClickWindow; ++import java.util.Map; + +final class PacketGuiClick { + + private static final int OFFHAND_SWAP_BUTTON = 40; ++ private static final int[] EMPTY_CHANGED_SLOTS = new int[0]; + + private final int windowId; + private final int slot; + private final int button; + private final WrapperPlayClientClickWindow.WindowClickType clickType; ++ private final int[] changedSlots; + + private PacketGuiClick( -+ int windowId, int slot, int button, WrapperPlayClientClickWindow.WindowClickType clickType) { ++ int windowId, ++ int slot, ++ int button, ++ WrapperPlayClientClickWindow.WindowClickType clickType, ++ int[] changedSlots) { + this.windowId = windowId; + this.slot = slot; + this.button = button; + this.clickType = clickType; ++ this.changedSlots = changedSlots; + } + + static PacketGuiClick from(WrapperPlayClientClickWindow packet) { @@ -1634,7 +1736,8 @@ index 0000000000000000000000000000000000000000..ba07a12d669675a549169f91e5c53db1 + packet.getWindowId(), + packet.getSlot(), + packet.getButton(), -+ packet.getWindowClickType()); ++ packet.getWindowClickType(), ++ changedSlots(packet)); + } + + int windowId() { @@ -1649,6 +1752,10 @@ index 0000000000000000000000000000000000000000..ba07a12d669675a549169f91e5c53db1 + return button; + } + ++ int[] changedSlots() { ++ return changedSlots; ++ } ++ + boolean isSafeTopClick(int topSize) { + if (slot < 0 || slot >= topSize) { + return false; @@ -1657,8 +1764,26 @@ index 0000000000000000000000000000000000000000..ba07a12d669675a549169f91e5c53db1 + return isPickupClick() || isQuickMoveClick() || isSwapClick() || isCloneClick(); + } + -+ boolean needsHardRepair() { -+ return isPickupClick() || isQuickMoveClick() || isSwapClick() || isCloneClick(); ++ PacketGuiRepairScope repairScope(int topSize) { ++ if (!isSafeTopClick(topSize)) { ++ return PacketGuiRepairScope.FULL_WINDOW; ++ } ++ ++ if (isPickupClick() || isCloneClick()) { ++ return PacketGuiRepairScope.TOP_SLOT_AND_CURSOR; ++ } ++ ++ if (isQuickMoveClick()) { ++ return PacketGuiRepairScope.TOP_SLOT_CURSOR_AND_PLAYER_SLOT; ++ } ++ ++ if (isSwapClick()) { ++ return isOffhandSwapClick() ++ ? PacketGuiRepairScope.TOP_SLOT_CURSOR_AND_OFFHAND ++ : PacketGuiRepairScope.TOP_SLOT_CURSOR_AND_PLAYER_SLOT; ++ } ++ ++ return PacketGuiRepairScope.FULL_WINDOW; + } + + boolean isLeftClick() { @@ -1685,6 +1810,18 @@ index 0000000000000000000000000000000000000000..ba07a12d669675a549169f91e5c53db1 + return isSwapClick() && button == OFFHAND_SWAP_BUTTON; + } + ++ int swappedPlayerWindowSlot() { ++ if (!isSwapClick()) { ++ return -1; ++ } ++ ++ if (button == OFFHAND_SWAP_BUTTON) { ++ return PacketInventoryConstants.SLOT_OFFHAND; ++ } ++ ++ return PacketInventoryConstants.HOTBAR_START + button; ++ } ++ + String clickIdentifier() { + if (isPickupClick()) { + if (button == 0) return "LEFT"; @@ -1704,7 +1841,7 @@ index 0000000000000000000000000000000000000000..ba07a12d669675a549169f91e5c53db1 + return "MIDDLE"; + } + -+ return clickType.name(); ++ return clickType == null ? "UNKNOWN" : clickType.name(); + } + + private boolean isPickupClick() { @@ -1724,6 +1861,25 @@ index 0000000000000000000000000000000000000000..ba07a12d669675a549169f91e5c53db1 + return clickType == WrapperPlayClientClickWindow.WindowClickType.CLONE; + } + ++ @SuppressWarnings("deprecation") ++ private static int[] changedSlots(WrapperPlayClientClickWindow packet) { ++ Map slots = packet.getHashedSlots(); ++ if (slots == null) { ++ slots = packet.getSlots().orElse(null); ++ } ++ ++ if (slots == null || slots.isEmpty()) { ++ return EMPTY_CHANGED_SLOTS; ++ } ++ ++ final int[] changedSlots = new int[slots.size()]; ++ int index = 0; ++ for (final Integer changedSlot : slots.keySet()) { ++ changedSlots[index++] = changedSlot == null ? -1 : changedSlot; ++ } ++ return changedSlots; ++ } ++ + @Override + public String toString() { + return "PacketGuiClick{" @@ -1864,13 +2020,12 @@ index 0000000000000000000000000000000000000000..89cf420ff84c58826d06cbe9d92c4f9b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java new file mode 100644 -index 0000000000000000000000000000000000000000..1e4d3441f44b118c75ab62bf24e846c68a30d955 +index 0000000000000000000000000000000000000000..be445839c0fedf34bea198e2b3aa3d8a6780a7c9 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java -@@ -0,0 +1,103 @@ +@@ -0,0 +1,85 @@ +package me.devnatan.inventoryframework.internal.packet; + -+import java.util.Arrays; +import java.util.Objects; +import net.kyori.adventure.text.Component; +import org.bukkit.inventory.ItemStack; @@ -1942,23 +2097,6 @@ index 0000000000000000000000000000000000000000..1e4d3441f44b118c75ab62bf24e846c6 + && PacketItemConverter.sameDisplayItem(topItems[slot], other.topItems[slot]); + } + -+ @Override -+ public boolean equals(Object o) { -+ if (this == o) return true; -+ if (!(o instanceof PacketGuiRender)) return false; -+ final PacketGuiRender that = (PacketGuiRender) o; -+ return rows == that.rows -+ && Objects.equals(rawTitle, that.rawTitle) -+ && Arrays.equals(topItems, that.topItems); -+ } -+ -+ @Override -+ public int hashCode() { -+ int result = Objects.hash(rawTitle, rows); -+ result = 31 * result + Arrays.hashCode(topItems); -+ return result; -+ } -+ + private static Component titleComponent(Object title) { + if (title instanceof Component) { + return (Component) title; @@ -1971,18 +2109,53 @@ index 0000000000000000000000000000000000000000..1e4d3441f44b118c75ab62bf24e846c6 + return Component.text(String.valueOf(title)); + } +} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRepairScope.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRepairScope.java +new file mode 100644 +index 0000000000000000000000000000000000000000..838bd2a2606696d3dafc5430b179410bfd13df7d +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRepairScope.java +@@ -0,0 +1,27 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++enum PacketGuiRepairScope { ++ NONE, ++ TOP_SLOT_AND_CURSOR, ++ TOP_SLOT_CURSOR_AND_PLAYER_SLOT, ++ TOP_SLOT_CURSOR_AND_OFFHAND, ++ FULL_WINDOW; ++ ++ boolean fullWindow() { ++ return this == FULL_WINDOW; ++ } ++ ++ boolean repairsTopSlot() { ++ return this == TOP_SLOT_AND_CURSOR ++ || this == TOP_SLOT_CURSOR_AND_PLAYER_SLOT ++ || this == TOP_SLOT_CURSOR_AND_OFFHAND; ++ } ++ ++ boolean repairsChangedPlayerSlots() { ++ return this == TOP_SLOT_CURSOR_AND_PLAYER_SLOT || this == TOP_SLOT_CURSOR_AND_OFFHAND; ++ } ++ ++ boolean repairsOffhand() { ++ return this == TOP_SLOT_CURSOR_AND_OFFHAND; ++ } ++} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java new file mode 100644 -index 0000000000000000000000000000000000000000..8994528f01d429b9959282dbed029caabb2a02a4 +index 0000000000000000000000000000000000000000..9c49c2dfe15932b8c35caa86e5c64f5910072ec3 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java -@@ -0,0 +1,195 @@ +@@ -0,0 +1,309 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.protocol.player.User; ++import java.util.Arrays; +import java.util.UUID; +import me.devnatan.inventoryframework.BukkitViewer; +import me.devnatan.inventoryframework.context.IFRenderContext; ++import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + @@ -2001,6 +2174,9 @@ index 0000000000000000000000000000000000000000..8994528f01d429b9959282dbed029caa + private boolean renderScheduled; + private boolean scheduledForceReopen; + private boolean scheduledHardResync; ++ private boolean[] scheduledTopSlotRepairs = new boolean[0]; ++ private final boolean[] scheduledPlayerSlotRepairs = new boolean[PacketInventoryConstants.INVENTORY_SIZE]; ++ private int scheduledPlayerSlotRepairCount; + private boolean closed; + private boolean closeRequested; + private int stateId = 1; @@ -2088,9 +2264,19 @@ index 0000000000000000000000000000000000000000..8994528f01d429b9959282dbed029caa + return stateId++; + } + -+ synchronized boolean scheduleRender(boolean forceReopen, boolean hardResync) { ++ synchronized boolean scheduleRender( ++ boolean forceReopen, ++ boolean hardResync, ++ PacketGuiRepairScope repairScope, ++ PacketGuiClick click) { ++ final PacketGuiRepairScope scope = repairScope == null ? PacketGuiRepairScope.NONE : repairScope; + scheduledForceReopen |= forceReopen; -+ scheduledHardResync |= hardResync; ++ scheduledHardResync |= hardResync || scope.fullWindow(); ++ ++ if (!scheduledHardResync && click != null) { ++ scheduleTargetedRepairs(scope, click); ++ } ++ + if (renderScheduled) { + return false; + } @@ -2100,10 +2286,17 @@ index 0000000000000000000000000000000000000000..8994528f01d429b9959282dbed029caa + } + + synchronized RenderRequest consumeRenderRequest() { -+ final RenderRequest request = new RenderRequest(scheduledForceReopen, scheduledHardResync); ++ final RenderRequest request = new RenderRequest( ++ scheduledForceReopen, ++ scheduledHardResync, ++ scheduledTopSlotRepairs.length == 0 ? new boolean[0] : scheduledTopSlotRepairs.clone(), ++ scheduledPlayerSlotRepairs()); + renderScheduled = false; + scheduledForceReopen = false; + scheduledHardResync = false; ++ scheduledTopSlotRepairs = new boolean[0]; ++ Arrays.fill(scheduledPlayerSlotRepairs, false); ++ scheduledPlayerSlotRepairCount = 0; + return request; + } + @@ -2111,15 +2304,14 @@ index 0000000000000000000000000000000000000000..8994528f01d429b9959282dbed029caa + ensureTopItemCache(render.size()); + + final ItemStack item = render.rawBukkitItem(slot); -+ final int fingerprint = PacketItemConverter.displayFingerprint(item); + final CachedPacketItem cached = topItemCache[slot]; -+ if (cached != null && cached.matches(item, fingerprint)) { ++ if (cached != null && cached.matches(item)) { + return PacketItemConverter.copy(cached.packetItem); + } + + final com.github.retrooper.packetevents.protocol.item.ItemStack packetItem = + PacketItemConverter.toPacket(item); -+ topItemCache[slot] = new CachedPacketItem(item, fingerprint, packetItem); ++ topItemCache[slot] = new CachedPacketItem(item, packetItem); + return PacketItemConverter.copy(packetItem); + } + @@ -2133,14 +2325,83 @@ index 0000000000000000000000000000000000000000..8994528f01d429b9959282dbed029caa + } + } + ++ private void scheduleTargetedRepairs(PacketGuiRepairScope scope, PacketGuiClick click) { ++ if (scope.repairsTopSlot()) { ++ scheduleTopSlotRepair(click.slot()); ++ } ++ ++ if (scope.repairsChangedPlayerSlots()) { ++ scheduleChangedPlayerSlotRepairs(click); ++ } ++ ++ if (scope.repairsOffhand()) { ++ schedulePlayerSlotRepair(PacketInventoryConstants.SLOT_OFFHAND); ++ } ++ } ++ ++ private void scheduleTopSlotRepair(int slot) { ++ if (slot < 0 || slot >= container.getSize()) { ++ return; ++ } ++ ++ if (scheduledTopSlotRepairs.length != container.getSize()) { ++ scheduledTopSlotRepairs = new boolean[container.getSize()]; ++ } ++ scheduledTopSlotRepairs[slot] = true; ++ } ++ ++ private void scheduleChangedPlayerSlotRepairs(PacketGuiClick click) { ++ final int topSize = container.getSize(); ++ for (final int changedSlot : click.changedSlots()) { ++ final int playerSlot = PacketViewerInventory.mapGuiContainerSlotToPlayerSlot(topSize, changedSlot); ++ schedulePlayerSlotRepair(playerSlot); ++ } ++ ++ schedulePlayerSlotRepair(click.swappedPlayerWindowSlot()); ++ } ++ ++ private void schedulePlayerSlotRepair(int playerSlot) { ++ if (playerSlot < 0 || playerSlot >= PacketInventoryConstants.INVENTORY_SIZE) { ++ return; ++ } ++ ++ if (!scheduledPlayerSlotRepairs[playerSlot]) { ++ scheduledPlayerSlotRepairs[playerSlot] = true; ++ scheduledPlayerSlotRepairCount++; ++ } ++ } ++ ++ private int[] scheduledPlayerSlotRepairs() { ++ if (scheduledPlayerSlotRepairCount == 0) { ++ return new int[0]; ++ } ++ ++ final int[] repairs = new int[scheduledPlayerSlotRepairCount]; ++ int index = 0; ++ for (int slot = 0; slot < scheduledPlayerSlotRepairs.length; slot++) { ++ if (scheduledPlayerSlotRepairs[slot]) { ++ repairs[index++] = slot; ++ } ++ } ++ return repairs; ++ } ++ + static final class RenderRequest { + + private final boolean forceReopen; + private final boolean hardResync; -+ -+ private RenderRequest(boolean forceReopen, boolean hardResync) { ++ private final boolean[] forcedTopSlotRepairs; ++ private final int[] playerSlotRepairs; ++ ++ private RenderRequest( ++ boolean forceReopen, ++ boolean hardResync, ++ boolean[] forcedTopSlotRepairs, ++ int[] playerSlotRepairs) { + this.forceReopen = forceReopen; + this.hardResync = hardResync; ++ this.forcedTopSlotRepairs = forcedTopSlotRepairs; ++ this.playerSlotRepairs = playerSlotRepairs; + } + + boolean forceReopen() { @@ -2150,25 +2411,49 @@ index 0000000000000000000000000000000000000000..8994528f01d429b9959282dbed029caa + boolean hardResync() { + return hardResync; + } ++ ++ boolean[] forcedTopSlotRepairs() { ++ return forcedTopSlotRepairs; ++ } ++ ++ int[] playerSlotRepairs() { ++ return playerSlotRepairs; ++ } + } + + private static final class CachedPacketItem { + ++ private final ItemStack sourceReference; + private final ItemStack bukkitItem; -+ private final int fingerprint; ++ private final Material type; ++ private final int amount; + private final com.github.retrooper.packetevents.protocol.item.ItemStack packetItem; + + private CachedPacketItem( -+ ItemStack bukkitItem, -+ int fingerprint, -+ com.github.retrooper.packetevents.protocol.item.ItemStack packetItem) { ++ ItemStack bukkitItem, com.github.retrooper.packetevents.protocol.item.ItemStack packetItem) { ++ this.sourceReference = bukkitItem; + this.bukkitItem = bukkitItem == null ? null : bukkitItem.clone(); -+ this.fingerprint = fingerprint; ++ this.type = PacketItemConverter.isEmpty(bukkitItem) ? Material.AIR : bukkitItem.getType(); ++ this.amount = PacketItemConverter.isEmpty(bukkitItem) ? 0 : bukkitItem.getAmount(); + this.packetItem = PacketItemConverter.copy(packetItem); + } + -+ private boolean matches(ItemStack item, int fingerprint) { -+ return this.fingerprint == fingerprint && PacketItemConverter.sameDisplayItem(bukkitItem, item); ++ private boolean matches(ItemStack item) { ++ if (item == sourceReference) { ++ return true; ++ } ++ ++ if (PacketItemConverter.isEmpty(item) && PacketItemConverter.isEmpty(bukkitItem)) { ++ return true; ++ } ++ ++ if (PacketItemConverter.isEmpty(item) || PacketItemConverter.isEmpty(bukkitItem)) { ++ return false; ++ } ++ ++ return item.getType() == type ++ && item.getAmount() == amount ++ && PacketItemConverter.sameDisplayItem(bukkitItem, item); + } + } +} @@ -2219,10 +2504,10 @@ index 0000000000000000000000000000000000000000..aa6ab4aba33324a21bab6f7707532550 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java new file mode 100644 -index 0000000000000000000000000000000000000000..03db18d4012fb02b485f06b365778835861a43e4 +index 0000000000000000000000000000000000000000..246044d56eab871e7aad763ec95d1aa40a3fc6f6 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java -@@ -0,0 +1,216 @@ +@@ -0,0 +1,220 @@ +package me.devnatan.inventoryframework.internal.packet; + +import io.github.retrooper.packetevents.util.SpigotConversionUtil; @@ -2272,6 +2557,10 @@ index 0000000000000000000000000000000000000000..03db18d4012fb02b485f06b365778835 + } + + static boolean sameDisplayItem(ItemStack first, ItemStack second) { ++ if (first == second) { ++ return true; ++ } ++ + if (isEmpty(first) && isEmpty(second)) { + return true; + } @@ -2280,11 +2569,11 @@ index 0000000000000000000000000000000000000000..03db18d4012fb02b485f06b365778835 + return false; + } + -+ return first.equals(second); -+ } ++ if (first.getType() != second.getType() || first.getAmount() != second.getAmount()) { ++ return false; ++ } + -+ static int displayFingerprint(ItemStack item) { -+ return isEmpty(item) ? 0 : item.hashCode(); ++ return first.equals(second); + } + + static boolean isEmpty(ItemStack item) { @@ -2441,10 +2730,10 @@ index 0000000000000000000000000000000000000000..03db18d4012fb02b485f06b365778835 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java new file mode 100644 -index 0000000000000000000000000000000000000000..c67b05644fd4a7e5063780d6f2cbcaef65fb29ca +index 0000000000000000000000000000000000000000..2a79e2d90b75a04436eda3c54780c226886d2501 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java -@@ -0,0 +1,248 @@ +@@ -0,0 +1,245 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.util.Map; @@ -2497,11 +2786,8 @@ index 0000000000000000000000000000000000000000..c67b05644fd4a7e5063780d6f2cbcaef + + ItemStack[] snapshotItems() { + synchronized (topItems) { -+ final ItemStack[] snapshot = new ItemStack[topItems.length]; -+ for (int slot = 0; slot < topItems.length; slot++) { -+ snapshot[slot] = topItems[slot] == null ? null : topItems[slot].clone(); -+ } -+ return snapshot; ++ // Items are cloned on write; preserving references lets the packet cache skip unchanged slots. ++ return topItems.clone(); + } + } + @@ -2553,10 +2839,10 @@ index 0000000000000000000000000000000000000000..c67b05644fd4a7e5063780d6f2cbcaef + throw new IndexOutOfBoundsException( + "Slot out of bounds: " + slot + " (size=" + topItems.length + ")"); + } -+ final ItemStack nextItem = item == null ? null : ((ItemStack) item).clone(); ++ final ItemStack nextItem = (ItemStack) item; + changed = !PacketItemConverter.sameDisplayItem(topItems[slot], nextItem); + if (changed) { -+ topItems[slot] = nextItem; ++ topItems[slot] = nextItem == null ? null : nextItem.clone(); + } + } + if (changed) { @@ -2867,19 +3153,19 @@ index ec3bd553181923362bece51aff5fec6983a8a52f..b5888370d5c9703c7487d4312df7e9c9 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/GlobalClickInterceptor.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/GlobalClickInterceptor.java @@ -4,7 +4,6 @@ import static me.devnatan.inventoryframework.ViewConfig.CANCEL_ON_CLICK; - + import me.devnatan.inventoryframework.VirtualView; import me.devnatan.inventoryframework.context.SlotClickContext; -import org.bukkit.event.inventory.InventoryClickEvent; import org.jetbrains.annotations.NotNull; - + /** @@ -18,10 +17,9 @@ public final class GlobalClickInterceptor implements PipelineInterceptor Date: Tue, 26 May 2026 20:12:00 +0200 Subject: [PATCH 14/50] optimize packet GUI repairs and async packet sending Avoid expensive full window item resyncs during normal GUI clicks, remove debug toString calls from hotpaths, and batch packet writes through a guarded send plan. --- ...0006-Add-internal-packet-GUI-backend.patch | 244 +++++++++++++++--- 1 file changed, 201 insertions(+), 43 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 98ef5c0..bb376fb 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -33,6 +33,41 @@ index 89850fa8d67a4ff08fbe5997ab94625e50c0cf19..daec84d3a7722c8390ae6624b01b84d5 -publish = { id = "com.vanniktech.maven.publish.base", version = "0.34.0" } \ No newline at end of file +publish = { id = "com.vanniktech.maven.publish.base", version = "0.34.0" } +diff --git a/inventory-framework-core/src/main/java/me/devnatan/inventoryframework/component/PaginationImpl.java b/inventory-framework-core/src/main/java/me/devnatan/inventoryframework/component/PaginationImpl.java +index 382dc753871faedef550b566b507e52fdd040e30..cb1bb0ac10c1c70d7ba60cd35271ff3210c469af 100644 +--- a/inventory-framework-core/src/main/java/me/devnatan/inventoryframework/component/PaginationImpl.java ++++ b/inventory-framework-core/src/main/java/me/devnatan/inventoryframework/component/PaginationImpl.java +@@ -282,7 +282,7 @@ public class PaginationImpl extends AbstractStateValue implements Pagination, In + + final Component component = componentFactory.create(); + +- debug(() -> " @ placeholder %d (index %d) = %s", layoutPosition, index, component.toString()); ++ debug(() -> " @ placeholder %d (index %d) = %s", layoutPosition, index, component); + getInternalComponents().add(component); + index++; + continue; +@@ -296,7 +296,7 @@ public class PaginationImpl extends AbstractStateValue implements Pagination, In + final ComponentFactory factory = elementFactory.create(this, index, layoutPosition, paginatedValue); + final Component component = factory.create(); + +- debug(() -> " @ added %d (index %d) = %s", layoutPosition, index, component.toString()); ++ debug(() -> " @ added %d (index %d) = %s", layoutPosition, index, component); + getInternalComponents().add(component); + } catch (final Exception exception) { + debug(() -> " @ failed to add %d (index %d) = %s", layoutPosition, index, exception.getMessage()); +diff --git a/inventory-framework-core/src/main/java/me/devnatan/inventoryframework/state/DefaultStateValueHost.java b/inventory-framework-core/src/main/java/me/devnatan/inventoryframework/state/DefaultStateValueHost.java +index e050121d60d533fd55677306fc3d0ea06b011fd8..4b9a363340f204570bcdac70319e1fa70a237fb4 100644 +--- a/inventory-framework-core/src/main/java/me/devnatan/inventoryframework/state/DefaultStateValueHost.java ++++ b/inventory-framework-core/src/main/java/me/devnatan/inventoryframework/state/DefaultStateValueHost.java +@@ -48,7 +48,7 @@ public class DefaultStateValueHost implements StateValueHost { + if (value == null) { + value = state.factory().create(this, state); + initializeState(id, value); +- IFDebug.debug("State %s lazily initialized (initialValue = %s)", id, value.toString()); ++ IFDebug.debug("State %s lazily initialized (initialValue = %s)", id, value); + } + + return value; diff --git a/inventory-framework-platform-bukkit/build.gradle.kts b/inventory-framework-platform-bukkit/build.gradle.kts index 6a29127fffd904f31720719a40c8f61bf4c1ae05..237bf07ee139b580f0148a28061fb36166787247 100644 --- a/inventory-framework-platform-bukkit/build.gradle.kts @@ -762,12 +797,13 @@ index 0000000000000000000000000000000000000000..b7efdbc7bce0e7f68d83abd93aeace52 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackendFactory.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackendFactory.java new file mode 100644 -index 0000000000000000000000000000000000000000..5c413479942dfd5f264279979cb2e84b88d43825 +index 0000000000000000000000000000000000000000..a2f72b8c7cb581fb4b0c252a7595d98b32657233 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackendFactory.java -@@ -0,0 +1,63 @@ +@@ -0,0 +1,69 @@ +package me.devnatan.inventoryframework.internal; + ++import java.util.logging.Logger; +import me.devnatan.inventoryframework.internal.packet.PacketGuiBackend; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.ApiStatus; @@ -786,7 +822,7 @@ index 0000000000000000000000000000000000000000..5c413479942dfd5f264279979cb2e84b + final BukkitGuiBackend bukkitBackend = new BukkitGuiBackend(); + final String configuredBackend = System.getProperty(BACKEND_PROPERTY, "bukkit"); + if (!PACKET_BACKEND.equalsIgnoreCase(configuredBackend)) { -+ owner.getLogger() ++ logger(owner) + .warning("[IF] GUI backend: Bukkit fallback enabled. " + + "Inventory GUIs use real Bukkit inventory items. " + + "To enable packet mode, start the server with -D" @@ -801,7 +837,7 @@ index 0000000000000000000000000000000000000000..5c413479942dfd5f264279979cb2e84b + } + + if (!isPacketEventsPresent()) { -+ owner.getLogger() ++ logger(owner) + .warning("[IF] GUI backend: Bukkit fallback enabled. " + + "Packet mode was requested, but PacketEvents is not available. " + + "Inventory GUIs use real Bukkit inventory items. " @@ -811,7 +847,7 @@ index 0000000000000000000000000000000000000000..5c413479942dfd5f264279979cb2e84b + return bukkitBackend; + } + -+ owner.getLogger() ++ logger(owner) + .fine("[IF] GUI backend: Packet mode requested. " + + "PacketEvents is present. " + + "Registering packet GUI backend using -D" @@ -820,6 +856,11 @@ index 0000000000000000000000000000000000000000..5c413479942dfd5f264279979cb2e84b + return new PacketGuiBackend(owner, bukkitBackend); + } + ++ private static Logger logger(Plugin owner) { ++ final Logger logger = owner.getLogger(); ++ return logger == null ? Logger.getLogger("InventoryFramework") : logger; ++ } ++ + private static boolean isPacketEventsPresent() { + try { + Class.forName(PACKET_EVENTS_FQN, false, GuiBackendFactory.class.getClassLoader()); @@ -831,16 +872,18 @@ index 0000000000000000000000000000000000000000..5c413479942dfd5f264279979cb2e84b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..652e5313677a6a994ab82e3863715564772ac33f +index 0000000000000000000000000000000000000000..a3a25616a20726f4fe200c879ca8586a42cbe445 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,861 @@ +@@ -0,0 +1,964 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; +import com.github.retrooper.packetevents.event.PacketListenerCommon; +import com.github.retrooper.packetevents.manager.server.ServerVersion; ++import com.github.retrooper.packetevents.netty.channel.ChannelHelper; +import com.github.retrooper.packetevents.protocol.player.User; ++import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerCloseWindow; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerOpenWindow; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetCursorItem; @@ -878,6 +921,8 @@ index 0000000000000000000000000000000000000000..652e5313677a6a994ab82e3863715564 + +public final class PacketGuiBackend implements GuiBackend { + ++ private static final boolean ASYNC_PACKET_SEND = ++ Boolean.parseBoolean(System.getProperty("me.devnatan.inventoryframework.packet.async-send", "true")); + private static final int MAX_WINDOW_ID = 127; + private static final String CLOSE_ORIGIN_CLIENT = "packet-gui-client-close"; + private static final String CLOSE_ORIGIN_SERVER = "packet-gui-server-close"; @@ -1392,30 +1437,36 @@ index 0000000000000000000000000000000000000000..652e5313677a6a994ab82e3863715564 + final boolean sendFullWindow = hardResync || reopen || previous == null; + + try { ++ final List> packets = new ArrayList<>(); + if (sendFullWindow) { + session.viewerInventory().snapshotFrom(session.player()); + } + + if (reopen) { -+ sendOpenWindow(session, render); ++ addOpenWindow(packets, session, render); + } + + if (sendFullWindow) { -+ sendWindowItems(session, render); ++ addWindowItems(packets, session, render); + } else { -+ sendChangedTopSlots(session, previous, render, forcedTopSlotRepairs); -+ sendPlayerSlotRepairs(session, render, playerSlotRepairs); ++ addChangedTopSlots(packets, session, previous, render, forcedTopSlotRepairs); ++ addPlayerSlotRepairs(packets, session, render, playerSlotRepairs); + } + -+ sendCursor(session); ++ addCursor(packets, session); + session.appliedRender(render); ++ sendRenderPlan(session, new PacketGuiSendPlan( ++ session.user(), ++ session.windowId(), ++ session.nextSendGeneration(), ++ packets)); + } catch (final RuntimeException exception) { + owner.getLogger().log(Level.WARNING, "Failed to send packet GUI render", exception); + closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true); + } + } + -+ private void sendOpenWindow(PacketGuiSession session, PacketGuiRender render) { ++ private void addOpenWindow(List> packets, PacketGuiSession session, PacketGuiRender render) { + final ServerVersion version = PacketEvents.getAPI().getServerManager().getVersion(); + final WrapperPlayServerOpenWindow packet; + if (version.isNewerThanOrEquals(ServerVersion.V_1_14)) { @@ -1425,10 +1476,10 @@ index 0000000000000000000000000000000000000000..652e5313677a6a994ab82e3863715564 + session.windowId(), "minecraft:chest", render.title(), render.size(), 0); + } + -+ session.user().sendPacket(packet); ++ packets.add(packet); + } + -+ private void sendWindowItems(PacketGuiSession session, PacketGuiRender render) { ++ private void addWindowItems(List> packets, PacketGuiSession session, PacketGuiRender render) { + final List items = + new ArrayList<>(render.size() + 36); + for (int slot = 0; slot < render.size(); slot++) { @@ -1436,15 +1487,15 @@ index 0000000000000000000000000000000000000000..652e5313677a6a994ab82e3863715564 + } + items.addAll(session.viewerInventory().mainAndHotbarItems()); + -+ session.user() -+ .sendPacket(new WrapperPlayServerWindowItems( -+ session.windowId(), -+ session.nextStateId(), -+ items, -+ session.viewerInventory().cursor())); ++ packets.add(new WrapperPlayServerWindowItems( ++ session.windowId(), ++ session.nextStateId(), ++ items, ++ session.viewerInventory().cursor())); + } + -+ private void sendChangedTopSlots( ++ private void addChangedTopSlots( ++ List> packets, + PacketGuiSession session, + PacketGuiRender previous, + PacketGuiRender render, @@ -1460,16 +1511,19 @@ index 0000000000000000000000000000000000000000..652e5313677a6a994ab82e3863715564 + stateId = session.nextStateId(); + } + -+ session.user() -+ .sendPacket(new WrapperPlayServerSetSlot( -+ session.windowId(), -+ stateId, -+ slot, -+ session.packetItem(render, slot))); ++ packets.add(new WrapperPlayServerSetSlot( ++ session.windowId(), ++ stateId, ++ slot, ++ session.packetItem(render, slot))); + } + } + -+ private void sendPlayerSlotRepairs(PacketGuiSession session, PacketGuiRender render, int[] playerSlotRepairs) { ++ private void addPlayerSlotRepairs( ++ List> packets, ++ PacketGuiSession session, ++ PacketGuiRender render, ++ int[] playerSlotRepairs) { + if (playerSlotRepairs.length == 0) { + return; + } @@ -1484,21 +1538,19 @@ index 0000000000000000000000000000000000000000..652e5313677a6a994ab82e3863715564 + stateId = session.nextStateId(); + } + -+ session.user() -+ .sendPacket(new WrapperPlayServerSetSlot( -+ session.windowId(), -+ stateId, -+ openGuiSlot, -+ item)); ++ packets.add(new WrapperPlayServerSetSlot( ++ session.windowId(), ++ stateId, ++ openGuiSlot, ++ item)); + continue; + } + -+ session.user() -+ .sendPacket(new WrapperPlayServerSetSlot( -+ PacketInventoryConstants.PLAYER_WINDOW_ID, -+ session.nextStateId(), -+ playerSlot, -+ item)); ++ packets.add(new WrapperPlayServerSetSlot( ++ PacketInventoryConstants.PLAYER_WINDOW_ID, ++ session.nextStateId(), ++ playerSlot, ++ item)); + } + } + @@ -1542,6 +1594,56 @@ index 0000000000000000000000000000000000000000..652e5313677a6a994ab82e3863715564 + session.user().sendPacket(new WrapperPlayServerSetCursorItem(session.viewerInventory().cursor())); + } + ++ private void addCursor(List> packets, PacketGuiSession session) { ++ packets.add(new WrapperPlayServerSetCursorItem(session.viewerInventory().cursor())); ++ } ++ ++ private void sendRenderPlan(PacketGuiSession session, PacketGuiSendPlan plan) { ++ if (plan.isEmpty()) { ++ return; ++ } ++ ++ if (!ASYNC_PACKET_SEND) { ++ sendRenderPlanNow(session, plan); ++ return; ++ } ++ ++ try { ++ ChannelHelper.runInEventLoop(plan.channel(), () -> sendRenderPlanNow(session, plan)); ++ } catch (final RuntimeException exception) { ++ owner.getLogger().log(Level.WARNING, "Failed to schedule async packet GUI send", exception); ++ sendRenderPlanNow(session, plan); ++ } ++ } ++ ++ private void sendRenderPlanNow(PacketGuiSession session, PacketGuiSendPlan plan) { ++ if (!canSendPlan(session, plan)) { ++ return; ++ } ++ ++ try { ++ for (final PacketWrapper packet : plan.packets()) { ++ if (!canSendPlan(session, plan)) { ++ return; ++ } ++ ++ plan.user().writePacket(packet); ++ } ++ plan.user().flushPackets(); ++ } catch (final RuntimeException exception) { ++ owner.getLogger().log(Level.WARNING, "Failed to send async packet GUI render", exception); ++ runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true)); ++ } ++ } ++ ++ private boolean canSendPlan(PacketGuiSession session, PacketGuiSendPlan plan) { ++ return isTracked(session) ++ && !session.closeRequested() ++ && session.windowId() == plan.windowId() ++ && session.acceptsSendGeneration(plan.generation()) ++ && ChannelHelper.isOpen(plan.channel()); ++ } ++ + private boolean closeSession( + PacketGuiSession session, boolean sendClosePacket, Object origin, boolean callClose, boolean syncInventory) { + if (session == null) { @@ -1557,6 +1659,7 @@ index 0000000000000000000000000000000000000000..652e5313677a6a994ab82e3863715564 + if (session.closed()) { + return false; + } ++ session.invalidatePendingSends(); + session.closeRequested(true); + session.closed(true); + } @@ -1695,6 +1798,47 @@ index 0000000000000000000000000000000000000000..652e5313677a6a994ab82e3863715564 + return Boolean.FALSE; + } + } ++ ++ private static final class PacketGuiSendPlan { ++ ++ private final User user; ++ private final Object channel; ++ private final int windowId; ++ private final long generation; ++ private final List> packets; ++ ++ private PacketGuiSendPlan(User user, int windowId, long generation, List> packets) { ++ this.user = user; ++ this.channel = user.getChannel(); ++ this.windowId = windowId; ++ this.generation = generation; ++ this.packets = List.copyOf(packets); ++ } ++ ++ private User user() { ++ return user; ++ } ++ ++ private Object channel() { ++ return channel; ++ } ++ ++ private int windowId() { ++ return windowId; ++ } ++ ++ private long generation() { ++ return generation; ++ } ++ ++ private List> packets() { ++ return packets; ++ } ++ ++ private boolean isEmpty() { ++ return packets.isEmpty(); ++ } ++ } +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java new file mode 100644 @@ -2144,10 +2288,10 @@ index 0000000000000000000000000000000000000000..838bd2a2606696d3dafc5430b179410b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java new file mode 100644 -index 0000000000000000000000000000000000000000..9c49c2dfe15932b8c35caa86e5c64f5910072ec3 +index 0000000000000000000000000000000000000000..b52ac34974ca8b80d4ace58bd6cd92bba31910e4 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java -@@ -0,0 +1,309 @@ +@@ -0,0 +1,323 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.protocol.player.User; @@ -2180,6 +2324,8 @@ index 0000000000000000000000000000000000000000..9c49c2dfe15932b8c35caa86e5c64f59 + private boolean closed; + private boolean closeRequested; + private int stateId = 1; ++ private long sendGeneration; ++ private long invalidatedSendGeneration; + + PacketGuiSession( + BukkitViewer viewer, @@ -2264,6 +2410,18 @@ index 0000000000000000000000000000000000000000..9c49c2dfe15932b8c35caa86e5c64f59 + return stateId++; + } + ++ synchronized long nextSendGeneration() { ++ return ++sendGeneration; ++ } ++ ++ synchronized void invalidatePendingSends() { ++ invalidatedSendGeneration = sendGeneration; ++ } ++ ++ synchronized boolean acceptsSendGeneration(long generation) { ++ return generation > invalidatedSendGeneration; ++ } ++ + synchronized boolean scheduleRender( + boolean forceReopen, + boolean hardResync, From 6e6e484e670ec3a10b8b7846f9961824d7cbc1b4 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 26 May 2026 21:33:44 +0200 Subject: [PATCH 15/50] offload packet item conversion Build packet GUI render/repair plans on the player scheduler, but move the expensive Bukkit-to-PacketEvents item conversion onto dedicated worker threads. This keeps GUI state access Folia-safe while reducing region thread load from PacketItemConverter/SpigotConversionUtil during packet GUI renders and click repairs. Async conversion is enabled by default and logs a warning when disabled. --- ...0006-Add-internal-packet-GUI-backend.patch | 545 +++++++++++++++--- 1 file changed, 468 insertions(+), 77 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index bb376fb..f7aafde 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -872,10 +872,10 @@ index 0000000000000000000000000000000000000000..a2f72b8c7cb581fb4b0c252a7595d98b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..a3a25616a20726f4fe200c879ca8586a42cbe445 +index 0000000000000000000000000000000000000000..34e42f25c77bce6eba4fd543e071c9f8a4ed8faa --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,964 @@ +@@ -0,0 +1,1263 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -895,6 +895,10 @@ index 0000000000000000000000000000000000000000..a3a25616a20726f4fe200c879ca8586a +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; ++import java.util.concurrent.ExecutorService; ++import java.util.concurrent.Executors; ++import java.util.concurrent.RejectedExecutionException; ++import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.function.Consumer; @@ -923,6 +927,9 @@ index 0000000000000000000000000000000000000000..a3a25616a20726f4fe200c879ca8586a + + private static final boolean ASYNC_PACKET_SEND = + Boolean.parseBoolean(System.getProperty("me.devnatan.inventoryframework.packet.async-send", "true")); ++ private static final boolean ASYNC_ITEM_CONVERSION = Boolean.parseBoolean( ++ System.getProperty("me.devnatan.inventoryframework.packet.async-conversion", "true")); ++ private static final int ASYNC_ITEM_CONVERSION_THREADS = 2; + private static final int MAX_WINDOW_ID = 127; + private static final String CLOSE_ORIGIN_CLIENT = "packet-gui-client-close"; + private static final String CLOSE_ORIGIN_SERVER = "packet-gui-server-close"; @@ -935,12 +942,16 @@ index 0000000000000000000000000000000000000000..a3a25616a20726f4fe200c879ca8586a + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); + private final ConcurrentMap viewerInventories = new ConcurrentHashMap<>(); + private final AtomicInteger nextWindowId = new AtomicInteger(1); ++ private final ExecutorService conversionExecutor; + private PacketListenerCommon listener; + private volatile boolean available = true; + + public PacketGuiBackend(@NotNull Plugin owner, @NotNull BukkitGuiBackend fallbackBackend) { + this.owner = owner; + this.fallbackBackend = fallbackBackend; ++ this.conversionExecutor = Executors.newFixedThreadPool( ++ ASYNC_ITEM_CONVERSION_THREADS, ++ new PacketGuiThreadFactory("IF Packet GUI Item Converter")); + } + + @Override @@ -981,6 +992,19 @@ index 0000000000000000000000000000000000000000..a3a25616a20726f4fe200c879ca8586a + owner.getLogger() + .info("[IF] GUI backend: Packet mode enabled. Inventory GUIs are rendered with fake packet items. " + + "This is the recommended mode for preventing GUI item duplication."); ++ if (ASYNC_ITEM_CONVERSION) { ++ owner.getLogger() ++ .info("[IF] Packet GUI async item conversion: enabled. " ++ + "Bukkit-to-packet item conversion is offloaded to " ++ + ASYNC_ITEM_CONVERSION_THREADS ++ + " dedicated worker threads."); ++ } else { ++ owner.getLogger() ++ .warning("[IF] Packet GUI async item conversion: disabled. " ++ + "Bukkit-to-packet item conversion will run on the player/region scheduler and may " ++ + "increase GUI click/render load. Re-enable with " ++ + "-Dme.devnatan.inventoryframework.packet.async-conversion=true."); ++ } + } catch (final RuntimeException exception) { + available = false; + owner.getLogger().log(Level.WARNING, "Failed to register packet GUI backend. Falling back to Bukkit.", exception); @@ -994,6 +1018,7 @@ index 0000000000000000000000000000000000000000..a3a25616a20726f4fe200c879ca8586a + } + sessions.clear(); + viewerInventories.clear(); ++ conversionExecutor.shutdownNow(); + + if (listener == null) { + return; @@ -1437,65 +1462,127 @@ index 0000000000000000000000000000000000000000..a3a25616a20726f4fe200c879ca8586a + final boolean sendFullWindow = hardResync || reopen || previous == null; + + try { -+ final List> packets = new ArrayList<>(); -+ if (sendFullWindow) { -+ session.viewerInventory().snapshotFrom(session.player()); -+ } ++ final PlayerInventorySnapshot playerInventorySnapshot = sendFullWindow ++ ? snapshotPlayerInventory(session.player()) ++ : null; ++ final PacketGuiConversionPlan conversionPlan = new PacketGuiConversionPlan( ++ session.user(), ++ session.windowId(), ++ session.nextSendGeneration()); + + if (reopen) { -+ addOpenWindow(packets, session, render); ++ addOpenWindow(conversionPlan, session, render); + } + + if (sendFullWindow) { -+ addWindowItems(packets, session, render); ++ addWindowItems(conversionPlan, session, render, playerInventorySnapshot); ++ addCursor(conversionPlan, playerInventorySnapshot.cursor()); + } else { -+ addChangedTopSlots(packets, session, previous, render, forcedTopSlotRepairs); -+ addPlayerSlotRepairs(packets, session, render, playerSlotRepairs); ++ addChangedTopSlots(conversionPlan, session, previous, render, forcedTopSlotRepairs); ++ addPlayerSlotRepairs(conversionPlan, session, render, playerSlotRepairs); ++ addCursor(conversionPlan, session.viewerInventory().cursor()); + } + -+ addCursor(packets, session); + session.appliedRender(render); -+ sendRenderPlan(session, new PacketGuiSendPlan( -+ session.user(), -+ session.windowId(), -+ session.nextSendGeneration(), -+ packets)); ++ convertAndSendRenderPlan(session, conversionPlan); + } catch (final RuntimeException exception) { -+ owner.getLogger().log(Level.WARNING, "Failed to send packet GUI render", exception); ++ owner.getLogger().log(Level.WARNING, "Failed to prepare packet GUI render", exception); + closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true); + } + } + -+ private void addOpenWindow(List> packets, PacketGuiSession session, PacketGuiRender render) { ++ private void addOpenWindow(PacketGuiConversionPlan conversionPlan, PacketGuiSession session, PacketGuiRender render) { + final ServerVersion version = PacketEvents.getAPI().getServerManager().getVersion(); -+ final WrapperPlayServerOpenWindow packet; -+ if (version.isNewerThanOrEquals(ServerVersion.V_1_14)) { -+ packet = new WrapperPlayServerOpenWindow(session.windowId(), render.rows() - 1, render.title()); -+ } else { -+ packet = new WrapperPlayServerOpenWindow( -+ session.windowId(), "minecraft:chest", render.title(), render.size(), 0); ++ final boolean modernWindowType = version.isNewerThanOrEquals(ServerVersion.V_1_14); ++ final int windowId = session.windowId(); ++ final int rows = render.rows(); ++ final int size = render.size(); ++ final net.kyori.adventure.text.Component title = render.title(); ++ conversionPlan.addOperation((targetSession, packets) -> { ++ final WrapperPlayServerOpenWindow packet; ++ if (modernWindowType) { ++ packet = new WrapperPlayServerOpenWindow(windowId, rows - 1, title); ++ } else { ++ packet = new WrapperPlayServerOpenWindow(windowId, "minecraft:chest", title, size, 0); ++ } ++ packets.add(packet); ++ return true; ++ }); ++ } ++ ++ private void addWindowItems( ++ PacketGuiConversionPlan conversionPlan, ++ PacketGuiSession session, ++ PacketGuiRender render, ++ PlayerInventorySnapshot playerInventorySnapshot) { ++ final int stateId = session.nextStateId(); ++ final PacketGuiSession.PacketItemSnapshot[] topItems = ++ new PacketGuiSession.PacketItemSnapshot[render.size()]; ++ for (int slot = 0; slot < render.size(); slot++) { ++ topItems[slot] = session.packetItemSnapshot(render, slot); + } + -+ packets.add(packet); ++ conversionPlan.addOperation((targetSession, packets) -> { ++ final List items = ++ new ArrayList<>(topItems.length + 36); ++ for (final PacketGuiSession.PacketItemSnapshot topItem : topItems) { ++ if (!canConvertPlan(targetSession, conversionPlan)) { ++ return false; ++ } ++ items.add(convertTopItem(targetSession, topItem)); ++ } ++ ++ for (int slot = PacketInventoryConstants.ITEMS_START; ++ slot < PacketInventoryConstants.ITEMS_START + 27; ++ slot++) { ++ if (!addConvertedPlayerInventoryItem(targetSession, conversionPlan, playerInventorySnapshot, items, slot)) { ++ return false; ++ } ++ } ++ ++ for (int slot = PacketInventoryConstants.HOTBAR_START; ++ slot < PacketInventoryConstants.HOTBAR_START + 9; ++ slot++) { ++ if (!addConvertedPlayerInventoryItem(targetSession, conversionPlan, playerInventorySnapshot, items, slot)) { ++ return false; ++ } ++ } ++ ++ final com.github.retrooper.packetevents.protocol.item.ItemStack cursor = ++ convertBukkitItem(playerInventorySnapshot.cursor()); ++ if (!canConvertPlan(targetSession, conversionPlan)) { ++ return false; ++ } ++ targetSession.viewerInventory().applyCursor(cursor); ++ ++ packets.add(new WrapperPlayServerWindowItems( ++ conversionPlan.windowId(), ++ stateId, ++ items, ++ cursor)); ++ return true; ++ }); + } + -+ private void addWindowItems(List> packets, PacketGuiSession session, PacketGuiRender render) { -+ final List items = -+ new ArrayList<>(render.size() + 36); -+ for (int slot = 0; slot < render.size(); slot++) { -+ items.add(session.packetItem(render, slot)); ++ private boolean addConvertedPlayerInventoryItem( ++ PacketGuiSession session, ++ PacketGuiConversionPlan conversionPlan, ++ PlayerInventorySnapshot playerInventorySnapshot, ++ List target, ++ int playerWindowSlot) { ++ final com.github.retrooper.packetevents.protocol.item.ItemStack item = ++ convertBukkitItem(playerInventorySnapshot.item(playerWindowSlot)); ++ if (!canConvertPlan(session, conversionPlan)) { ++ return false; + } -+ items.addAll(session.viewerInventory().mainAndHotbarItems()); + -+ packets.add(new WrapperPlayServerWindowItems( -+ session.windowId(), -+ session.nextStateId(), -+ items, -+ session.viewerInventory().cursor())); ++ session.viewerInventory().applySlot(playerWindowSlot, item); ++ target.add(PacketItemConverter.copy(item)); ++ return true; + } + + private void addChangedTopSlots( -+ List> packets, ++ PacketGuiConversionPlan conversionPlan, + PacketGuiSession session, + PacketGuiRender previous, + PacketGuiRender render, @@ -1511,16 +1598,28 @@ index 0000000000000000000000000000000000000000..a3a25616a20726f4fe200c879ca8586a + stateId = session.nextStateId(); + } + -+ packets.add(new WrapperPlayServerSetSlot( -+ session.windowId(), -+ stateId, -+ slot, -+ session.packetItem(render, slot))); ++ final int currentStateId = stateId; ++ final int currentSlot = slot; ++ final PacketGuiSession.PacketItemSnapshot itemSnapshot = session.packetItemSnapshot(render, currentSlot); ++ conversionPlan.addOperation((targetSession, packets) -> { ++ final com.github.retrooper.packetevents.protocol.item.ItemStack item = ++ convertTopItem(targetSession, itemSnapshot); ++ if (!canConvertPlan(targetSession, conversionPlan)) { ++ return false; ++ } ++ ++ packets.add(new WrapperPlayServerSetSlot( ++ conversionPlan.windowId(), ++ currentStateId, ++ currentSlot, ++ item)); ++ return true; ++ }); + } + } + + private void addPlayerSlotRepairs( -+ List> packets, ++ PacketGuiConversionPlan conversionPlan, + PacketGuiSession session, + PacketGuiRender render, + int[] playerSlotRepairs) { @@ -1528,38 +1627,73 @@ index 0000000000000000000000000000000000000000..a3a25616a20726f4fe200c879ca8586a + return; + } + -+ int stateId = -1; ++ int guiStateId = -1; + for (final int playerSlot : playerSlotRepairs) { -+ final com.github.retrooper.packetevents.protocol.item.ItemStack item = -+ snapshotPlayerInventorySlot(session, playerSlot); ++ final ItemStack itemSnapshot = cloneItem(playerInventoryItem(session.player(), playerSlot)); + final int openGuiSlot = mapPlayerWindowSlotToOpenGuiSlot(render.size(), playerSlot); ++ final int packetWindowId; ++ final int packetSlot; ++ final int stateId; + if (openGuiSlot >= 0) { -+ if (stateId < 0) { -+ stateId = session.nextStateId(); ++ if (guiStateId < 0) { ++ guiStateId = session.nextStateId(); + } -+ -+ packets.add(new WrapperPlayServerSetSlot( -+ session.windowId(), -+ stateId, -+ openGuiSlot, -+ item)); -+ continue; ++ packetWindowId = session.windowId(); ++ packetSlot = openGuiSlot; ++ stateId = guiStateId; ++ } else { ++ packetWindowId = PacketInventoryConstants.PLAYER_WINDOW_ID; ++ packetSlot = playerSlot; ++ stateId = session.nextStateId(); + } + -+ packets.add(new WrapperPlayServerSetSlot( -+ PacketInventoryConstants.PLAYER_WINDOW_ID, -+ session.nextStateId(), -+ playerSlot, -+ item)); ++ conversionPlan.addOperation((targetSession, packets) -> { ++ final com.github.retrooper.packetevents.protocol.item.ItemStack item = convertBukkitItem(itemSnapshot); ++ if (!canConvertPlan(targetSession, conversionPlan)) { ++ return false; ++ } ++ ++ targetSession.viewerInventory().applySlot(playerSlot, item); ++ packets.add(new WrapperPlayServerSetSlot(packetWindowId, stateId, packetSlot, item)); ++ return true; ++ }); + } + } + -+ private com.github.retrooper.packetevents.protocol.item.ItemStack snapshotPlayerInventorySlot( -+ PacketGuiSession session, int playerWindowSlot) { ++ private com.github.retrooper.packetevents.protocol.item.ItemStack convertTopItem( ++ PacketGuiSession session, PacketGuiSession.PacketItemSnapshot snapshot) { ++ if (snapshot.cached()) { ++ return snapshot.cachedPacketItem(); ++ } ++ + final com.github.retrooper.packetevents.protocol.item.ItemStack item = -+ PacketItemConverter.toPacket(playerInventoryItem(session.player(), playerWindowSlot)); -+ session.viewerInventory().applySlot(playerWindowSlot, item); -+ return item; ++ PacketItemConverter.toPacket(snapshot.bukkitItem()); ++ session.cachePacketItem(snapshot, item); ++ return PacketItemConverter.copy(item); ++ } ++ ++ private static com.github.retrooper.packetevents.protocol.item.ItemStack convertBukkitItem(ItemStack item) { ++ return PacketItemConverter.toPacket(item); ++ } ++ ++ private static PlayerInventorySnapshot snapshotPlayerInventory(Player player) { ++ final PlayerInventory inventory = player.getInventory(); ++ final ItemStack[] slots = new ItemStack[PacketInventoryConstants.INVENTORY_SIZE]; ++ ++ for (int slot = 0; slot <= 35; slot++) { ++ slots[PacketInventoryConstants.playerInventorySlotToContainerSlot(slot)] = cloneItem(inventory.getItem(slot)); ++ } ++ ++ slots[PacketInventoryConstants.SLOT_HELMET] = cloneItem(inventory.getHelmet()); ++ slots[PacketInventoryConstants.SLOT_CHESTPLATE] = cloneItem(inventory.getChestplate()); ++ slots[PacketInventoryConstants.SLOT_LEGGINGS] = cloneItem(inventory.getLeggings()); ++ slots[PacketInventoryConstants.SLOT_BOOTS] = cloneItem(inventory.getBoots()); ++ slots[PacketInventoryConstants.SLOT_OFFHAND] = cloneItem(inventory.getItemInOffHand()); ++ return new PlayerInventorySnapshot(slots, cloneItem(player.getItemOnCursor())); ++ } ++ ++ private static ItemStack cloneItem(ItemStack item) { ++ return item == null ? null : item.clone(); + } + + private static ItemStack playerInventoryItem(Player player, int playerWindowSlot) { @@ -1594,8 +1728,83 @@ index 0000000000000000000000000000000000000000..a3a25616a20726f4fe200c879ca8586a + session.user().sendPacket(new WrapperPlayServerSetCursorItem(session.viewerInventory().cursor())); + } + -+ private void addCursor(List> packets, PacketGuiSession session) { -+ packets.add(new WrapperPlayServerSetCursorItem(session.viewerInventory().cursor())); ++ private void addCursor(PacketGuiConversionPlan conversionPlan, ItemStack cursorSnapshot) { ++ conversionPlan.addOperation((targetSession, packets) -> { ++ final com.github.retrooper.packetevents.protocol.item.ItemStack cursor = convertBukkitItem(cursorSnapshot); ++ if (!canConvertPlan(targetSession, conversionPlan)) { ++ return false; ++ } ++ ++ targetSession.viewerInventory().applyCursor(cursor); ++ packets.add(new WrapperPlayServerSetCursorItem(cursor)); ++ return true; ++ }); ++ } ++ ++ private void addCursor( ++ PacketGuiConversionPlan conversionPlan, ++ com.github.retrooper.packetevents.protocol.item.ItemStack cursor) { ++ final com.github.retrooper.packetevents.protocol.item.ItemStack packetCursor = PacketItemConverter.copy(cursor); ++ conversionPlan.addOperation((targetSession, packets) -> { ++ packets.add(new WrapperPlayServerSetCursorItem(packetCursor)); ++ return true; ++ }); ++ } ++ ++ private void convertAndSendRenderPlan(PacketGuiSession session, PacketGuiConversionPlan conversionPlan) { ++ if (conversionPlan.isEmpty()) { ++ return; ++ } ++ ++ if (!ASYNC_ITEM_CONVERSION) { ++ sendConvertedRenderPlan(session, conversionPlan); ++ return; ++ } ++ ++ try { ++ conversionExecutor.execute(() -> sendConvertedRenderPlan(session, conversionPlan)); ++ } catch (final RejectedExecutionException exception) { ++ if (available) { ++ owner.getLogger().log(Level.WARNING, "Failed to schedule async packet GUI item conversion", exception); ++ } ++ sendConvertedRenderPlan(session, conversionPlan); ++ } ++ } ++ ++ private void sendConvertedRenderPlan(PacketGuiSession session, PacketGuiConversionPlan conversionPlan) { ++ if (!canConvertPlan(session, conversionPlan)) { ++ return; ++ } ++ ++ try { ++ final List> packets = new ArrayList<>(); ++ for (final PacketGuiConversionOperation operation : conversionPlan.operations()) { ++ if (!canConvertPlan(session, conversionPlan)) { ++ return; ++ } ++ ++ if (!operation.addPackets(session, packets)) { ++ return; ++ } ++ } ++ ++ sendRenderPlan(session, new PacketGuiSendPlan( ++ conversionPlan.user(), ++ conversionPlan.windowId(), ++ conversionPlan.generation(), ++ packets)); ++ } catch (final RuntimeException exception) { ++ owner.getLogger().log(Level.WARNING, "Failed to convert packet GUI items asynchronously", exception); ++ runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true)); ++ } ++ } ++ ++ private boolean canConvertPlan(PacketGuiSession session, PacketGuiConversionPlan plan) { ++ return isTracked(session) ++ && !session.closeRequested() ++ && session.windowId() == plan.windowId() ++ && session.acceptsSendGeneration(plan.generation()) ++ && ChannelHelper.isOpen(plan.channel()); + } + + private void sendRenderPlan(PacketGuiSession session, PacketGuiSendPlan plan) { @@ -1799,6 +2008,96 @@ index 0000000000000000000000000000000000000000..a3a25616a20726f4fe200c879ca8586a + } + } + ++ ++ private interface PacketGuiConversionOperation { ++ ++ boolean addPackets(PacketGuiSession session, List> packets); ++ } ++ ++ private static final class PacketGuiConversionPlan { ++ ++ private final User user; ++ private final Object channel; ++ private final int windowId; ++ private final long generation; ++ private final List operations = new ArrayList<>(); ++ ++ private PacketGuiConversionPlan(User user, int windowId, long generation) { ++ this.user = user; ++ this.channel = user.getChannel(); ++ this.windowId = windowId; ++ this.generation = generation; ++ } ++ ++ private void addOperation(PacketGuiConversionOperation operation) { ++ operations.add(operation); ++ } ++ ++ private User user() { ++ return user; ++ } ++ ++ private Object channel() { ++ return channel; ++ } ++ ++ private int windowId() { ++ return windowId; ++ } ++ ++ private long generation() { ++ return generation; ++ } ++ ++ private List operations() { ++ return operations; ++ } ++ ++ private boolean isEmpty() { ++ return operations.isEmpty(); ++ } ++ } ++ ++ private static final class PlayerInventorySnapshot { ++ ++ private final ItemStack[] slots; ++ private final ItemStack cursor; ++ ++ private PlayerInventorySnapshot(ItemStack[] slots, ItemStack cursor) { ++ this.slots = slots; ++ this.cursor = cursor; ++ } ++ ++ private ItemStack item(int playerWindowSlot) { ++ if (playerWindowSlot < 0 || playerWindowSlot >= slots.length) { ++ return null; ++ } ++ ++ return slots[playerWindowSlot]; ++ } ++ ++ private ItemStack cursor() { ++ return cursor; ++ } ++ } ++ ++ private static final class PacketGuiThreadFactory implements ThreadFactory { ++ ++ private final String name; ++ private final AtomicInteger threadId = new AtomicInteger(1); ++ ++ private PacketGuiThreadFactory(String name) { ++ this.name = name; ++ } ++ ++ @Override ++ public Thread newThread(Runnable runnable) { ++ final Thread thread = new Thread(runnable, name + " #" + threadId.getAndIncrement()); ++ thread.setDaemon(true); ++ return thread; ++ } ++ } ++ + private static final class PacketGuiSendPlan { + + private final User user; @@ -2288,10 +2587,10 @@ index 0000000000000000000000000000000000000000..838bd2a2606696d3dafc5430b179410b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java new file mode 100644 -index 0000000000000000000000000000000000000000..b52ac34974ca8b80d4ace58bd6cd92bba31910e4 +index 0000000000000000000000000000000000000000..385be48437ff8be5817055aa9a27f8a9c86d81cc --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java -@@ -0,0 +1,323 @@ +@@ -0,0 +1,415 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.protocol.player.User; @@ -2419,7 +2718,7 @@ index 0000000000000000000000000000000000000000..b52ac34974ca8b80d4ace58bd6cd92bb + } + + synchronized boolean acceptsSendGeneration(long generation) { -+ return generation > invalidatedSendGeneration; ++ return generation == sendGeneration && generation > invalidatedSendGeneration; + } + + synchronized boolean scheduleRender( @@ -2459,18 +2758,42 @@ index 0000000000000000000000000000000000000000..b52ac34974ca8b80d4ace58bd6cd92bb + } + + synchronized com.github.retrooper.packetevents.protocol.item.ItemStack packetItem(PacketGuiRender render, int slot) { ++ final PacketItemSnapshot snapshot = packetItemSnapshot(render, slot); ++ if (snapshot.cached()) { ++ return snapshot.cachedPacketItem(); ++ } ++ ++ final com.github.retrooper.packetevents.protocol.item.ItemStack packetItem = ++ PacketItemConverter.toPacket(snapshot.bukkitItem()); ++ cachePacketItem(snapshot, packetItem); ++ return PacketItemConverter.copy(packetItem); ++ } ++ ++ synchronized PacketItemSnapshot packetItemSnapshot(PacketGuiRender render, int slot) { + ensureTopItemCache(render.size()); + + final ItemStack item = render.rawBukkitItem(slot); + final CachedPacketItem cached = topItemCache[slot]; + if (cached != null && cached.matches(item)) { -+ return PacketItemConverter.copy(cached.packetItem); ++ return PacketItemSnapshot.cached(render.size(), slot, cached.packetItem); + } + -+ final com.github.retrooper.packetevents.protocol.item.ItemStack packetItem = -+ PacketItemConverter.toPacket(item); -+ topItemCache[slot] = new CachedPacketItem(item, packetItem); -+ return PacketItemConverter.copy(packetItem); ++ return PacketItemSnapshot.uncached(render.size(), slot, item); ++ } ++ ++ synchronized void cachePacketItem( ++ PacketItemSnapshot snapshot, com.github.retrooper.packetevents.protocol.item.ItemStack packetItem) { ++ if (snapshot == null || snapshot.cached()) { ++ return; ++ } ++ ++ ensureTopItemCache(snapshot.renderSize()); ++ if (snapshot.slot() < 0 || snapshot.slot() >= topItemCache.length) { ++ return; ++ } ++ ++ topItemCache[snapshot.slot()] = ++ new CachedPacketItem(snapshot.sourceReference(), snapshot.bukkitItem(), packetItem); + } + + synchronized void clearPacketItemCache() { @@ -2579,6 +2902,72 @@ index 0000000000000000000000000000000000000000..b52ac34974ca8b80d4ace58bd6cd92bb + } + } + ++ static final class PacketItemSnapshot { ++ ++ private final int renderSize; ++ private final int slot; ++ private final ItemStack sourceReference; ++ private final ItemStack bukkitItem; ++ private final boolean cached; ++ private final com.github.retrooper.packetevents.protocol.item.ItemStack cachedPacketItem; ++ ++ private PacketItemSnapshot( ++ int renderSize, ++ int slot, ++ ItemStack sourceReference, ++ ItemStack bukkitItem, ++ boolean cached, ++ com.github.retrooper.packetevents.protocol.item.ItemStack cachedPacketItem) { ++ this.renderSize = renderSize; ++ this.slot = slot; ++ this.sourceReference = sourceReference; ++ this.bukkitItem = bukkitItem; ++ this.cached = cached; ++ this.cachedPacketItem = PacketItemConverter.copy(cachedPacketItem); ++ } ++ ++ static PacketItemSnapshot cached( ++ int renderSize, ++ int slot, ++ com.github.retrooper.packetevents.protocol.item.ItemStack packetItem) { ++ return new PacketItemSnapshot(renderSize, slot, null, null, true, packetItem); ++ } ++ ++ static PacketItemSnapshot uncached(int renderSize, int slot, ItemStack item) { ++ return new PacketItemSnapshot( ++ renderSize, ++ slot, ++ item, ++ item == null ? null : item.clone(), ++ false, ++ com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY); ++ } ++ ++ int renderSize() { ++ return renderSize; ++ } ++ ++ int slot() { ++ return slot; ++ } ++ ++ ItemStack sourceReference() { ++ return sourceReference; ++ } ++ ++ ItemStack bukkitItem() { ++ return bukkitItem; ++ } ++ ++ boolean cached() { ++ return cached; ++ } ++ ++ com.github.retrooper.packetevents.protocol.item.ItemStack cachedPacketItem() { ++ return PacketItemConverter.copy(cachedPacketItem); ++ } ++ } ++ + private static final class CachedPacketItem { + + private final ItemStack sourceReference; @@ -2588,8 +2977,10 @@ index 0000000000000000000000000000000000000000..b52ac34974ca8b80d4ace58bd6cd92bb + private final com.github.retrooper.packetevents.protocol.item.ItemStack packetItem; + + private CachedPacketItem( -+ ItemStack bukkitItem, com.github.retrooper.packetevents.protocol.item.ItemStack packetItem) { -+ this.sourceReference = bukkitItem; ++ ItemStack sourceReference, ++ ItemStack bukkitItem, ++ com.github.retrooper.packetevents.protocol.item.ItemStack packetItem) { ++ this.sourceReference = sourceReference; + this.bukkitItem = bukkitItem == null ? null : bukkitItem.clone(); + this.type = PacketItemConverter.isEmpty(bukkitItem) ? Material.AIR : bukkitItem.getType(); + this.amount = PacketItemConverter.isEmpty(bukkitItem) ? 0 : bukkitItem.getAmount(); From fdceae4e0dda17888f40917b3568f3e0194ac1ae Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Sat, 30 May 2026 12:26:27 +0200 Subject: [PATCH 16/50] Fix packet GUI outside click handling --- ...0006-Add-internal-packet-GUI-backend.patch | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index f7aafde..4beff98 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -872,10 +872,10 @@ index 0000000000000000000000000000000000000000..a2f72b8c7cb581fb4b0c252a7595d98b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..34e42f25c77bce6eba4fd543e071c9f8a4ed8faa +index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334093ac860 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1263 @@ +@@ -0,0 +1,1272 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1141,12 +1141,17 @@ index 0000000000000000000000000000000000000000..34e42f25c77bce6eba4fd543e071c9f8 + } + + final PacketGuiRepairScope repairScope = click.repairScope(session.container().getSize()); ++ if (click.isOutsideClick()) { ++ runOnPlayer(session.player(), () -> handlePacketClick(session, click, repairScope)); ++ return; ++ } ++ + if (repairScope.fullWindow()) { + requestRender(session, false, true); + return; + } + -+ runOnPlayer(session.player(), () -> handleSafeTopClick(session, click, repairScope)); ++ runOnPlayer(session.player(), () -> handlePacketClick(session, click, repairScope)); + } + + void handleWindowClose(User user, int windowId) { @@ -1349,21 +1354,25 @@ index 0000000000000000000000000000000000000000..34e42f25c77bce6eba4fd543e071c9f8 + inventoryFor(user.getUUID()).applyCursor(item); + } + -+ private void handleSafeTopClick( ++ private void handlePacketClick( + PacketGuiSession session, PacketGuiClick click, PacketGuiRepairScope repairScope) { + if (!isTracked(session)) { + return; + } + + final IFRenderContext context = session.context(); -+ final Component clickedComponent = context.getComponentsAt(click.slot()).stream() -+ .filter(Component::isVisible) -+ .findFirst() -+ .orElse(null); ++ final boolean outsideClick = click.isOutsideClick(); ++ final Component clickedComponent = outsideClick ++ ? null ++ : context.getComponentsAt(click.slot()).stream() ++ .filter(Component::isVisible) ++ .findFirst() ++ .orElse(null); ++ final ItemStack currentItem = outsideClick ? null : session.container().item(click.slot()); + + final PacketSlotClickOrigin origin = new PacketSlotClickOrigin( + session.player(), -+ session.container().item(click.slot()), ++ currentItem, + click, + click.slot(), + click.clickIdentifier(), @@ -1372,7 +1381,7 @@ index 0000000000000000000000000000000000000000..34e42f25c77bce6eba4fd543e071c9f8 + click.isMiddleClick(), + click.isShiftClick(), + click.isKeyboardClick(), -+ false, ++ outsideClick, + false); + + try { @@ -2141,10 +2150,10 @@ index 0000000000000000000000000000000000000000..34e42f25c77bce6eba4fd543e071c9f8 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java new file mode 100644 -index 0000000000000000000000000000000000000000..bc0bf258f523442060af97569fe76518dd51364e +index 0000000000000000000000000000000000000000..dfcd9d0b454ab265c9dba997646ddd91a2e80374 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java -@@ -0,0 +1,188 @@ +@@ -0,0 +1,192 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientClickWindow; @@ -2199,6 +2208,10 @@ index 0000000000000000000000000000000000000000..bc0bf258f523442060af97569fe76518 + return changedSlots; + } + ++ boolean isOutsideClick() { ++ return slot < 0; ++ } ++ + boolean isSafeTopClick(int topSize) { + if (slot < 0 || slot >= topSize) { + return false; From e466ecf3704a62a6a635909cebae78f4fb04cee6 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Sat, 30 May 2026 15:30:47 +0200 Subject: [PATCH 17/50] Add guarded native packet GUI sender Use native outbound inventory packets for packet GUI rendering so surf-api PacketLore can process clientbound GUI items, while falling back to the Bukkit backend when the current Minecraft version is not explicitly supported. --- ...0006-Add-internal-packet-GUI-backend.patch | 713 +++++++++++++----- 1 file changed, 528 insertions(+), 185 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 4beff98..b87e16e 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -872,10 +872,10 @@ index 0000000000000000000000000000000000000000..a2f72b8c7cb581fb4b0c252a7595d98b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334093ac860 +index 0000000000000000000000000000000000000000..c8fb4d405218d628147e60bfe81350767232de6a --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1272 @@ +@@ -0,0 +1,1209 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -883,22 +883,14 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 +import com.github.retrooper.packetevents.manager.server.ServerVersion; +import com.github.retrooper.packetevents.netty.channel.ChannelHelper; +import com.github.retrooper.packetevents.protocol.player.User; -+import com.github.retrooper.packetevents.wrapper.PacketWrapper; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerCloseWindow; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerOpenWindow; -+import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetCursorItem; -+import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetSlot; -+import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerWindowItems; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; -+import java.util.concurrent.ExecutorService; -+import java.util.concurrent.Executors; -+import java.util.concurrent.RejectedExecutionException; -+import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.function.Consumer; @@ -925,11 +917,6 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + +public final class PacketGuiBackend implements GuiBackend { + -+ private static final boolean ASYNC_PACKET_SEND = -+ Boolean.parseBoolean(System.getProperty("me.devnatan.inventoryframework.packet.async-send", "true")); -+ private static final boolean ASYNC_ITEM_CONVERSION = Boolean.parseBoolean( -+ System.getProperty("me.devnatan.inventoryframework.packet.async-conversion", "true")); -+ private static final int ASYNC_ITEM_CONVERSION_THREADS = 2; + private static final int MAX_WINDOW_ID = 127; + private static final String CLOSE_ORIGIN_CLIENT = "packet-gui-client-close"; + private static final String CLOSE_ORIGIN_SERVER = "packet-gui-server-close"; @@ -942,16 +929,13 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); + private final ConcurrentMap viewerInventories = new ConcurrentHashMap<>(); + private final AtomicInteger nextWindowId = new AtomicInteger(1); -+ private final ExecutorService conversionExecutor; ++ private PacketGuiNativeOutboundSender nativeOutbound; + private PacketListenerCommon listener; + private volatile boolean available = true; + + public PacketGuiBackend(@NotNull Plugin owner, @NotNull BukkitGuiBackend fallbackBackend) { + this.owner = owner; + this.fallbackBackend = fallbackBackend; -+ this.conversionExecutor = Executors.newFixedThreadPool( -+ ASYNC_ITEM_CONVERSION_THREADS, -+ new PacketGuiThreadFactory("IF Packet GUI Item Converter")); + } + + @Override @@ -988,23 +972,40 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + return; + } + -+ listener = PacketEvents.getAPI().getEventManager().registerListener(new PacketGuiPacketListener(this)); ++ final PacketGuiNativeOutboundSender.InitializationResult nativeSender = ++ PacketGuiNativeOutboundSender.initialize(); + owner.getLogger() -+ .info("[IF] GUI backend: Packet mode enabled. Inventory GUIs are rendered with fake packet items. " -+ + "This is the recommended mode for preventing GUI item duplication."); -+ if (ASYNC_ITEM_CONVERSION) { -+ owner.getLogger() -+ .info("[IF] Packet GUI async item conversion: enabled. " -+ + "Bukkit-to-packet item conversion is offloaded to " -+ + ASYNC_ITEM_CONVERSION_THREADS -+ + " dedicated worker threads."); -+ } else { ++ .info("[IF] GUI backend: Native packet GUI probe detected Minecraft " ++ + nativeSender.minecraftVersion() ++ + " (Bukkit " ++ + nativeSender.bukkitVersion() ++ + ", server package " ++ + nativeSender.serverPackage() ++ + ", PacketEvents " ++ + PacketEvents.getAPI().getServerManager().getVersion() ++ + ")."); ++ ++ if (!nativeSender.available()) { ++ available = false; + owner.getLogger() -+ .warning("[IF] Packet GUI async item conversion: disabled. " -+ + "Bukkit-to-packet item conversion will run on the player/region scheduler and may " -+ + "increase GUI click/render load. Re-enable with " -+ + "-Dme.devnatan.inventoryframework.packet.async-conversion=true."); ++ .warning("[IF] GUI backend: Bukkit fallback enabled. " ++ + "Packet mode was requested, but the native packet GUI outbound sender is unavailable. " ++ + nativeSender.message() ++ + " Inventory GUIs use real Bukkit inventory items."); ++ if (nativeSender.failure() != null) { ++ owner.getLogger() ++ .log(Level.WARNING, "[IF] Native packet GUI outbound sender self-check failed.", nativeSender.failure()); ++ } ++ return; + } ++ ++ nativeOutbound = nativeSender.sender(); ++ listener = PacketEvents.getAPI().getEventManager().registerListener(new PacketGuiPacketListener(this)); ++ owner.getLogger() ++ .info("[IF] GUI backend: Packet mode enabled. Inventory GUIs are rendered with fake packet items. " ++ + "Native outbound sender is active for Minecraft " ++ + nativeSender.minecraftVersion() ++ + ". This is the recommended mode for preventing GUI item duplication."); + } catch (final RuntimeException exception) { + available = false; + owner.getLogger().log(Level.WARNING, "Failed to register packet GUI backend. Falling back to Bukkit.", exception); @@ -1018,7 +1019,7 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + } + sessions.clear(); + viewerInventories.clear(); -+ conversionExecutor.shutdownNow(); ++ nativeOutbound = null; + + if (listener == null) { + return; @@ -1228,7 +1229,7 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + final PacketViewerInventory inventory = inventoryFor(user.getUUID()); + if (windowId == PacketInventoryConstants.PLAYER_WINDOW_ID) { + inventory.applyPlayerWindowItems(items, carried); -+ mirrorPlayerInventoryWindow(user.getUUID(), inventory); ++ mirrorPlayerInventoryWindow(user.getUUID()); + return; + } + @@ -1268,14 +1269,14 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + } + } + -+ private void mirrorPlayerInventoryWindow(UUID viewerId, PacketViewerInventory inventory) { ++ private void mirrorPlayerInventoryWindow(UUID viewerId) { + final PacketGuiSession session = sessions.get(viewerId); + if (!isTracked(session) || session.closeRequested()) { + return; + } + + final List guiSlots = new ArrayList<>(36); -+ final List items = new ArrayList<>(36); ++ final List playerSlots = new ArrayList<>(36); + for (int slot = PacketInventoryConstants.ITEMS_START; + slot < PacketInventoryConstants.HOTBAR_START + 9; + slot++) { @@ -1285,7 +1286,7 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + } + + guiSlots.add(guiSlot); -+ items.add(inventory.item(slot)); ++ playerSlots.add(slot); + } + + runOnPlayer(session.player(), () -> { @@ -1295,12 +1296,12 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + + final int stateId = session.nextStateId(); + for (int index = 0; index < guiSlots.size(); index++) { -+ session.user() -+ .sendPacket(new WrapperPlayServerSetSlot( -+ session.windowId(), -+ stateId, -+ guiSlots.get(index), -+ items.get(index))); ++ nativeOutbound.sendContainerSetSlot( ++ session.player(), ++ session.windowId(), ++ stateId, ++ guiSlots.get(index), ++ cloneItem(playerInventoryItem(session.player(), playerSlots.get(index)))); + } + }); + } @@ -1317,18 +1318,17 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + return; + } + -+ final com.github.retrooper.packetevents.protocol.item.ItemStack mirrorItem = PacketItemConverter.copy(item); + runOnPlayer(session.player(), () -> { + if (!isTracked(session) || session.closeRequested()) { + return; + } + -+ session.user() -+ .sendPacket(new WrapperPlayServerSetSlot( -+ session.windowId(), -+ session.nextStateId(), -+ guiSlot, -+ mirrorItem)); ++ nativeOutbound.sendContainerSetSlot( ++ session.player(), ++ session.windowId(), ++ session.nextStateId(), ++ guiSlot, ++ cloneItem(playerInventoryItem(session.player(), playerWindowSlot))); + }); + } + @@ -1489,7 +1489,7 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + } else { + addChangedTopSlots(conversionPlan, session, previous, render, forcedTopSlotRepairs); + addPlayerSlotRepairs(conversionPlan, session, render, playerSlotRepairs); -+ addCursor(conversionPlan, session.viewerInventory().cursor()); ++ addCursor(conversionPlan, cloneItem(session.player().getItemOnCursor())); + } + + session.appliedRender(render); @@ -1514,7 +1514,7 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + } else { + packet = new WrapperPlayServerOpenWindow(windowId, "minecraft:chest", title, size, 0); + } -+ packets.add(packet); ++ packets.add((sender, player) -> targetSession.user().sendPacket(packet)); + return true; + }); + } @@ -1525,26 +1525,25 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + PacketGuiRender render, + PlayerInventorySnapshot playerInventorySnapshot) { + final int stateId = session.nextStateId(); -+ final PacketGuiSession.PacketItemSnapshot[] topItems = -+ new PacketGuiSession.PacketItemSnapshot[render.size()]; ++ final ItemStack[] topItems = new ItemStack[render.size()]; + for (int slot = 0; slot < render.size(); slot++) { -+ topItems[slot] = session.packetItemSnapshot(render, slot); ++ topItems[slot] = render.bukkitItem(slot); + } ++ final ItemStack cursor = playerInventorySnapshot.cursor(); + + conversionPlan.addOperation((targetSession, packets) -> { -+ final List items = -+ new ArrayList<>(topItems.length + 36); -+ for (final PacketGuiSession.PacketItemSnapshot topItem : topItems) { ++ final List items = new ArrayList<>(topItems.length + 36); ++ for (final ItemStack topItem : topItems) { + if (!canConvertPlan(targetSession, conversionPlan)) { + return false; + } -+ items.add(convertTopItem(targetSession, topItem)); ++ items.add(cloneItem(topItem)); + } + + for (int slot = PacketInventoryConstants.ITEMS_START; + slot < PacketInventoryConstants.ITEMS_START + 27; + slot++) { -+ if (!addConvertedPlayerInventoryItem(targetSession, conversionPlan, playerInventorySnapshot, items, slot)) { ++ if (!addPlayerInventoryItem(targetSession, conversionPlan, playerInventorySnapshot, items, slot)) { + return false; + } + } @@ -1552,41 +1551,32 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + for (int slot = PacketInventoryConstants.HOTBAR_START; + slot < PacketInventoryConstants.HOTBAR_START + 9; + slot++) { -+ if (!addConvertedPlayerInventoryItem(targetSession, conversionPlan, playerInventorySnapshot, items, slot)) { ++ if (!addPlayerInventoryItem(targetSession, conversionPlan, playerInventorySnapshot, items, slot)) { + return false; + } + } + -+ final com.github.retrooper.packetevents.protocol.item.ItemStack cursor = -+ convertBukkitItem(playerInventorySnapshot.cursor()); -+ if (!canConvertPlan(targetSession, conversionPlan)) { -+ return false; -+ } -+ targetSession.viewerInventory().applyCursor(cursor); -+ -+ packets.add(new WrapperPlayServerWindowItems( ++ packets.add((sender, player) -> sender.sendContainerSetContent( ++ player, + conversionPlan.windowId(), + stateId, + items, -+ cursor)); ++ cloneItem(cursor))); + return true; + }); + } + -+ private boolean addConvertedPlayerInventoryItem( ++ private boolean addPlayerInventoryItem( + PacketGuiSession session, + PacketGuiConversionPlan conversionPlan, + PlayerInventorySnapshot playerInventorySnapshot, -+ List target, ++ List target, + int playerWindowSlot) { -+ final com.github.retrooper.packetevents.protocol.item.ItemStack item = -+ convertBukkitItem(playerInventorySnapshot.item(playerWindowSlot)); + if (!canConvertPlan(session, conversionPlan)) { + return false; + } + -+ session.viewerInventory().applySlot(playerWindowSlot, item); -+ target.add(PacketItemConverter.copy(item)); ++ target.add(cloneItem(playerInventorySnapshot.item(playerWindowSlot))); + return true; + } + @@ -1609,19 +1599,18 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + + final int currentStateId = stateId; + final int currentSlot = slot; -+ final PacketGuiSession.PacketItemSnapshot itemSnapshot = session.packetItemSnapshot(render, currentSlot); ++ final ItemStack itemSnapshot = render.bukkitItem(currentSlot); + conversionPlan.addOperation((targetSession, packets) -> { -+ final com.github.retrooper.packetevents.protocol.item.ItemStack item = -+ convertTopItem(targetSession, itemSnapshot); + if (!canConvertPlan(targetSession, conversionPlan)) { + return false; + } + -+ packets.add(new WrapperPlayServerSetSlot( ++ packets.add((sender, player) -> sender.sendContainerSetSlot( ++ player, + conversionPlan.windowId(), + currentStateId, + currentSlot, -+ item)); ++ cloneItem(itemSnapshot))); + return true; + }); + } @@ -1640,51 +1629,42 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + for (final int playerSlot : playerSlotRepairs) { + final ItemStack itemSnapshot = cloneItem(playerInventoryItem(session.player(), playerSlot)); + final int openGuiSlot = mapPlayerWindowSlotToOpenGuiSlot(render.size(), playerSlot); -+ final int packetWindowId; -+ final int packetSlot; + final int stateId; + if (openGuiSlot >= 0) { + if (guiStateId < 0) { + guiStateId = session.nextStateId(); + } -+ packetWindowId = session.windowId(); -+ packetSlot = openGuiSlot; + stateId = guiStateId; + } else { -+ packetWindowId = PacketInventoryConstants.PLAYER_WINDOW_ID; -+ packetSlot = playerSlot; + stateId = session.nextStateId(); + } ++ final int playerInventorySlot = PacketInventoryConstants.containerSlotToPlayerInventorySlot(playerSlot); + + conversionPlan.addOperation((targetSession, packets) -> { -+ final com.github.retrooper.packetevents.protocol.item.ItemStack item = convertBukkitItem(itemSnapshot); + if (!canConvertPlan(targetSession, conversionPlan)) { + return false; + } + -+ targetSession.viewerInventory().applySlot(playerSlot, item); -+ packets.add(new WrapperPlayServerSetSlot(packetWindowId, stateId, packetSlot, item)); ++ if (openGuiSlot >= 0) { ++ packets.add((sender, player) -> sender.sendContainerSetSlot( ++ player, ++ targetSession.windowId(), ++ stateId, ++ openGuiSlot, ++ cloneItem(itemSnapshot))); ++ } else { ++ packets.add((sender, player) -> sender.sendPlayerInventorySlot( ++ player, ++ playerInventorySlot, ++ playerSlot, ++ stateId, ++ cloneItem(itemSnapshot))); ++ } + return true; + }); + } + } + -+ private com.github.retrooper.packetevents.protocol.item.ItemStack convertTopItem( -+ PacketGuiSession session, PacketGuiSession.PacketItemSnapshot snapshot) { -+ if (snapshot.cached()) { -+ return snapshot.cachedPacketItem(); -+ } -+ -+ final com.github.retrooper.packetevents.protocol.item.ItemStack item = -+ PacketItemConverter.toPacket(snapshot.bukkitItem()); -+ session.cachePacketItem(snapshot, item); -+ return PacketItemConverter.copy(item); -+ } -+ -+ private static com.github.retrooper.packetevents.protocol.item.ItemStack convertBukkitItem(ItemStack item) { -+ return PacketItemConverter.toPacket(item); -+ } -+ + private static PlayerInventorySnapshot snapshotPlayerInventory(Player player) { + final PlayerInventory inventory = player.getInventory(); + final ItemStack[] slots = new ItemStack[PacketInventoryConstants.INVENTORY_SIZE]; @@ -1734,28 +1714,16 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + } + + private void sendCursor(PacketGuiSession session) { -+ session.user().sendPacket(new WrapperPlayServerSetCursorItem(session.viewerInventory().cursor())); ++ nativeOutbound.sendCursor(session.player(), cloneItem(session.player().getItemOnCursor())); + } + + private void addCursor(PacketGuiConversionPlan conversionPlan, ItemStack cursorSnapshot) { + conversionPlan.addOperation((targetSession, packets) -> { -+ final com.github.retrooper.packetevents.protocol.item.ItemStack cursor = convertBukkitItem(cursorSnapshot); + if (!canConvertPlan(targetSession, conversionPlan)) { + return false; + } + -+ targetSession.viewerInventory().applyCursor(cursor); -+ packets.add(new WrapperPlayServerSetCursorItem(cursor)); -+ return true; -+ }); -+ } -+ -+ private void addCursor( -+ PacketGuiConversionPlan conversionPlan, -+ com.github.retrooper.packetevents.protocol.item.ItemStack cursor) { -+ final com.github.retrooper.packetevents.protocol.item.ItemStack packetCursor = PacketItemConverter.copy(cursor); -+ conversionPlan.addOperation((targetSession, packets) -> { -+ packets.add(new WrapperPlayServerSetCursorItem(packetCursor)); ++ packets.add((sender, player) -> sender.sendCursor(player, cloneItem(cursorSnapshot))); + return true; + }); + } @@ -1765,19 +1733,12 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + return; + } + -+ if (!ASYNC_ITEM_CONVERSION) { -+ sendConvertedRenderPlan(session, conversionPlan); ++ if (!isOnPlayerThread(session.player())) { ++ runOnPlayer(session.player(), () -> sendConvertedRenderPlan(session, conversionPlan)); + return; + } + -+ try { -+ conversionExecutor.execute(() -> sendConvertedRenderPlan(session, conversionPlan)); -+ } catch (final RejectedExecutionException exception) { -+ if (available) { -+ owner.getLogger().log(Level.WARNING, "Failed to schedule async packet GUI item conversion", exception); -+ } -+ sendConvertedRenderPlan(session, conversionPlan); -+ } ++ sendConvertedRenderPlan(session, conversionPlan); + } + + private void sendConvertedRenderPlan(PacketGuiSession session, PacketGuiConversionPlan conversionPlan) { @@ -1786,7 +1747,7 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + } + + try { -+ final List> packets = new ArrayList<>(); ++ final List packets = new ArrayList<>(); + for (final PacketGuiConversionOperation operation : conversionPlan.operations()) { + if (!canConvertPlan(session, conversionPlan)) { + return; @@ -1798,18 +1759,19 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + } + + sendRenderPlan(session, new PacketGuiSendPlan( -+ conversionPlan.user(), ++ conversionPlan.channel(), + conversionPlan.windowId(), + conversionPlan.generation(), + packets)); + } catch (final RuntimeException exception) { -+ owner.getLogger().log(Level.WARNING, "Failed to convert packet GUI items asynchronously", exception); ++ owner.getLogger().log(Level.WARNING, "Failed to prepare native packet GUI item packets", exception); + runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true)); + } + } + + private boolean canConvertPlan(PacketGuiSession session, PacketGuiConversionPlan plan) { + return isTracked(session) ++ && nativeOutbound != null + && !session.closeRequested() + && session.windowId() == plan.windowId() + && session.acceptsSendGeneration(plan.generation()) @@ -1821,17 +1783,12 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + return; + } + -+ if (!ASYNC_PACKET_SEND) { -+ sendRenderPlanNow(session, plan); ++ if (!isOnPlayerThread(session.player())) { ++ runOnPlayer(session.player(), () -> sendRenderPlanNow(session, plan)); + return; + } + -+ try { -+ ChannelHelper.runInEventLoop(plan.channel(), () -> sendRenderPlanNow(session, plan)); -+ } catch (final RuntimeException exception) { -+ owner.getLogger().log(Level.WARNING, "Failed to schedule async packet GUI send", exception); -+ sendRenderPlanNow(session, plan); -+ } ++ sendRenderPlanNow(session, plan); + } + + private void sendRenderPlanNow(PacketGuiSession session, PacketGuiSendPlan plan) { @@ -1840,22 +1797,22 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + } + + try { -+ for (final PacketWrapper packet : plan.packets()) { ++ for (final PacketGuiOutboundPacket packet : plan.packets()) { + if (!canSendPlan(session, plan)) { + return; + } + -+ plan.user().writePacket(packet); ++ packet.send(nativeOutbound, session.player()); + } -+ plan.user().flushPackets(); + } catch (final RuntimeException exception) { -+ owner.getLogger().log(Level.WARNING, "Failed to send async packet GUI render", exception); ++ owner.getLogger().log(Level.WARNING, "Failed to send native packet GUI render", exception); + runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true)); + } + } + + private boolean canSendPlan(PacketGuiSession session, PacketGuiSendPlan plan) { + return isTracked(session) ++ && nativeOutbound != null + && !session.closeRequested() + && session.windowId() == plan.windowId() + && session.acceptsSendGeneration(plan.generation()) @@ -2020,19 +1977,22 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + + private interface PacketGuiConversionOperation { + -+ boolean addPackets(PacketGuiSession session, List> packets); ++ boolean addPackets(PacketGuiSession session, List packets); ++ } ++ ++ private interface PacketGuiOutboundPacket { ++ ++ void send(PacketGuiNativeOutboundSender sender, Player player); + } + + private static final class PacketGuiConversionPlan { + -+ private final User user; + private final Object channel; + private final int windowId; + private final long generation; + private final List operations = new ArrayList<>(); + + private PacketGuiConversionPlan(User user, int windowId, long generation) { -+ this.user = user; + this.channel = user.getChannel(); + this.windowId = windowId; + this.generation = generation; @@ -2042,10 +2002,6 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + operations.add(operation); + } + -+ private User user() { -+ return user; -+ } -+ + private Object channel() { + return channel; + } @@ -2090,43 +2046,24 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + } + } + -+ private static final class PacketGuiThreadFactory implements ThreadFactory { -+ -+ private final String name; -+ private final AtomicInteger threadId = new AtomicInteger(1); -+ -+ private PacketGuiThreadFactory(String name) { -+ this.name = name; -+ } -+ -+ @Override -+ public Thread newThread(Runnable runnable) { -+ final Thread thread = new Thread(runnable, name + " #" + threadId.getAndIncrement()); -+ thread.setDaemon(true); -+ return thread; -+ } -+ } -+ + private static final class PacketGuiSendPlan { + -+ private final User user; + private final Object channel; + private final int windowId; + private final long generation; -+ private final List> packets; -+ -+ private PacketGuiSendPlan(User user, int windowId, long generation, List> packets) { -+ this.user = user; -+ this.channel = user.getChannel(); ++ private final List packets; ++ ++ private PacketGuiSendPlan( ++ Object channel, ++ int windowId, ++ long generation, ++ List packets) { ++ this.channel = channel; + this.windowId = windowId; + this.generation = generation; + this.packets = List.copyOf(packets); + } + -+ private User user() { -+ return user; -+ } -+ + private Object channel() { + return channel; + } @@ -2139,7 +2076,7 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + return generation; + } + -+ private List> packets() { ++ private List packets() { + return packets; + } + @@ -2148,6 +2085,7 @@ index 0000000000000000000000000000000000000000..0b437e29515dc65e1366bf8ad9f5a334 + } + } +} + diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java new file mode 100644 index 0000000000000000000000000000000000000000..dfcd9d0b454ab265c9dba997646ddd91a2e80374 @@ -2346,6 +2284,387 @@ index 0000000000000000000000000000000000000000..dfcd9d0b454ab265c9dba997646ddd91 + + '}'; + } +} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java +new file mode 100644 +index 0000000000000000000000000000000000000000..ea9d5c6e1fefc0023abd6709960237e32f12dade +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java +@@ -0,0 +1,374 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import java.lang.reflect.Constructor; ++import java.lang.reflect.Field; ++import java.lang.reflect.Method; ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Set; ++import org.bukkit.entity.Player; ++import org.bukkit.inventory.ItemStack; ++ ++final class PacketGuiNativeOutboundSender { ++ ++ private static final Set SUPPORTED_MINECRAFT_VERSIONS = Set.of("26.1.2"); ++ ++ private final Class packetClass; ++ private final Class nmsItemStackClass; ++ private final Object emptyItemStack; ++ private final Method asNmsCopy; ++ private final Method getHandle; ++ private final Constructor containerSetContentConstructor; ++ private final Constructor containerSetSlotConstructor; ++ private final Constructor setCursorItemConstructor; ++ private final Constructor setPlayerInventoryConstructor; ++ ++ private PacketGuiNativeOutboundSender() { ++ try { ++ this.packetClass = Class.forName("net.minecraft.network.protocol.Packet"); ++ this.nmsItemStackClass = Class.forName("net.minecraft.world.item.ItemStack"); ++ this.emptyItemStack = staticField(nmsItemStackClass, "EMPTY").get(null); ++ this.asNmsCopy = craftItemStackClass().getMethod("asNMSCopy", ItemStack.class); ++ this.getHandle = craftClass("entity.CraftPlayer").getMethod("getHandle"); ++ this.containerSetContentConstructor = Class.forName( ++ "net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket") ++ .getConstructor(int.class, int.class, List.class, nmsItemStackClass); ++ this.containerSetSlotConstructor = Class.forName( ++ "net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket") ++ .getConstructor(int.class, int.class, int.class, nmsItemStackClass); ++ this.setCursorItemConstructor = optionalConstructor( ++ "net.minecraft.network.protocol.game.ClientboundSetCursorItemPacket", ++ nmsItemStackClass); ++ this.setPlayerInventoryConstructor = optionalConstructor( ++ "net.minecraft.network.protocol.game.ClientboundSetPlayerInventoryPacket", ++ int.class, ++ nmsItemStackClass); ++ } catch (final ReflectiveOperationException exception) { ++ throw new IllegalStateException("Failed to initialize native packet GUI item sender", exception); ++ } ++ } ++ ++ static InitializationResult initialize() { ++ final String minecraftVersion = minecraftVersion(); ++ final String bukkitVersion = bukkitVersion(); ++ final String serverPackage = serverPackage(); ++ ++ if (!SUPPORTED_MINECRAFT_VERSIONS.contains(minecraftVersion)) { ++ return InitializationResult.unavailable( ++ minecraftVersion, ++ bukkitVersion, ++ serverPackage, ++ "Supported native packet GUI Minecraft versions: " ++ + SUPPORTED_MINECRAFT_VERSIONS ++ + "; detected " ++ + minecraftVersion ++ + "."); ++ } ++ ++ try { ++ return InitializationResult.available( ++ new PacketGuiNativeOutboundSender(), ++ minecraftVersion, ++ bukkitVersion, ++ serverPackage, ++ "Native packet GUI outbound sender initialized for Minecraft " + minecraftVersion + "."); ++ } catch (final RuntimeException exception) { ++ return InitializationResult.unavailable( ++ minecraftVersion, ++ bukkitVersion, ++ serverPackage, ++ "Native packet GUI outbound sender could not be initialized for Minecraft " ++ + minecraftVersion ++ + ".", ++ exception); ++ } ++ } ++ ++ void sendContainerSetContent( ++ Player player, ++ int windowId, ++ int stateId, ++ List items, ++ ItemStack cursor) { ++ final List nmsItems = new ArrayList<>(items.size()); ++ for (final ItemStack item : items) { ++ nmsItems.add(toNmsItem(item)); ++ } ++ ++ send(player, construct(containerSetContentConstructor, windowId, stateId, nmsItems, toNmsItem(cursor))); ++ } ++ ++ void sendContainerSetSlot(Player player, int windowId, int stateId, int slot, ItemStack item) { ++ send(player, construct(containerSetSlotConstructor, windowId, stateId, slot, toNmsItem(item))); ++ } ++ ++ void sendCursor(Player player, ItemStack item) { ++ final Object nmsItem = toNmsItem(item); ++ if (setCursorItemConstructor != null) { ++ send(player, construct(setCursorItemConstructor, nmsItem)); ++ return; ++ } ++ ++ send(player, construct(containerSetSlotConstructor, -1, -1, -1, nmsItem)); ++ } ++ ++ void sendPlayerInventorySlot( ++ Player player, ++ int playerInventorySlot, ++ int fallbackContainerSlot, ++ int fallbackStateId, ++ ItemStack item) { ++ final Object nmsItem = toNmsItem(item); ++ if (setPlayerInventoryConstructor != null && playerInventorySlot >= 0) { ++ send(player, construct(setPlayerInventoryConstructor, playerInventorySlot, nmsItem)); ++ return; ++ } ++ ++ send(player, construct( ++ containerSetSlotConstructor, ++ PacketInventoryConstants.PLAYER_WINDOW_ID, ++ fallbackStateId, ++ fallbackContainerSlot, ++ nmsItem)); ++ } ++ ++ private Object toNmsItem(ItemStack item) { ++ if (PacketItemConverter.isEmpty(item)) { ++ return emptyItemStack; ++ } ++ ++ try { ++ final Object converted = asNmsCopy.invoke(null, PacketItemConverter.normalizedCopy(item)); ++ return converted == null ? emptyItemStack : converted; ++ } catch (final ReflectiveOperationException exception) { ++ throw new IllegalStateException("Failed to convert Bukkit item to native item stack", exception); ++ } ++ } ++ ++ private Object construct(Constructor constructor, Object... arguments) { ++ try { ++ return constructor.newInstance(arguments); ++ } catch (final ReflectiveOperationException exception) { ++ throw new IllegalStateException("Failed to create native packet GUI item packet", exception); ++ } ++ } ++ ++ private void send(Player player, Object packet) { ++ final Object handle = handle(player); ++ final Object connection = connection(handle); ++ final Method send = sendMethod(connection.getClass(), packet.getClass()); ++ try { ++ send.invoke(connection, packet); ++ } catch (final ReflectiveOperationException exception) { ++ throw new IllegalStateException("Failed to send native packet GUI item packet", exception); ++ } ++ } ++ ++ private Object handle(Player player) { ++ try { ++ return getHandle.invoke(player); ++ } catch (final ReflectiveOperationException exception) { ++ throw new IllegalStateException("Failed to access native player handle", exception); ++ } ++ } ++ ++ private Object connection(Object handle) { ++ try { ++ final Field connection = field(handle.getClass(), "connection"); ++ return connection.get(handle); ++ } catch (final ReflectiveOperationException ignored) { ++ return discoverConnection(handle); ++ } ++ } ++ ++ private Object discoverConnection(Object handle) { ++ Class type = handle.getClass(); ++ while (type != null) { ++ for (final Field field : type.getDeclaredFields()) { ++ try { ++ field.setAccessible(true); ++ final Object value = field.get(handle); ++ if (value != null && findSendMethod(value.getClass(), packetClass) != null) { ++ return value; ++ } ++ } catch (final ReflectiveOperationException ignored) { ++ // Try the next field; this is a compatibility fallback for obfuscated runtimes. ++ } ++ } ++ type = type.getSuperclass(); ++ } ++ ++ throw new IllegalStateException("Failed to discover native player connection"); ++ } ++ ++ private Method sendMethod(Class connectionClass, Class concretePacketClass) { ++ final Method method = findSendMethod(connectionClass, concretePacketClass); ++ if (method == null) { ++ throw new IllegalStateException("Failed to find native player connection send method"); ++ } ++ return method; ++ } ++ ++ private Method findSendMethod(Class connectionClass, Class concretePacketClass) { ++ Class type = connectionClass; ++ while (type != null) { ++ for (final Method method : type.getDeclaredMethods()) { ++ if (method.getParameterCount() != 1 || method.getReturnType() != Void.TYPE) { ++ continue; ++ } ++ ++ if (!method.getParameterTypes()[0].isAssignableFrom(concretePacketClass)) { ++ continue; ++ } ++ ++ method.setAccessible(true); ++ return method; ++ } ++ type = type.getSuperclass(); ++ } ++ ++ return null; ++ } ++ ++ private static Class craftItemStackClass() throws ClassNotFoundException { ++ return craftClass("inventory.CraftItemStack"); ++ } ++ ++ private static Class craftClass(String className) throws ClassNotFoundException { ++ try { ++ return Class.forName("org.bukkit.craftbukkit." + className); ++ } catch (final ClassNotFoundException ignored) { ++ final String serverPackage = org.bukkit.Bukkit.getServer().getClass().getPackage().getName(); ++ return Class.forName(serverPackage + "." + className); ++ } ++ } ++ ++ private static Constructor optionalConstructor(String className, Class... parameterTypes) { ++ try { ++ return Class.forName(className).getConstructor(parameterTypes); ++ } catch (final ReflectiveOperationException ignored) { ++ return null; ++ } ++ } ++ ++ private static Field staticField(Class type, String name) throws NoSuchFieldException { ++ final Field field = type.getField(name); ++ field.setAccessible(true); ++ return field; ++ } ++ ++ private static Field field(Class type, String name) throws NoSuchFieldException { ++ Class current = type; ++ while (current != null) { ++ try { ++ final Field field = current.getDeclaredField(name); ++ field.setAccessible(true); ++ return field; ++ } catch (final NoSuchFieldException ignored) { ++ current = current.getSuperclass(); ++ } ++ } ++ ++ throw new NoSuchFieldException(name); ++ } ++ static final class InitializationResult { ++ ++ private final PacketGuiNativeOutboundSender sender; ++ private final String minecraftVersion; ++ private final String bukkitVersion; ++ private final String serverPackage; ++ private final String message; ++ private final Throwable failure; ++ ++ private InitializationResult( ++ PacketGuiNativeOutboundSender sender, ++ String minecraftVersion, ++ String bukkitVersion, ++ String serverPackage, ++ String message, ++ Throwable failure) { ++ this.sender = sender; ++ this.minecraftVersion = minecraftVersion; ++ this.bukkitVersion = bukkitVersion; ++ this.serverPackage = serverPackage; ++ this.message = message; ++ this.failure = failure; ++ } ++ ++ static InitializationResult available( ++ PacketGuiNativeOutboundSender sender, ++ String minecraftVersion, ++ String bukkitVersion, ++ String serverPackage, ++ String message) { ++ return new InitializationResult(sender, minecraftVersion, bukkitVersion, serverPackage, message, null); ++ } ++ ++ static InitializationResult unavailable( ++ String minecraftVersion, String bukkitVersion, String serverPackage, String message) { ++ return unavailable(minecraftVersion, bukkitVersion, serverPackage, message, null); ++ } ++ ++ static InitializationResult unavailable( ++ String minecraftVersion, ++ String bukkitVersion, ++ String serverPackage, ++ String message, ++ Throwable failure) { ++ return new InitializationResult(null, minecraftVersion, bukkitVersion, serverPackage, message, failure); ++ } ++ ++ boolean available() { ++ return sender != null; ++ } ++ ++ PacketGuiNativeOutboundSender sender() { ++ return sender; ++ } ++ ++ String minecraftVersion() { ++ return minecraftVersion; ++ } ++ ++ String bukkitVersion() { ++ return bukkitVersion; ++ } ++ ++ String serverPackage() { ++ return serverPackage; ++ } ++ ++ String message() { ++ return message; ++ } ++ ++ Throwable failure() { ++ return failure; ++ } ++ } ++ ++ private static String minecraftVersion() { ++ try { ++ return String.valueOf(org.bukkit.Bukkit.class.getMethod("getMinecraftVersion").invoke(null)); ++ } catch (final ReflectiveOperationException exception) { ++ return "unknown"; ++ } ++ } ++ ++ private static String bukkitVersion() { ++ try { ++ return String.valueOf(org.bukkit.Bukkit.getBukkitVersion()); ++ } catch (final RuntimeException exception) { ++ return "unknown"; ++ } ++ } ++ ++ private static String serverPackage() { ++ try { ++ return org.bukkit.Bukkit.getServer().getClass().getPackage().getName(); ++ } catch (final RuntimeException exception) { ++ return "unknown"; ++ } ++ } ++ ++} + diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java new file mode 100644 index 0000000000000000000000000000000000000000..89cf420ff84c58826d06cbe9d92c4f9b3f074d03 @@ -3021,10 +3340,10 @@ index 0000000000000000000000000000000000000000..385be48437ff8be5817055aa9a27f8a9 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java new file mode 100644 -index 0000000000000000000000000000000000000000..aa6ab4aba33324a21bab6f7707532550c222af1a +index 0000000000000000000000000000000000000000..afc341fc07b9f309a53a9602f43c712b47d0cb07 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java -@@ -0,0 +1,39 @@ +@@ -0,0 +1,59 @@ +package me.devnatan.inventoryframework.internal.packet; + +final class PacketInventoryConstants { @@ -3063,13 +3382,33 @@ index 0000000000000000000000000000000000000000..aa6ab4aba33324a21bab6f7707532550 + return -1; + } + } ++ ++ static int containerSlotToPlayerInventorySlot(int containerSlot) { ++ if (containerSlot >= ITEMS_START && containerSlot < HOTBAR_START) return containerSlot; ++ if (containerSlot >= HOTBAR_START && containerSlot < HOTBAR_START + 9) return containerSlot - HOTBAR_START; ++ ++ switch (containerSlot) { ++ case SLOT_BOOTS: ++ return 36; ++ case SLOT_LEGGINGS: ++ return 37; ++ case SLOT_CHESTPLATE: ++ return 38; ++ case SLOT_HELMET: ++ return 39; ++ case SLOT_OFFHAND: ++ return 40; ++ default: ++ return -1; ++ } ++ } +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java new file mode 100644 -index 0000000000000000000000000000000000000000..246044d56eab871e7aad763ec95d1aa40a3fc6f6 +index 0000000000000000000000000000000000000000..826c90cd7aa87ba859946727b973b178c1338380 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java -@@ -0,0 +1,220 @@ +@@ -0,0 +1,224 @@ +package me.devnatan.inventoryframework.internal.packet; + +import io.github.retrooper.packetevents.util.SpigotConversionUtil; @@ -3142,6 +3481,10 @@ index 0000000000000000000000000000000000000000..246044d56eab871e7aad763ec95d1aa4 + return item == null || item.getType() == Material.AIR || item.getAmount() <= 0; + } + ++ static ItemStack normalizedCopy(ItemStack item) { ++ return normalizeItem(item); ++ } ++ + private static ItemStack normalizeItem(ItemStack item) { + final ItemStack copy = item.clone(); + final ItemMeta meta = copy.getItemMeta(); From c56412155704459d155f956861579ac206ad5afe Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Sat, 30 May 2026 23:42:09 +0200 Subject: [PATCH 18/50] Support packet GUI player-inventory clicks Forward mapped bottom-inventory clicks through the InventoryFramework click pipeline as entity-container clicks and allow packet click origins to mutate the clicked player inventory item for views such as surf-shop storage insertion. --- ...0006-Add-internal-packet-GUI-backend.patch | 278 +++++++++++++++--- 1 file changed, 237 insertions(+), 41 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index b87e16e..4afcba1 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -220,16 +220,17 @@ index 2d97a162afacb6aa9652d9d83079088761bbd35b..4dc75f811d1a888cee0851a70377ac76 // region Internals diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java new file mode 100644 -index 0000000000000000000000000000000000000000..07639c5a46b064d0292e9f90ea9deb77559bdb38 +index 0000000000000000000000000000000000000000..7b40d1de04d9eda6ab89b9f8bc354f01b6736ba0 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java -@@ -0,0 +1,89 @@ +@@ -0,0 +1,100 @@ +package me.devnatan.inventoryframework.context; + +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryType; ++import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.jetbrains.annotations.NotNull; @@ -254,6 +255,16 @@ index 0000000000000000000000000000000000000000..07639c5a46b064d0292e9f90ea9deb77 + } + + @Override ++ public void setCurrentItem(@Nullable ItemStack item) { ++ event.setCurrentItem(item); ++ } ++ ++ @Override ++ public @Nullable Inventory getClickedInventory() { ++ return event.getClickedInventory(); ++ } ++ ++ @Override + public Object getPlatformEvent() { + return event; + } @@ -325,12 +336,101 @@ index d73ddb29859761e6505b001fb4d303f953cb99e3..686664b4211880e0dc7651cebda4e66c import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.UnmodifiableView; +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickEvent.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickEvent.java +new file mode 100644 +index 0000000000000000000000000000000000000000..f25b9629c69a3b3b97a025010ff908bf28cabda3 +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickEvent.java +@@ -0,0 +1,83 @@ ++package me.devnatan.inventoryframework.context; ++ ++import org.bukkit.entity.Player; ++import org.bukkit.event.inventory.ClickType; ++import org.bukkit.event.inventory.InventoryAction; ++import org.bukkit.event.inventory.InventoryClickEvent; ++import org.bukkit.event.inventory.InventoryType; ++import org.bukkit.inventory.Inventory; ++import org.bukkit.inventory.InventoryView; ++import org.bukkit.inventory.ItemStack; ++import org.jetbrains.annotations.ApiStatus; ++import org.jetbrains.annotations.NotNull; ++import org.jetbrains.annotations.Nullable; ++ ++/** ++ * A synthetic {@link InventoryClickEvent} used by the packet GUI backend, where no real Bukkit click ++ * event exists. ++ * ++ *

It preserves binary compatibility for consumers that read ++ * {@link SlotClickContext#getClickOrigin()} as an {@link InventoryClickEvent} (e.g. ++ * {@code event.setCurrentItem(...)}). Item and cancellation mutations are routed back to the packet ++ * {@link SlotClickOrigin} / {@link SlotClickContext} and never touch a real server-side inventory. ++ * ++ *

This is an internal inventory-framework API that should not be used from outside of this ++ * library. No compatibility guarantees are provided. ++ */ ++@ApiStatus.Internal ++final class PacketSlotClickEvent extends InventoryClickEvent { ++ ++ private final SlotClickContext context; ++ private final SlotClickOrigin origin; ++ ++ private PacketSlotClickEvent( ++ @NotNull InventoryView view, ++ @NotNull ClickType click, ++ @NotNull SlotClickContext context, ++ @NotNull SlotClickOrigin origin) { ++ // A valid (real) view is required by the base constructor; slot 0 keeps the constructor's ++ // slot conversion safe across versions. All item access is overridden below so the view is ++ // never used to read or mutate inventory contents. ++ super(view, InventoryType.SlotType.CONTAINER, 0, click, InventoryAction.NOTHING); ++ this.context = context; ++ this.origin = origin; ++ } ++ ++ static PacketSlotClickEvent create(@NotNull SlotClickContext context, @NotNull SlotClickOrigin origin) { ++ final Player player = origin.getPlayer(); ++ return new PacketSlotClickEvent(player.getOpenInventory(), mapClickType(origin), context, origin); ++ } ++ ++ private static ClickType mapClickType(SlotClickOrigin origin) { ++ if (origin.isKeyboardClick()) return ClickType.NUMBER_KEY; ++ if (origin.isMiddleClick()) return ClickType.MIDDLE; ++ if (origin.isShiftClick()) return origin.isRightClick() ? ClickType.SHIFT_RIGHT : ClickType.SHIFT_LEFT; ++ if (origin.isRightClick()) return ClickType.RIGHT; ++ return ClickType.LEFT; ++ } ++ ++ @Override ++ public @Nullable ItemStack getCurrentItem() { ++ return origin.getCurrentItem(); ++ } ++ ++ @Override ++ public void setCurrentItem(@Nullable ItemStack stack) { ++ origin.setCurrentItem(stack); ++ } ++ ++ @Override ++ public @Nullable Inventory getClickedInventory() { ++ return origin.getClickedInventory(); ++ } ++ ++ @Override ++ public boolean isCancelled() { ++ return context.isCancelled(); ++ } ++ ++ @Override ++ public void setCancelled(boolean cancel) { ++ context.setCancelled(cancel); ++ } ++} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java new file mode 100644 -index 0000000000000000000000000000000000000000..ad0d7f2d02291a259c44d206fd483b560e88e865 +index 0000000000000000000000000000000000000000..f8414b68cc3a0ec537f013a65e583799965b84d1 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java -@@ -0,0 +1,122 @@ +@@ -0,0 +1,127 @@ +package me.devnatan.inventoryframework.context; + +import org.bukkit.entity.Player; @@ -343,7 +443,7 @@ index 0000000000000000000000000000000000000000..ad0d7f2d02291a259c44d206fd483b56 +public final class PacketSlotClickOrigin implements SlotClickOrigin { + + private final Player player; -+ private final ItemStack currentItem; ++ private ItemStack currentItem; + private final Object platformEvent; + private final int rawSlot; + private final String clickIdentifier; @@ -394,6 +494,11 @@ index 0000000000000000000000000000000000000000..ad0d7f2d02291a259c44d206fd483b56 + } + + @Override ++ public void setCurrentItem(@Nullable ItemStack item) { ++ this.currentItem = item == null ? null : item.clone(); ++ } ++ ++ @Override + public Object getPlatformEvent() { + return this; + } @@ -472,7 +577,7 @@ index b89eae09cdfbbc5c01ef378071801f9add48af63..e1ccb7b9309017dbf69102f151151824 } } diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickContext.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickContext.java -index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..21b66e39189ed4eda8e09f31e03f3e2780c58cf0 100644 +index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..d775d2ee670a09587aecd243ba66479351623b7c 100644 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickContext.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickContext.java @@ -5,11 +5,8 @@ import me.devnatan.inventoryframework.ViewContainer; @@ -487,17 +592,19 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..21b66e39189ed4eda8e09f31e03f3e27 import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -@@ -19,7 +16,8 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext +@@ -19,9 +16,10 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext private final Viewer whoClicked; private final ViewContainer clickedContainer; private final Component clickedComponent; - private final InventoryClickEvent clickOrigin; + private final SlotClickOrigin clickOrigin; -+ private final InventoryClickEvent inventoryClickOrigin; private final boolean combined; private boolean cancelled; ++ private InventoryClickEvent compatClickEvent; -@@ -32,19 +30,37 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext + @ApiStatus.Internal + public SlotClickContext( +@@ -32,30 +30,62 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext @Nullable Component clickedComponent, @NotNull InventoryClickEvent clickOrigin, boolean combined) { @@ -523,7 +630,6 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..21b66e39189ed4eda8e09f31e03f3e27 this.clickedComponent = clickedComponent; - this.clickOrigin = clickOrigin; + this.clickOrigin = normalizeOrigin(clickOrigin); -+ this.inventoryClickOrigin = clickOrigin instanceof InventoryClickEvent ? (InventoryClickEvent) clickOrigin : null; this.combined = combined; + this.cancelled = this.clickOrigin.isCancelled(); } @@ -537,20 +643,35 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..21b66e39189ed4eda8e09f31e03f3e27 } /** -@@ -55,7 +71,11 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext +- * The event that triggered this context. ++ * The Bukkit click event that triggered this context. ++ *

++ * In the classic Bukkit inventory backend this is the real {@link InventoryClickEvent}. In the ++ * packet GUI backend, where no real Bukkit event exists, a lightweight compatibility event is ++ * synthesized lazily and returned instead; reading or mutating it (e.g. ++ * {@link InventoryClickEvent#setCurrentItem(org.bukkit.inventory.ItemStack)}) is routed back to ++ * the packet click origin and never touches a real server-side inventory. ++ *

++ * The return type is kept as {@link InventoryClickEvent} for binary compatibility with existing ++ * consumers compiled against the classic backend. + *

+ * This is an internal inventory-framework API that should not be used from outside of + * this library. No compatibility guarantees are provided. */ @NotNull public InventoryClickEvent getClickOrigin() { - return clickOrigin; -+ if (inventoryClickOrigin == null) -+ throw new UnsupportedOperationException( -+ "InventoryClickEvent is not available when using the packet GUI backend."); ++ final Object platform = clickOrigin.getPlatformEvent(); ++ if (platform instanceof InventoryClickEvent) return (InventoryClickEvent) platform; + -+ return inventoryClickOrigin; ++ if (compatClickEvent == null) { ++ compatClickEvent = PacketSlotClickEvent.create(this, clickOrigin); ++ } ++ return compatClickEvent; } /** -@@ -84,12 +104,12 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext +@@ -84,12 +114,12 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext @Override public final void setCancelled(boolean cancelled) { this.cancelled = cancelled; @@ -565,7 +686,7 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..21b66e39189ed4eda8e09f31e03f3e27 } @Override -@@ -99,42 +119,42 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext +@@ -99,42 +129,42 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext @Override public final boolean isLeftClick() { @@ -616,7 +737,7 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..21b66e39189ed4eda8e09f31e03f3e27 } @Override -@@ -176,4 +196,11 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext +@@ -176,4 +206,11 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext public final boolean isCombined() { return combined; } @@ -630,13 +751,14 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..21b66e39189ed4eda8e09f31e03f3e27 } diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java new file mode 100644 -index 0000000000000000000000000000000000000000..7da19162ef16eaac0d32ab89d90ebf26de350ce1 +index 0000000000000000000000000000000000000000..eb49a5a8ee944a53d7eb47528eb58e541a83d1b4 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java -@@ -0,0 +1,42 @@ +@@ -0,0 +1,56 @@ +package me.devnatan.inventoryframework.context; + +import org.bukkit.entity.Player; ++import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; @@ -651,6 +773,19 @@ index 0000000000000000000000000000000000000000..7da19162ef16eaac0d32ab89d90ebf26 + @Nullable + ItemStack getCurrentItem(); + ++ void setCurrentItem(@Nullable ItemStack item); ++ ++ /** ++ * The inventory that was actually clicked, when available on the platform. ++ * ++ * @return The clicked inventory, or {@code null} when the platform does not expose one (e.g. the ++ * packet GUI backend). ++ */ ++ @Nullable ++ default Inventory getClickedInventory() { ++ return null; ++ } ++ + Object getPlatformEvent(); + + int getRawSlot(); @@ -872,10 +1007,10 @@ index 0000000000000000000000000000000000000000..a2f72b8c7cb581fb4b0c252a7595d98b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..c8fb4d405218d628147e60bfe81350767232de6a +index 0000000000000000000000000000000000000000..b0f09c51843558bb1ef9ccb6f2710e1c3d0c746d --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1209 @@ +@@ -0,0 +1,1249 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1361,14 +1496,34 @@ index 0000000000000000000000000000000000000000..c8fb4d405218d628147e60bfe8135076 + } + + final IFRenderContext context = session.context(); ++ final int topSize = session.container().getSize(); + final boolean outsideClick = click.isOutsideClick(); -+ final Component clickedComponent = outsideClick -+ ? null -+ : context.getComponentsAt(click.slot()).stream() -+ .filter(Component::isVisible) -+ .findFirst() -+ .orElse(null); -+ final ItemStack currentItem = outsideClick ? null : session.container().item(click.slot()); ++ final boolean playerInventoryClick = !outsideClick && click.isPlayerInventoryClick(topSize); ++ final int mappedPlayerSlot = playerInventoryClick ++ ? PacketViewerInventory.mapGuiContainerSlotToPlayerSlot(topSize, click.slot()) ++ : -1; ++ ++ final Component clickedComponent; ++ final ItemStack currentItem; ++ final ViewContainer clickedContainer; ++ if (outsideClick) { ++ clickedComponent = null; ++ currentItem = null; ++ clickedContainer = context.getContainer(); ++ } else if (playerInventoryClick) { ++ // Bottom/player-inventory click: mirror the Bukkit backend by routing it through the ++ // player's own (entity) container so View#onClick observes an entity-container click. ++ clickedComponent = null; ++ currentItem = cloneItem(playerInventoryItem(session.player(), mappedPlayerSlot)); ++ clickedContainer = session.viewer().getSelfContainer(); ++ } else { ++ clickedComponent = context.getComponentsAt(click.slot()).stream() ++ .filter(Component::isVisible) ++ .findFirst() ++ .orElse(null); ++ currentItem = session.container().item(click.slot()); ++ clickedContainer = context.getContainer(); ++ } + + final PacketSlotClickOrigin origin = new PacketSlotClickOrigin( + session.player(), @@ -1382,7 +1537,7 @@ index 0000000000000000000000000000000000000000..c8fb4d405218d628147e60bfe8135076 + click.isShiftClick(), + click.isKeyboardClick(), + outsideClick, -+ false); ++ playerInventoryClick); + + try { + final IFSlotClickContext clickContext = context.getRoot() @@ -1390,7 +1545,7 @@ index 0000000000000000000000000000000000000000..c8fb4d405218d628147e60bfe8135076 + .createSlotClickContext( + click.slot(), + session.viewer(), -+ context.getContainer(), ++ clickedContainer, + clickedComponent, + origin, + false); @@ -1402,6 +1557,12 @@ index 0000000000000000000000000000000000000000..c8fb4d405218d628147e60bfe8135076 + return; + } + ++ // Only mapped player-inventory clicks may mutate the real inventory. Top fake GUI items must ++ // never touch the player's inventory. ++ if (playerInventoryClick && mappedPlayerSlot >= 0 && !origin.isCancelled()) { ++ applyPlayerInventoryItem(session.player(), mappedPlayerSlot, origin.getCurrentItem()); ++ } ++ + requestRender(session, false, false, repairScope, click); + } + @@ -1685,6 +1846,20 @@ index 0000000000000000000000000000000000000000..c8fb4d405218d628147e60bfe8135076 + return item == null ? null : item.clone(); + } + ++ private static void applyPlayerInventoryItem(Player player, int playerWindowSlot, ItemStack item) { ++ final PlayerInventory inventory = player.getInventory(); ++ if (playerWindowSlot >= PacketInventoryConstants.ITEMS_START ++ && playerWindowSlot < PacketInventoryConstants.HOTBAR_START) { ++ inventory.setItem(playerWindowSlot, item); ++ return; ++ } ++ ++ if (playerWindowSlot >= PacketInventoryConstants.HOTBAR_START ++ && playerWindowSlot < PacketInventoryConstants.HOTBAR_START + 9) { ++ inventory.setItem(playerWindowSlot - PacketInventoryConstants.HOTBAR_START, item); ++ } ++ } ++ + private static ItemStack playerInventoryItem(Player player, int playerWindowSlot) { + final PlayerInventory inventory = player.getInventory(); + if (playerWindowSlot >= PacketInventoryConstants.ITEMS_START @@ -2085,13 +2260,12 @@ index 0000000000000000000000000000000000000000..c8fb4d405218d628147e60bfe8135076 + } + } +} - diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java new file mode 100644 -index 0000000000000000000000000000000000000000..dfcd9d0b454ab265c9dba997646ddd91a2e80374 +index 0000000000000000000000000000000000000000..989fed145f2d8edbbe75581a3249ed808eb7a64c --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java -@@ -0,0 +1,192 @@ +@@ -0,0 +1,203 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientClickWindow; @@ -2158,9 +2332,20 @@ index 0000000000000000000000000000000000000000..dfcd9d0b454ab265c9dba997646ddd91 + return isPickupClick() || isQuickMoveClick() || isSwapClick() || isCloneClick(); + } + ++ boolean isPlayerInventoryClick(int topSize) { ++ return slot >= topSize && PacketViewerInventory.mapGuiContainerSlotToPlayerSlot(topSize, slot) >= 0; ++ } ++ ++ boolean isSafePlayerInventoryClick(int topSize) { ++ return isPlayerInventoryClick(topSize) ++ && (isPickupClick() || isQuickMoveClick() || isSwapClick() || isCloneClick()); ++ } ++ + PacketGuiRepairScope repairScope(int topSize) { + if (!isSafeTopClick(topSize)) { -+ return PacketGuiRepairScope.FULL_WINDOW; ++ return isSafePlayerInventoryClick(topSize) ++ ? PacketGuiRepairScope.PLAYER_INVENTORY ++ : PacketGuiRepairScope.FULL_WINDOW; + } + + if (isPickupClick() || isCloneClick()) { @@ -2286,7 +2471,7 @@ index 0000000000000000000000000000000000000000..dfcd9d0b454ab265c9dba997646ddd91 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java new file mode 100644 -index 0000000000000000000000000000000000000000..ea9d5c6e1fefc0023abd6709960237e32f12dade +index 0000000000000000000000000000000000000000..3e614a1f5833fcc04075d73800a4658509bcefe4 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java @@ -0,0 +1,374 @@ @@ -2664,7 +2849,6 @@ index 0000000000000000000000000000000000000000..ea9d5c6e1fefc0023abd6709960237e3 + } + +} - diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java new file mode 100644 index 0000000000000000000000000000000000000000..89cf420ff84c58826d06cbe9d92c4f9b3f074d03 @@ -2886,10 +3070,10 @@ index 0000000000000000000000000000000000000000..be445839c0fedf34bea198e2b3aa3d8a +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRepairScope.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRepairScope.java new file mode 100644 -index 0000000000000000000000000000000000000000..838bd2a2606696d3dafc5430b179410bfd13df7d +index 0000000000000000000000000000000000000000..bfe5e483593193cc8e62027a4e2684e0f6c0f8a0 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRepairScope.java -@@ -0,0 +1,27 @@ +@@ -0,0 +1,32 @@ +package me.devnatan.inventoryframework.internal.packet; + +enum PacketGuiRepairScope { @@ -2897,12 +3081,17 @@ index 0000000000000000000000000000000000000000..838bd2a2606696d3dafc5430b179410b + TOP_SLOT_AND_CURSOR, + TOP_SLOT_CURSOR_AND_PLAYER_SLOT, + TOP_SLOT_CURSOR_AND_OFFHAND, ++ PLAYER_INVENTORY, + FULL_WINDOW; + + boolean fullWindow() { + return this == FULL_WINDOW; + } + ++ boolean repairsClickedPlayerSlot() { ++ return this == PLAYER_INVENTORY; ++ } ++ + boolean repairsTopSlot() { + return this == TOP_SLOT_AND_CURSOR + || this == TOP_SLOT_CURSOR_AND_PLAYER_SLOT @@ -2919,10 +3108,10 @@ index 0000000000000000000000000000000000000000..838bd2a2606696d3dafc5430b179410b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java new file mode 100644 -index 0000000000000000000000000000000000000000..385be48437ff8be5817055aa9a27f8a9c86d81cc +index 0000000000000000000000000000000000000000..180b53e896495308f79c1ed717bd2cbf306a7b27 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java -@@ -0,0 +1,415 @@ +@@ -0,0 +1,422 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.protocol.player.User; @@ -3139,6 +3328,13 @@ index 0000000000000000000000000000000000000000..385be48437ff8be5817055aa9a27f8a9 + } + + private void scheduleTargetedRepairs(PacketGuiRepairScope scope, PacketGuiClick click) { ++ if (scope.repairsClickedPlayerSlot()) { ++ schedulePlayerSlotRepair( ++ PacketViewerInventory.mapGuiContainerSlotToPlayerSlot(container.getSize(), click.slot())); ++ scheduleChangedPlayerSlotRepairs(click); ++ return; ++ } ++ + if (scope.repairsTopSlot()) { + scheduleTopSlotRepair(click.slot()); + } From 78c345f9a204ee84a574c2ae92c0afdee3fec780 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Sat, 30 May 2026 23:48:57 +0200 Subject: [PATCH 19/50] Fix bottom shift-click packet repair Use a full window repair for player-inventory QUICK_MOVE clicks so client-side shift-click predictions against fake packet GUI slots are reverted correctly. --- patches/0006-Add-internal-packet-GUI-backend.patch | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 4afcba1..a95e1dc 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1010,7 +1010,7 @@ new file mode 100644 index 0000000000000000000000000000000000000000..b0f09c51843558bb1ef9ccb6f2710e1c3d0c746d --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1249 @@ +@@ -0,0 +1,1250 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1276,13 +1276,14 @@ index 0000000000000000000000000000000000000000..b0f09c51843558bb1ef9ccb6f2710e1c + return; + } + -+ final PacketGuiRepairScope repairScope = click.repairScope(session.container().getSize()); ++ final int topSize = session.container().getSize(); ++ final PacketGuiRepairScope repairScope = click.repairScope(topSize); + if (click.isOutsideClick()) { + runOnPlayer(session.player(), () -> handlePacketClick(session, click, repairScope)); + return; + } + -+ if (repairScope.fullWindow()) { ++ if (repairScope.fullWindow() && !(click.isPlayerInventoryClick(topSize) && click.isShiftClick())) { + requestRender(session, false, true); + return; + } @@ -2265,7 +2266,7 @@ new file mode 100644 index 0000000000000000000000000000000000000000..989fed145f2d8edbbe75581a3249ed808eb7a64c --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java -@@ -0,0 +1,203 @@ +@@ -0,0 +1,202 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientClickWindow; @@ -2337,8 +2338,7 @@ index 0000000000000000000000000000000000000000..989fed145f2d8edbbe75581a3249ed80 + } + + boolean isSafePlayerInventoryClick(int topSize) { -+ return isPlayerInventoryClick(topSize) -+ && (isPickupClick() || isQuickMoveClick() || isSwapClick() || isCloneClick()); ++ return isPlayerInventoryClick(topSize) && (isPickupClick() || isSwapClick() || isCloneClick()); + } + + PacketGuiRepairScope repairScope(int topSize) { From e262467e887883ec5127b38d2ba4cb64974802b1 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:16:02 +0200 Subject: [PATCH 20/50] test(packet): add packetevents test deps and pin slot mapping behaviour --- ...0006-Add-internal-packet-GUI-backend.patch | 140 ++++++++++++++++-- 1 file changed, 127 insertions(+), 13 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index a95e1dc..c3e4076 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -69,18 +69,22 @@ index e050121d60d533fd55677306fc3d0ea06b011fd8..4b9a363340f204570bcdac70319e1fa7 return value; diff --git a/inventory-framework-platform-bukkit/build.gradle.kts b/inventory-framework-platform-bukkit/build.gradle.kts -index 6a29127fffd904f31720719a40c8f61bf4c1ae05..237bf07ee139b580f0148a28061fb36166787247 100644 +index 6a29127fffd904f31720719a40c8f61bf4c1ae05..82ffa251b58814e38dd326d136370e5d064bcbde 100644 --- a/inventory-framework-platform-bukkit/build.gradle.kts +++ b/inventory-framework-platform-bukkit/build.gradle.kts -@@ -13,6 +13,7 @@ dependencies { +@@ -13,8 +13,11 @@ dependencies { api(projects.inventoryFrameworkPlatform) runtimeOnly(projects.inventoryFrameworkAnvilInput) compileOnly(libs.spigot) + compileOnly(libs.packetevents.spigot) testCompileOnly(libs.spigot) testRuntimeOnly(libs.spigot) ++ testCompileOnly(libs.packetevents.spigot) ++ testRuntimeOnly(libs.packetevents.spigot) testImplementation(projects.inventoryFrameworkApi) -@@ -39,5 +40,6 @@ bukkit { + testImplementation(projects.inventoryFrameworkTest) + implementation(libs.folialib) +@@ -39,5 +42,6 @@ bukkit { website = "https://github.com/DevNatan/inventory-framework" apiVersion = "1.13" authors = listOf("SaiintBrisson", "DevNatan", "sasuked") @@ -1007,7 +1011,7 @@ index 0000000000000000000000000000000000000000..a2f72b8c7cb581fb4b0c252a7595d98b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..b0f09c51843558bb1ef9ccb6f2710e1c3d0c746d +index 0000000000000000000000000000000000000000..23049112f36d08a93d851f293fd1288e4fd14c42 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java @@ -0,0 +1,1250 @@ @@ -2263,7 +2267,7 @@ index 0000000000000000000000000000000000000000..b0f09c51843558bb1ef9ccb6f2710e1c +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java new file mode 100644 -index 0000000000000000000000000000000000000000..989fed145f2d8edbbe75581a3249ed808eb7a64c +index 0000000000000000000000000000000000000000..7526b7afe435c3c0ae694a8a2c8345466e156348 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java @@ -0,0 +1,202 @@ @@ -2471,10 +2475,10 @@ index 0000000000000000000000000000000000000000..989fed145f2d8edbbe75581a3249ed80 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java new file mode 100644 -index 0000000000000000000000000000000000000000..3e614a1f5833fcc04075d73800a4658509bcefe4 +index 0000000000000000000000000000000000000000..fbebf59371b8e4a9cb757d045dd48671af45a769 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java -@@ -0,0 +1,374 @@ +@@ -0,0 +1,425 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.lang.reflect.Constructor; @@ -2482,7 +2486,10 @@ index 0000000000000000000000000000000000000000..3e614a1f5833fcc04075d73800a46585 +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; ++import java.util.Objects; +import java.util.Set; ++import java.util.concurrent.ConcurrentHashMap; ++import java.util.concurrent.ConcurrentMap; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + @@ -2499,6 +2506,8 @@ index 0000000000000000000000000000000000000000..3e614a1f5833fcc04075d73800a46585 + private final Constructor containerSetSlotConstructor; + private final Constructor setCursorItemConstructor; + private final Constructor setPlayerInventoryConstructor; ++ private final ConcurrentMap, Field> connectionFields = new ConcurrentHashMap<>(); ++ private final ConcurrentMap sendMethods = new ConcurrentHashMap<>(); + + private PacketGuiNativeOutboundSender() { + try { @@ -2650,8 +2659,19 @@ index 0000000000000000000000000000000000000000..3e614a1f5833fcc04075d73800a46585 + } + + private Object connection(Object handle) { ++ final Class handleClass = handle.getClass(); ++ final Field cachedField = connectionFields.get(handleClass); ++ if (cachedField != null) { ++ try { ++ return cachedField.get(handle); ++ } catch (final ReflectiveOperationException exception) { ++ connectionFields.remove(handleClass, cachedField); ++ } ++ } ++ + try { -+ final Field connection = field(handle.getClass(), "connection"); ++ final Field connection = field(handleClass, "connection"); ++ connectionFields.putIfAbsent(handleClass, connection); + return connection.get(handle); + } catch (final ReflectiveOperationException ignored) { + return discoverConnection(handle); @@ -2666,6 +2686,7 @@ index 0000000000000000000000000000000000000000..3e614a1f5833fcc04075d73800a46585 + field.setAccessible(true); + final Object value = field.get(handle); + if (value != null && findSendMethod(value.getClass(), packetClass) != null) { ++ connectionFields.putIfAbsent(handle.getClass(), field); + return value; + } + } catch (final ReflectiveOperationException ignored) { @@ -2679,11 +2700,13 @@ index 0000000000000000000000000000000000000000..3e614a1f5833fcc04075d73800a46585 + } + + private Method sendMethod(Class connectionClass, Class concretePacketClass) { -+ final Method method = findSendMethod(connectionClass, concretePacketClass); -+ if (method == null) { -+ throw new IllegalStateException("Failed to find native player connection send method"); -+ } -+ return method; ++ return sendMethods.computeIfAbsent(new SendMethodKey(connectionClass, concretePacketClass), key -> { ++ final Method method = findSendMethod(key.connectionClass, key.concretePacketClass); ++ if (method == null) { ++ throw new IllegalStateException("Failed to find native player connection send method"); ++ } ++ return method; ++ }); + } + + private Method findSendMethod(Class connectionClass, Class concretePacketClass) { @@ -2748,6 +2771,38 @@ index 0000000000000000000000000000000000000000..3e614a1f5833fcc04075d73800a46585 + + throw new NoSuchFieldException(name); + } ++ ++ private static final class SendMethodKey { ++ ++ private final Class connectionClass; ++ private final Class concretePacketClass; ++ ++ private SendMethodKey(Class connectionClass, Class concretePacketClass) { ++ this.connectionClass = connectionClass; ++ this.concretePacketClass = concretePacketClass; ++ } ++ ++ @Override ++ public boolean equals(Object other) { ++ if (this == other) { ++ return true; ++ } ++ ++ if (!(other instanceof SendMethodKey)) { ++ return false; ++ } ++ ++ final SendMethodKey that = (SendMethodKey) other; ++ return connectionClass.equals(that.connectionClass) ++ && concretePacketClass.equals(that.concretePacketClass); ++ } ++ ++ @Override ++ public int hashCode() { ++ return Objects.hash(connectionClass, concretePacketClass); ++ } ++ } ++ + static final class InitializationResult { + + private final PacketGuiNativeOutboundSender sender; @@ -4319,6 +4374,65 @@ index 6499f33961d5fbf084ca15f00a65b38d22e4fd0b..3f89ed26c49e0c94da9ea5bc27782675 final Component component = context.getComponent(); if (!(component instanceof ItemComponent) || !component.isVisible()) return; +diff --git a/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstantsTest.java b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstantsTest.java +new file mode 100644 +index 0000000000000000000000000000000000000000..6b7b81adbc1430fe023c82a742ab5005fa01b64e +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstantsTest.java +@@ -0,0 +1,53 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import static org.junit.jupiter.api.Assertions.assertEquals; ++ ++import org.junit.jupiter.api.Test; ++ ++class PacketInventoryConstantsTest { ++ ++ @Test ++ void mapsHotbarToTheEndOfThePlayerContainer() { ++ assertEquals(36, PacketInventoryConstants.playerInventorySlotToContainerSlot(0)); ++ assertEquals(44, PacketInventoryConstants.playerInventorySlotToContainerSlot(8)); ++ } ++ ++ @Test ++ void keepsMainInventorySlotsUnchanged() { ++ assertEquals(9, PacketInventoryConstants.playerInventorySlotToContainerSlot(9)); ++ assertEquals(35, PacketInventoryConstants.playerInventorySlotToContainerSlot(35)); ++ } ++ ++ @Test ++ void mapsArmourAndOffhand() { ++ assertEquals(8, PacketInventoryConstants.playerInventorySlotToContainerSlot(36)); ++ assertEquals(7, PacketInventoryConstants.playerInventorySlotToContainerSlot(37)); ++ assertEquals(6, PacketInventoryConstants.playerInventorySlotToContainerSlot(38)); ++ assertEquals(5, PacketInventoryConstants.playerInventorySlotToContainerSlot(39)); ++ assertEquals(45, PacketInventoryConstants.playerInventorySlotToContainerSlot(40)); ++ } ++ ++ @Test ++ void rejectsOutOfRangePlayerInventorySlots() { ++ assertEquals(-1, PacketInventoryConstants.playerInventorySlotToContainerSlot(-1)); ++ assertEquals(-1, PacketInventoryConstants.playerInventorySlotToContainerSlot(41)); ++ } ++ ++ @Test ++ void rejectsUnmappableContainerSlots() { ++ assertEquals(-1, PacketInventoryConstants.containerSlotToPlayerInventorySlot(0)); ++ assertEquals(-1, PacketInventoryConstants.containerSlotToPlayerInventorySlot(4)); ++ assertEquals(-1, PacketInventoryConstants.containerSlotToPlayerInventorySlot(46)); ++ } ++ ++ @Test ++ void roundTripsEveryPlayerInventorySlot() { ++ for (int slot = 0; slot <= 40; slot++) { ++ final int containerSlot = PacketInventoryConstants.playerInventorySlotToContainerSlot(slot); ++ assertEquals( ++ slot, ++ PacketInventoryConstants.containerSlotToPlayerInventorySlot(containerSlot), ++ "round trip failed for player inventory slot " + slot); ++ } ++ } ++} diff --git a/settings.gradle.kts b/settings.gradle.kts index 05585a2bd587532365bfe98563ecc18047386144..f26ceb55f94d93d4eaf30706bf82998bab48f278 100644 --- a/settings.gradle.kts From 8703ae2c1f71601d4650b442997f2391e8bc0dbc Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:20:10 +0200 Subject: [PATCH 21/50] fix(packet): fail closed on non-pickup slot -999 clicks --- ...0006-Add-internal-packet-GUI-backend.patch | 174 +++++++++++++++--- 1 file changed, 147 insertions(+), 27 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index c3e4076..c1dc961 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1011,10 +1011,10 @@ index 0000000000000000000000000000000000000000..a2f72b8c7cb581fb4b0c252a7595d98b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..23049112f36d08a93d851f293fd1288e4fd14c42 +index 0000000000000000000000000000000000000000..4288622baab670cec914570308efa67138ead488 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1250 @@ +@@ -0,0 +1,1252 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1282,12 +1282,14 @@ index 0000000000000000000000000000000000000000..23049112f36d08a93d851f293fd1288e + + final int topSize = session.container().getSize(); + final PacketGuiRepairScope repairScope = click.repairScope(topSize); -+ if (click.isOutsideClick()) { -+ runOnPlayer(session.player(), () -> handlePacketClick(session, click, repairScope)); -+ return; -+ } + -+ if (repairScope.fullWindow() && !(click.isPlayerInventoryClick(topSize) && click.isShiftClick())) { ++ // Fail closed: everything that is not an explicitly supported interaction is denied without running ++ // user callbacks. Two carve-outs are routed on purpose - genuine outside clicks (views use them for ++ // back navigation) and bottom-inventory shift-clicks (views observe them as entity-container clicks). ++ final boolean routed = click.isOutsideClick() ++ || (click.isPlayerInventoryClick(topSize) && click.isShiftClick()) ++ || !repairScope.fullWindow(); ++ if (!routed) { + requestRender(session, false, true); + return; + } @@ -2267,10 +2269,10 @@ index 0000000000000000000000000000000000000000..23049112f36d08a93d851f293fd1288e +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java new file mode 100644 -index 0000000000000000000000000000000000000000..7526b7afe435c3c0ae694a8a2c8345466e156348 +index 0000000000000000000000000000000000000000..79f9d50878de8ae5c091f17c8a7e92bc16acf2d7 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java -@@ -0,0 +1,202 @@ +@@ -0,0 +1,221 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientClickWindow; @@ -2279,6 +2281,7 @@ index 0000000000000000000000000000000000000000..7526b7afe435c3c0ae694a8a2c834546 +final class PacketGuiClick { + + private static final int OFFHAND_SWAP_BUTTON = 40; ++ private static final int OUTSIDE_SLOT = -999; + private static final int[] EMPTY_CHANGED_SLOTS = new int[0]; + + private final int windowId; @@ -2309,6 +2312,20 @@ index 0000000000000000000000000000000000000000..7526b7afe435c3c0ae694a8a2c834546 + changedSlots(packet)); + } + ++ /** ++ * Builds a click directly from its wire values. Only used by tests; the production path goes through ++ * {@link #from(WrapperPlayClientClickWindow)}. ++ */ ++ static PacketGuiClick of( ++ int windowId, ++ int slot, ++ int button, ++ WrapperPlayClientClickWindow.WindowClickType clickType, ++ int... changedSlots) { ++ return new PacketGuiClick( ++ windowId, slot, button, clickType, changedSlots == null ? EMPTY_CHANGED_SLOTS : changedSlots); ++ } ++ + int windowId() { + return windowId; + } @@ -2325,8 +2342,15 @@ index 0000000000000000000000000000000000000000..7526b7afe435c3c0ae694a8a2c834546 + return changedSlots; + } + ++ /** ++ * Whether this is a genuine click outside the window. ++ * ++ *

Only a plain pickup (mode 0, button 0 or 1) on slot -999 is an outside click. Drag (QUICK_CRAFT) and ++ * cursor-drop (THROW) packets carry slot -999 as well but are not user-visible clicks; treating them as ++ * outside clicks made a single drag run the click pipeline twice. ++ */ + boolean isOutsideClick() { -+ return slot < 0; ++ return slot == OUTSIDE_SLOT && isPickupClick(); + } + + boolean isSafeTopClick(int topSize) { @@ -2405,26 +2429,23 @@ index 0000000000000000000000000000000000000000..7526b7afe435c3c0ae694a8a2c834546 + return PacketInventoryConstants.HOTBAR_START + button; + } + ++ /** ++ * The Bukkit {@link org.bukkit.event.inventory.ClickType} name for this click. ++ * ++ *

Always the name of a real enum constant, so consumers can call {@code ClickType.valueOf(...)} on it ++ * exactly as they can with the Bukkit backend. PacketEvents' own protocol names must never leak here. ++ */ + String clickIdentifier() { -+ if (isPickupClick()) { -+ if (button == 0) return "LEFT"; -+ if (button == 1) return "RIGHT"; -+ } -+ -+ if (isQuickMoveClick()) { -+ if (button == 0) return "SHIFT_LEFT"; -+ if (button == 1) return "SHIFT_RIGHT"; -+ } -+ -+ if (isSwapClick()) { -+ return button == OFFHAND_SWAP_BUTTON ? "SWAP_OFFHAND" : "NUMBER_KEY"; -+ } -+ -+ if (isCloneClick()) { -+ return "MIDDLE"; ++ if (isPickupClick()) return button == 0 ? "LEFT" : "RIGHT"; ++ if (isQuickMoveClick()) return button == 0 ? "SHIFT_LEFT" : "SHIFT_RIGHT"; ++ if (isSwapClick()) return button == OFFHAND_SWAP_BUTTON ? "SWAP_OFFHAND" : "NUMBER_KEY"; ++ if (isCloneClick()) return "MIDDLE"; ++ if (clickType == WrapperPlayClientClickWindow.WindowClickType.THROW) { ++ return button == 0 ? "DROP" : "CONTROL_DROP"; + } ++ if (clickType == WrapperPlayClientClickWindow.WindowClickType.PICKUP_ALL) return "DOUBLE_CLICK"; + -+ return clickType == null ? "UNKNOWN" : clickType.name(); ++ return "UNKNOWN"; + } + + private boolean isPickupClick() { @@ -4374,6 +4395,105 @@ index 6499f33961d5fbf084ca15f00a65b38d22e4fd0b..3f89ed26c49e0c94da9ea5bc27782675 final Component component = context.getComponent(); if (!(component instanceof ItemComponent) || !component.isVisible()) return; +diff --git a/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClickTest.java b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClickTest.java +new file mode 100644 +index 0000000000000000000000000000000000000000..fe656625c26fc2f0931141aa5e24bf5df236892b +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClickTest.java +@@ -0,0 +1,93 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; ++import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.junit.jupiter.api.Assertions.assertFalse; ++import static org.junit.jupiter.api.Assertions.assertTrue; ++ ++import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientClickWindow.WindowClickType; ++import org.bukkit.event.inventory.ClickType; ++import org.junit.jupiter.api.Test; ++ ++class PacketGuiClickTest { ++ ++ private static final int TOP_SIZE = 27; ++ private static final int OUTSIDE = -999; ++ ++ @Test ++ void classifiesSupportedTopClicks() { ++ assertEquals( ++ PacketGuiRepairScope.TOP_SLOT_AND_CURSOR, ++ PacketGuiClick.of(1, 0, 0, WindowClickType.PICKUP).repairScope(TOP_SIZE)); ++ assertEquals( ++ PacketGuiRepairScope.TOP_SLOT_AND_CURSOR, ++ PacketGuiClick.of(1, 0, 0, WindowClickType.CLONE).repairScope(TOP_SIZE)); ++ assertEquals( ++ PacketGuiRepairScope.TOP_SLOT_CURSOR_AND_PLAYER_SLOT, ++ PacketGuiClick.of(1, 0, 1, WindowClickType.QUICK_MOVE).repairScope(TOP_SIZE)); ++ assertEquals( ++ PacketGuiRepairScope.TOP_SLOT_CURSOR_AND_PLAYER_SLOT, ++ PacketGuiClick.of(1, 0, 3, WindowClickType.SWAP).repairScope(TOP_SIZE)); ++ assertEquals( ++ PacketGuiRepairScope.TOP_SLOT_CURSOR_AND_OFFHAND, ++ PacketGuiClick.of(1, 0, 40, WindowClickType.SWAP).repairScope(TOP_SIZE)); ++ } ++ ++ @Test ++ void treatsOnlyPickupOnSlotMinus999AsOutsideClick() { ++ assertTrue(PacketGuiClick.of(1, OUTSIDE, 0, WindowClickType.PICKUP).isOutsideClick()); ++ assertTrue(PacketGuiClick.of(1, OUTSIDE, 1, WindowClickType.PICKUP).isOutsideClick()); ++ assertFalse(PacketGuiClick.of(1, OUTSIDE, 2, WindowClickType.PICKUP).isOutsideClick()); ++ assertFalse(PacketGuiClick.of(1, 0, 0, WindowClickType.PICKUP).isOutsideClick()); ++ } ++ ++ @Test ++ void deniesEveryNonPickupClickOnSlotMinus999() { ++ for (final WindowClickType type : WindowClickType.values()) { ++ for (final int button : new int[] {0, 1, 2, 4, 5, 6, 8, 9, 10, 40}) { ++ final PacketGuiClick click = PacketGuiClick.of(1, OUTSIDE, button, type); ++ final boolean pickup = type == WindowClickType.PICKUP && (button == 0 || button == 1); ++ final String label = type + "/" + button; ++ ++ assertEquals(pickup, click.isOutsideClick(), "isOutsideClick for " + label); ++ ++ if (!pickup) { ++ assertEquals( ++ PacketGuiRepairScope.FULL_WINDOW, ++ click.repairScope(TOP_SIZE), ++ "repairScope for " + label); ++ } ++ } ++ } ++ } ++ ++ @Test ++ void deniesDragThrowAndDoubleClickOnRealSlots() { ++ for (final WindowClickType type : ++ new WindowClickType[] {WindowClickType.QUICK_CRAFT, WindowClickType.THROW, WindowClickType.PICKUP_ALL ++ }) { ++ for (final int button : new int[] {0, 1, 2, 4, 8, 10}) { ++ assertEquals( ++ PacketGuiRepairScope.FULL_WINDOW, ++ PacketGuiClick.of(1, 0, button, type).repairScope(TOP_SIZE), ++ "top slot " + type + "/" + button); ++ assertEquals( ++ PacketGuiRepairScope.FULL_WINDOW, ++ PacketGuiClick.of(1, TOP_SIZE + 4, button, type).repairScope(TOP_SIZE), ++ "bottom slot " + type + "/" + button); ++ } ++ } ++ } ++ ++ @Test ++ void neverLeaksNonBukkitClickIdentifiers() { ++ for (final WindowClickType type : WindowClickType.values()) { ++ for (int button = -1; button <= 41; button++) { ++ final String identifier = PacketGuiClick.of(1, 0, button, type).clickIdentifier(); ++ assertDoesNotThrow( ++ () -> ClickType.valueOf(identifier), ++ "identifier '" + identifier + "' is not a Bukkit ClickType (" + type + "/" + button + ")"); ++ } ++ } ++ } ++} diff --git a/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstantsTest.java b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstantsTest.java new file mode 100644 index 0000000000000000000000000000000000000000..6b7b81adbc1430fe023c82a742ab5005fa01b64e From eb03cb7dbb0d05688ed3ba15e9e06b9af5cd967e Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:23:16 +0200 Subject: [PATCH 22/50] fix(packet): guard window ids and close real menus on open --- ...0006-Add-internal-packet-GUI-backend.patch | 168 ++++++++++++++++-- 1 file changed, 153 insertions(+), 15 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index c1dc961..18d7d01 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1011,10 +1011,10 @@ index 0000000000000000000000000000000000000000..a2f72b8c7cb581fb4b0c252a7595d98b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..4288622baab670cec914570308efa67138ead488 +index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc4508729d911025 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1252 @@ +@@ -0,0 +1,1285 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1049,6 +1049,8 @@ index 0000000000000000000000000000000000000000..4288622baab670cec914570308efa671 +import org.bukkit.Bukkit; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; ++import org.bukkit.event.inventory.InventoryType; ++import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.plugin.Plugin; @@ -1056,7 +1058,6 @@ index 0000000000000000000000000000000000000000..4288622baab670cec914570308efa671 + +public final class PacketGuiBackend implements GuiBackend { + -+ private static final int MAX_WINDOW_ID = 127; + private static final String CLOSE_ORIGIN_CLIENT = "packet-gui-client-close"; + private static final String CLOSE_ORIGIN_SERVER = "packet-gui-server-close"; + private static final String CLOSE_ORIGIN_QUIT = "packet-gui-player-quit"; @@ -1212,19 +1213,55 @@ index 0000000000000000000000000000000000000000..4288622baab670cec914570308efa671 + return; + } + -+ final PacketGuiSession previous = sessions.get(player.getUniqueId()); ++ final PacketViewerInventory viewerInventory = inventoryFor(player.getUniqueId()); ++ final int externalWindowId = viewerInventory.openWindowId(); ++ ++ // A real server-side menu must not stay open behind the fake window: the client would render the GUI ++ // while the server keeps ticking the real container, and closing the GUI would never release it. ++ closeRealInventory(player); ++ ++ final PacketGuiSession session = new PacketGuiSession( ++ viewer, ++ user, ++ PacketGuiWindowIds.allocate(nextWindowId, externalWindowId), ++ container, ++ viewerInventory); ++ ++ // Publish before tearing down the previous session: closeSession runs the CLOSE pipeline, i.e. ++ // arbitrary developer code that may re-enter open(). The conditional remove inside closeSession keeps ++ // it from evicting this session. ++ final PacketGuiSession previous = sessions.put(player.getUniqueId(), session); + if (previous != null) { -+ closeSession(previous, false, CLOSE_ORIGIN_SERVER, true, true); ++ try { ++ closeSession(previous, false, CLOSE_ORIGIN_SERVER, true, true); ++ } catch (final RuntimeException exception) { ++ owner.getLogger().log(Level.WARNING, "Failed to close the previous packet GUI session", exception); ++ } + } + -+ final PacketViewerInventory viewerInventory = inventoryFor(player.getUniqueId()); ++ if (sessions.get(player.getUniqueId()) != session) { ++ // A close handler opened another view; that session owns the window now. Drop ours without running ++ // its close pipeline - it was never opened - and without sending a close packet. ++ closeSession(session, false, CLOSE_ORIGIN_SERVER, false, false); ++ return; ++ } + -+ final PacketGuiSession session = -+ new PacketGuiSession(viewer, user, allocateWindowId(), container, viewerInventory); -+ sessions.put(player.getUniqueId(), session); + renderSession(session, true, true); + } + ++ /** ++ * Finalizes a real server-side inventory before a packet GUI takes over the client's window. ++ */ ++ private void closeRealInventory(Player player) { ++ final InventoryView open = player.getOpenInventory(); ++ final InventoryType type = open == null ? null : open.getType(); ++ if (type == null || type == InventoryType.CRAFTING || type == InventoryType.PLAYER) { ++ return; ++ } ++ ++ player.closeInventory(); ++ } ++ + void close(@NotNull PacketViewContainer container, boolean sendClosePacket) { + for (final PacketGuiSession session : List.copyOf(sessions.values())) { + if (session.container() == container) { @@ -2064,10 +2101,6 @@ index 0000000000000000000000000000000000000000..4288622baab670cec914570308efa671 + return session != null && !session.closed() && sessions.get(session.viewerId()) == session; + } + -+ private int allocateWindowId() { -+ return Math.max(1, nextWindowId.getAndUpdate(previous -> previous >= MAX_WINDOW_ID ? 1 : previous + 1)); -+ } -+ + private void runOnPlayer(Player player, Runnable task) { + if (isOnPlayerThread(player)) { + task.run(); @@ -3610,6 +3643,52 @@ index 0000000000000000000000000000000000000000..180b53e896495308f79c1ed717bd2cbf + } + } +} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIds.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIds.java +new file mode 100644 +index 0000000000000000000000000000000000000000..3552d4e9df03a9e574cd94461129301346da2195 +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIds.java +@@ -0,0 +1,40 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import java.util.concurrent.atomic.AtomicInteger; ++ ++/** ++ * Allocates the fake container ids used for packet GUI windows. ++ * ++ *

Vanilla hands out container ids for real menus from the same numeric range, and the client resolves ++ * window ownership by id alone. Reusing an id that a real container currently holds would make the client ++ * apply that container's updates to the GUI screen and route the player's clicks into the GUI pipeline, so ++ * the viewer's active external window id is always skipped. ++ */ ++final class PacketGuiWindowIds { ++ ++ static final int MAX_WINDOW_ID = 127; ++ ++ private PacketGuiWindowIds() {} ++ ++ /** ++ * Returns the next usable window id. ++ * ++ * @param counter The rolling id counter shared by all viewers. ++ * @param forbiddenWindowId The window id the viewer currently has open for a real container, or ++ * {@code 0} when no external window is tracked. ++ */ ++ static int allocate(AtomicInteger counter, int forbiddenWindowId) { ++ for (int attempt = 0; attempt < MAX_WINDOW_ID; attempt++) { ++ final int candidate = next(counter); ++ if (candidate != forbiddenWindowId) { ++ return candidate; ++ } ++ } ++ ++ return next(counter); ++ } ++ ++ private static int next(AtomicInteger counter) { ++ return Math.max(1, counter.getAndUpdate(previous -> previous >= MAX_WINDOW_ID ? 1 : previous + 1)); ++ } ++} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java new file mode 100644 index 0000000000000000000000000000000000000000..afc341fc07b9f309a53a9602f43c712b47d0cb07 @@ -4158,10 +4237,10 @@ index 0000000000000000000000000000000000000000..2a79e2d90b75a04436eda3c54780c226 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java new file mode 100644 -index 0000000000000000000000000000000000000000..fc2ec585cbb708f5c6ebff72c7c1b0000b987a31 +index 0000000000000000000000000000000000000000..bf9c47739306bb49d881663b934ec3d44837058d --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java -@@ -0,0 +1,163 @@ +@@ -0,0 +1,171 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.util.ArrayList; @@ -4273,6 +4352,14 @@ index 0000000000000000000000000000000000000000..fc2ec585cbb708f5c6ebff72c7c1b000 + return items; + } + ++ /** ++ * The window id of the real container the viewer currently has open, or ++ * {@link PacketInventoryConstants#PLAYER_WINDOW_ID} when none is tracked. ++ */ ++ synchronized int openWindowId() { ++ return openWindowId; ++ } ++ + synchronized void setOpenWindow(int windowId, int topSize) { + openWindowId = windowId; + openWindowTopSize = topSize; @@ -4494,6 +4581,57 @@ index 0000000000000000000000000000000000000000..fe656625c26fc2f0931141aa5e24bf5d + } + } +} +diff --git a/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIdsTest.java b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIdsTest.java +new file mode 100644 +index 0000000000000000000000000000000000000000..7716d3a45577823a7728f8bfe4eb0150bd757af7 +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIdsTest.java +@@ -0,0 +1,45 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.junit.jupiter.api.Assertions.assertNotEquals; ++import static org.junit.jupiter.api.Assertions.assertTrue; ++ ++import java.util.concurrent.atomic.AtomicInteger; ++import org.junit.jupiter.api.Test; ++ ++class PacketGuiWindowIdsTest { ++ ++ @Test ++ void handsOutConsecutiveIdsWhenNothingIsForbidden() { ++ final AtomicInteger counter = new AtomicInteger(1); ++ assertEquals(1, PacketGuiWindowIds.allocate(counter, 0)); ++ assertEquals(2, PacketGuiWindowIds.allocate(counter, 0)); ++ assertEquals(3, PacketGuiWindowIds.allocate(counter, 0)); ++ } ++ ++ @Test ++ void skipsTheWindowIdTheViewerAlreadyHasOpen() { ++ final AtomicInteger counter = new AtomicInteger(5); ++ assertEquals(6, PacketGuiWindowIds.allocate(counter, 5)); ++ } ++ ++ @Test ++ void neverReturnsTheForbiddenIdAcrossTheWholeRange() { ++ final AtomicInteger counter = new AtomicInteger(1); ++ for (int i = 0; i < PacketGuiWindowIds.MAX_WINDOW_ID * 2; i++) { ++ assertNotEquals(42, PacketGuiWindowIds.allocate(counter, 42)); ++ } ++ } ++ ++ @Test ++ void rollsOverAtTheMaximumAndNeverReturnsZero() { ++ final AtomicInteger counter = new AtomicInteger(PacketGuiWindowIds.MAX_WINDOW_ID); ++ assertEquals(PacketGuiWindowIds.MAX_WINDOW_ID, PacketGuiWindowIds.allocate(counter, 0)); ++ assertEquals(1, PacketGuiWindowIds.allocate(counter, 0)); ++ ++ final AtomicInteger fresh = new AtomicInteger(1); ++ for (int i = 0; i < PacketGuiWindowIds.MAX_WINDOW_ID * 3; i++) { ++ assertTrue(PacketGuiWindowIds.allocate(fresh, 0) >= 1); ++ } ++ } ++} diff --git a/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstantsTest.java b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstantsTest.java new file mode 100644 index 0000000000000000000000000000000000000000..6b7b81adbc1430fe023c82a742ab5005fa01b64e From 443e77d5cce625e39db5235c99664fbb18ec26d1 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:26:10 +0200 Subject: [PATCH 23/50] fix(packet): probe native sender capabilities instead of pinning one MC version --- ...0006-Add-internal-packet-GUI-backend.patch | 118 +++++++++++++++--- 1 file changed, 99 insertions(+), 19 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 18d7d01..9c25571 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -2529,10 +2529,10 @@ index 0000000000000000000000000000000000000000..79f9d50878de8ae5c091f17c8a7e92bc +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java new file mode 100644 -index 0000000000000000000000000000000000000000..fbebf59371b8e4a9cb757d045dd48671af45a769 +index 0000000000000000000000000000000000000000..358fcab62856c545272bc3c371c36c9df2209fcd --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java -@@ -0,0 +1,425 @@ +@@ -0,0 +1,505 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.lang.reflect.Constructor; @@ -2541,7 +2541,6 @@ index 0000000000000000000000000000000000000000..fbebf59371b8e4a9cb757d045dd48671 +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; -+import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.bukkit.entity.Player; @@ -2549,7 +2548,7 @@ index 0000000000000000000000000000000000000000..fbebf59371b8e4a9cb757d045dd48671 + +final class PacketGuiNativeOutboundSender { + -+ private static final Set SUPPORTED_MINECRAFT_VERSIONS = Set.of("26.1.2"); ++ private static final String NATIVE_PROPERTY = "inventory-framework.gui-backend.native"; + + private final Class packetClass; + private final Class nmsItemStackClass; @@ -2557,6 +2556,8 @@ index 0000000000000000000000000000000000000000..fbebf59371b8e4a9cb757d045dd48671 + private final Method asNmsCopy; + private final Method getHandle; + private final Constructor containerSetContentConstructor; ++ private final boolean containerSetContentTakesNonNullList; ++ private final Method nonNullListWithSize; + private final Constructor containerSetSlotConstructor; + private final Constructor setCursorItemConstructor; + private final Constructor setPlayerInventoryConstructor; @@ -2570,9 +2571,24 @@ index 0000000000000000000000000000000000000000..fbebf59371b8e4a9cb757d045dd48671 + this.emptyItemStack = staticField(nmsItemStackClass, "EMPTY").get(null); + this.asNmsCopy = craftItemStackClass().getMethod("asNMSCopy", ItemStack.class); + this.getHandle = craftClass("entity.CraftPlayer").getMethod("getHandle"); -+ this.containerSetContentConstructor = Class.forName( -+ "net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket") -+ .getConstructor(int.class, int.class, List.class, nmsItemStackClass); ++ // Vanilla's public constructor takes NonNullList on most versions and a plain List on others. ++ // Class#getConstructor matches parameter types exactly, so both shapes are probed. ++ final Class containerSetContent = ++ Class.forName("net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket"); ++ final Class nonNullListClass = optionalClass("net.minecraft.core.NonNullList"); ++ Constructor setContent = nonNullListClass == null ++ ? null ++ : optionalConstructor( ++ containerSetContent, int.class, int.class, nonNullListClass, nmsItemStackClass); ++ final boolean takesNonNullList = setContent != null; ++ if (setContent == null) { ++ setContent = containerSetContent.getConstructor(int.class, int.class, List.class, nmsItemStackClass); ++ } ++ this.containerSetContentConstructor = setContent; ++ this.containerSetContentTakesNonNullList = takesNonNullList; ++ this.nonNullListWithSize = ++ takesNonNullList ? nonNullListClass.getMethod("withSize", int.class, Object.class) : null; ++ + this.containerSetSlotConstructor = Class.forName( + "net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket") + .getConstructor(int.class, int.class, int.class, nmsItemStackClass); @@ -2593,21 +2609,19 @@ index 0000000000000000000000000000000000000000..fbebf59371b8e4a9cb757d045dd48671 + final String bukkitVersion = bukkitVersion(); + final String serverPackage = serverPackage(); + -+ if (!SUPPORTED_MINECRAFT_VERSIONS.contains(minecraftVersion)) { ++ if ("off".equalsIgnoreCase(System.getProperty(NATIVE_PROPERTY, "on"))) { + return InitializationResult.unavailable( + minecraftVersion, + bukkitVersion, + serverPackage, -+ "Supported native packet GUI Minecraft versions: " -+ + SUPPORTED_MINECRAFT_VERSIONS -+ + "; detected " -+ + minecraftVersion -+ + "."); ++ "The native packet GUI outbound sender was disabled with -D" + NATIVE_PROPERTY + "=off."); + } + + try { ++ final PacketGuiNativeOutboundSender sender = new PacketGuiNativeOutboundSender(); ++ sender.selfCheck(); + return InitializationResult.available( -+ new PacketGuiNativeOutboundSender(), ++ sender, + minecraftVersion, + bukkitVersion, + serverPackage, @@ -2617,25 +2631,75 @@ index 0000000000000000000000000000000000000000..fbebf59371b8e4a9cb757d045dd48671 + minecraftVersion, + bukkitVersion, + serverPackage, -+ "Native packet GUI outbound sender could not be initialized for Minecraft " ++ "The server's packet classes do not match what the native packet GUI outbound sender " ++ + "expects on Minecraft " + + minecraftVersion + + ".", + exception); + } + } + ++ /** ++ * Builds one of every packet this sender emits, without sending anything. ++ * ++ *

Support is decided by capability, not by a version string: a signature mismatch in the server's ++ * packet classes surfaces here, at startup, instead of on the first GUI open. ++ */ ++ private void selfCheck() { ++ final List probeItems = new ArrayList<>(9); ++ for (int slot = 0; slot < 9; slot++) { ++ probeItems.add(null); ++ } ++ ++ construct(containerSetContentConstructor, 1, 1, nmsItemList(probeItems), emptyItemStack); ++ construct(containerSetSlotConstructor, 1, 1, 0, emptyItemStack); ++ if (setCursorItemConstructor != null) { ++ construct(setCursorItemConstructor, emptyItemStack); ++ } ++ if (setPlayerInventoryConstructor != null) { ++ construct(setPlayerInventoryConstructor, 0, emptyItemStack); ++ } ++ } ++ + void sendContainerSetContent( + Player player, + int windowId, + int stateId, + List items, + ItemStack cursor) { -+ final List nmsItems = new ArrayList<>(items.size()); -+ for (final ItemStack item : items) { -+ nmsItems.add(toNmsItem(item)); ++ send( ++ player, ++ construct( ++ containerSetContentConstructor, ++ windowId, ++ stateId, ++ nmsItemList(items), ++ toNmsItem(cursor))); ++ } ++ ++ /** ++ * Converts the items into the list type the resolved packet constructor expects. ++ */ ++ private Object nmsItemList(List items) { ++ if (!containerSetContentTakesNonNullList) { ++ final List nmsItems = new ArrayList<>(items.size()); ++ for (final ItemStack item : items) { ++ nmsItems.add(toNmsItem(item)); ++ } ++ return nmsItems; + } + -+ send(player, construct(containerSetContentConstructor, windowId, stateId, nmsItems, toNmsItem(cursor))); ++ try { ++ @SuppressWarnings("unchecked") ++ final List nmsItems = ++ (List) nonNullListWithSize.invoke(null, items.size(), emptyItemStack); ++ for (int slot = 0; slot < items.size(); slot++) { ++ nmsItems.set(slot, toNmsItem(items.get(slot))); ++ } ++ return nmsItems; ++ } catch (final ReflectiveOperationException exception) { ++ throw new IllegalStateException("Failed to build a native item list", exception); ++ } + } + + void sendContainerSetSlot(Player player, int windowId, int stateId, int slot, ItemStack item) { @@ -2797,6 +2861,22 @@ index 0000000000000000000000000000000000000000..fbebf59371b8e4a9cb757d045dd48671 + } + } + ++ private static Class optionalClass(String className) { ++ try { ++ return Class.forName(className); ++ } catch (final ClassNotFoundException ignored) { ++ return null; ++ } ++ } ++ ++ private static Constructor optionalConstructor(Class type, Class... parameterTypes) { ++ try { ++ return type.getConstructor(parameterTypes); ++ } catch (final NoSuchMethodException ignored) { ++ return null; ++ } ++ } ++ + private static Constructor optionalConstructor(String className, Class... parameterTypes) { + try { + return Class.forName(className).getConstructor(parameterTypes); From e29ff4bb37984b6a71c628f1f2d4617360dce206 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:31:57 +0200 Subject: [PATCH 24/50] fix(packet): harden session lifecycle and scheduling --- ...0006-Add-internal-packet-GUI-backend.patch | 363 +++++++++++++----- 1 file changed, 270 insertions(+), 93 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 9c25571..f016c3b 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -112,7 +112,7 @@ index 16e4791981a4fd159659190b2ac3e1a961e28e90..78c704d7457a6fe653346e5d10037292 @Override diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java -index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..199757af2e76e5ffecbc8a1d2c2ca954f050938e 100644 +index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..182a0b071c6c563c52e2a75bb7b382c384efdf16 100644 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/IFInventoryListener.java @@ -5,6 +5,8 @@ import me.devnatan.inventoryframework.context.IFCloseContext; @@ -124,15 +124,19 @@ index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..199757af2e76e5ffecbc8a1d2c2ca954 import me.devnatan.inventoryframework.pipeline.StandardPipelinePhases; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; -@@ -13,6 +15,7 @@ import org.bukkit.event.Listener; +@@ -13,8 +15,11 @@ import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; import org.bukkit.event.inventory.InventoryType; ++import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.player.PlayerDropItemEvent; ++import org.bukkit.event.player.PlayerRespawnEvent; import org.bukkit.event.player.PlayerPickupItemEvent; -@@ -24,9 +27,15 @@ import org.bukkit.inventory.PlayerInventory; + import org.bukkit.event.player.PlayerQuitEvent; + import org.bukkit.event.server.PluginDisableEvent; +@@ -24,9 +29,15 @@ import org.bukkit.inventory.PlayerInventory; final class IFInventoryListener implements Listener { private final ViewFrame viewFrame; @@ -148,7 +152,7 @@ index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..199757af2e76e5ffecbc8a1d2c2ca954 } @EventHandler -@@ -39,6 +48,8 @@ final class IFInventoryListener implements Listener { +@@ -39,6 +50,8 @@ final class IFInventoryListener implements Listener { @EventHandler public void onPlayerQuit(PlayerQuitEvent event) { final Player player = (Player) event.getPlayer(); @@ -157,7 +161,7 @@ index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..199757af2e76e5ffecbc8a1d2c2ca954 final Viewer viewer = viewFrame.getViewer(player); if (viewer == null) return; -@@ -49,6 +60,13 @@ final class IFInventoryListener implements Listener { +@@ -49,6 +62,23 @@ final class IFInventoryListener implements Listener { root.getPipeline().execute(StandardPipelinePhases.CLOSE, closeContext); } @@ -167,6 +171,16 @@ index e7d2c7a0b3eb58341695ee8111d31473c76b0aed..199757af2e76e5ffecbc8a1d2c2ca954 + + guiBackend.handleExternalInventoryOpen((Player) event.getPlayer()); + } ++ ++ @EventHandler(priority = EventPriority.MONITOR) ++ public void onPlayerChangedWorld(final PlayerChangedWorldEvent event) { ++ guiBackend.handleWorldChange(event.getPlayer()); ++ } ++ ++ @EventHandler(priority = EventPriority.MONITOR) ++ public void onPlayerRespawn(final PlayerRespawnEvent event) { ++ guiBackend.handleWorldChange(event.getPlayer()); ++ } + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) public void onInventoryClick(final InventoryClickEvent event) { @@ -896,10 +910,10 @@ index 0000000000000000000000000000000000000000..d4e57da254cdf676539e0ee3ab3862b7 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..b7efdbc7bce0e7f68d83abd93aeace52279326bb +index 0000000000000000000000000000000000000000..1ebc23498c0bf82552da3ef8c78d433385ceb688 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackend.java -@@ -0,0 +1,34 @@ +@@ -0,0 +1,44 @@ +package me.devnatan.inventoryframework.internal; + +import me.devnatan.inventoryframework.ViewContainer; @@ -930,6 +944,16 @@ index 0000000000000000000000000000000000000000..b7efdbc7bce0e7f68d83abd93aeace52 + return false; + } + ++ /** ++ * Finalizes any GUI the viewer has open because they changed world or respawned. The client drops its ++ * screen in both cases without sending a close packet. ++ * ++ * @return {@code true} when a session was finalized by this backend. ++ */ ++ default boolean handleWorldChange(@NotNull Player player) { ++ return false; ++ } ++ + default boolean isPacketBackend() { + return false; + } @@ -1011,10 +1035,10 @@ index 0000000000000000000000000000000000000000..a2f72b8c7cb581fb4b0c252a7595d98b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc4508729d911025 +index 0000000000000000000000000000000000000000..47d33671e613f6b2f584ef4a0a61af6820a47ba4 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1285 @@ +@@ -0,0 +1,1430 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1027,6 +1051,7 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; ++import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; @@ -1063,15 +1088,24 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 + private static final String CLOSE_ORIGIN_QUIT = "packet-gui-player-quit"; + private static final String CLOSE_ORIGIN_EXTERNAL_OPEN = "packet-gui-external-inventory-open"; + private static final String CLOSE_ORIGIN_SHUTDOWN = "packet-gui-shutdown"; ++ private static final String CLOSE_ORIGIN_WORLD_CHANGE = "packet-gui-world-change"; ++ ++ private static final Method PLAYER_GET_SCHEDULER = optionalMethod(Player.class, "getScheduler"); ++ private static final Method BUKKIT_IS_OWNED_BY_CURRENT_REGION = ++ optionalMethod(Bukkit.class, "isOwnedByCurrentRegion", Entity.class); ++ private static final ConcurrentMap, Method> SCHEDULER_RUN = new ConcurrentHashMap<>(); ++ private static final ConcurrentMap, Method> SCHEDULER_RUN_DELAYED = new ConcurrentHashMap<>(); + + private final Plugin owner; + private final BukkitGuiBackend fallbackBackend; + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); + private final ConcurrentMap viewerInventories = new ConcurrentHashMap<>(); + private final AtomicInteger nextWindowId = new AtomicInteger(1); -+ private PacketGuiNativeOutboundSender nativeOutbound; ++ private final Set reportedUnsupportedTypes = ConcurrentHashMap.newKeySet(); ++ private volatile PacketGuiNativeOutboundSender nativeOutbound; + private PacketListenerCommon listener; + private volatile boolean available = true; ++ private volatile boolean regionOwnershipFailureLogged; + + public PacketGuiBackend(@NotNull Plugin owner, @NotNull BukkitGuiBackend fallbackBackend) { + this.owner = owner; @@ -1084,14 +1118,18 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 + @NotNull ViewType type, + int size, + Object title) { -+ if (!available || !(context instanceof IFRenderContext)) { ++ if (!available || nativeOutbound == null || !(context instanceof IFRenderContext)) { + return fallbackBackend.createContainer(context, type, size, title); + } + + if (!ViewType.CHEST.equals(type) || (size != 0 && size % type.getColumns() != 0)) { -+ owner.getLogger() -+ .fine("Packet GUI backend currently supports chest-style containers only. " -+ + "Using Bukkit backend for " + type.getIdentifier() + "."); ++ if (reportedUnsupportedTypes.add(type.getIdentifier())) { ++ owner.getLogger() ++ .info("[IF] GUI backend: " + type.getIdentifier() ++ + " containers are not supported by the packet backend and keep using real " ++ + "Bukkit inventory items. Only chest-style containers are rendered with fake " ++ + "packet items."); ++ } + return fallbackBackend.createContainer(context, type, size, title); + } + @@ -1154,24 +1192,26 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 + + @Override + public void unregister() { ++ // Stop handing out packet containers before anything else, otherwise an in-flight async open job can ++ // still install a session into the maps this method is about to clear. ++ available = false; ++ ++ if (listener != null) { ++ try { ++ PacketEvents.getAPI().getEventManager().unregisterListener(listener); ++ } catch (final RuntimeException exception) { ++ owner.getLogger().log(Level.WARNING, "Failed to unregister packet GUI listener", exception); ++ } finally { ++ listener = null; ++ } ++ } ++ + for (final PacketGuiSession session : List.copyOf(sessions.values())) { + closeSession(session, false, CLOSE_ORIGIN_SHUTDOWN, false, false); + } + sessions.clear(); + viewerInventories.clear(); + nativeOutbound = null; -+ -+ if (listener == null) { -+ return; -+ } -+ -+ try { -+ PacketEvents.getAPI().getEventManager().unregisterListener(listener); -+ } catch (final RuntimeException exception) { -+ owner.getLogger().log(Level.WARNING, "Failed to unregister packet GUI listener", exception); -+ } finally { -+ listener = null; -+ } + } + + @Override @@ -1179,9 +1219,11 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 + final PacketGuiSession session = sessions.get(player.getUniqueId()); + if (session == null) return false; + -+ closeSession(session, false, CLOSE_ORIGIN_QUIT, true, true); ++ // The return value decides whether IFInventoryListener skips its own quit cleanup, so it has to reflect ++ // whether this backend really finalized the session. ++ final boolean closed = closeSession(session, false, CLOSE_ORIGIN_QUIT, true, true); + viewerInventories.remove(player.getUniqueId()); -+ return true; ++ return closed; + } + + @Override @@ -1189,8 +1231,15 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 + final PacketGuiSession session = sessions.get(player.getUniqueId()); + if (session == null) return false; + -+ closeSession(session, false, CLOSE_ORIGIN_EXTERNAL_OPEN, true, true); -+ return true; ++ return closeSession(session, false, CLOSE_ORIGIN_EXTERNAL_OPEN, true, true); ++ } ++ ++ @Override ++ public boolean handleWorldChange(@NotNull Player player) { ++ final PacketGuiSession session = sessions.get(player.getUniqueId()); ++ if (session == null) return false; ++ ++ return closeSession(session, true, CLOSE_ORIGIN_WORLD_CHANGE, true, true); + } + + @Override @@ -1370,14 +1419,17 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 + final PacketGuiSession session = sessions.get(user.getUUID()); + if (isTracked(session)) { + session.closeRequested(true); -+ runOnPlayer( -+ session.player(), -+ () -> closeSession( -+ session, -+ false, -+ CLOSE_ORIGIN_SERVER, -+ true, -+ false)); ++ runOnPlayer(session.player(), () -> { ++ final Player player = session.player(); ++ if (!closeSession(session, false, CLOSE_ORIGIN_SERVER, true, false)) { ++ return; ++ } ++ ++ // The client keeps its locally predicted stacks for the window's bottom rows. Resync the real ++ // inventory one tick later, so the container-0 content packet arrives after the client is back ++ // in its own inventory menu and does not get discarded. ++ runOnPlayerNextTick(player, player::updateInventory); ++ }); + return; + } + @@ -1469,18 +1521,25 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 + } + + runOnPlayer(session.player(), () -> { -+ if (!isTracked(session) || session.closeRequested()) { ++ final PacketGuiNativeOutboundSender sender = nativeOutbound; ++ if (sender == null || !isTracked(session) || session.closeRequested()) { + return; + } + -+ final int stateId = session.nextStateId(); -+ for (int index = 0; index < guiSlots.size(); index++) { -+ nativeOutbound.sendContainerSetSlot( -+ session.player(), -+ session.windowId(), -+ stateId, -+ guiSlots.get(index), -+ cloneItem(playerInventoryItem(session.player(), playerSlots.get(index)))); ++ try { ++ final int stateId = session.nextStateId(); ++ for (int index = 0; index < guiSlots.size(); index++) { ++ sender.sendContainerSetSlot( ++ session.player(), ++ session.windowId(), ++ stateId, ++ guiSlots.get(index), ++ cloneItem(playerInventoryItem(session.player(), playerSlots.get(index)))); ++ } ++ } catch (final RuntimeException exception) { ++ owner.getLogger() ++ .log(Level.WARNING, "Failed to mirror the player inventory into a packet GUI", exception); ++ closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true); + } + }); + } @@ -1498,16 +1557,23 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 + } + + runOnPlayer(session.player(), () -> { -+ if (!isTracked(session) || session.closeRequested()) { ++ final PacketGuiNativeOutboundSender sender = nativeOutbound; ++ if (sender == null || !isTracked(session) || session.closeRequested()) { + return; + } + -+ nativeOutbound.sendContainerSetSlot( -+ session.player(), -+ session.windowId(), -+ session.nextStateId(), -+ guiSlot, -+ cloneItem(playerInventoryItem(session.player(), playerWindowSlot))); ++ try { ++ sender.sendContainerSetSlot( ++ session.player(), ++ session.windowId(), ++ session.nextStateId(), ++ guiSlot, ++ cloneItem(playerInventoryItem(session.player(), playerWindowSlot))); ++ } catch (final RuntimeException exception) { ++ owner.getLogger() ++ .log(Level.WARNING, "Failed to mirror a player inventory slot into a packet GUI", exception); ++ closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true); ++ } + }); + } + @@ -1933,7 +1999,12 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 + } + + private void sendCursor(PacketGuiSession session) { -+ nativeOutbound.sendCursor(session.player(), cloneItem(session.player().getItemOnCursor())); ++ final PacketGuiNativeOutboundSender sender = nativeOutbound; ++ if (sender == null) { ++ return; ++ } ++ ++ sender.sendCursor(session.player(), cloneItem(session.player().getItemOnCursor())); + } + + private void addCursor(PacketGuiConversionPlan conversionPlan, ItemStack cursorSnapshot) { @@ -2011,7 +2082,8 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 + } + + private void sendRenderPlanNow(PacketGuiSession session, PacketGuiSendPlan plan) { -+ if (!canSendPlan(session, plan)) { ++ final PacketGuiNativeOutboundSender sender = nativeOutbound; ++ if (sender == null || !canSendPlan(session, plan)) { + return; + } + @@ -2021,7 +2093,7 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 + return; + } + -+ packet.send(nativeOutbound, session.player()); ++ packet.send(sender, session.player()); + } + } catch (final RuntimeException exception) { + owner.getLogger().log(Level.WARNING, "Failed to send native packet GUI render", exception); @@ -2111,6 +2183,14 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 + return; + } + ++ if (PLAYER_GET_SCHEDULER != null) { ++ // The entity scheduler exists but refused the task, which only happens once the player's scheduler ++ // is retired. Falling back to the global scheduler would run GUI work on the wrong thread, so the ++ // session is discarded instead. ++ abandonSession(player); ++ return; ++ } ++ + Bukkit.getScheduler().runTask(owner, task); + } + @@ -2119,54 +2199,122 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 + return; + } + ++ if (PLAYER_GET_SCHEDULER != null) { ++ abandonSession(player); ++ return; ++ } ++ + Bukkit.getScheduler().runTask(owner, task); + } + + private boolean tryRunEntityScheduler(Player player, Runnable task) { -+ final Object scheduler; -+ try { -+ final Method getScheduler = player.getClass().getMethod("getScheduler"); -+ scheduler = getScheduler.invoke(player); -+ } catch (final NoSuchMethodException ignored) { ++ final Object scheduler = entityScheduler(player); ++ if (scheduler == null) { + return false; -+ } catch (final ReflectiveOperationException exception) { -+ throw new IllegalStateException("Failed to access packet GUI player scheduler", exception); + } + -+ try { -+ final Method run = scheduler.getClass().getMethod("run", Plugin.class, Consumer.class, Runnable.class); -+ final Consumer scheduledTaskConsumer = ignored -> task.run(); -+ run.invoke(scheduler, owner, scheduledTaskConsumer, null); -+ return true; -+ } catch (final NoSuchMethodException ignored) { -+ throw new IllegalStateException("Player scheduler does not expose a run method"); -+ } catch (final ReflectiveOperationException exception) { -+ throw new IllegalStateException("Failed to schedule packet GUI task on the player scheduler", exception); ++ final Method run = schedulerMethod( ++ SCHEDULER_RUN, scheduler.getClass(), "run", Plugin.class, Consumer.class, Runnable.class); ++ if (run == null) { ++ return false; + } ++ ++ return invokeScheduler(run, scheduler, owner, consumer(task), retired(player)); + } + + private boolean tryRunEntitySchedulerDelayed(Player player, Runnable task) { -+ final Object scheduler; -+ try { -+ final Method getScheduler = player.getClass().getMethod("getScheduler"); -+ scheduler = getScheduler.invoke(player); -+ } catch (final NoSuchMethodException ignored) { ++ final Object scheduler = entityScheduler(player); ++ if (scheduler == null) { + return false; ++ } ++ ++ final Method runDelayed = schedulerMethod( ++ SCHEDULER_RUN_DELAYED, ++ scheduler.getClass(), ++ "runDelayed", ++ Plugin.class, ++ Consumer.class, ++ Runnable.class, ++ long.class); ++ if (runDelayed == null) { ++ return tryRunEntityScheduler(player, task); ++ } ++ ++ return invokeScheduler(runDelayed, scheduler, owner, consumer(task), retired(player), 1L); ++ } ++ ++ private Object entityScheduler(Player player) { ++ if (PLAYER_GET_SCHEDULER == null) { ++ return null; ++ } ++ ++ try { ++ return PLAYER_GET_SCHEDULER.invoke(player); + } catch (final ReflectiveOperationException exception) { -+ throw new IllegalStateException("Failed to access packet GUI player scheduler", exception); ++ owner.getLogger().log(Level.WARNING, "Failed to access the packet GUI player scheduler", exception); ++ return null; + } ++ } + ++ private Method schedulerMethod( ++ ConcurrentMap, Method> cache, ++ Class schedulerClass, ++ String name, ++ Class... parameterTypes) { ++ final Method cached = cache.get(schedulerClass); ++ if (cached != null) { ++ return cached; ++ } ++ ++ final Method resolved = optionalMethod(schedulerClass, name, parameterTypes); ++ if (resolved != null) { ++ cache.putIfAbsent(schedulerClass, resolved); ++ } ++ return resolved; ++ } ++ ++ /** ++ * Invokes a scheduler method and reports whether the task was actually accepted. Folia returns ++ * {@code null} from {@code run}/{@code runDelayed} when the entity scheduler is retired; ignoring that ++ * silently drops the task and latches the session's scheduled-render flag forever. ++ */ ++ private boolean invokeScheduler(Method method, Object scheduler, Object... arguments) { + try { -+ final Method runDelayed = -+ scheduler.getClass().getMethod("runDelayed", Plugin.class, Consumer.class, Runnable.class, long.class); -+ final Consumer scheduledTaskConsumer = ignored -> task.run(); -+ runDelayed.invoke(scheduler, owner, scheduledTaskConsumer, null, 1L); -+ return true; -+ } catch (final NoSuchMethodException ignored) { -+ return tryRunEntityScheduler(player, task); ++ return method.invoke(scheduler, arguments) != null; + } catch (final ReflectiveOperationException exception) { -+ throw new IllegalStateException("Failed to schedule packet GUI task on the player scheduler", exception); ++ owner.getLogger().log(Level.WARNING, "Failed to schedule a packet GUI task", exception); ++ return false; ++ } ++ } ++ ++ private static Consumer consumer(Runnable task) { ++ return ignored -> task.run(); ++ } ++ ++ private Runnable retired(Player player) { ++ return () -> abandonSession(player); ++ } ++ ++ /** ++ * Drops a session whose viewer can no longer be scheduled on. No close packet and no close pipeline: the ++ * client is already gone or has been moved, and running developer code here would be off-thread. ++ */ ++ private void abandonSession(Player player) { ++ final PacketGuiSession session = sessions.remove(player.getUniqueId()); ++ if (session == null) { ++ return; + } ++ ++ synchronized (session) { ++ session.invalidatePendingSends(); ++ session.closeRequested(true); ++ session.closed(true); ++ } ++ session.clearScheduledRender(); ++ session.viewerInventory().resetOpenWindow(); ++ owner.getLogger() ++ .fine("Discarded packet GUI session for " + player.getName() ++ + ": the viewer's scheduler is no longer accepting tasks."); + } + + private boolean isOnPlayerThread(Player player) { @@ -2179,13 +2327,34 @@ index 0000000000000000000000000000000000000000..944642b518fe77718837d046dc450872 + } + + private Boolean isOwnedByCurrentRegion(Player player) { ++ if (BUKKIT_IS_OWNED_BY_CURRENT_REGION == null) { ++ return null; ++ } ++ + try { -+ final Method method = Bukkit.class.getMethod("isOwnedByCurrentRegion", Entity.class); -+ return Boolean.TRUE.equals(method.invoke(null, player)); ++ return Boolean.TRUE.equals(BUKKIT_IS_OWNED_BY_CURRENT_REGION.invoke(null, player)); ++ } catch (final ReflectiveOperationException exception) { ++ if (!regionOwnershipFailureLogged) { ++ regionOwnershipFailureLogged = true; ++ owner.getLogger() ++ .log( ++ Level.WARNING, ++ "Failed to query region ownership; falling back to the primary thread check.", ++ exception); ++ } ++ // null makes the caller fall back to Bukkit#isPrimaryThread instead of permanently claiming ++ // "wrong thread", which used to make every render reschedule itself forever. ++ return null; ++ } ++ } ++ ++ private static Method optionalMethod(Class owner, String name, Class... parameterTypes) { ++ try { ++ final Method method = owner.getMethod(name, parameterTypes); ++ method.setAccessible(true); ++ return method; + } catch (final NoSuchMethodException ignored) { + return null; -+ } catch (final ReflectiveOperationException ignored) { -+ return Boolean.FALSE; + } + } + @@ -3297,10 +3466,10 @@ index 0000000000000000000000000000000000000000..bfe5e483593193cc8e62027a4e2684e0 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java new file mode 100644 -index 0000000000000000000000000000000000000000..180b53e896495308f79c1ed717bd2cbf306a7b27 +index 0000000000000000000000000000000000000000..d1936ea5e42a894eca52d28ebc7a0dae57b1c084 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java -@@ -0,0 +1,422 @@ +@@ -0,0 +1,430 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.protocol.player.User; @@ -3467,6 +3636,14 @@ index 0000000000000000000000000000000000000000..180b53e896495308f79c1ed717bd2cbf + return request; + } + ++ /** ++ * Releases the scheduled-render latch without producing a request. Used when the task that was supposed to ++ * consume the request can never run, so a later {@link #scheduleRender} is not swallowed. ++ */ ++ synchronized void clearScheduledRender() { ++ renderScheduled = false; ++ } ++ + synchronized com.github.retrooper.packetevents.protocol.item.ItemStack packetItem(PacketGuiRender render, int slot) { + final PacketItemSnapshot snapshot = packetItemSnapshot(render, slot); + if (snapshot.cached()) { From d73af8c2a9888b2325b53069ef7fc12295f734d0 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:37:08 +0200 Subject: [PATCH 25/50] fix(packet): align packet click API with the Bukkit backend --- ...0006-Add-internal-packet-GUI-backend.patch | 270 +++++++++++++++--- 1 file changed, 235 insertions(+), 35 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index f016c3b..9ff271c 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -238,14 +238,15 @@ index 2d97a162afacb6aa9652d9d83079088761bbd35b..4dc75f811d1a888cee0851a70377ac76 // region Internals diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java new file mode 100644 -index 0000000000000000000000000000000000000000..7b40d1de04d9eda6ab89b9f8bc354f01b6736ba0 +index 0000000000000000000000000000000000000000..72d6166df08878e2e7a53864427ab4141728061a --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java -@@ -0,0 +1,100 @@ +@@ -0,0 +1,116 @@ +package me.devnatan.inventoryframework.context; + +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; ++import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; @@ -293,6 +294,21 @@ index 0000000000000000000000000000000000000000..7b40d1de04d9eda6ab89b9f8bc354f01 + } + + @Override ++ public @NotNull InventoryType.SlotType getSlotType() { ++ return event.getSlotType(); ++ } ++ ++ @Override ++ public @NotNull InventoryAction getAction() { ++ return event.getAction(); ++ } ++ ++ @Override ++ public int getHotbarButton() { ++ return event.getHotbarButton(); ++ } ++ ++ @Override + public boolean isLeftClick() { + return event.isLeftClick(); + } @@ -356,10 +372,10 @@ index d73ddb29859761e6505b001fb4d303f953cb99e3..686664b4211880e0dc7651cebda4e66c import org.jetbrains.annotations.UnmodifiableView; diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickEvent.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickEvent.java new file mode 100644 -index 0000000000000000000000000000000000000000..f25b9629c69a3b3b97a025010ff908bf28cabda3 +index 0000000000000000000000000000000000000000..4a65fe5083774dc33e9601abfbc89bd644a012b5 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickEvent.java -@@ -0,0 +1,83 @@ +@@ -0,0 +1,138 @@ +package me.devnatan.inventoryframework.context; + +import org.bukkit.entity.Player; @@ -397,10 +413,9 @@ index 0000000000000000000000000000000000000000..f25b9629c69a3b3b97a025010ff908bf + @NotNull ClickType click, + @NotNull SlotClickContext context, + @NotNull SlotClickOrigin origin) { -+ // A valid (real) view is required by the base constructor; slot 0 keeps the constructor's -+ // slot conversion safe across versions. All item access is overridden below so the view is -+ // never used to read or mutate inventory contents. -+ super(view, InventoryType.SlotType.CONTAINER, 0, click, InventoryAction.NOTHING); ++ // A real (non-null) view is required by the base constructor. Every accessor that would reach it is ++ // overridden below, either with data from the packet click or with an explicit failure. ++ super(view, origin.getSlotType(), origin.getRawSlot(), click, origin.getAction()); + this.context = context; + this.origin = origin; + } @@ -411,11 +426,13 @@ index 0000000000000000000000000000000000000000..f25b9629c69a3b3b97a025010ff908bf + } + + private static ClickType mapClickType(SlotClickOrigin origin) { -+ if (origin.isKeyboardClick()) return ClickType.NUMBER_KEY; -+ if (origin.isMiddleClick()) return ClickType.MIDDLE; -+ if (origin.isShiftClick()) return origin.isRightClick() ? ClickType.SHIFT_RIGHT : ClickType.SHIFT_LEFT; -+ if (origin.isRightClick()) return ClickType.RIGHT; -+ return ClickType.LEFT; ++ // The click identifier is always the name of a real Bukkit ClickType constant, so it carries drop and ++ // double-click cases that the boolean accessors cannot express. ++ try { ++ return ClickType.valueOf(origin.getClickIdentifier()); ++ } catch (final IllegalArgumentException ignored) { ++ return ClickType.UNKNOWN; ++ } + } + + @Override @@ -434,6 +451,60 @@ index 0000000000000000000000000000000000000000..f25b9629c69a3b3b97a025010ff908bf + } + + @Override ++ public int getRawSlot() { ++ return origin.getRawSlot(); ++ } ++ ++ @Override ++ public int getSlot() { ++ return origin.getRawSlot(); ++ } ++ ++ @Override ++ public @NotNull InventoryType.SlotType getSlotType() { ++ return origin.getSlotType(); ++ } ++ ++ @Override ++ public @NotNull InventoryAction getAction() { ++ return origin.getAction(); ++ } ++ ++ @Override ++ public int getHotbarButton() { ++ return origin.getHotbarButton(); ++ } ++ ++ /** ++ * @throws UnsupportedOperationException always — a packet GUI has no real inventory, and returning the ++ * player's own view here would let GUI display items be written into it. ++ */ ++ @Override ++ public @NotNull Inventory getInventory() { ++ throw new UnsupportedOperationException( ++ "A packet GUI has no real Bukkit Inventory. Use SlotClickContext#getClickedContainer instead."); ++ } ++ ++ /** ++ * @throws UnsupportedOperationException always — see {@link #getInventory()}. ++ */ ++ @Override ++ public @NotNull ItemStack getCursor() { ++ throw new UnsupportedOperationException( ++ "A packet GUI drives a virtual cursor. Reading the real server-side cursor is not supported."); ++ } ++ ++ /** ++ * @throws UnsupportedOperationException always — writing the real cursor would turn a GUI display item ++ * into a real, droppable item. ++ */ ++ @Override ++ public void setCursor(@Nullable ItemStack stack) { ++ throw new UnsupportedOperationException( ++ "A packet GUI drives a virtual cursor. Writing the real server-side cursor is not supported."); ++ } ++ ++ @Override + public boolean isCancelled() { + return context.isCancelled(); + } @@ -445,13 +516,15 @@ index 0000000000000000000000000000000000000000..f25b9629c69a3b3b97a025010ff908bf +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java new file mode 100644 -index 0000000000000000000000000000000000000000..f8414b68cc3a0ec537f013a65e583799965b84d1 +index 0000000000000000000000000000000000000000..4ee11f62518dbaf6e61c43d3ba5817249d330494 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java -@@ -0,0 +1,127 @@ +@@ -0,0 +1,154 @@ +package me.devnatan.inventoryframework.context; + +import org.bukkit.entity.Player; ++import org.bukkit.event.inventory.InventoryAction; ++import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; @@ -462,7 +535,9 @@ index 0000000000000000000000000000000000000000..f8414b68cc3a0ec537f013a65e583799 + + private final Player player; + private ItemStack currentItem; -+ private final Object platformEvent; ++ private final InventoryType.SlotType slotType; ++ private final InventoryAction action; ++ private final int hotbarButton; + private final int rawSlot; + private final String clickIdentifier; + private final boolean leftClick; @@ -472,12 +547,18 @@ index 0000000000000000000000000000000000000000..f8414b68cc3a0ec537f013a65e583799 + private final boolean keyboardClick; + private final boolean outsideClick; + private final boolean onEntityContainer; -+ private boolean cancelled = true; ++ ++ // Matches the Bukkit backend, where the context starts uncancelled. Fail-closed behaviour does not depend ++ // on this flag: the click packet itself is cancelled in PacketGuiPacketListener before the framework runs, ++ // so nothing vanilla can mutate regardless of what a handler does here. ++ private boolean cancelled; + + public PacketSlotClickOrigin( + @NotNull Player player, + @Nullable ItemStack currentItem, -+ Object platformEvent, ++ @NotNull InventoryType.SlotType slotType, ++ @NotNull InventoryAction action, ++ int hotbarButton, + int rawSlot, + @NotNull String clickIdentifier, + boolean leftClick, @@ -489,7 +570,9 @@ index 0000000000000000000000000000000000000000..f8414b68cc3a0ec537f013a65e583799 + boolean onEntityContainer) { + this.player = player; + this.currentItem = currentItem == null ? null : currentItem.clone(); -+ this.platformEvent = platformEvent; ++ this.slotType = slotType; ++ this.action = action; ++ this.hotbarButton = hotbarButton; + this.rawSlot = rawSlot; + this.clickIdentifier = clickIdentifier; + this.leftClick = leftClick; @@ -527,6 +610,21 @@ index 0000000000000000000000000000000000000000..f8414b68cc3a0ec537f013a65e583799 + } + + @Override ++ public @NotNull InventoryType.SlotType getSlotType() { ++ return slotType; ++ } ++ ++ @Override ++ public @NotNull InventoryAction getAction() { ++ return action; ++ } ++ ++ @Override ++ public int getHotbarButton() { ++ return hotbarButton; ++ } ++ ++ @Override + public boolean isLeftClick() { + return leftClick; + } @@ -577,11 +675,53 @@ index 0000000000000000000000000000000000000000..f8414b68cc3a0ec537f013a65e583799 + } +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java -index b89eae09cdfbbc5c01ef378071801f9add48af63..e1ccb7b9309017dbf69102f1511518247a66f85f 100644 +index b89eae09cdfbbc5c01ef378071801f9add48af63..992fbb75c57e056a275b5d48f0817a4ee9f02122 100644 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/RenderContext.java -@@ -169,6 +169,12 @@ public final class RenderContext extends PlatformRenderContext + * In the classic Bukkit inventory backend this is the real {@link InventoryClickEvent}. In the + * packet GUI backend, where no real Bukkit event exists, a lightweight compatibility event is -+ * synthesized lazily and returned instead; reading or mutating it (e.g. -+ * {@link InventoryClickEvent#setCurrentItem(org.bukkit.inventory.ItemStack)}) is routed back to -+ * the packet click origin and never touches a real server-side inventory. ++ * synthesized lazily. ++ *

++ * On that synthesized event {@code getCurrentItem}, {@code setCurrentItem}, {@code getClickedInventory}, ++ * {@code isCancelled}, {@code setCancelled}, {@code getRawSlot}, {@code getSlot}, {@code getSlotType}, ++ * {@code getAction}, {@code getHotbarButton} and {@code getClick} are served from the packet click, with ++ * {@code getAction} being a best-effort mapping. {@code getInventory()}, {@code getCursor()} and ++ * {@code setCursor(...)} throw {@link UnsupportedOperationException}: a packet GUI has no real inventory, ++ * and writing the real cursor would turn a GUI display item into a real one. + *

+ * The return type is kept as {@link InventoryClickEvent} for binary compatibility with existing + * consumers compiled against the classic backend. @@ -689,7 +834,7 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..d775d2ee670a09587aecd243ba664793 } /** -@@ -84,12 +114,12 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext +@@ -84,12 +119,13 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext @Override public final void setCancelled(boolean cancelled) { this.cancelled = cancelled; @@ -700,11 +845,12 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..d775d2ee670a09587aecd243ba664793 @Override public final Object getPlatformEvent() { - return clickOrigin; -+ return clickOrigin.getPlatformEvent(); ++ final Object platform = clickOrigin.getPlatformEvent(); ++ return platform instanceof InventoryClickEvent ? platform : getClickOrigin(); } @Override -@@ -99,42 +129,42 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext +@@ -99,42 +135,42 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext @Override public final boolean isLeftClick() { @@ -755,7 +901,7 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..d775d2ee670a09587aecd243ba664793 } @Override -@@ -176,4 +206,11 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext +@@ -176,4 +212,11 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext public final boolean isCombined() { return combined; } @@ -769,13 +915,15 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..d775d2ee670a09587aecd243ba664793 } diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java new file mode 100644 -index 0000000000000000000000000000000000000000..eb49a5a8ee944a53d7eb47528eb58e541a83d1b4 +index 0000000000000000000000000000000000000000..8046cc16d595b3eafebc38ecec3a99d871bddcd4 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java -@@ -0,0 +1,56 @@ +@@ -0,0 +1,81 @@ +package me.devnatan.inventoryframework.context; + +import org.bukkit.entity.Player; ++import org.bukkit.event.inventory.InventoryAction; ++import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.ApiStatus; @@ -808,6 +956,29 @@ index 0000000000000000000000000000000000000000..eb49a5a8ee944a53d7eb47528eb58e54 + + int getRawSlot(); + ++ /** ++ * The Bukkit slot type of the clicked slot. ++ */ ++ @NotNull ++ default InventoryType.SlotType getSlotType() { ++ throw new UnsupportedOperationException("getSlotType is not available for " + getClass().getName()); ++ } ++ ++ /** ++ * The Bukkit action this click represents. Best effort on platforms without a real Bukkit event. ++ */ ++ @NotNull ++ default InventoryAction getAction() { ++ throw new UnsupportedOperationException("getAction is not available for " + getClass().getName()); ++ } ++ ++ /** ++ * The hotbar slot index for a number-key click, or {@code -1} for every other click. ++ */ ++ default int getHotbarButton() { ++ return -1; ++ } ++ + boolean isLeftClick(); + + boolean isRightClick(); @@ -1035,10 +1206,10 @@ index 0000000000000000000000000000000000000000..a2f72b8c7cb581fb4b0c252a7595d98b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..47d33671e613f6b2f584ef4a0a61af6820a47ba4 +index 0000000000000000000000000000000000000000..c979a67ba880405a887526d7b98854d87f2c75de --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1430 @@ +@@ -0,0 +1,1459 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1074,6 +1245,7 @@ index 0000000000000000000000000000000000000000..47d33671e613f6b2f584ef4a0a61af68 +import org.bukkit.Bukkit; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; ++import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; @@ -1638,7 +1810,9 @@ index 0000000000000000000000000000000000000000..47d33671e613f6b2f584ef4a0a61af68 + final PacketSlotClickOrigin origin = new PacketSlotClickOrigin( + session.player(), + currentItem, -+ click, ++ slotTypeOf(click, topSize), ++ actionOf(click), ++ click.isKeyboardClick() && !click.isOffhandSwapClick() ? click.button() : -1, + click.slot(), + click.clickIdentifier(), + click.isLeftClick(), @@ -2348,6 +2522,32 @@ index 0000000000000000000000000000000000000000..47d33671e613f6b2f584ef4a0a61af68 + } + } + ++ private static InventoryType.SlotType slotTypeOf(PacketGuiClick click, int topSize) { ++ if (click.slot() < 0) { ++ return InventoryType.SlotType.OUTSIDE; ++ } ++ ++ if (click.slot() >= topSize + 27 && click.slot() < topSize + 36) { ++ return InventoryType.SlotType.QUICKBAR; ++ } ++ ++ return InventoryType.SlotType.CONTAINER; ++ } ++ ++ /** ++ * Best-effort mapping from the wire click to a Bukkit action. The packet protocol carries less information ++ * than Bukkit's action enum, so this is a label for consumers, not a contract. ++ */ ++ private static InventoryAction actionOf(PacketGuiClick click) { ++ if (click.isShiftClick()) return InventoryAction.MOVE_TO_OTHER_INVENTORY; ++ if (click.isKeyboardClick()) return InventoryAction.HOTBAR_SWAP; ++ if (click.isMiddleClick()) return InventoryAction.CLONE_STACK; ++ if (click.isLeftClick()) return InventoryAction.PICKUP_ALL; ++ if (click.isRightClick()) return InventoryAction.PICKUP_HALF; ++ ++ return InventoryAction.NOTHING; ++ } ++ + private static Method optionalMethod(Class owner, String name, Class... parameterTypes) { + try { + final Method method = owner.getMethod(name, parameterTypes); From a04ce3f2fbab9cbfcf8ea198b021e9ec021e6dae Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:49:18 +0200 Subject: [PATCH 26/50] refactor(packet): drop dead cache, dedupe slot mapping, keep click-event accessors usable Removes the packet-item cache in PacketGuiSession (154 lines), which had no caller since the native sender converts Bukkit items to NMS directly, along with PacketItemConverter#toBukkit, PacketGuiRender#rawBukkitItem, the one-argument PacketGuiRender#from, PacketGuiSession#currentRender and PacketViewContainer#changeBaseTitle. Moves the GUI-window slot mapping into PacketInventoryConstants so the four copies that existed become one, and makes applyPlayerInventoryItem symmetric with playerInventoryItem: armour and offhand can now be written, and an unmappable slot is logged instead of silently dropped. The native sender caches its send method against the Packet supertype, so a single cache entry serves all four send paths. forceNonItalic no longer recurses into child components; children inherit the root decoration, so rewriting them destroyed italics a view had set on purpose. Also reverts the throwing overrides of getInventory/getCursor/setCursor on the synthesized InventoryClickEvent. Keeping the event fully usable is deliberate so plugins written against the Bukkit backend need no rewrite. The accessors resolve against the player's own view; that is now stated in the javadoc together with a warning not to write through them from a packet GUI, instead of the previous claim that they were routed to the packet click origin. --- ...0006-Add-internal-packet-GUI-backend.patch | 499 ++++++------------ 1 file changed, 164 insertions(+), 335 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 9ff271c..32bbf3e 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -372,10 +372,10 @@ index d73ddb29859761e6505b001fb4d303f953cb99e3..686664b4211880e0dc7651cebda4e66c import org.jetbrains.annotations.UnmodifiableView; diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickEvent.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickEvent.java new file mode 100644 -index 0000000000000000000000000000000000000000..4a65fe5083774dc33e9601abfbc89bd644a012b5 +index 0000000000000000000000000000000000000000..0bcf210d460ef6ec23a92fc658904b852e8f50d2 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickEvent.java -@@ -0,0 +1,138 @@ +@@ -0,0 +1,118 @@ +package me.devnatan.inventoryframework.context; + +import org.bukkit.entity.Player; @@ -475,34 +475,14 @@ index 0000000000000000000000000000000000000000..4a65fe5083774dc33e9601abfbc89bd6 + return origin.getHotbarButton(); + } + -+ /** -+ * @throws UnsupportedOperationException always — a packet GUI has no real inventory, and returning the -+ * player's own view here would let GUI display items be written into it. -+ */ -+ @Override -+ public @NotNull Inventory getInventory() { -+ throw new UnsupportedOperationException( -+ "A packet GUI has no real Bukkit Inventory. Use SlotClickContext#getClickedContainer instead."); -+ } -+ -+ /** -+ * @throws UnsupportedOperationException always — see {@link #getInventory()}. -+ */ -+ @Override -+ public @NotNull ItemStack getCursor() { -+ throw new UnsupportedOperationException( -+ "A packet GUI drives a virtual cursor. Reading the real server-side cursor is not supported."); -+ } -+ -+ /** -+ * @throws UnsupportedOperationException always — writing the real cursor would turn a GUI display item -+ * into a real, droppable item. -+ */ -+ @Override -+ public void setCursor(@Nullable ItemStack stack) { -+ throw new UnsupportedOperationException( -+ "A packet GUI drives a virtual cursor. Writing the real server-side cursor is not supported."); -+ } ++ // getInventory(), getCursor(), setCursor() and getView() are deliberately NOT overridden: the whole point ++ // of this class is that consumers compiled against the Bukkit backend can keep calling InventoryClickEvent ++ // methods without a rewrite, so nothing here may throw. They resolve against the player's own view. ++ // ++ // Reading is harmless - in packet mode the server-side cursor is empty and the top inventory is the ++ // player's crafting grid. Writing through them (setCursor(...), getInventory().setItem(...)) does reach ++ // real server-side state and must not be used from a packet GUI handler; see the note on ++ // SlotClickContext#getClickOrigin(). + + @Override + public boolean isCancelled() { @@ -735,7 +715,7 @@ index b89eae09cdfbbc5c01ef378071801f9add48af63..992fbb75c57e056a275b5d48f0817a4e } } diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickContext.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickContext.java -index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..94ef0907fb4d553804c4687207b0e8b0318218c2 100644 +index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..95601e57489215ddf2641f413a690b91b8bcfa07 100644 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickContext.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickContext.java @@ -5,11 +5,8 @@ import me.devnatan.inventoryframework.ViewContainer; @@ -762,7 +742,7 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..94ef0907fb4d553804c4687207b0e8b0 @ApiStatus.Internal public SlotClickContext( -@@ -32,30 +30,67 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext +@@ -32,30 +30,72 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext @Nullable Component clickedComponent, @NotNull InventoryClickEvent clickOrigin, boolean combined) { @@ -810,10 +790,15 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..94ef0907fb4d553804c4687207b0e8b0 + *

+ * On that synthesized event {@code getCurrentItem}, {@code setCurrentItem}, {@code getClickedInventory}, + * {@code isCancelled}, {@code setCancelled}, {@code getRawSlot}, {@code getSlot}, {@code getSlotType}, -+ * {@code getAction}, {@code getHotbarButton} and {@code getClick} are served from the packet click, with -+ * {@code getAction} being a best-effort mapping. {@code getInventory()}, {@code getCursor()} and -+ * {@code setCursor(...)} throw {@link UnsupportedOperationException}: a packet GUI has no real inventory, -+ * and writing the real cursor would turn a GUI display item into a real one. ++ * {@code getAction}, {@code getHotbarButton} and {@code getClick} are served from the packet click and ++ * never touch a real server-side inventory; {@code getAction} is a best-effort mapping. ++ *

++ * The remaining inherited accessors are left intact so that code written for the Bukkit backend keeps ++ * working, but they resolve against the player's own inventory view, because a packet GUI has no real ++ * inventory of its own. Reading them is harmless. Writing through them - {@code setCursor(...)} or ++ * {@code getInventory().setItem(...)} - does reach real server-side state and would turn a GUI display ++ * item into a real, droppable one, so a packet GUI handler must not do that. Use ++ * {@link #getClickedContainer()} and {@code setCurrentItem} instead. + *

+ * The return type is kept as {@link InventoryClickEvent} for binary compatibility with existing + * consumers compiled against the classic backend. @@ -834,7 +819,7 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..94ef0907fb4d553804c4687207b0e8b0 } /** -@@ -84,12 +119,13 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext +@@ -84,12 +124,13 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext @Override public final void setCancelled(boolean cancelled) { this.cancelled = cancelled; @@ -850,7 +835,7 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..94ef0907fb4d553804c4687207b0e8b0 } @Override -@@ -99,42 +135,42 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext +@@ -99,42 +140,42 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext @Override public final boolean isLeftClick() { @@ -901,7 +886,7 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..94ef0907fb4d553804c4687207b0e8b0 } @Override -@@ -176,4 +212,11 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext +@@ -176,4 +217,11 @@ public class SlotClickContext extends SlotContext implements IFSlotClickContext public final boolean isCombined() { return combined; } @@ -1206,10 +1191,10 @@ index 0000000000000000000000000000000000000000..a2f72b8c7cb581fb4b0c252a7595d98b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..c979a67ba880405a887526d7b98854d87f2c75de +index 0000000000000000000000000000000000000000..48bd8555ef0a1fb41c724f40cfb3b896d97f4b4e --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1459 @@ +@@ -0,0 +1,1472 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1243,6 +1228,7 @@ index 0000000000000000000000000000000000000000..c979a67ba880405a887526d7b98854d8 +import me.devnatan.inventoryframework.internal.GuiBackend; +import me.devnatan.inventoryframework.pipeline.StandardPipelinePhases; +import org.bukkit.Bukkit; ++import org.bukkit.Material; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryAction; @@ -1750,17 +1736,7 @@ index 0000000000000000000000000000000000000000..c979a67ba880405a887526d7b98854d8 + } + + private static int mapPlayerWindowSlotToOpenGuiSlot(int topSize, int playerWindowSlot) { -+ if (playerWindowSlot >= PacketInventoryConstants.ITEMS_START -+ && playerWindowSlot < PacketInventoryConstants.HOTBAR_START) { -+ return topSize + (playerWindowSlot - PacketInventoryConstants.ITEMS_START); -+ } -+ -+ if (playerWindowSlot >= PacketInventoryConstants.HOTBAR_START -+ && playerWindowSlot < PacketInventoryConstants.HOTBAR_START + 9) { -+ return topSize + 27 + (playerWindowSlot - PacketInventoryConstants.HOTBAR_START); -+ } -+ -+ return -1; ++ return PacketInventoryConstants.playerWindowSlotToGuiContainerSlot(topSize, playerWindowSlot); + } + + void trackCursor(User user, com.github.retrooper.packetevents.protocol.item.ItemStack item) { @@ -1911,7 +1887,6 @@ index 0000000000000000000000000000000000000000..c979a67ba880405a887526d7b98854d8 + + final PacketGuiRender render = PacketGuiRender.from(session.container(), session.viewer().getId()); + final PacketGuiRender previous = session.appliedRender(); -+ session.currentRender(render); + final boolean reopen = forceReopen || !render.sameWindow(previous); + final boolean sendFullWindow = hardResync || reopen || previous == null; + @@ -2130,18 +2105,42 @@ index 0000000000000000000000000000000000000000..c979a67ba880405a887526d7b98854d8 + return item == null ? null : item.clone(); + } + -+ private static void applyPlayerInventoryItem(Player player, int playerWindowSlot, ItemStack item) { ++ /** ++ * Writes an item back into the viewer's real inventory. Kept symmetric with ++ * {@link #playerInventoryItem(Player, int)}: every slot that can be read can also be written, and a slot ++ * that cannot be mapped is reported instead of silently dropped. ++ */ ++ private void applyPlayerInventoryItem(Player player, int playerWindowSlot, ItemStack item) { + final PlayerInventory inventory = player.getInventory(); -+ if (playerWindowSlot >= PacketInventoryConstants.ITEMS_START -+ && playerWindowSlot < PacketInventoryConstants.HOTBAR_START) { -+ inventory.setItem(playerWindowSlot, item); -+ return; ++ switch (playerWindowSlot) { ++ case PacketInventoryConstants.SLOT_HELMET: ++ inventory.setHelmet(item); ++ return; ++ case PacketInventoryConstants.SLOT_CHESTPLATE: ++ inventory.setChestplate(item); ++ return; ++ case PacketInventoryConstants.SLOT_LEGGINGS: ++ inventory.setLeggings(item); ++ return; ++ case PacketInventoryConstants.SLOT_BOOTS: ++ inventory.setBoots(item); ++ return; ++ case PacketInventoryConstants.SLOT_OFFHAND: ++ inventory.setItemInOffHand(item == null ? new ItemStack(Material.AIR) : item); ++ return; ++ default: ++ break; + } + -+ if (playerWindowSlot >= PacketInventoryConstants.HOTBAR_START -+ && playerWindowSlot < PacketInventoryConstants.HOTBAR_START + 9) { -+ inventory.setItem(playerWindowSlot - PacketInventoryConstants.HOTBAR_START, item); ++ final int inventorySlot = PacketInventoryConstants.containerSlotToPlayerInventorySlot(playerWindowSlot); ++ if (inventorySlot < 0) { ++ owner.getLogger() ++ .warning("Refusing to write packet GUI player inventory slot " + playerWindowSlot ++ + ": the slot cannot be mapped to a Bukkit inventory index."); ++ return; + } ++ ++ inventory.setItem(inventorySlot, item); + } + + private static ItemStack playerInventoryItem(Player player, int playerWindowSlot) { @@ -2305,7 +2304,6 @@ index 0000000000000000000000000000000000000000..c979a67ba880405a887526d7b98854d8 + } + + sessions.remove(session.viewerId(), session); -+ session.clearPacketItemCache(); + session.viewerInventory().resetOpenWindow(); + if (sendClosePacket) { + try { @@ -2898,10 +2896,10 @@ index 0000000000000000000000000000000000000000..79f9d50878de8ae5c091f17c8a7e92bc +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java new file mode 100644 -index 0000000000000000000000000000000000000000..358fcab62856c545272bc3c371c36c9df2209fcd +index 0000000000000000000000000000000000000000..c3fc8e898b89e93e21d9a4ff24b8630f6849bd14 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java -@@ -0,0 +1,505 @@ +@@ -0,0 +1,475 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.lang.reflect.Constructor; @@ -2909,7 +2907,6 @@ index 0000000000000000000000000000000000000000..358fcab62856c545272bc3c371c36c9d +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; -+import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.bukkit.entity.Player; @@ -2931,7 +2928,7 @@ index 0000000000000000000000000000000000000000..358fcab62856c545272bc3c371c36c9d + private final Constructor setCursorItemConstructor; + private final Constructor setPlayerInventoryConstructor; + private final ConcurrentMap, Field> connectionFields = new ConcurrentHashMap<>(); -+ private final ConcurrentMap sendMethods = new ConcurrentHashMap<>(); ++ private final ConcurrentMap, Method> sendMethods = new ConcurrentHashMap<>(); + + private PacketGuiNativeOutboundSender() { + try { @@ -3129,7 +3126,7 @@ index 0000000000000000000000000000000000000000..358fcab62856c545272bc3c371c36c9d + private void send(Player player, Object packet) { + final Object handle = handle(player); + final Object connection = connection(handle); -+ final Method send = sendMethod(connection.getClass(), packet.getClass()); ++ final Method send = sendMethod(connection.getClass()); + try { + send.invoke(connection, packet); + } catch (final ReflectiveOperationException exception) { @@ -3186,9 +3183,11 @@ index 0000000000000000000000000000000000000000..358fcab62856c545272bc3c371c36c9d + throw new IllegalStateException("Failed to discover native player connection"); + } + -+ private Method sendMethod(Class connectionClass, Class concretePacketClass) { -+ return sendMethods.computeIfAbsent(new SendMethodKey(connectionClass, concretePacketClass), key -> { -+ final Method method = findSendMethod(key.connectionClass, key.concretePacketClass); ++ private Method sendMethod(Class connectionClass) { ++ return sendMethods.computeIfAbsent(connectionClass, type -> { ++ // Resolve against the Packet supertype so one cached method serves every packet this sender emits, ++ // instead of one entry per concrete packet class. ++ final Method method = findSendMethod(type, packetClass); + if (method == null) { + throw new IllegalStateException("Failed to find native player connection send method"); + } @@ -3275,37 +3274,6 @@ index 0000000000000000000000000000000000000000..358fcab62856c545272bc3c371c36c9d + throw new NoSuchFieldException(name); + } + -+ private static final class SendMethodKey { -+ -+ private final Class connectionClass; -+ private final Class concretePacketClass; -+ -+ private SendMethodKey(Class connectionClass, Class concretePacketClass) { -+ this.connectionClass = connectionClass; -+ this.concretePacketClass = concretePacketClass; -+ } -+ -+ @Override -+ public boolean equals(Object other) { -+ if (this == other) { -+ return true; -+ } -+ -+ if (!(other instanceof SendMethodKey)) { -+ return false; -+ } -+ -+ final SendMethodKey that = (SendMethodKey) other; -+ return connectionClass.equals(that.connectionClass) -+ && concretePacketClass.equals(that.concretePacketClass); -+ } -+ -+ @Override -+ public int hashCode() { -+ return Objects.hash(connectionClass, concretePacketClass); -+ } -+ } -+ + static final class InitializationResult { + + private final PacketGuiNativeOutboundSender sender; @@ -3537,10 +3505,10 @@ index 0000000000000000000000000000000000000000..89cf420ff84c58826d06cbe9d92c4f9b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java new file mode 100644 -index 0000000000000000000000000000000000000000..be445839c0fedf34bea198e2b3aa3d8a6780a7c9 +index 0000000000000000000000000000000000000000..7a3fda77476e1cdd99a01d98c7ca97f8d2b9be91 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java -@@ -0,0 +1,85 @@ +@@ -0,0 +1,71 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.util.Objects; @@ -3561,16 +3529,6 @@ index 0000000000000000000000000000000000000000..be445839c0fedf34bea198e2b3aa3d8a + this.topItems = topItems; + } + -+ static PacketGuiRender from(PacketViewContainer container) { -+ final ItemStack[] topItems = container.snapshotItems(); -+ final Object rawTitle = container.getRawTitle(null); -+ return new PacketGuiRender( -+ rawTitle, -+ titleComponent(rawTitle), -+ Math.max(1, container.getRowsCount()), -+ topItems); -+ } -+ + static PacketGuiRender from(PacketViewContainer container, String viewerId) { + final ItemStack[] topItems = container.snapshotItems(); + final Object rawTitle = container.getRawTitle(viewerId); @@ -3598,10 +3556,6 @@ index 0000000000000000000000000000000000000000..be445839c0fedf34bea198e2b3aa3d8a + return item == null ? null : item.clone(); + } + -+ ItemStack rawBukkitItem(int slot) { -+ return topItems[slot]; -+ } -+ + boolean sameWindow(PacketGuiRender other) { + return other != null && rows == other.rows && Objects.equals(rawTitle, other.rawTitle); + } @@ -3666,10 +3620,10 @@ index 0000000000000000000000000000000000000000..bfe5e483593193cc8e62027a4e2684e0 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java new file mode 100644 -index 0000000000000000000000000000000000000000..d1936ea5e42a894eca52d28ebc7a0dae57b1c084 +index 0000000000000000000000000000000000000000..96ae373865eef6117ae36003c3199b6d63e248f2 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java -@@ -0,0 +1,430 @@ +@@ -0,0 +1,265 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.protocol.player.User; @@ -3677,9 +3631,7 @@ index 0000000000000000000000000000000000000000..d1936ea5e42a894eca52d28ebc7a0dae +import java.util.UUID; +import me.devnatan.inventoryframework.BukkitViewer; +import me.devnatan.inventoryframework.context.IFRenderContext; -+import org.bukkit.Material; +import org.bukkit.entity.Player; -+import org.bukkit.inventory.ItemStack; + +final class PacketGuiSession { + @@ -3690,8 +3642,6 @@ index 0000000000000000000000000000000000000000..d1936ea5e42a894eca52d28ebc7a0dae + private final int windowId; + private final PacketViewContainer container; + private final PacketViewerInventory viewerInventory; -+ private CachedPacketItem[] topItemCache = new CachedPacketItem[0]; -+ private PacketGuiRender currentRender; + private PacketGuiRender appliedRender; + private boolean renderScheduled; + private boolean scheduledForceReopen; @@ -3752,14 +3702,6 @@ index 0000000000000000000000000000000000000000..d1936ea5e42a894eca52d28ebc7a0dae + return viewerInventory; + } + -+ synchronized PacketGuiRender currentRender() { -+ return currentRender; -+ } -+ -+ synchronized void currentRender(PacketGuiRender currentRender) { -+ this.currentRender = currentRender; -+ } -+ + synchronized PacketGuiRender appliedRender() { + return appliedRender; + } @@ -3844,55 +3786,6 @@ index 0000000000000000000000000000000000000000..d1936ea5e42a894eca52d28ebc7a0dae + renderScheduled = false; + } + -+ synchronized com.github.retrooper.packetevents.protocol.item.ItemStack packetItem(PacketGuiRender render, int slot) { -+ final PacketItemSnapshot snapshot = packetItemSnapshot(render, slot); -+ if (snapshot.cached()) { -+ return snapshot.cachedPacketItem(); -+ } -+ -+ final com.github.retrooper.packetevents.protocol.item.ItemStack packetItem = -+ PacketItemConverter.toPacket(snapshot.bukkitItem()); -+ cachePacketItem(snapshot, packetItem); -+ return PacketItemConverter.copy(packetItem); -+ } -+ -+ synchronized PacketItemSnapshot packetItemSnapshot(PacketGuiRender render, int slot) { -+ ensureTopItemCache(render.size()); -+ -+ final ItemStack item = render.rawBukkitItem(slot); -+ final CachedPacketItem cached = topItemCache[slot]; -+ if (cached != null && cached.matches(item)) { -+ return PacketItemSnapshot.cached(render.size(), slot, cached.packetItem); -+ } -+ -+ return PacketItemSnapshot.uncached(render.size(), slot, item); -+ } -+ -+ synchronized void cachePacketItem( -+ PacketItemSnapshot snapshot, com.github.retrooper.packetevents.protocol.item.ItemStack packetItem) { -+ if (snapshot == null || snapshot.cached()) { -+ return; -+ } -+ -+ ensureTopItemCache(snapshot.renderSize()); -+ if (snapshot.slot() < 0 || snapshot.slot() >= topItemCache.length) { -+ return; -+ } -+ -+ topItemCache[snapshot.slot()] = -+ new CachedPacketItem(snapshot.sourceReference(), snapshot.bukkitItem(), packetItem); -+ } -+ -+ synchronized void clearPacketItemCache() { -+ topItemCache = new CachedPacketItem[0]; -+ } -+ -+ private void ensureTopItemCache(int size) { -+ if (topItemCache.length != size) { -+ topItemCache = new CachedPacketItem[size]; -+ } -+ } -+ + private void scheduleTargetedRepairs(PacketGuiRepairScope scope, PacketGuiClick click) { + if (scope.repairsClickedPlayerSlot()) { + schedulePlayerSlotRepair( @@ -3995,110 +3888,6 @@ index 0000000000000000000000000000000000000000..d1936ea5e42a894eca52d28ebc7a0dae + return playerSlotRepairs; + } + } -+ -+ static final class PacketItemSnapshot { -+ -+ private final int renderSize; -+ private final int slot; -+ private final ItemStack sourceReference; -+ private final ItemStack bukkitItem; -+ private final boolean cached; -+ private final com.github.retrooper.packetevents.protocol.item.ItemStack cachedPacketItem; -+ -+ private PacketItemSnapshot( -+ int renderSize, -+ int slot, -+ ItemStack sourceReference, -+ ItemStack bukkitItem, -+ boolean cached, -+ com.github.retrooper.packetevents.protocol.item.ItemStack cachedPacketItem) { -+ this.renderSize = renderSize; -+ this.slot = slot; -+ this.sourceReference = sourceReference; -+ this.bukkitItem = bukkitItem; -+ this.cached = cached; -+ this.cachedPacketItem = PacketItemConverter.copy(cachedPacketItem); -+ } -+ -+ static PacketItemSnapshot cached( -+ int renderSize, -+ int slot, -+ com.github.retrooper.packetevents.protocol.item.ItemStack packetItem) { -+ return new PacketItemSnapshot(renderSize, slot, null, null, true, packetItem); -+ } -+ -+ static PacketItemSnapshot uncached(int renderSize, int slot, ItemStack item) { -+ return new PacketItemSnapshot( -+ renderSize, -+ slot, -+ item, -+ item == null ? null : item.clone(), -+ false, -+ com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY); -+ } -+ -+ int renderSize() { -+ return renderSize; -+ } -+ -+ int slot() { -+ return slot; -+ } -+ -+ ItemStack sourceReference() { -+ return sourceReference; -+ } -+ -+ ItemStack bukkitItem() { -+ return bukkitItem; -+ } -+ -+ boolean cached() { -+ return cached; -+ } -+ -+ com.github.retrooper.packetevents.protocol.item.ItemStack cachedPacketItem() { -+ return PacketItemConverter.copy(cachedPacketItem); -+ } -+ } -+ -+ private static final class CachedPacketItem { -+ -+ private final ItemStack sourceReference; -+ private final ItemStack bukkitItem; -+ private final Material type; -+ private final int amount; -+ private final com.github.retrooper.packetevents.protocol.item.ItemStack packetItem; -+ -+ private CachedPacketItem( -+ ItemStack sourceReference, -+ ItemStack bukkitItem, -+ com.github.retrooper.packetevents.protocol.item.ItemStack packetItem) { -+ this.sourceReference = sourceReference; -+ this.bukkitItem = bukkitItem == null ? null : bukkitItem.clone(); -+ this.type = PacketItemConverter.isEmpty(bukkitItem) ? Material.AIR : bukkitItem.getType(); -+ this.amount = PacketItemConverter.isEmpty(bukkitItem) ? 0 : bukkitItem.getAmount(); -+ this.packetItem = PacketItemConverter.copy(packetItem); -+ } -+ -+ private boolean matches(ItemStack item) { -+ if (item == sourceReference) { -+ return true; -+ } -+ -+ if (PacketItemConverter.isEmpty(item) && PacketItemConverter.isEmpty(bukkitItem)) { -+ return true; -+ } -+ -+ if (PacketItemConverter.isEmpty(item) || PacketItemConverter.isEmpty(bukkitItem)) { -+ return false; -+ } -+ -+ return item.getType() == type -+ && item.getAmount() == amount -+ && PacketItemConverter.sameDisplayItem(bukkitItem, item); -+ } -+ } +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIds.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIds.java new file mode 100644 @@ -4148,10 +3937,10 @@ index 0000000000000000000000000000000000000000..3552d4e9df03a9e574cd944611293013 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java new file mode 100644 -index 0000000000000000000000000000000000000000..afc341fc07b9f309a53a9602f43c712b47d0cb07 +index 0000000000000000000000000000000000000000..e43f86319e18629837065af50a00b55afebaaa33 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java -@@ -0,0 +1,59 @@ +@@ -0,0 +1,94 @@ +package me.devnatan.inventoryframework.internal.packet; + +final class PacketInventoryConstants { @@ -4191,6 +3980,41 @@ index 0000000000000000000000000000000000000000..afc341fc07b9f309a53a9602f43c712b + } + } + ++ /** ++ * Maps a slot index of an open chest-style GUI window to the corresponding player-container slot. ++ * ++ *

A chest window is laid out as {@code topSize} container slots, then the 27 main inventory slots, then ++ * the 9 hotbar slots. ++ * ++ * @return The player-container slot, or {@code -1} when the index is not part of the player section. ++ */ ++ static int guiContainerSlotToPlayerWindowSlot(int topSize, int containerSlot) { ++ final int relativeSlot = containerSlot - topSize; ++ if (relativeSlot < 0) return -1; ++ if (relativeSlot < 27) return ITEMS_START + relativeSlot; ++ if (relativeSlot < 36) return HOTBAR_START + (relativeSlot - 27); ++ ++ return -1; ++ } ++ ++ /** ++ * The inverse of {@link #guiContainerSlotToPlayerWindowSlot(int, int)}. ++ * ++ * @return The GUI window slot, or {@code -1} for slots that are not visible in the window (armour, offhand ++ * and the crafting grid). ++ */ ++ static int playerWindowSlotToGuiContainerSlot(int topSize, int playerWindowSlot) { ++ if (playerWindowSlot >= ITEMS_START && playerWindowSlot < HOTBAR_START) { ++ return topSize + (playerWindowSlot - ITEMS_START); ++ } ++ ++ if (playerWindowSlot >= HOTBAR_START && playerWindowSlot < HOTBAR_START + 9) { ++ return topSize + 27 + (playerWindowSlot - HOTBAR_START); ++ } ++ ++ return -1; ++ } ++ + static int containerSlotToPlayerInventorySlot(int containerSlot) { + if (containerSlot >= ITEMS_START && containerSlot < HOTBAR_START) return containerSlot; + if (containerSlot >= HOTBAR_START && containerSlot < HOTBAR_START + 9) return containerSlot - HOTBAR_START; @@ -4213,10 +4037,10 @@ index 0000000000000000000000000000000000000000..afc341fc07b9f309a53a9602f43c712b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java new file mode 100644 -index 0000000000000000000000000000000000000000..826c90cd7aa87ba859946727b973b178c1338380 +index 0000000000000000000000000000000000000000..36d89dbbe2e6dfccc773206b109bfac77e5e0bd2 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java -@@ -0,0 +1,224 @@ +@@ -0,0 +1,217 @@ +package me.devnatan.inventoryframework.internal.packet; + +import io.github.retrooper.packetevents.util.SpigotConversionUtil; @@ -4250,14 +4074,6 @@ index 0000000000000000000000000000000000000000..826c90cd7aa87ba859946727b973b178 + : converted.copy(); + } + -+ static ItemStack toBukkit(com.github.retrooper.packetevents.protocol.item.ItemStack item) { -+ if (item == null || item.isEmpty()) { -+ return null; -+ } -+ -+ return SpigotConversionUtil.toBukkitItemStack(item.copy()); -+ } -+ + static com.github.retrooper.packetevents.protocol.item.ItemStack copy( + com.github.retrooper.packetevents.protocol.item.ItemStack item) { + return item == null || item.isEmpty() @@ -4427,26 +4243,27 @@ index 0000000000000000000000000000000000000000..826c90cd7aa87ba859946727b973b178 + } + } + ++ /** ++ * Marks a component as explicitly non-italic so Minecraft does not apply its default italic styling to item ++ * names and lore. ++ * ++ *

Only the root style is touched, and only when it does not already carry an explicit decision: children ++ * inherit the root's decoration, so rewriting them would destroy italics a view set on purpose. ++ */ + private static Component forceNonItalic(Component component) { -+ Component normalized = component.decoration(TextDecoration.ITALIC, TextDecoration.State.FALSE); -+ if (normalized.children().isEmpty()) { -+ return normalized; ++ if (component.decoration(TextDecoration.ITALIC) != TextDecoration.State.NOT_SET) { ++ return component; + } + -+ final List children = normalized.children(); -+ final List normalizedChildren = new ArrayList<>(children.size()); -+ for (final Component child : children) { -+ normalizedChildren.add(forceNonItalic(child)); -+ } -+ return normalized.children(normalizedChildren); ++ return component.decoration(TextDecoration.ITALIC, TextDecoration.State.FALSE); + } +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java new file mode 100644 -index 0000000000000000000000000000000000000000..2a79e2d90b75a04436eda3c54780c226886d2501 +index 0000000000000000000000000000000000000000..390fed3cabff5e3872e7eaedd211c8f7e8400cd7 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java -@@ -0,0 +1,245 @@ +@@ -0,0 +1,236 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.util.Map; @@ -4499,7 +4316,7 @@ index 0000000000000000000000000000000000000000..2a79e2d90b75a04436eda3c54780c226 + + ItemStack[] snapshotItems() { + synchronized (topItems) { -+ // Items are cloned on write; preserving references lets the packet cache skip unchanged slots. ++ // Items are cloned on write, so handing out the array itself is safe. + return topItems.clone(); + } + } @@ -4644,15 +4461,6 @@ index 0000000000000000000000000000000000000000..2a79e2d90b75a04436eda3c54780c226 + } + } + -+ void changeBaseTitle(@Nullable Object title) { -+ if (Objects.equals(this.title, title)) { -+ return; -+ } -+ -+ this.title = title; -+ backend.requestResync(this); -+ } -+ + @Override + public boolean isEntityContainer() { + return false; @@ -4694,10 +4502,10 @@ index 0000000000000000000000000000000000000000..2a79e2d90b75a04436eda3c54780c226 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java new file mode 100644 -index 0000000000000000000000000000000000000000..bf9c47739306bb49d881663b934ec3d44837058d +index 0000000000000000000000000000000000000000..49c1df0bc324fb4d7f4e71b40dc198ee968a0d54 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java -@@ -0,0 +1,171 @@ +@@ -0,0 +1,158 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.util.ArrayList; @@ -4846,20 +4654,7 @@ index 0000000000000000000000000000000000000000..bf9c47739306bb49d881663b934ec3d4 + } + + static int mapGuiContainerSlotToPlayerSlot(int topSize, int containerSlot) { -+ final int relativeSlot = containerSlot - topSize; -+ if (relativeSlot < 0) { -+ return -1; -+ } -+ -+ if (relativeSlot < 27) { -+ return PacketInventoryConstants.ITEMS_START + relativeSlot; -+ } -+ -+ if (relativeSlot < 36) { -+ return PacketInventoryConstants.HOTBAR_START + (relativeSlot - 27); -+ } -+ -+ return -1; ++ return PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(topSize, containerSlot); + } + + private void appendRange(List items, int sourceStart, int amount) { @@ -5091,10 +4886,10 @@ index 0000000000000000000000000000000000000000..7716d3a45577823a7728f8bfe4eb0150 +} diff --git a/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstantsTest.java b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstantsTest.java new file mode 100644 -index 0000000000000000000000000000000000000000..6b7b81adbc1430fe023c82a742ab5005fa01b64e +index 0000000000000000000000000000000000000000..662e20f77195cda3a67631e605c9ec6078f44283 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstantsTest.java -@@ -0,0 +1,53 @@ +@@ -0,0 +1,87 @@ +package me.devnatan.inventoryframework.internal.packet; + +import static org.junit.jupiter.api.Assertions.assertEquals; @@ -5147,6 +4942,40 @@ index 0000000000000000000000000000000000000000..6b7b81adbc1430fe023c82a742ab5005 + "round trip failed for player inventory slot " + slot); + } + } ++ ++ @Test ++ void mapsGuiContainerSlotsToPlayerWindowSlots() { ++ assertEquals(9, PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(27, 27)); ++ assertEquals(35, PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(27, 53)); ++ assertEquals(36, PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(27, 54)); ++ assertEquals(44, PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(27, 62)); ++ assertEquals(-1, PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(27, 26)); ++ assertEquals(-1, PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(27, 63)); ++ } ++ ++ @Test ++ void mapsPlayerWindowSlotsBackToGuiContainerSlots() { ++ assertEquals(27, PacketInventoryConstants.playerWindowSlotToGuiContainerSlot(27, 9)); ++ assertEquals(53, PacketInventoryConstants.playerWindowSlotToGuiContainerSlot(27, 35)); ++ assertEquals(54, PacketInventoryConstants.playerWindowSlotToGuiContainerSlot(27, 36)); ++ assertEquals(62, PacketInventoryConstants.playerWindowSlotToGuiContainerSlot(27, 44)); ++ assertEquals(-1, PacketInventoryConstants.playerWindowSlotToGuiContainerSlot(27, 5)); ++ assertEquals(-1, PacketInventoryConstants.playerWindowSlotToGuiContainerSlot(27, 45)); ++ } ++ ++ @Test ++ void roundTripsGuiAndPlayerWindowSlotsForEveryChestSize() { ++ for (final int topSize : new int[] {9, 18, 27, 36, 45, 54}) { ++ for (int guiSlot = topSize; guiSlot < topSize + 36; guiSlot++) { ++ final int playerWindowSlot = ++ PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(topSize, guiSlot); ++ assertEquals( ++ guiSlot, ++ PacketInventoryConstants.playerWindowSlotToGuiContainerSlot(topSize, playerWindowSlot), ++ "round trip failed for topSize " + topSize + " gui slot " + guiSlot); ++ } ++ } ++ } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 05585a2bd587532365bfe98563ecc18047386144..f26ceb55f94d93d4eaf30706bf82998bab48f278 100644 From f154a75b9b967aae774a1f8fa75ef99ce146b568 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:59:26 +0200 Subject: [PATCH 27/50] style(packet): log levels, ApiStatus, formatting and docs --- docs/packet-gui-backend.md | 108 ++++++++ ...0006-Add-internal-packet-GUI-backend.patch | 257 +++++++++++++++--- 2 files changed, 327 insertions(+), 38 deletions(-) create mode 100644 docs/packet-gui-backend.md diff --git a/docs/packet-gui-backend.md b/docs/packet-gui-backend.md new file mode 100644 index 0000000..c775dfd --- /dev/null +++ b/docs/packet-gui-backend.md @@ -0,0 +1,108 @@ +# Packet GUI Backend + +An optional rendering backend that draws GUI contents as virtual packet items instead of placing real items +into a Bukkit inventory. The public InventoryFramework API is unchanged; the classic Bukkit backend stays +available and is still the default. + +## Activation + +The backend is opt-in via a JVM system property: + +``` +-Dinventory-framework.gui-backend=packet +``` + +Requirements: + +- **PacketEvents must be installed and initialized before InventoryFramework.** It is declared as a + `softdepend`. If it is missing or not yet initialized, the framework logs a warning and uses the Bukkit + backend. +- The server's packet classes must match what the native outbound sender expects (see below). + +To force the native outbound sender off without disabling packet mode entirely — useful for narrowing down a +suspected packet problem — start with: + +``` +-Dinventory-framework.gui-backend.native=off +``` + +That makes the sender report itself unavailable, which in turn falls the whole packet backend back to Bukkit +inventories. + +## How availability is decided + +There is no Minecraft version allowlist. On startup `PacketGuiNativeOutboundSender.initialize()`: + +1. resolves every NMS class, constructor, field and method it needs, +2. probes both shapes of the `ClientboundContainerSetContentPacket` constructor (`NonNullList` and `List`), +3. runs a **self-check** that actually constructs one of every packet it will ever send — container content, + container slot, cursor and player inventory slot — without sending anything. + +Only if all three succeed is packet mode enabled. Any mismatch produces a single warning naming the detected +Minecraft version, and every GUI silently keeps using real Bukkit inventory items. + +Startup log lines to look for: + +- `Native packet GUI probe detected Minecraft (…)` — always logged, tells you what was detected. +- `Packet mode enabled. Inventory GUIs are rendered with fake packet items.` — the good case. +- `Bukkit fallback enabled. …` at WARNING — packet mode was requested but could not be honoured. The message + names the reason. + +## Known limitations + +- **Chest-style containers only.** `ViewType.CHEST` with a size that is a multiple of 9. Anvil, hopper, + dropper, dispenser and every other type keep using real Bukkit inventories with real items. The first time + such a type is encountered it is logged once at INFO. +- **The viewer's own inventory is effectively read-only while a packet GUI is open.** Click packets are + cancelled before vanilla sees them, so nothing moves by itself. A view can still act on bottom-inventory + clicks: the click is delivered as an entity-container click, and a handler that calls + `setCancelled(false)` **and** changes `clickOrigin.currentItem` gets that item written back to the real + slot. Vanilla pickup/swap/quick-move semantics are deliberately not emulated. +- **Drag, drop, double-click and unknown click modes are denied.** They are cancelled and answered with a full + resync; no view callback runs for them. Only a plain pickup on slot `-999` counts as an outside click. +- **`RenderContext#getInventory()` throws** `UnsupportedOperationException` in packet mode. Probe with + `RenderContext#isBackedByRealInventory()` first. +- **`SlotClickContext#getClickOrigin()` returns a synthesized `InventoryClickEvent`.** Item access, + cancellation, slot, slot type, action, hotbar button and click type are served from the packet click. The + inherited `getInventory()`, `getCursor()` and `setCursor(...)` are left intact so existing plugins keep + compiling and running, but they resolve against the player's *own* inventory view. Reading them is harmless; + writing through them reaches real server-side state and must not be done from a packet GUI handler. + +## Verification checklist + +Manual checklist from `AGENTS.md`. Fill in when validating a build on a real server. + +| Szenario | Erwartet | Geprüft am | Ergebnis | +|---|---|---|---| +| Opening a simple GUI | Window opens with the configured title and size | | | +| Displaying all top slots | Every rendered slot shows its item | | | +| Title rendering | Plain and Adventure component titles both render | | | +| Rows/size rendering | 1–6 row chests all open at the right size | | | +| Clicking a normal button | The view's click handler runs once | | | +| Refresh/rerender after click | Changed slots update, unchanged ones are not resent | | | +| Page/screen replacement | Navigating between views replaces the window cleanly | | | +| Close handling | ESC closes the GUI and fires `onClose` exactly once | | | +| Player quit cleanup | Session and viewer are removed, `onClose` fires once | | | +| External inventory open cleanup | Opening a real chest finalizes the packet session | | | +| Shift-click denial | **Changed:** top-slot shift-clicks are *routed* to the view, not denied; the packet is still cancelled | | | +| Number-key denial | **Changed:** top-slot number-key swaps are *routed* to the view; the packet is still cancelled | | | +| Drag denial | Denied, no callback, full resync | | | +| Double-click denial | Denied, no callback, full resync | | | +| Drop denial | Denied, no callback, full resync | | | +| Offhand swap denial | **Changed:** routed to the view; the packet is still cancelled | | | +| Cursor ghost-item correction | No item sticks to the cursor after any click | | | +| Bottom inventory visual correctness | The player's own items render correctly and snap back when clicked | | | +| No GUI display items in real server inventory contents | `/invsee` or a dump shows no GUI icons in any real inventory | | | +| Window id collision | Opening a GUI while a real chest is open closes the chest and never reuses its window id | | | +| World change / respawn | Session is finalized, no stale viewer keeps receiving GUI packets | | | + +The four rows marked **Changed** deviate from the original AGENTS.md expectation. Those click modes are +deliberately routed into the click API rather than denied, because the packet is already cancelled at the +listener, so nothing vanilla can mutate. What *is* denied is drag, drop, double-click and any unrecognised +mode. + +## Build note + +`spotlessCheck` and `spotlessApply` do not run under this project's JDK 25 toolchain — +palantir-java-format throws `NoSuchMethodError` on `Log$DeferredDiagnosticHandler.getDiagnostics`. Formatting +in the packet backend is therefore maintained by hand: 4-space indentation, 120-column limit, no tabs. diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 32bbf3e..928f748 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -3,6 +3,36 @@ From: Keviro Date: Mon, 25 May 2026 23:04:19 +0200 Subject: [PATCH] Add internal packet GUI backend +Renders GUI contents as virtual packet items instead of placing real items into +Bukkit inventories, so GUI display items cannot be duplicated out of a menu. The +public InventoryFramework API is unchanged and the Bukkit inventory backend stays +available as a fallback. + +The backend is opt-in via -Dinventory-framework.gui-backend=packet and requires +PacketEvents at runtime; without it the Bukkit backend is used. + +Outbound content packets are built and sent through a small reflective NMS sender +rather than PacketEvents wrappers, because surf-api's PacketLore layer only sees +packets that travel the server's own outbound path. The sender is isolated in +PacketGuiNativeOutboundSender and gated by a startup capability probe: it resolves +everything it needs and then constructs one of every packet it emits, so a +signature mismatch disables packet mode in favour of the Bukkit backend instead of +failing on the first GUI open. There is no Minecraft version allowlist. + +Click handling fails closed. The click packet is cancelled in the PacketEvents +listener before the framework runs, and anything that is not an explicitly +supported interaction is denied with a full resync and without invoking view +callbacks. Only a plain pickup on slot -999 counts as an outside click, so drag +and cursor-drop packets no longer reach the click pipeline. + +Window ids skip the id the viewer currently has open for a real container, and +opening a packet GUI finalizes any real server-side menu first, so a fake window +can never share an id with a live vanilla container. + +Known limitations: only chest-style containers are rendered with packet items, and +the viewer's own inventory is read-only while a packet GUI is open unless a view +explicitly un-cancels the click and rewrites the clicked item. See +docs/packet-gui-backend.md. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89850fa8d67a4ff08fbe5997ab94625e50c0cf19..daec84d3a7722c8390ae6624b01b84d52c596df9 100644 @@ -1040,10 +1070,10 @@ index 50f57dbc949c8cdc15ff0e908d46ac876adca3ed..f6dec24a61221bb5e56421ec85ecdcac diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/BukkitGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/BukkitGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..d4e57da254cdf676539e0ee3ab3862b7e7e27a5f +index 0000000000000000000000000000000000000000..a6a7f90ce6335143cf8fbeb694a414aef9183548 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/BukkitGuiBackend.java -@@ -0,0 +1,20 @@ +@@ -0,0 +1,25 @@ +package me.devnatan.inventoryframework.internal; + +import me.devnatan.inventoryframework.BukkitViewContainer; @@ -1052,8 +1082,13 @@ index 0000000000000000000000000000000000000000..d4e57da254cdf676539e0ee3ab3862b7 +import me.devnatan.inventoryframework.context.IFContext; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; ++import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + ++/** ++ * The classic backend: every GUI is a real Bukkit {@link Inventory} holding real items. ++ */ ++@ApiStatus.Internal +public final class BukkitGuiBackend implements GuiBackend { + + @Override @@ -1116,10 +1151,10 @@ index 0000000000000000000000000000000000000000..1ebc23498c0bf82552da3ef8c78d4333 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackendFactory.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackendFactory.java new file mode 100644 -index 0000000000000000000000000000000000000000..a2f72b8c7cb581fb4b0c252a7595d98b32657233 +index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b2e2ea2e7 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/GuiBackendFactory.java -@@ -0,0 +1,69 @@ +@@ -0,0 +1,71 @@ +package me.devnatan.inventoryframework.internal; + +import java.util.logging.Logger; @@ -1141,8 +1176,10 @@ index 0000000000000000000000000000000000000000..a2f72b8c7cb581fb4b0c252a7595d98b + final BukkitGuiBackend bukkitBackend = new BukkitGuiBackend(); + final String configuredBackend = System.getProperty(BACKEND_PROPERTY, "bukkit"); + if (!PACKET_BACKEND.equalsIgnoreCase(configuredBackend)) { ++ // The default, unconfigured path is not a problem worth a warning - only an explicit request for ++ // packet mode that could not be honoured is (see below). + logger(owner) -+ .warning("[IF] GUI backend: Bukkit fallback enabled. " ++ .fine("[IF] GUI backend: Bukkit fallback enabled. " + + "Inventory GUIs use real Bukkit inventory items. " + + "To enable packet mode, start the server with -D" + + BACKEND_PROPERTY @@ -1191,10 +1228,10 @@ index 0000000000000000000000000000000000000000..a2f72b8c7cb581fb4b0c252a7595d98b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..48bd8555ef0a1fb41c724f40cfb3b896d97f4b4e +index 0000000000000000000000000000000000000000..709ee674e5a3138b04478939953cb3aedea76fc3 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1472 @@ +@@ -0,0 +1,1499 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1237,8 +1274,19 @@ index 0000000000000000000000000000000000000000..48bd8555ef0a1fb41c724f40cfb3b896 +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.plugin.Plugin; ++import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + ++/** ++ * Renders GUIs as virtual packet items instead of placing real items into a Bukkit inventory. ++ * ++ *

Owns the per-viewer sessions and drives the whole flow: allocating a fake window id and sending the ++ * open-screen packet, diffing renders into content or per-slot packets, intercepting and classifying inbound ++ * clicks before vanilla can act on them, and tearing sessions down on close, quit, world change or shutdown. ++ * Falls back to {@link BukkitGuiBackend} whenever packet mode is unavailable or the container type is not ++ * chest-shaped. ++ */ ++@ApiStatus.Internal +public final class PacketGuiBackend implements GuiBackend { + + private static final String CLOSE_ORIGIN_CLIENT = "packet-gui-client-close"; @@ -1325,12 +1373,16 @@ index 0000000000000000000000000000000000000000..48bd8555ef0a1fb41c724f40cfb3b896 + available = false; + owner.getLogger() + .warning("[IF] GUI backend: Bukkit fallback enabled. " -+ + "Packet mode was requested, but the native packet GUI outbound sender is unavailable. " ++ + "Packet mode was requested, but the native packet GUI outbound sender is " ++ + "unavailable. " + + nativeSender.message() + + " Inventory GUIs use real Bukkit inventory items."); + if (nativeSender.failure() != null) { + owner.getLogger() -+ .log(Level.WARNING, "[IF] Native packet GUI outbound sender self-check failed.", nativeSender.failure()); ++ .log( ++ Level.WARNING, ++ "[IF] Native packet GUI outbound sender self-check failed.", ++ nativeSender.failure()); + } + return; + } @@ -1344,7 +1396,11 @@ index 0000000000000000000000000000000000000000..48bd8555ef0a1fb41c724f40cfb3b896 + + ". This is the recommended mode for preventing GUI item duplication."); + } catch (final RuntimeException exception) { + available = false; -+ owner.getLogger().log(Level.WARNING, "Failed to register packet GUI backend. Falling back to Bukkit.", exception); ++ owner.getLogger() ++ .log( ++ Level.WARNING, ++ "Failed to register packet GUI backend. Falling back to Bukkit.", ++ exception); + } + } + @@ -1920,7 +1976,8 @@ index 0000000000000000000000000000000000000000..48bd8555ef0a1fb41c724f40cfb3b896 + } + } + -+ private void addOpenWindow(PacketGuiConversionPlan conversionPlan, PacketGuiSession session, PacketGuiRender render) { ++ private void addOpenWindow( ++ PacketGuiConversionPlan conversionPlan, PacketGuiSession session, PacketGuiRender render) { + final ServerVersion version = PacketEvents.getAPI().getServerManager().getVersion(); + final boolean modernWindowType = version.isNewerThanOrEquals(ServerVersion.V_1_14); + final int windowId = session.windowId(); @@ -2090,7 +2147,8 @@ index 0000000000000000000000000000000000000000..48bd8555ef0a1fb41c724f40cfb3b896 + final ItemStack[] slots = new ItemStack[PacketInventoryConstants.INVENTORY_SIZE]; + + for (int slot = 0; slot <= 35; slot++) { -+ slots[PacketInventoryConstants.playerInventorySlotToContainerSlot(slot)] = cloneItem(inventory.getItem(slot)); ++ slots[PacketInventoryConstants.playerInventorySlotToContainerSlot(slot)] = ++ cloneItem(inventory.getItem(slot)); + } + + slots[PacketInventoryConstants.SLOT_HELMET] = cloneItem(inventory.getHelmet()); @@ -2284,13 +2342,19 @@ index 0000000000000000000000000000000000000000..48bd8555ef0a1fb41c724f40cfb3b896 + } + + private boolean closeSession( -+ PacketGuiSession session, boolean sendClosePacket, Object origin, boolean callClose, boolean syncInventory) { ++ PacketGuiSession session, ++ boolean sendClosePacket, ++ Object origin, ++ boolean callClose, ++ boolean syncInventory) { + if (session == null) { + return false; + } + + if (!isOnPlayerThread(session.player()) && (sendClosePacket || callClose)) { -+ runOnPlayer(session.player(), () -> closeSession(session, sendClosePacket, origin, callClose, syncInventory)); ++ runOnPlayer( ++ session.player(), ++ () -> closeSession(session, sendClosePacket, origin, callClose, syncInventory)); + return true; + } + @@ -2669,15 +2733,23 @@ index 0000000000000000000000000000000000000000..48bd8555ef0a1fb41c724f40cfb3b896 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java new file mode 100644 -index 0000000000000000000000000000000000000000..79f9d50878de8ae5c091f17c8a7e92bc16acf2d7 +index 0000000000000000000000000000000000000000..bf3cf7aa62909849e7af6eacc801d8f92bac1e6f --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java -@@ -0,0 +1,221 @@ +@@ -0,0 +1,229 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientClickWindow; +import java.util.Map; + ++/** ++ * An inbound container-click packet, decoded into the questions the backend actually asks of it: what kind of ++ * click it is, whether it is safe to route into the framework, which slots have to be repaired afterwards, and ++ * which Bukkit {@code ClickType} it corresponds to. ++ * ++ *

Immutable and free of PacketEvents state, so it can be built on the netty thread and handed to the ++ * player's thread without further synchronization. ++ */ +final class PacketGuiClick { + + private static final int OFFHAND_SWAP_BUTTON = 40; @@ -2896,10 +2968,10 @@ index 0000000000000000000000000000000000000000..79f9d50878de8ae5c091f17c8a7e92bc +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java new file mode 100644 -index 0000000000000000000000000000000000000000..c3fc8e898b89e93e21d9a4ff24b8630f6849bd14 +index 0000000000000000000000000000000000000000..251f44ed68dfba2c9f0a36b7d779ec1263c35a41 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java -@@ -0,0 +1,475 @@ +@@ -0,0 +1,484 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.lang.reflect.Constructor; @@ -2912,6 +2984,15 @@ index 0000000000000000000000000000000000000000..c3fc8e898b89e93e21d9a4ff24b8630f +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + ++/** ++ * Builds and sends the GUI content packets through the server's own outbound path via reflection. ++ * ++ *

Deliberately not PacketEvents: surf-api's PacketLore layer only decorates packets that travel the ++ * server's native outbound path, so GUI items sent through PacketEvents would lose their dynamic lore. All ++ * NMS knowledge is confined to this class and gated by {@link #initialize()}, which resolves every class, ++ * constructor and field it needs and then constructs one of every packet as a self-check. Any mismatch makes ++ * the whole packet backend fall back to Bukkit inventories rather than fail at runtime. ++ */ +final class PacketGuiNativeOutboundSender { + + private static final String NATIVE_PROPERTY = "inventory-framework.gui-backend.native"; @@ -3377,10 +3458,10 @@ index 0000000000000000000000000000000000000000..c3fc8e898b89e93e21d9a4ff24b8630f +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java new file mode 100644 -index 0000000000000000000000000000000000000000..89cf420ff84c58826d06cbe9d92c4f9b3f074d03 +index 0000000000000000000000000000000000000000..6c57219bded1721625338161728081f06fca9813 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java -@@ -0,0 +1,122 @@ +@@ -0,0 +1,129 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.event.PacketListenerAbstract; @@ -3403,6 +3484,13 @@ index 0000000000000000000000000000000000000000..89cf420ff84c58826d06cbe9d92c4f9b +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetSlot; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerWindowItems; + ++/** ++ * The PacketEvents bridge: cancels click and close packets that belong to a packet GUI before vanilla can act ++ * on them, and watches outbound inventory packets to keep track of which real window a viewer has open. ++ * ++ *

Runs on netty threads. Everything here only captures packet data and hands it to ++ * {@link PacketGuiBackend}, which hops to the viewer's thread before touching any Bukkit state. ++ */ +final class PacketGuiPacketListener extends PacketListenerAbstract { + + private final PacketGuiBackend backend; @@ -3505,16 +3593,22 @@ index 0000000000000000000000000000000000000000..89cf420ff84c58826d06cbe9d92c4f9b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java new file mode 100644 -index 0000000000000000000000000000000000000000..7a3fda77476e1cdd99a01d98c7ca97f8d2b9be91 +index 0000000000000000000000000000000000000000..b08e2dc2684aa5ca04844b5f496a0a4e1c5174d5 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java -@@ -0,0 +1,71 @@ +@@ -0,0 +1,77 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.util.Objects; +import net.kyori.adventure.text.Component; +import org.bukkit.inventory.ItemStack; + ++/** ++ * An immutable snapshot of one frame of a packet GUI: title, row count and top-slot items. ++ * ++ *

Two snapshots are diffed to decide whether the window has to be reopened, fully resent, or only patched ++ * slot by slot. ++ */ +final class PacketGuiRender { + + private final Object rawTitle; @@ -3582,12 +3676,19 @@ index 0000000000000000000000000000000000000000..7a3fda77476e1cdd99a01d98c7ca97f8 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRepairScope.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRepairScope.java new file mode 100644 -index 0000000000000000000000000000000000000000..bfe5e483593193cc8e62027a4e2684e0f6c0f8a0 +index 0000000000000000000000000000000000000000..862304c2a30d0bae12cb4165678016441db8c7e6 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRepairScope.java -@@ -0,0 +1,32 @@ +@@ -0,0 +1,39 @@ +package me.devnatan.inventoryframework.internal.packet; + ++/** ++ * How much of the client's predicted state has to be corrected after a click. ++ * ++ *

The client simulates a click locally before the server answers, so every click needs a repair. The scope ++ * decides how targeted that repair can be; {@link #FULL_WINDOW} doubles as the fail-closed verdict for clicks ++ * the backend refuses to route into the framework. ++ */ +enum PacketGuiRepairScope { + NONE, + TOP_SLOT_AND_CURSOR, @@ -3620,10 +3721,10 @@ index 0000000000000000000000000000000000000000..bfe5e483593193cc8e62027a4e2684e0 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java new file mode 100644 -index 0000000000000000000000000000000000000000..96ae373865eef6117ae36003c3199b6d63e248f2 +index 0000000000000000000000000000000000000000..c41bf5c17d4e8533b5e343dd272e719c7cc97112 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java -@@ -0,0 +1,265 @@ +@@ -0,0 +1,273 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.protocol.player.User; @@ -3633,6 +3734,14 @@ index 0000000000000000000000000000000000000000..96ae373865eef6117ae36003c3199b6d +import me.devnatan.inventoryframework.context.IFRenderContext; +import org.bukkit.entity.Player; + ++/** ++ * One viewer's open packet GUI: window id, container state id, the last applied render and the pending ++ * render request. ++ * ++ *

Touched from netty threads (inbound clicks) and the viewer's thread (rendering), so every accessor is ++ * synchronized on the session. Render requests are coalesced through a latch so a burst of slot changes ++ * produces a single render pass. ++ */ +final class PacketGuiSession { + + private final UUID viewerId; @@ -3937,12 +4046,17 @@ index 0000000000000000000000000000000000000000..3552d4e9df03a9e574cd944611293013 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java new file mode 100644 -index 0000000000000000000000000000000000000000..e43f86319e18629837065af50a00b55afebaaa33 +index 0000000000000000000000000000000000000000..c6ed7053e89d189987f66763d4ecacdb4dd34c85 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java -@@ -0,0 +1,94 @@ +@@ -0,0 +1,99 @@ +package me.devnatan.inventoryframework.internal.packet; + ++/** ++ * The single source of truth for the protocol's inventory slot layout and every mapping between the three ++ * numbering schemes involved: Bukkit's {@code PlayerInventory} index, the player container's slot index, and ++ * the slot index inside an open chest-style GUI window. ++ */ +final class PacketInventoryConstants { + + static final int PLAYER_WINDOW_ID = 0; @@ -4037,10 +4151,10 @@ index 0000000000000000000000000000000000000000..e43f86319e18629837065af50a00b55a +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java new file mode 100644 -index 0000000000000000000000000000000000000000..36d89dbbe2e6dfccc773206b109bfac77e5e0bd2 +index 0000000000000000000000000000000000000000..f244c5c220a03b273b1578a740ea8aa06143763e --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java -@@ -0,0 +1,217 @@ +@@ -0,0 +1,223 @@ +package me.devnatan.inventoryframework.internal.packet; + +import io.github.retrooper.packetevents.util.SpigotConversionUtil; @@ -4053,6 +4167,12 @@ index 0000000000000000000000000000000000000000..36d89dbbe2e6dfccc773206b109bfac7 +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + ++/** ++ * Prepares Bukkit items for display in a packet GUI. ++ * ++ *

Normalizes the default italic styling Minecraft applies to custom item names and lore, and provides the ++ * cheap equality check the render diff uses to decide whether a slot actually changed. ++ */ +final class PacketItemConverter { + + private static final Method DISPLAY_NAME_GETTER = itemMetaMethod("displayName"); @@ -4260,10 +4380,10 @@ index 0000000000000000000000000000000000000000..36d89dbbe2e6dfccc773206b109bfac7 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java new file mode 100644 -index 0000000000000000000000000000000000000000..390fed3cabff5e3872e7eaedd211c8f7e8400cd7 +index 0000000000000000000000000000000000000000..6b1f73f28147bef735305a8fa43ed8c5a73648e2 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewContainer.java -@@ -0,0 +1,236 @@ +@@ -0,0 +1,284 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.util.Map; @@ -4274,10 +4394,22 @@ index 0000000000000000000000000000000000000000..390fed3cabff5e3872e7eaedd211c8f7 +import me.devnatan.inventoryframework.ViewType; +import me.devnatan.inventoryframework.Viewer; +import me.devnatan.inventoryframework.context.IFRenderContext; ++import net.kyori.adventure.text.Component; ++import net.kyori.adventure.text.TextComponent; ++import net.kyori.adventure.text.TranslatableComponent; +import org.bukkit.inventory.ItemStack; ++import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + ++/** ++ * The authoritative virtual top container of a packet GUI. ++ * ++ *

Holds the rendered items and the per-viewer titles in plain arrays and maps; nothing here is ever a real ++ * Bukkit inventory, which is what keeps GUI display items from being duplicated out of a menu. Mutations mark ++ * the container dirty and ask {@link PacketGuiBackend} for a resync. ++ */ ++@ApiStatus.Internal +public final class PacketViewContainer implements ViewContainer { + + private final PacketGuiBackend backend; @@ -4477,8 +4609,44 @@ index 0000000000000000000000000000000000000000..390fed3cabff5e3872e7eaedd211c8f7 + throw new IllegalStateException("Unsupported item type: " + item.getClass().getName()); + } + ++ /** ++ * Renders a configured title for {@link ViewContainer#getTitle()}. ++ * ++ *

Returns {@code null} when no title is set, so {@code PlatformContext#getTitle()} can fall back to the ++ * view type's default title, and serializes Adventure components to plain text instead of dumping ++ * {@code toString()} output. ++ */ + private static String titleAsString(Object title) { -+ return title == null ? "" : String.valueOf(title); ++ if (title == null) { ++ return null; ++ } ++ ++ if (title instanceof Component) { ++ final StringBuilder builder = new StringBuilder(); ++ appendPlainText(builder, (Component) title); ++ return builder.toString(); ++ } ++ ++ return String.valueOf(title); ++ } ++ ++ /** ++ * Renders a component tree as plain text. ++ * ++ *

Adventure's {@code PlainTextComponentSerializer} lives in a separate artifact this module does not ++ * depend on, and the only consumer of the result is {@link ViewContainer#getTitle()}, so walking the ++ * literal content is enough. Anything that is neither text nor translatable contributes only its children. ++ */ ++ private static void appendPlainText(StringBuilder builder, Component component) { ++ if (component instanceof TextComponent) { ++ builder.append(((TextComponent) component).content()); ++ } else if (component instanceof TranslatableComponent) { ++ builder.append(((TranslatableComponent) component).key()); ++ } ++ ++ for (final Component child : component.children()) { ++ appendPlainText(builder, child); ++ } + } + + @Override @@ -4502,10 +4670,10 @@ index 0000000000000000000000000000000000000000..390fed3cabff5e3872e7eaedd211c8f7 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java new file mode 100644 -index 0000000000000000000000000000000000000000..49c1df0bc324fb4d7f4e71b40dc198ee968a0d54 +index 0000000000000000000000000000000000000000..47afe29fb8b215ba7596f95d8c9728a2f40453ee --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java -@@ -0,0 +1,158 @@ +@@ -0,0 +1,171 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.util.ArrayList; @@ -4514,6 +4682,14 @@ index 0000000000000000000000000000000000000000..49c1df0bc324fb4d7f4e71b40dc198ee +import org.bukkit.entity.Player; +import org.bukkit.inventory.PlayerInventory; + ++/** ++ * Per-viewer packet-side state: which real window the viewer currently has open, plus a mirror of their ++ * inventory contents fed from outbound packets. ++ * ++ *

The open-window id is what keeps a fake GUI window from colliding with a live vanilla container. The ++ * item mirror is currently only written, not read - rendering reads the live Bukkit inventory instead - and is ++ * kept for the planned move to a fully authoritative packet-side model. ++ */ +final class PacketViewerInventory { + + private final com.github.retrooper.packetevents.protocol.item.ItemStack[] slots = @@ -4533,7 +4709,9 @@ index 0000000000000000000000000000000000000000..49c1df0bc324fb4d7f4e71b40dc198ee + final PlayerInventory inventory = player.getInventory(); + + for (int slot = 0; slot <= 35; slot++) { -+ applySlot(PacketInventoryConstants.playerInventorySlotToContainerSlot(slot), PacketItemConverter.toPacket(inventory.getItem(slot))); ++ applySlot( ++ PacketInventoryConstants.playerInventorySlotToContainerSlot(slot), ++ PacketItemConverter.toPacket(inventory.getItem(slot))); + } + + applySlot(PacketInventoryConstants.SLOT_HELMET, PacketItemConverter.toPacket(inventory.getHelmet())); @@ -4607,7 +4785,9 @@ index 0000000000000000000000000000000000000000..49c1df0bc324fb4d7f4e71b40dc198ee + } + + synchronized com.github.retrooper.packetevents.protocol.item.ItemStack cursor() { -+ return cursorKnown ? PacketItemConverter.copy(cursor) : com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY; ++ return cursorKnown ++ ? PacketItemConverter.copy(cursor) ++ : com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY; + } + + synchronized List mainAndHotbarItems() { @@ -4657,7 +4837,8 @@ index 0000000000000000000000000000000000000000..49c1df0bc324fb4d7f4e71b40dc198ee + return PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(topSize, containerSlot); + } + -+ private void appendRange(List items, int sourceStart, int amount) { ++ private void appendRange( ++ List items, int sourceStart, int amount) { + for (int index = 0; index < amount; index++) { + items.add(item(sourceStart + index)); + } From 3bf6ef0c9ebe4edb2e2ca60d60ade96d95e3523c Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:46:00 +0200 Subject: [PATCH 28/50] fix(packet): recognise outside clicks by click mode, not slot sentinel An outside click has two wire forms and the backend only accepted one of them, so clicking outside a packet GUI did nothing at all. PICKUP on a negative slot is what a client sends while holding an item; THROW on a negative slot is what it sends with an empty cursor. A packet GUI never puts anything on the real cursor, so in practice only the THROW form ever arrives - exactly the form that was being rejected. The negative slot stays the discriminator. THROW on a real slot is the drop key and remains denied, and drag (QUICK_CRAFT) carries a negative slot as well but is still never routed, so a single drag cannot run the click pipeline twice. Outside clicks now report ClickType.LEFT/RIGHT rather than DROP, matching the Bukkit backend, where SlotType.OUTSIDE carries that distinction instead of the click type. Also adds -Dinventory-framework.gui-backend.debug-clicks=true, which logs every inbound click with its slot, button, click type, computed repair scope and routing decision. That switch is what identified this bug on a live server. --- docs/packet-gui-backend.md | 19 ++- ...0006-Add-internal-packet-GUI-backend.patch | 115 ++++++++++++++---- 2 files changed, 111 insertions(+), 23 deletions(-) diff --git a/docs/packet-gui-backend.md b/docs/packet-gui-backend.md index c775dfd..e6449f1 100644 --- a/docs/packet-gui-backend.md +++ b/docs/packet-gui-backend.md @@ -26,6 +26,15 @@ suspected packet problem — start with: -Dinventory-framework.gui-backend.native=off ``` +When a click does not reach a view, log every inbound click and the routing decision taken for it: + +``` +-Dinventory-framework.gui-backend.debug-clicks=true +``` + +Each click then produces one INFO line naming the window id, slot, button, click type, the computed repair +scope and whether it was routed into the click pipeline or denied. + That makes the sender report itself unavailable, which in turn falls the whole packet backend back to Bukkit inventories. @@ -58,8 +67,14 @@ Startup log lines to look for: clicks: the click is delivered as an entity-container click, and a handler that calls `setCancelled(false)` **and** changes `clickOrigin.currentItem` gets that item written back to the real slot. Vanilla pickup/swap/quick-move semantics are deliberately not emulated. -- **Drag, drop, double-click and unknown click modes are denied.** They are cancelled and answered with a full - resync; no view callback runs for them. Only a plain pickup on slot `-999` counts as an outside click. +- **Drag, double-click, the drop key and unknown click modes are denied.** They are cancelled and answered + with a full resync; no view callback runs for them. +- **Outside clicks are routed.** The protocol has two wire forms for them, and both are accepted on a negative + slot: `PICKUP` (mode 0) when the player holds an item, and `THROW` (mode 4) when the cursor is empty. A + packet GUI never puts anything on the real cursor, so in practice the client always sends the `THROW` form. + The negative slot is what separates these from their in-window meaning — `THROW` on a real slot is the drop + key and stays denied, and drag (`QUICK_CRAFT`) carries a negative slot too but is never treated as a click. + Consumers see such a click as `ClickType.LEFT`/`RIGHT` with `SlotType.OUTSIDE`, matching the Bukkit backend. - **`RenderContext#getInventory()` throws** `UnsupportedOperationException` in packet mode. Probe with `RenderContext#isBackedByRealInventory()` first. - **`SlotClickContext#getClickOrigin()` returns a synthesized `InventoryClickEvent`.** Item access, diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 928f748..4619805 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1228,10 +1228,10 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..709ee674e5a3138b04478939953cb3aedea76fc3 +index 0000000000000000000000000000000000000000..9728fcab2f11af8a5d64337fede38a0c5eed9ceb --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1499 @@ +@@ -0,0 +1,1516 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1296,6 +1296,13 @@ index 0000000000000000000000000000000000000000..709ee674e5a3138b04478939953cb3ae + private static final String CLOSE_ORIGIN_SHUTDOWN = "packet-gui-shutdown"; + private static final String CLOSE_ORIGIN_WORLD_CHANGE = "packet-gui-world-change"; + ++ /** ++ * Logs every inbound GUI click and the routing decision taken for it. Enable with ++ * {@code -Dinventory-framework.gui-backend.debug-clicks=true} when a click does not reach a view. ++ */ ++ private static final boolean DEBUG_CLICKS = ++ Boolean.getBoolean("inventory-framework.gui-backend.debug-clicks"); ++ + private static final Method PLAYER_GET_SCHEDULER = optionalMethod(Player.class, "getScheduler"); + private static final Method BUKKIT_IS_OWNED_BY_CURRENT_REGION = + optionalMethod(Bukkit.class, "isOwnedByCurrentRegion", Entity.class); @@ -1589,6 +1596,16 @@ index 0000000000000000000000000000000000000000..709ee674e5a3138b04478939953cb3ae + final boolean routed = click.isOutsideClick() + || (click.isPlayerInventoryClick(topSize) && click.isShiftClick()) + || !repairScope.fullWindow(); ++ ++ if (DEBUG_CLICKS) { ++ owner.getLogger() ++ .info("[IF] packet GUI click " + click + " topSize=" + topSize + " scope=" + repairScope ++ + " outside=" + click.isOutsideClick() ++ + " bottom=" + click.isPlayerInventoryClick(topSize) ++ + " identifier=" + click.clickIdentifier() ++ + " -> " + (routed ? "routed to the click pipeline" : "denied, full resync")); ++ } ++ + if (!routed) { + requestRender(session, false, true); + return; @@ -2733,10 +2750,10 @@ index 0000000000000000000000000000000000000000..709ee674e5a3138b04478939953cb3ae +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java new file mode 100644 -index 0000000000000000000000000000000000000000..bf3cf7aa62909849e7af6eacc801d8f92bac1e6f +index 0000000000000000000000000000000000000000..61555752645c56582c133550d96cd287b0e5b3ba --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java -@@ -0,0 +1,229 @@ +@@ -0,0 +1,244 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientClickWindow; @@ -2753,7 +2770,6 @@ index 0000000000000000000000000000000000000000..bf3cf7aa62909849e7af6eacc801d8f9 +final class PacketGuiClick { + + private static final int OFFHAND_SWAP_BUTTON = 40; -+ private static final int OUTSIDE_SLOT = -999; + private static final int[] EMPTY_CHANGED_SLOTS = new int[0]; + + private final int windowId; @@ -2817,12 +2833,25 @@ index 0000000000000000000000000000000000000000..bf3cf7aa62909849e7af6eacc801d8f9 + /** + * Whether this is a genuine click outside the window. + * -+ *

Only a plain pickup (mode 0, button 0 or 1) on slot -999 is an outside click. Drag (QUICK_CRAFT) and -+ * cursor-drop (THROW) packets carry slot -999 as well but are not user-visible clicks; treating them as -+ * outside clicks made a single drag run the click pipeline twice. ++ *

The protocol expresses an outside click two ways, depending on the cursor: ++ * {@code PICKUP} on a negative slot when the player is holding an item, and {@code THROW} on a negative ++ * slot when the cursor is empty. A packet GUI never puts anything on the real cursor, so in practice the ++ * client always sends the {@code THROW} form. ++ * ++ *

The negative slot is what separates these from their in-window meaning: {@code THROW} on a real slot ++ * is the drop key and stays denied, as does drag ({@code QUICK_CRAFT}), which also carries a negative ++ * slot but is not a user-visible click — routing it made a single drag run the click pipeline twice. + */ + boolean isOutsideClick() { -+ return slot == OUTSIDE_SLOT && isPickupClick(); ++ if (slot >= 0) { ++ return false; ++ } ++ ++ return isPickupClick() || isDropOutsideClick(); ++ } ++ ++ private boolean isDropOutsideClick() { ++ return clickType == WrapperPlayClientClickWindow.WindowClickType.THROW && (button == 0 || button == 1); + } + + boolean isSafeTopClick(int topSize) { @@ -2908,6 +2937,9 @@ index 0000000000000000000000000000000000000000..bf3cf7aa62909849e7af6eacc801d8f9 + * exactly as they can with the Bukkit backend. PacketEvents' own protocol names must never leak here. + */ + String clickIdentifier() { ++ // An outside click is LEFT/RIGHT on the Bukkit backend regardless of which wire form carried it; the ++ // OUTSIDE slot type is what distinguishes it there, not the click type. ++ if (isOutsideClick()) return button == 0 ? "LEFT" : "RIGHT"; + if (isPickupClick()) return button == 0 ? "LEFT" : "RIGHT"; + if (isQuickMoveClick()) return button == 0 ? "SHIFT_LEFT" : "SHIFT_RIGHT"; + if (isSwapClick()) return button == OFFHAND_SWAP_BUTTON ? "SWAP_OFFHAND" : "NUMBER_KEY"; @@ -4917,10 +4949,10 @@ index 6499f33961d5fbf084ca15f00a65b38d22e4fd0b..3f89ed26c49e0c94da9ea5bc27782675 if (!(component instanceof ItemComponent) || !component.isVisible()) return; diff --git a/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClickTest.java b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClickTest.java new file mode 100644 -index 0000000000000000000000000000000000000000..fe656625c26fc2f0931141aa5e24bf5df236892b +index 0000000000000000000000000000000000000000..248745ce12a28003a4a137f68eb397a303ea3578 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClickTest.java -@@ -0,0 +1,93 @@ +@@ -0,0 +1,134 @@ +package me.devnatan.inventoryframework.internal.packet; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -4957,29 +4989,70 @@ index 0000000000000000000000000000000000000000..fe656625c26fc2f0931141aa5e24bf5d + } + + @Test -+ void treatsOnlyPickupOnSlotMinus999AsOutsideClick() { ++ void recognisesBothWireFormsOfAnOutsideClick() { ++ // THROW on a negative slot is what a client sends when the cursor is empty - the normal case in a ++ // packet GUI, since it never puts anything on the real cursor. PICKUP on a negative slot is the ++ // holding-an-item variant. ++ assertTrue(PacketGuiClick.of(1, OUTSIDE, 0, WindowClickType.THROW).isOutsideClick()); ++ assertTrue(PacketGuiClick.of(1, OUTSIDE, 1, WindowClickType.THROW).isOutsideClick()); + assertTrue(PacketGuiClick.of(1, OUTSIDE, 0, WindowClickType.PICKUP).isOutsideClick()); + assertTrue(PacketGuiClick.of(1, OUTSIDE, 1, WindowClickType.PICKUP).isOutsideClick()); ++ ++ assertFalse(PacketGuiClick.of(1, OUTSIDE, 2, WindowClickType.THROW).isOutsideClick()); + assertFalse(PacketGuiClick.of(1, OUTSIDE, 2, WindowClickType.PICKUP).isOutsideClick()); + assertFalse(PacketGuiClick.of(1, 0, 0, WindowClickType.PICKUP).isOutsideClick()); + } + + @Test -+ void deniesEveryNonPickupClickOnSlotMinus999() { ++ void keepsTheDropKeyDeniedBecauseItCarriesARealSlot() { ++ // THROW on a real slot is the drop key (Q / Ctrl+Q), not an outside click. ++ for (final int slot : new int[] {0, 13, TOP_SIZE, TOP_SIZE + 4}) { ++ final PacketGuiClick click = PacketGuiClick.of(1, slot, 0, WindowClickType.THROW); ++ assertFalse(click.isOutsideClick(), "drop key on slot " + slot); ++ assertEquals(PacketGuiRepairScope.FULL_WINDOW, click.repairScope(TOP_SIZE), "drop key on slot " + slot); ++ } ++ } ++ ++ @Test ++ void acceptsAnyNegativeSlotSentinelButNeverADrag() { ++ for (final int slot : new int[] {-1, -2, -999, Short.MIN_VALUE}) { ++ assertTrue( ++ PacketGuiClick.of(1, slot, 0, WindowClickType.THROW).isOutsideClick(), ++ "empty-cursor outside click on slot " + slot); ++ assertTrue( ++ PacketGuiClick.of(1, slot, 0, WindowClickType.PICKUP).isOutsideClick(), ++ "holding-item outside click on slot " + slot); ++ assertFalse( ++ PacketGuiClick.of(1, slot, 0, WindowClickType.QUICK_CRAFT).isOutsideClick(), ++ "drag on slot " + slot + " must never be an outside click"); ++ } ++ } ++ ++ @Test ++ void reportsOutsideClicksAsLeftOrRightLikeTheBukkitBackend() { ++ assertEquals("LEFT", PacketGuiClick.of(1, OUTSIDE, 0, WindowClickType.THROW).clickIdentifier()); ++ assertEquals("RIGHT", PacketGuiClick.of(1, OUTSIDE, 1, WindowClickType.THROW).clickIdentifier()); ++ assertEquals("LEFT", PacketGuiClick.of(1, OUTSIDE, 0, WindowClickType.PICKUP).clickIdentifier()); ++ // The drop key keeps its own identifier. ++ assertEquals("DROP", PacketGuiClick.of(1, 13, 0, WindowClickType.THROW).clickIdentifier()); ++ assertEquals("CONTROL_DROP", PacketGuiClick.of(1, 13, 1, WindowClickType.THROW).clickIdentifier()); ++ } ++ ++ @Test ++ void classifiesEveryWireFormOnSlotMinus999() { + for (final WindowClickType type : WindowClickType.values()) { + for (final int button : new int[] {0, 1, 2, 4, 5, 6, 8, 9, 10, 40}) { + final PacketGuiClick click = PacketGuiClick.of(1, OUTSIDE, button, type); -+ final boolean pickup = type == WindowClickType.PICKUP && (button == 0 || button == 1); ++ final boolean outside = (type == WindowClickType.PICKUP || type == WindowClickType.THROW) ++ && (button == 0 || button == 1); + final String label = type + "/" + button; + -+ assertEquals(pickup, click.isOutsideClick(), "isOutsideClick for " + label); ++ assertEquals(outside, click.isOutsideClick(), "isOutsideClick for " + label); + -+ if (!pickup) { -+ assertEquals( -+ PacketGuiRepairScope.FULL_WINDOW, -+ click.repairScope(TOP_SIZE), -+ "repairScope for " + label); -+ } ++ // Everything on a negative slot is FULL_WINDOW either way; for outside clicks the backend ++ // routes them anyway, for the rest that scope is the denial. ++ assertEquals( ++ PacketGuiRepairScope.FULL_WINDOW, click.repairScope(TOP_SIZE), "repairScope for " + label); + } + } + } From d6758a4b7487be9dc8b5fc8bcdbe3782dbcb9276 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:46:10 +0200 Subject: [PATCH 29/50] build: fix the delegating task wrapper path and add publishToMavenLocal `cmd /c gradlew.bat` resolves against PATH rather than the task's workingDir, so the delegating shadowJar and publish tasks failed with "gradlew.bat is either misspelled or could not be found". They now invoke the wrapper by absolute path. Adds publishToMavenLocal to the same set, so a branch build can be installed into ~/.m2 and consumed by surf-api, which shades this fork instead of depending on it at runtime. docs/testing-with-surf-api.md describes that round trip and why a composite build is the wrong tool for it. --- build.gradle.kts | 9 ++- docs/testing-with-surf-api.md | 103 ++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 docs/testing-with-surf-api.md diff --git a/build.gradle.kts b/build.gradle.kts index e1355ef..d02ed2d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,15 +30,18 @@ val isWindows = System.getProperty("os.name") .lowercase() .contains("windows") -val gradlew = if (isWindows) "gradlew.bat" else "./gradlew" +// The wrapper is addressed by absolute path: `cmd /c gradlew.bat` resolves against PATH rather than the +// task's workingDir and fails with "gradlew.bat is either misspelled or could not be found". +val targetDir = layout.projectDirectory.dir("inventory-framework") +val gradlew = targetDir.file(if (isWindows) "gradlew.bat" else "gradlew").asFile.absolutePath -listOf("shadowJar", "publish").forEach { taskName -> +listOf("shadowJar", "publish", "publishToMavenLocal").forEach { taskName -> tasks.register(taskName) { group = if (taskName == "shadowJar") "build" else "publishing" description = "Runs './gradlew $taskName' inside inventory-framework." dependsOn("applyPatches") - workingDir = layout.projectDirectory.dir("inventory-framework").asFile + workingDir = targetDir.asFile val args = listOf(taskName) if (isWindows) { diff --git a/docs/testing-with-surf-api.md b/docs/testing-with-surf-api.md new file mode 100644 index 0000000..4ee5288 --- /dev/null +++ b/docs/testing-with-surf-api.md @@ -0,0 +1,103 @@ +# Einen Branch-Build in surf-api testen + +`surf-inventory-framework` wird nicht direkt deployed — surf-api zieht es als Maven-Dependency und shaded es +beim `shadowJar` nach `dev.slne.surf.api.libs.devnatan.inventoryframework`. Um einen Branch dieses Forks auf +einem Server zu testen, muss er also erst als Artefakt vorliegen, das surf-api auflösen kann. + +``` +surf-inventory-framework surf-api + 7 Module, group Katalog: inventory-framework = "1.0.4" + dev.slne.forks.inventoryframework → inventory-framework-platform-paper + Version aus root gradle.properties → inventory-framework-platform-bukkit + │ │ + │ publishToMavenLocal │ shadowJar relocated + ▼ ▼ + ~/.m2 ──────────────────────────► surf-api-paper-server-*-all.jar +``` + +## Ablauf + +**1. Fork lokal publizieren** + +`gradle.properties` im Fork-Root trägt die Version. Für Branch-Tests eine SNAPSHOT-Version verwenden — +Gradle cached Release-Versionen aus mavenLocal und würde beim zweiten Durchlauf das alte Jar nehmen. + +```properties +version=1.0.5-packet-guis-SNAPSHOT +``` + +Dann vom Fork-Root aus: + +```bash +./gradlew publishToMavenLocal +``` + +Das führt intern `applyPatches` aus und publiziert alle sieben Module nach `~/.m2`. Prüfen: + +```bash +ls ~/.m2/repository/dev/slne/forks/inventoryframework/inventory-framework-platform-bukkit/ +``` + +**2. surf-api dagegen bauen** + +```bash +cd ../surf-api +./gradlew :surf-api-paper:surf-api-paper-server:shadowJar \ + -PinventoryFramework.localVersion=1.0.5-packet-guis-SNAPSHOT +``` + +Die Property schaltet in `buildSrc/src/main/kotlin/core-convention.gradle.kts` zwei Dinge frei: +`mavenLocal()` als Repository und eine `resolutionStrategy`, die jede Dependency der Gruppe +`dev.slne.forks.inventoryframework` auf diese Version zwingt. Der Katalogeintrag bleibt unangetastet. +Ohne die Property ist der Block inert — die Änderung ist also commit-fähig und beeinflusst CI nicht. + +Zur Kontrolle loggt jedes Projekt beim Konfigurieren: + +``` +[surf] :surf-api-paper:surf-api-paper-server: resolving dev.slne.forks.inventoryframework + from mavenLocal at 1.0.5-packet-guis-SNAPSHOT +``` + +Das fertige Jar liegt unter `surf-api-paper/surf-api-paper-server/build/libs/`. + +Wer die Property nicht jedes Mal tippen will, legt sie in eine lokale, nicht eingecheckte +`gradle.properties` in surf-api. + +**3. Auf dem Server aktivieren** + +Das Packet-Backend ist opt-in: + +``` +-Dinventory-framework.gui-backend=packet +``` + +PacketEvents muss installiert sein und vor InventoryFramework laden. Details und die +Verifikations-Checkliste stehen in `docs/packet-gui-backend.md`. + +## Iterieren + +Nach jeder Codeänderung im Fork: + +```bash +cd surf-inventory-framework && ./gradlew publishToMavenLocal +cd ../surf-api && ./gradlew :surf-api-paper:surf-api-paper-server:shadowJar \ + -PinventoryFramework.localVersion=1.0.5-packet-guis-SNAPSHOT +``` + +Weil die Version ein SNAPSHOT ist, zieht Gradle das neu publizierte Jar ohne +`--refresh-dependencies`. + +## Vor dem Merge zurückdrehen + +- `gradle.properties` im Fork: SNAPSHOT-Suffix entfernen, also zurück auf eine Release-Version. +- Die surf-api-Änderung kann bleiben (ohne Property inert) oder wird zurückgenommen. +- Der Katalogeintrag `inventory-framework` in surf-api wird erst auf die neue Version gehoben, wenn der + Fork regulär nach `reposilite.slne.dev/releases` publiziert wurde. + +## Warum kein Composite Build + +`includeBuild("../surf-inventory-framework/inventory-framework")` mit Dependency-Substitution wäre schneller +zu iterieren, würde aber das Projekt statt des publizierten Artefakts einbinden. Das Paper-Modul shaded beim +Publizieren das Bukkit-Modul mit hinein; eine Substitution umgeht das und baut damit etwas anderes als das, +was produktiv ausgeliefert wird. Für einen Test, der genau die produktive Artefaktform prüfen soll, ist der +mavenLocal-Umweg das ehrlichere Verfahren. From 569c8387b6287b68a1145ecb3c45e0bb450095d5 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:52:12 +0200 Subject: [PATCH 30/50] style(packet): use pattern matching in SlotClickContext#normalizeOrigin Picks up the only substantive change from ced7b1c on origin/packet-guis, which is otherwise a whole-file reindentation of SlotClickContext.java from spaces to tabs. That reindentation is not merged: it contradicts the palantir format the project applies everywhere else and turns an eighty-line change into a four-hundred-line diff, which is exactly what makes the click-origin refactor hard to review. --- patches/0006-Add-internal-packet-GUI-backend.patch | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 4619805..6d96b2c 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -745,7 +745,7 @@ index b89eae09cdfbbc5c01ef378071801f9add48af63..992fbb75c57e056a275b5d48f0817a4e } } diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickContext.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickContext.java -index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..95601e57489215ddf2641f413a690b91b8bcfa07 100644 +index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..92eb5a88f4fbe962ed36ca2f80bf8daa26b8a58b 100644 --- a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickContext.java +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickContext.java @@ -5,11 +5,8 @@ import me.devnatan.inventoryframework.ViewContainer; @@ -922,8 +922,8 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..95601e57489215ddf2641f413a690b91 } + + private SlotClickOrigin normalizeOrigin(Object origin) { -+ if (origin instanceof SlotClickOrigin) return (SlotClickOrigin) origin; -+ if (origin instanceof InventoryClickEvent) return new BukkitSlotClickOrigin((InventoryClickEvent) origin); ++ if (origin instanceof SlotClickOrigin slotClickOrigin) return slotClickOrigin; ++ if (origin instanceof InventoryClickEvent event) return new BukkitSlotClickOrigin(event); + + throw new IllegalArgumentException("Unsupported click origin: " + origin.getClass().getName()); + } From ae35f8ceb5e1d586ef7d863999ada06f77948562 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:14:21 +0200 Subject: [PATCH 31/50] refactor(packet): schedule through FoliaLib instead of hand-rolled reflection The backend reached the entity scheduler through its own reflection layer, so the module carried two unrelated Folia strategies that had to be kept in sync by hand: this one and the FoliaLib instance BukkitElementFactory already uses. FoliaLib covers every case the reflection did, and exposes the failure signal natively. isOwnedByCurrentRegion now goes through PlatformScheduler, which resolves to a real region check on Folia and to Server#isPrimaryThread on Paper and Spigot, so the "method absent" and "invocation failed" branches disappear along with the cached Method handles. The inline fast path stays: FoliaLib always defers, while several call sites depend on the work having happened by the time they return - open() publishes the session before rendering it, and closeSession runs the close pipeline synchronously. Scheduling failures keep their meaning. runAtEntityWithFallback and the retired callback of runAtEntityLater both discard the session, and a null WrappedTask from the next-tick path does the same, so a dropped render task cannot leave the render request latched. --- docs/packet-gui-backend.md | 12 ++ ...0006-Add-internal-packet-GUI-backend.patch | 184 +++--------------- 2 files changed, 34 insertions(+), 162 deletions(-) diff --git a/docs/packet-gui-backend.md b/docs/packet-gui-backend.md index e6449f1..ea6301c 100644 --- a/docs/packet-gui-backend.md +++ b/docs/packet-gui-backend.md @@ -116,6 +116,18 @@ deliberately routed into the click API rather than denied, because the packet is listener, so nothing vanilla can mutate. What *is* denied is drag, drop, double-click and any unrecognised mode. +## Scheduling + +All GUI work runs on the thread that owns the viewer, scheduled through FoliaLib's `PlatformScheduler`, which +the module already depends on. On Folia that is the player's region scheduler; on Paper and Spigot it resolves +to the primary thread. When the task is already on the right thread it runs inline rather than being deferred — +several call sites depend on the work having happened by the time they return, most importantly `open()`, which +publishes the session before rendering it. + +If the scheduler refuses a task, the viewer is gone: the session is discarded rather than run on the wrong +thread. That matters for the next-tick path in particular, because a dropped render task would otherwise leave +the session's render request latched and swallow every later one. + ## Build note `spotlessCheck` and `spotlessApply` do not run under this project's JDK 25 toolchain — diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 6d96b2c..cd24e39 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1228,10 +1228,10 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..9728fcab2f11af8a5d64337fede38a0c5eed9ceb +index 0000000000000000000000000000000000000000..06ba925f0dd63cf361bc522787ff789757e26e63 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1516 @@ +@@ -0,0 +1,1376 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1241,7 +1241,8 @@ index 0000000000000000000000000000000000000000..9728fcab2f11af8a5d64337fede38a0c +import com.github.retrooper.packetevents.protocol.player.User; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerCloseWindow; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerOpenWindow; -+import java.lang.reflect.Method; ++import com.tcoded.folialib.FoliaLib; ++import com.tcoded.folialib.impl.PlatformScheduler; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; @@ -1250,7 +1251,6 @@ index 0000000000000000000000000000000000000000..9728fcab2f11af8a5d64337fede38a0c +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; -+import java.util.function.Consumer; +import me.devnatan.inventoryframework.BukkitViewer; +import me.devnatan.inventoryframework.RootView; +import me.devnatan.inventoryframework.ViewContainer; @@ -1264,9 +1264,7 @@ index 0000000000000000000000000000000000000000..9728fcab2f11af8a5d64337fede38a0c +import me.devnatan.inventoryframework.internal.BukkitGuiBackend; +import me.devnatan.inventoryframework.internal.GuiBackend; +import me.devnatan.inventoryframework.pipeline.StandardPipelinePhases; -+import org.bukkit.Bukkit; +import org.bukkit.Material; -+import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryType; @@ -1303,13 +1301,8 @@ index 0000000000000000000000000000000000000000..9728fcab2f11af8a5d64337fede38a0c + private static final boolean DEBUG_CLICKS = + Boolean.getBoolean("inventory-framework.gui-backend.debug-clicks"); + -+ private static final Method PLAYER_GET_SCHEDULER = optionalMethod(Player.class, "getScheduler"); -+ private static final Method BUKKIT_IS_OWNED_BY_CURRENT_REGION = -+ optionalMethod(Bukkit.class, "isOwnedByCurrentRegion", Entity.class); -+ private static final ConcurrentMap, Method> SCHEDULER_RUN = new ConcurrentHashMap<>(); -+ private static final ConcurrentMap, Method> SCHEDULER_RUN_DELAYED = new ConcurrentHashMap<>(); -+ + private final Plugin owner; ++ private final PlatformScheduler scheduler; + private final BukkitGuiBackend fallbackBackend; + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); + private final ConcurrentMap viewerInventories = new ConcurrentHashMap<>(); @@ -1318,11 +1311,11 @@ index 0000000000000000000000000000000000000000..9728fcab2f11af8a5d64337fede38a0c + private volatile PacketGuiNativeOutboundSender nativeOutbound; + private PacketListenerCommon listener; + private volatile boolean available = true; -+ private volatile boolean regionOwnershipFailureLogged; + + public PacketGuiBackend(@NotNull Plugin owner, @NotNull BukkitGuiBackend fallbackBackend) { + this.owner = owner; + this.fallbackBackend = fallbackBackend; ++ this.scheduler = new FoliaLib(owner).getScheduler(); + } + + @Override @@ -2426,126 +2419,35 @@ index 0000000000000000000000000000000000000000..9728fcab2f11af8a5d64337fede38a0c + return session != null && !session.closed() && sessions.get(session.viewerId()) == session; + } + ++ /** ++ * Runs a task on the thread that owns the viewer. ++ * ++ *

Runs inline when already on that thread: several call sites depend on the work having happened by the ++ * time they return, most importantly {@code open()}, which publishes the session before rendering it. ++ */ + private void runOnPlayer(Player player, Runnable task) { + if (isOnPlayerThread(player)) { + task.run(); + return; + } + -+ if (tryRunEntityScheduler(player, task)) { -+ return; -+ } -+ -+ if (PLAYER_GET_SCHEDULER != null) { -+ // The entity scheduler exists but refused the task, which only happens once the player's scheduler -+ // is retired. Falling back to the global scheduler would run GUI work on the wrong thread, so the -+ // session is discarded instead. -+ abandonSession(player); -+ return; -+ } -+ -+ Bukkit.getScheduler().runTask(owner, task); -+ } -+ -+ private void runOnPlayerNextTick(Player player, Runnable task) { -+ if (tryRunEntitySchedulerDelayed(player, task)) { -+ return; -+ } -+ -+ if (PLAYER_GET_SCHEDULER != null) { -+ abandonSession(player); -+ return; -+ } -+ -+ Bukkit.getScheduler().runTask(owner, task); -+ } -+ -+ private boolean tryRunEntityScheduler(Player player, Runnable task) { -+ final Object scheduler = entityScheduler(player); -+ if (scheduler == null) { -+ return false; -+ } -+ -+ final Method run = schedulerMethod( -+ SCHEDULER_RUN, scheduler.getClass(), "run", Plugin.class, Consumer.class, Runnable.class); -+ if (run == null) { -+ return false; -+ } -+ -+ return invokeScheduler(run, scheduler, owner, consumer(task), retired(player)); -+ } -+ -+ private boolean tryRunEntitySchedulerDelayed(Player player, Runnable task) { -+ final Object scheduler = entityScheduler(player); -+ if (scheduler == null) { -+ return false; -+ } -+ -+ final Method runDelayed = schedulerMethod( -+ SCHEDULER_RUN_DELAYED, -+ scheduler.getClass(), -+ "runDelayed", -+ Plugin.class, -+ Consumer.class, -+ Runnable.class, -+ long.class); -+ if (runDelayed == null) { -+ return tryRunEntityScheduler(player, task); -+ } -+ -+ return invokeScheduler(runDelayed, scheduler, owner, consumer(task), retired(player), 1L); -+ } -+ -+ private Object entityScheduler(Player player) { -+ if (PLAYER_GET_SCHEDULER == null) { -+ return null; -+ } -+ -+ try { -+ return PLAYER_GET_SCHEDULER.invoke(player); -+ } catch (final ReflectiveOperationException exception) { -+ owner.getLogger().log(Level.WARNING, "Failed to access the packet GUI player scheduler", exception); -+ return null; -+ } -+ } -+ -+ private Method schedulerMethod( -+ ConcurrentMap, Method> cache, -+ Class schedulerClass, -+ String name, -+ Class... parameterTypes) { -+ final Method cached = cache.get(schedulerClass); -+ if (cached != null) { -+ return cached; -+ } -+ -+ final Method resolved = optionalMethod(schedulerClass, name, parameterTypes); -+ if (resolved != null) { -+ cache.putIfAbsent(schedulerClass, resolved); -+ } -+ return resolved; ++ scheduler.runAtEntityWithFallback(player, ignored -> task.run(), () -> abandonSession(player)); + } + + /** -+ * Invokes a scheduler method and reports whether the task was actually accepted. Folia returns -+ * {@code null} from {@code run}/{@code runDelayed} when the entity scheduler is retired; ignoring that -+ * silently drops the task and latches the session's scheduled-render flag forever. ++ * Runs a task on the viewer's thread on the following tick. ++ * ++ *

A {@code null} task means the scheduler refused the work because the viewer is gone; the session is ++ * then discarded so a latched render request cannot swallow every later one. + */ -+ private boolean invokeScheduler(Method method, Object scheduler, Object... arguments) { -+ try { -+ return method.invoke(scheduler, arguments) != null; -+ } catch (final ReflectiveOperationException exception) { -+ owner.getLogger().log(Level.WARNING, "Failed to schedule a packet GUI task", exception); -+ return false; ++ private void runOnPlayerNextTick(Player player, Runnable task) { ++ if (scheduler.runAtEntityLater(player, task, () -> abandonSession(player), 1L) == null) { ++ abandonSession(player); + } + } + -+ private static Consumer consumer(Runnable task) { -+ return ignored -> task.run(); -+ } -+ -+ private Runnable retired(Player player) { -+ return () -> abandonSession(player); ++ private boolean isOnPlayerThread(Player player) { ++ return scheduler.isOwnedByCurrentRegion(player); + } + + /** @@ -2570,37 +2472,6 @@ index 0000000000000000000000000000000000000000..9728fcab2f11af8a5d64337fede38a0c + + ": the viewer's scheduler is no longer accepting tasks."); + } + -+ private boolean isOnPlayerThread(Player player) { -+ final Boolean ownedByCurrentRegion = isOwnedByCurrentRegion(player); -+ if (ownedByCurrentRegion != null) { -+ return ownedByCurrentRegion; -+ } -+ -+ return Bukkit.isPrimaryThread(); -+ } -+ -+ private Boolean isOwnedByCurrentRegion(Player player) { -+ if (BUKKIT_IS_OWNED_BY_CURRENT_REGION == null) { -+ return null; -+ } -+ -+ try { -+ return Boolean.TRUE.equals(BUKKIT_IS_OWNED_BY_CURRENT_REGION.invoke(null, player)); -+ } catch (final ReflectiveOperationException exception) { -+ if (!regionOwnershipFailureLogged) { -+ regionOwnershipFailureLogged = true; -+ owner.getLogger() -+ .log( -+ Level.WARNING, -+ "Failed to query region ownership; falling back to the primary thread check.", -+ exception); -+ } -+ // null makes the caller fall back to Bukkit#isPrimaryThread instead of permanently claiming -+ // "wrong thread", which used to make every render reschedule itself forever. -+ return null; -+ } -+ } -+ + private static InventoryType.SlotType slotTypeOf(PacketGuiClick click, int topSize) { + if (click.slot() < 0) { + return InventoryType.SlotType.OUTSIDE; @@ -2627,17 +2498,6 @@ index 0000000000000000000000000000000000000000..9728fcab2f11af8a5d64337fede38a0c + return InventoryAction.NOTHING; + } + -+ private static Method optionalMethod(Class owner, String name, Class... parameterTypes) { -+ try { -+ final Method method = owner.getMethod(name, parameterTypes); -+ method.setAccessible(true); -+ return method; -+ } catch (final NoSuchMethodException ignored) { -+ return null; -+ } -+ } -+ -+ + private interface PacketGuiConversionOperation { + + boolean addPackets(PacketGuiSession session, List packets); From 39a19c8d44e9db9241a4ec642e52d96fd2bc18da Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:43:20 +0200 Subject: [PATCH 32/50] fix(packet): stop an outbound close packet from killing the GUI being opened Opening a GUI while another one was already open sometimes produced no window at all - the command had to be issued several times. The window id was allocated each time, so open() ran; the session was torn down again right after. handleInventoryClosePacket ignored the window id entirely, so any outbound ClientboundContainerClose for the viewer finalized whatever session was registered. Publishing the new session before closing the previous one, which the re-entrancy fix introduced, put that new session in the map exactly while the previous view's CLOSE pipeline was running - and that pipeline is arbitrary developer and framework code which routinely ends up calling Player#closeInventory. Its close packet then finalized the session that was still being opened. open() now marks the viewer while it runs, and close packets seen during that window are ignored: they are caused by our own teardown, not by the viewer. Independently, the handler only reacts to a close for its own window or for the player container - the latter being what Player#closeInventory emits, since the server never knows about the fake window. A close for any unrelated window no longer touches the session. --- ...0006-Add-internal-packet-GUI-backend.patch | 67 +++++++++++++------ 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index cd24e39..bdad946 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1228,10 +1228,10 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..06ba925f0dd63cf361bc522787ff789757e26e63 +index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b93172b53 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1376 @@ +@@ -0,0 +1,1403 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1308,6 +1308,13 @@ index 0000000000000000000000000000000000000000..06ba925f0dd63cf361bc522787ff7897 + private final ConcurrentMap viewerInventories = new ConcurrentHashMap<>(); + private final AtomicInteger nextWindowId = new AtomicInteger(1); + private final Set reportedUnsupportedTypes = ConcurrentHashMap.newKeySet(); ++ ++ /** ++ * Viewers currently inside {@link #open}. Outbound close packets seen while a viewer is in this set were ++ * caused by our own open sequence tearing down the previous view, not by the viewer closing the new one. ++ */ ++ private final Set openingViewers = ConcurrentHashMap.newKeySet(); ++ + private volatile PacketGuiNativeOutboundSender nativeOutbound; + private PacketListenerCommon listener; + private volatile boolean available = true; @@ -1490,26 +1497,34 @@ index 0000000000000000000000000000000000000000..06ba925f0dd63cf361bc522787ff7897 + container, + viewerInventory); + -+ // Publish before tearing down the previous session: closeSession runs the CLOSE pipeline, i.e. -+ // arbitrary developer code that may re-enter open(). The conditional remove inside closeSession keeps -+ // it from evicting this session. -+ final PacketGuiSession previous = sessions.put(player.getUniqueId(), session); -+ if (previous != null) { -+ try { -+ closeSession(previous, false, CLOSE_ORIGIN_SERVER, true, true); -+ } catch (final RuntimeException exception) { -+ owner.getLogger().log(Level.WARNING, "Failed to close the previous packet GUI session", exception); ++ // Tearing the previous session down runs its CLOSE pipeline, i.e. arbitrary developer and framework ++ // code. That code routinely ends up calling Player#closeInventory, whose outbound close packet must ++ // not be mistaken for the viewer closing the window we are in the middle of opening. ++ openingViewers.add(player.getUniqueId()); ++ try { ++ // Publish before the teardown: the close pipeline may re-enter open(), and the conditional remove ++ // inside closeSession keeps it from evicting this session. ++ final PacketGuiSession previous = sessions.put(player.getUniqueId(), session); ++ if (previous != null) { ++ try { ++ closeSession(previous, false, CLOSE_ORIGIN_SERVER, true, true); ++ } catch (final RuntimeException exception) { ++ owner.getLogger() ++ .log(Level.WARNING, "Failed to close the previous packet GUI session", exception); ++ } + } -+ } + -+ if (sessions.get(player.getUniqueId()) != session) { -+ // A close handler opened another view; that session owns the window now. Drop ours without running -+ // its close pipeline - it was never opened - and without sending a close packet. -+ closeSession(session, false, CLOSE_ORIGIN_SERVER, false, false); -+ return; -+ } ++ if (sessions.get(player.getUniqueId()) != session) { ++ // A close handler opened another view; that session owns the window now. Drop ours without ++ // running its close pipeline - it was never opened - and without sending a close packet. ++ closeSession(session, false, CLOSE_ORIGIN_SERVER, false, false); ++ return; ++ } + -+ renderSession(session, true, true); ++ renderSession(session, true, true); ++ } finally { ++ openingViewers.remove(player.getUniqueId()); ++ } + } + + /** @@ -1640,8 +1655,20 @@ index 0000000000000000000000000000000000000000..06ba925f0dd63cf361bc522787ff7897 + return; + } + ++ if (openingViewers.contains(user.getUUID())) { ++ // Emitted by our own open sequence while the previous view was being closed, not by the viewer ++ // closing the window we are opening. ++ return; ++ } ++ + final PacketGuiSession session = sessions.get(user.getUUID()); -+ if (isTracked(session)) { ++ ++ // Only a close for our own window, or for the player container - which is what Player#closeInventory ++ // emits, since the server never knows about the fake window - means this session is going away. A ++ // close for any other window belongs to something else entirely. ++ final boolean closesThisSession = isTracked(session) ++ && (windowId == session.windowId() || windowId == PacketInventoryConstants.PLAYER_WINDOW_ID); ++ if (closesThisSession) { + session.closeRequested(true); + runOnPlayer(session.player(), () -> { + final Player player = session.player(); From 891b7c064475ebfa5c48a99462f4bc5f057592d1 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:26:51 +0200 Subject: [PATCH 33/50] refactor(packet): drop the write-only viewer inventory mirror PacketViewerInventory kept a 46-slot copy of every viewer's inventory plus their cursor, fed from intercepted outbound packets. Nothing ever read it: the bottom rows of a GUI are rendered from live Bukkit reads. It was kept because of a rule that live reads would break surf-api's PacketLore. A test on the server disproves that: the bottom rows go through the native outbound sender, i.e. the same path as vanilla, and an enchanted item in the viewer's inventory shows its enchantment lore inside a packet GUI, decorated exactly once. Making the mirror authoritative would have been the wrong direction anyway - its contents come from packets PacketLore may already have decorated, so resending them risks decorating twice. What is left is PacketViewerWindowTracker, which only remembers the id of the real container window so PacketGuiWindowIds never hands out a colliding fake id. The per-slot repaints stay; they are live behaviour and read from Bukkit, not from a mirror. The listener now skips decoding outbound inventory packets for viewers without a session, so players who have no GUI open stop paying for the interception at all. With the mirror gone, PacketItemConverter no longer converts to PacketEvents items either, which removes the last use of SpigotConversionUtil. --- docs/packet-gui-backend.md | 13 + ...0006-Add-internal-packet-GUI-backend.patch | 347 +++++------------- 2 files changed, 104 insertions(+), 256 deletions(-) diff --git a/docs/packet-gui-backend.md b/docs/packet-gui-backend.md index ea6301c..534b033 100644 --- a/docs/packet-gui-backend.md +++ b/docs/packet-gui-backend.md @@ -116,6 +116,19 @@ deliberately routed into the click API rather than denied, because the packet is listener, so nothing vanilla can mutate. What *is* denied is drag, drop, double-click and any unrecognised mode. +## Where the rendered items come from + +The top rows come from the view's own render model in `PacketViewContainer`; they are never real inventory +contents. The bottom rows are read live from the viewer's Bukkit inventory and sent through +`PacketGuiNativeOutboundSender`, i.e. the same outbound path vanilla uses. + +That last point matters for surf-api's PacketLore: because the packets travel the server's normal outbound +path, an enchanted item in the viewer's inventory shows its enchantment lore inside a packet GUI exactly as it +does anywhere else, decorated once. The backend deliberately keeps **no** mirror of the viewer's items — items +captured from already-intercepted outbound packets would be decorated a second time when resent. The only +packet-side viewer state is `PacketViewerWindowTracker`, which remembers the id of the real container window so +a fake window id never collides with it. + ## Scheduling All GUI work runs on the thread that owns the viewer, scheduled through FoliaLib's `PlatformScheduler`, which diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index bdad946..59c3c91 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1228,10 +1228,10 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b93172b53 +index 0000000000000000000000000000000000000000..560c745f8dbf173a43665de61cf47336e4e8ef87 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1403 @@ +@@ -0,0 +1,1389 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1305,7 +1305,7 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b + private final PlatformScheduler scheduler; + private final BukkitGuiBackend fallbackBackend; + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); -+ private final ConcurrentMap viewerInventories = new ConcurrentHashMap<>(); ++ private final ConcurrentMap windowTrackers = new ConcurrentHashMap<>(); + private final AtomicInteger nextWindowId = new AtomicInteger(1); + private final Set reportedUnsupportedTypes = ConcurrentHashMap.newKeySet(); + @@ -1431,7 +1431,7 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b + closeSession(session, false, CLOSE_ORIGIN_SHUTDOWN, false, false); + } + sessions.clear(); -+ viewerInventories.clear(); ++ windowTrackers.clear(); + nativeOutbound = null; + } + @@ -1443,7 +1443,7 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b + // The return value decides whether IFInventoryListener skips its own quit cleanup, so it has to reflect + // whether this backend really finalized the session. + final boolean closed = closeSession(session, false, CLOSE_ORIGIN_QUIT, true, true); -+ viewerInventories.remove(player.getUniqueId()); ++ windowTrackers.remove(player.getUniqueId()); + return closed; + } + @@ -1483,8 +1483,8 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b + return; + } + -+ final PacketViewerInventory viewerInventory = inventoryFor(player.getUniqueId()); -+ final int externalWindowId = viewerInventory.openWindowId(); ++ final PacketViewerWindowTracker windowTracker = windowTrackerFor(player.getUniqueId()); ++ final int externalWindowId = windowTracker.openWindowId(); + + // A real server-side menu must not stay open behind the fake window: the client would render the GUI + // while the server keeps ticking the real container, and closing the GUI would never release it. @@ -1495,7 +1495,7 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b + user, + PacketGuiWindowIds.allocate(nextWindowId, externalWindowId), + container, -+ viewerInventory); ++ windowTracker); + + // Tearing the previous session down runs its CLOSE pipeline, i.e. arbitrary developer and framework + // code. That code routinely ends up calling Player#closeInventory, whose outbound close packet must @@ -1631,17 +1631,17 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b + if (!isTracked(session) || session.windowId() != windowId) { + return; + } -+ session.viewerInventory().closeWindow(windowId); ++ session.windowTracker().closeWindow(windowId); + runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_CLIENT, true, true)); + } + -+ void handleExternalInventoryOpen(User user, int windowId, int topSize) { ++ void handleExternalInventoryOpen(User user, int windowId) { + if (user == null || user.getUUID() == null) { + return; + } + + if (!isGuiWindow(user, windowId)) { -+ inventoryFor(user.getUUID()).setOpenWindow(windowId, topSize); ++ windowTrackerFor(user.getUUID()).setOpenWindow(windowId); + } + + final PacketGuiSession session = sessions.get(user.getUUID()); @@ -1684,7 +1684,7 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b + return; + } + -+ inventoryFor(user.getUUID()).closeWindow(windowId); ++ windowTrackerFor(user.getUUID()).closeWindow(windowId); + } + + void handleDisconnect(UUID viewerId) { @@ -1696,59 +1696,54 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b + if (session != null) { + closeSession(session, false, CLOSE_ORIGIN_QUIT, false, false); + } -+ viewerInventories.remove(viewerId); ++ windowTrackers.remove(viewerId); + } + -+ void trackWindowItems( -+ User user, -+ int windowId, -+ List items, -+ com.github.retrooper.packetevents.protocol.item.ItemStack carried) { ++ /** ++ * A content packet for the viewer's own inventory window means no container is open any more, and that the ++ * bottom rows of an open packet GUI have to be repainted from the new inventory state. ++ */ ++ void trackPlayerWindowItems(User user) { + if (user == null || user.getUUID() == null) { + return; + } + -+ final PacketViewerInventory inventory = inventoryFor(user.getUUID()); -+ if (windowId == PacketInventoryConstants.PLAYER_WINDOW_ID) { -+ inventory.applyPlayerWindowItems(items, carried); -+ mirrorPlayerInventoryWindow(user.getUUID()); -+ return; -+ } -+ -+ inventory.applyContainerWindowItems(windowId, items, carried); ++ windowTrackerFor(user.getUUID()).resetOpenWindow(); ++ mirrorPlayerInventoryWindow(user.getUUID()); + } + -+ void trackPlayerInventorySlot( -+ User user, int slot, com.github.retrooper.packetevents.protocol.item.ItemStack item) { ++ /** ++ * The server changed one slot of the viewer's real inventory; repaint the matching slot of the open GUI. ++ */ ++ void trackPlayerInventorySlot(User user, int slot) { + if (user == null || user.getUUID() == null) { + return; + } + -+ final int mappedSlot = PacketInventoryConstants.playerInventorySlotToContainerSlot(slot); -+ inventoryFor(user.getUUID()).applySlot(mappedSlot, item); -+ mirrorPlayerInventorySlot(user.getUUID(), mappedSlot, item); ++ mirrorPlayerInventorySlot( ++ user.getUUID(), PacketInventoryConstants.playerInventorySlotToContainerSlot(slot)); + } + -+ void trackWindowSlot( -+ User user, int windowId, int slot, com.github.retrooper.packetevents.protocol.item.ItemStack item) { ++ /** ++ * Same as {@link #trackPlayerInventorySlot}, for the set-slot form that addresses the player container ++ * directly. Slots of any other window are none of our business. ++ */ ++ void trackPlayerContainerSlot(User user, int windowId, int slot) { + if (user == null || user.getUUID() == null || slot < 0) { + return; + } + -+ final PacketViewerInventory inventory = inventoryFor(user.getUUID()); + if (windowId == PacketInventoryConstants.PLAYER_WINDOW_ID) { -+ inventory.applySlot(slot, item); -+ mirrorPlayerInventorySlot(user.getUUID(), slot, item); -+ return; ++ mirrorPlayerInventorySlot(user.getUUID(), slot); + } ++ } + -+ final PacketGuiSession session = sessions.get(user.getUUID()); -+ final int mappedSlot = isTracked(session) && session.windowId() == windowId -+ ? PacketViewerInventory.mapGuiContainerSlotToPlayerSlot(session.container().getSize(), slot) -+ : inventory.mapContainerSlotToPlayerSlot(windowId, slot); -+ if (mappedSlot >= 0) { -+ inventory.applySlot(mappedSlot, item); -+ } ++ /** ++ * Whether the viewer has a packet GUI open. Lets the listener skip decoding outbound inventory packets for ++ * everyone else on the server. ++ */ ++ boolean hasSession(User user) { ++ return user != null && user.getUUID() != null && isTracked(sessions.get(user.getUUID())); + } + + private void mirrorPlayerInventoryWindow(UUID viewerId) { @@ -1795,8 +1790,7 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b + }); + } + -+ private void mirrorPlayerInventorySlot( -+ UUID viewerId, int playerWindowSlot, com.github.retrooper.packetevents.protocol.item.ItemStack item) { ++ private void mirrorPlayerInventorySlot(UUID viewerId, int playerWindowSlot) { + final PacketGuiSession session = sessions.get(viewerId); + if (!isTracked(session) || session.closeRequested()) { + return; @@ -1832,14 +1826,6 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b + return PacketInventoryConstants.playerWindowSlotToGuiContainerSlot(topSize, playerWindowSlot); + } + -+ void trackCursor(User user, com.github.retrooper.packetevents.protocol.item.ItemStack item) { -+ if (user == null || user.getUUID() == null) { -+ return; -+ } -+ -+ inventoryFor(user.getUUID()).applyCursor(item); -+ } -+ + private void handlePacketClick( + PacketGuiSession session, PacketGuiClick click, PacketGuiRepairScope repairScope) { + if (!isTracked(session)) { @@ -1851,7 +1837,7 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b + final boolean outsideClick = click.isOutsideClick(); + final boolean playerInventoryClick = !outsideClick && click.isPlayerInventoryClick(topSize); + final int mappedPlayerSlot = playerInventoryClick -+ ? PacketViewerInventory.mapGuiContainerSlotToPlayerSlot(topSize, click.slot()) ++ ? PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(topSize, click.slot()) + : -1; + + final Component clickedComponent; @@ -2405,7 +2391,7 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b + } + + sessions.remove(session.viewerId(), session); -+ session.viewerInventory().resetOpenWindow(); ++ session.windowTracker().resetOpenWindow(); + if (sendClosePacket) { + try { + sendCursor(session); @@ -2433,8 +2419,8 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b + root.getPipeline().execute(StandardPipelinePhases.CLOSE, closeContext); + } + -+ private PacketViewerInventory inventoryFor(UUID viewerId) { -+ return viewerInventories.computeIfAbsent(viewerId, ignored -> new PacketViewerInventory()); ++ private PacketViewerWindowTracker windowTrackerFor(UUID viewerId) { ++ return windowTrackers.computeIfAbsent(viewerId, ignored -> new PacketViewerWindowTracker()); + } + + private User user(UUID viewerId) { @@ -2493,7 +2479,7 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b + session.closed(true); + } + session.clearScheduledRender(); -+ session.viewerInventory().resetOpenWindow(); ++ session.windowTracker().resetOpenWindow(); + owner.getLogger() + .fine("Discarded packet GUI session for " + player.getName() + + ": the viewer's scheduler is no longer accepting tasks."); @@ -2637,7 +2623,7 @@ index 0000000000000000000000000000000000000000..6458e064215285e7135f863f7330411b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java new file mode 100644 -index 0000000000000000000000000000000000000000..61555752645c56582c133550d96cd287b0e5b3ba +index 0000000000000000000000000000000000000000..0da7f70a1658150890b2500d7251bef4177ef278 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiClick.java @@ -0,0 +1,244 @@ @@ -2750,7 +2736,7 @@ index 0000000000000000000000000000000000000000..61555752645c56582c133550d96cd287 + } + + boolean isPlayerInventoryClick(int topSize) { -+ return slot >= topSize && PacketViewerInventory.mapGuiContainerSlotToPlayerSlot(topSize, slot) >= 0; ++ return slot >= topSize && PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(topSize, slot) >= 0; + } + + boolean isSafePlayerInventoryClick(int topSize) { @@ -3377,7 +3363,7 @@ index 0000000000000000000000000000000000000000..251f44ed68dfba2c9f0a36b7d779ec12 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java new file mode 100644 -index 0000000000000000000000000000000000000000..6c57219bded1721625338161728081f06fca9813 +index 0000000000000000000000000000000000000000..36f011f845d718429c5892e584489c7f2880422b --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java @@ -0,0 +1,129 @@ @@ -3389,7 +3375,6 @@ index 0000000000000000000000000000000000000000..6c57219bded1721625338161728081f0 +import com.github.retrooper.packetevents.event.PacketSendEvent; +import com.github.retrooper.packetevents.event.UserDisconnectEvent; +import com.github.retrooper.packetevents.protocol.ConnectionState; -+import com.github.retrooper.packetevents.protocol.item.ItemStack; +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon; +import com.github.retrooper.packetevents.protocol.player.User; @@ -3398,7 +3383,6 @@ index 0000000000000000000000000000000000000000..6c57219bded1721625338161728081f0 +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerCloseWindow; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerOpenHorseWindow; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerOpenWindow; -+import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetCursorItem; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetPlayerInventory; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetSlot; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerWindowItems; @@ -3456,42 +3440,44 @@ index 0000000000000000000000000000000000000000..6c57219bded1721625338161728081f0 + + final User user = event.getUser(); + final PacketTypeCommon packetType = event.getPacketType(); ++ ++ // These three only exist to repaint the bottom rows of an open GUI. Decoding the wrapper is the ++ // expensive part, so viewers without a session - i.e. almost everyone on the server - skip it entirely. + if (packetType == PacketType.Play.Server.WINDOW_ITEMS) { -+ final WrapperPlayServerWindowItems packet = new WrapperPlayServerWindowItems(event); -+ final ItemStack carried = packet.getCarriedItem().orElse(ItemStack.EMPTY); -+ backend.trackWindowItems(user, packet.getWindowId(), packet.getItems(), carried); ++ if (backend.hasSession(user) ++ && new WrapperPlayServerWindowItems(event).getWindowId() ++ == PacketInventoryConstants.PLAYER_WINDOW_ID) { ++ backend.trackPlayerWindowItems(user); ++ } + return; + } + + if (packetType == PacketType.Play.Server.SET_PLAYER_INVENTORY) { -+ final WrapperPlayServerSetPlayerInventory packet = new WrapperPlayServerSetPlayerInventory(event); -+ backend.trackPlayerInventorySlot(user, packet.getSlot(), packet.getStack()); ++ if (backend.hasSession(user)) { ++ backend.trackPlayerInventorySlot(user, new WrapperPlayServerSetPlayerInventory(event).getSlot()); ++ } + return; + } + + if (packetType == PacketType.Play.Server.SET_SLOT) { -+ final WrapperPlayServerSetSlot packet = new WrapperPlayServerSetSlot(event); -+ backend.trackWindowSlot(user, packet.getWindowId(), packet.getSlot(), packet.getItem()); -+ return; -+ } -+ -+ if (packetType == PacketType.Play.Server.SET_CURSOR_ITEM) { -+ final WrapperPlayServerSetCursorItem packet = new WrapperPlayServerSetCursorItem(event); -+ backend.trackCursor(user, packet.getStack()); ++ if (backend.hasSession(user)) { ++ final WrapperPlayServerSetSlot packet = new WrapperPlayServerSetSlot(event); ++ backend.trackPlayerContainerSlot(user, packet.getWindowId(), packet.getSlot()); ++ } + return; + } + + if (packetType == PacketType.Play.Server.OPEN_WINDOW) { + final WrapperPlayServerOpenWindow packet = new WrapperPlayServerOpenWindow(event); + if (!backend.isGuiWindow(user, packet.getContainerId())) { -+ backend.handleExternalInventoryOpen(user, packet.getContainerId(), -1); ++ backend.handleExternalInventoryOpen(user, packet.getContainerId()); + } + return; + } + + if (packetType == PacketType.Play.Server.OPEN_HORSE_WINDOW) { + final WrapperPlayServerOpenHorseWindow packet = new WrapperPlayServerOpenHorseWindow(event); -+ backend.handleExternalInventoryOpen(user, packet.getWindowId(), packet.getSlotCount()); ++ backend.handleExternalInventoryOpen(user, packet.getWindowId()); + return; + } + @@ -3640,7 +3626,7 @@ index 0000000000000000000000000000000000000000..862304c2a30d0bae12cb416567801644 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java new file mode 100644 -index 0000000000000000000000000000000000000000..c41bf5c17d4e8533b5e343dd272e719c7cc97112 +index 0000000000000000000000000000000000000000..caf7e4bcdc4b93a38b5bb856499a69cdfe2ecec3 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java @@ -0,0 +1,273 @@ @@ -3669,7 +3655,7 @@ index 0000000000000000000000000000000000000000..c41bf5c17d4e8533b5e343dd272e719c + private final User user; + private final int windowId; + private final PacketViewContainer container; -+ private final PacketViewerInventory viewerInventory; ++ private final PacketViewerWindowTracker windowTracker; + private PacketGuiRender appliedRender; + private boolean renderScheduled; + private boolean scheduledForceReopen; @@ -3688,14 +3674,14 @@ index 0000000000000000000000000000000000000000..c41bf5c17d4e8533b5e343dd272e719c + User user, + int windowId, + PacketViewContainer container, -+ PacketViewerInventory viewerInventory) { ++ PacketViewerWindowTracker windowTracker) { + this.viewer = viewer; + this.player = viewer.getPlayer(); + this.viewerId = player.getUniqueId(); + this.user = user; + this.windowId = windowId; + this.container = container; -+ this.viewerInventory = viewerInventory; ++ this.windowTracker = windowTracker; + } + + synchronized UUID viewerId() { @@ -3726,8 +3712,8 @@ index 0000000000000000000000000000000000000000..c41bf5c17d4e8533b5e343dd272e719c + return container.getContext(); + } + -+ synchronized PacketViewerInventory viewerInventory() { -+ return viewerInventory; ++ synchronized PacketViewerWindowTracker windowTracker() { ++ return windowTracker; + } + + synchronized PacketGuiRender appliedRender() { @@ -3817,7 +3803,7 @@ index 0000000000000000000000000000000000000000..c41bf5c17d4e8533b5e343dd272e719c + private void scheduleTargetedRepairs(PacketGuiRepairScope scope, PacketGuiClick click) { + if (scope.repairsClickedPlayerSlot()) { + schedulePlayerSlotRepair( -+ PacketViewerInventory.mapGuiContainerSlotToPlayerSlot(container.getSize(), click.slot())); ++ PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(container.getSize(), click.slot())); + scheduleChangedPlayerSlotRepairs(click); + return; + } @@ -3849,7 +3835,7 @@ index 0000000000000000000000000000000000000000..c41bf5c17d4e8533b5e343dd272e719c + private void scheduleChangedPlayerSlotRepairs(PacketGuiClick click) { + final int topSize = container.getSize(); + for (final int changedSlot : click.changedSlots()) { -+ final int playerSlot = PacketViewerInventory.mapGuiContainerSlotToPlayerSlot(topSize, changedSlot); ++ final int playerSlot = PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(topSize, changedSlot); + schedulePlayerSlotRepair(playerSlot); + } + @@ -4070,13 +4056,12 @@ index 0000000000000000000000000000000000000000..c6ed7053e89d189987f66763d4ecacdb +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java new file mode 100644 -index 0000000000000000000000000000000000000000..f244c5c220a03b273b1578a740ea8aa06143763e +index 0000000000000000000000000000000000000000..64a961f8ad57fff4c73976a3fc111953d2d317b4 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java -@@ -0,0 +1,223 @@ +@@ -0,0 +1,203 @@ +package me.devnatan.inventoryframework.internal.packet; + -+import io.github.retrooper.packetevents.util.SpigotConversionUtil; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; @@ -4101,25 +4086,6 @@ index 0000000000000000000000000000000000000000..f244c5c220a03b273b1578a740ea8aa0 + + private PacketItemConverter() {} + -+ static com.github.retrooper.packetevents.protocol.item.ItemStack toPacket(ItemStack item) { -+ if (isEmpty(item)) { -+ return com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY; -+ } -+ -+ final com.github.retrooper.packetevents.protocol.item.ItemStack converted = -+ SpigotConversionUtil.fromBukkitItemStack(normalizeItem(item)); -+ return converted == null || converted.isEmpty() -+ ? com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY -+ : converted.copy(); -+ } -+ -+ static com.github.retrooper.packetevents.protocol.item.ItemStack copy( -+ com.github.retrooper.packetevents.protocol.item.ItemStack item) { -+ return item == null || item.isEmpty() -+ ? com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY -+ : item.copy(); -+ } -+ + static boolean sameDisplayItem(ItemStack first, ItemStack second) { + if (first == second) { + return true; @@ -4587,134 +4553,29 @@ index 0000000000000000000000000000000000000000..6b1f73f28147bef735305a8fa43ed8c5 + + '}'; + } +} -diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerWindowTracker.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerWindowTracker.java new file mode 100644 -index 0000000000000000000000000000000000000000..47afe29fb8b215ba7596f95d8c9728a2f40453ee +index 0000000000000000000000000000000000000000..8f73512e02404bcb445b1b1630d45f9303264c57 --- /dev/null -+++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerInventory.java -@@ -0,0 +1,171 @@ ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketViewerWindowTracker.java +@@ -0,0 +1,40 @@ +package me.devnatan.inventoryframework.internal.packet; + -+import java.util.ArrayList; -+import java.util.Arrays; -+import java.util.List; -+import org.bukkit.entity.Player; -+import org.bukkit.inventory.PlayerInventory; -+ +/** -+ * Per-viewer packet-side state: which real window the viewer currently has open, plus a mirror of their -+ * inventory contents fed from outbound packets. ++ * Remembers which real container window a viewer currently has open. + * -+ *

The open-window id is what keeps a fake GUI window from colliding with a live vanilla container. The -+ * item mirror is currently only written, not read - rendering reads the live Bukkit inventory instead - and is -+ * kept for the planned move to a fully authoritative packet-side model. ++ *

That is the one piece of packet-side viewer state the backend needs: {@link PacketGuiWindowIds} uses it to ++ * avoid handing out a fake window id that a live vanilla container already owns, which would make the client ++ * apply that container's updates to the GUI screen. ++ * ++ *

There is deliberately no mirror of the viewer's items here. The bottom rows of a packet GUI are read live ++ * from the Bukkit inventory and sent through {@link PacketGuiNativeOutboundSender}, i.e. the same outbound path ++ * as vanilla, so surf-api's PacketLore decorates them exactly once. Re-sending items captured from already ++ * intercepted outbound packets would risk decorating them twice. + */ -+final class PacketViewerInventory { -+ -+ private final com.github.retrooper.packetevents.protocol.item.ItemStack[] slots = -+ new com.github.retrooper.packetevents.protocol.item.ItemStack[PacketInventoryConstants.INVENTORY_SIZE]; -+ private final boolean[] knownSlots = new boolean[PacketInventoryConstants.INVENTORY_SIZE]; -+ private com.github.retrooper.packetevents.protocol.item.ItemStack cursor = -+ com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY; -+ private boolean cursorKnown; -+ private int openWindowId = PacketInventoryConstants.PLAYER_WINDOW_ID; -+ private int openWindowTopSize = PacketInventoryConstants.INVENTORY_SIZE; -+ -+ PacketViewerInventory() { -+ Arrays.fill(slots, com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY); -+ } -+ -+ synchronized void snapshotFrom(Player player) { -+ final PlayerInventory inventory = player.getInventory(); ++final class PacketViewerWindowTracker { + -+ for (int slot = 0; slot <= 35; slot++) { -+ applySlot( -+ PacketInventoryConstants.playerInventorySlotToContainerSlot(slot), -+ PacketItemConverter.toPacket(inventory.getItem(slot))); -+ } -+ -+ applySlot(PacketInventoryConstants.SLOT_HELMET, PacketItemConverter.toPacket(inventory.getHelmet())); -+ applySlot(PacketInventoryConstants.SLOT_CHESTPLATE, PacketItemConverter.toPacket(inventory.getChestplate())); -+ applySlot(PacketInventoryConstants.SLOT_LEGGINGS, PacketItemConverter.toPacket(inventory.getLeggings())); -+ applySlot(PacketInventoryConstants.SLOT_BOOTS, PacketItemConverter.toPacket(inventory.getBoots())); -+ applySlot(PacketInventoryConstants.SLOT_OFFHAND, PacketItemConverter.toPacket(inventory.getItemInOffHand())); -+ applyCursor(PacketItemConverter.toPacket(player.getItemOnCursor())); -+ } -+ -+ synchronized void applyPlayerWindowItems( -+ List items, -+ com.github.retrooper.packetevents.protocol.item.ItemStack carried) { -+ Arrays.fill(slots, com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY); -+ Arrays.fill(knownSlots, false); -+ -+ final int limit = Math.min(items.size(), PacketInventoryConstants.INVENTORY_SIZE); -+ for (int slot = 0; slot < limit; slot++) { -+ slots[slot] = PacketItemConverter.copy(items.get(slot)); -+ knownSlots[slot] = true; -+ } -+ -+ applyCursor(carried); -+ resetOpenWindow(); -+ } -+ -+ synchronized void applyContainerWindowItems( -+ int windowId, -+ List items, -+ com.github.retrooper.packetevents.protocol.item.ItemStack carried) { -+ if (items.size() >= 36) { -+ setOpenWindow(windowId, items.size() - 36); -+ final int playerSectionStart = items.size() - 36; -+ -+ for (int index = 0; index < 27; index++) { -+ applySlot(PacketInventoryConstants.ITEMS_START + index, items.get(playerSectionStart + index)); -+ } -+ -+ for (int index = 0; index < 9; index++) { -+ applySlot(PacketInventoryConstants.HOTBAR_START + index, items.get(playerSectionStart + 27 + index)); -+ } -+ } -+ -+ applyCursor(carried); -+ } -+ -+ synchronized void applySlot(int slot, com.github.retrooper.packetevents.protocol.item.ItemStack item) { -+ if (slot < 0 || slot >= PacketInventoryConstants.INVENTORY_SIZE) { -+ return; -+ } -+ -+ slots[slot] = PacketItemConverter.copy(item); -+ knownSlots[slot] = true; -+ } -+ -+ synchronized void applyCursor(com.github.retrooper.packetevents.protocol.item.ItemStack item) { -+ cursor = PacketItemConverter.copy(item); -+ cursorKnown = true; -+ } -+ -+ synchronized boolean isKnown(int slot) { -+ return slot >= 0 && slot < PacketInventoryConstants.INVENTORY_SIZE && knownSlots[slot]; -+ } -+ -+ synchronized com.github.retrooper.packetevents.protocol.item.ItemStack item(int slot) { -+ if (!isKnown(slot)) { -+ return com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY; -+ } -+ -+ return PacketItemConverter.copy(slots[slot]); -+ } -+ -+ synchronized com.github.retrooper.packetevents.protocol.item.ItemStack cursor() { -+ return cursorKnown -+ ? PacketItemConverter.copy(cursor) -+ : com.github.retrooper.packetevents.protocol.item.ItemStack.EMPTY; -+ } -+ -+ synchronized List mainAndHotbarItems() { -+ final List items = new ArrayList<>(36); -+ appendRange(items, PacketInventoryConstants.ITEMS_START, 27); -+ appendRange(items, PacketInventoryConstants.HOTBAR_START, 9); -+ return items; -+ } ++ private int openWindowId = PacketInventoryConstants.PLAYER_WINDOW_ID; + + /** + * The window id of the real container the viewer currently has open, or @@ -4724,9 +4585,8 @@ index 0000000000000000000000000000000000000000..47afe29fb8b215ba7596f95d8c9728a2 + return openWindowId; + } + -+ synchronized void setOpenWindow(int windowId, int topSize) { ++ synchronized void setOpenWindow(int windowId) { + openWindowId = windowId; -+ openWindowTopSize = topSize; + } + + synchronized void closeWindow(int windowId) { @@ -4737,32 +4597,7 @@ index 0000000000000000000000000000000000000000..47afe29fb8b215ba7596f95d8c9728a2 + + synchronized void resetOpenWindow() { + openWindowId = PacketInventoryConstants.PLAYER_WINDOW_ID; -+ openWindowTopSize = PacketInventoryConstants.INVENTORY_SIZE; -+ } -+ -+ synchronized int mapContainerSlotToPlayerSlot(int windowId, int containerSlot) { -+ if (windowId == PacketInventoryConstants.PLAYER_WINDOW_ID) { -+ return containerSlot; -+ } -+ -+ if (windowId != openWindowId || openWindowTopSize < 0) { -+ return -1; -+ } -+ -+ return mapGuiContainerSlotToPlayerSlot(openWindowTopSize, containerSlot); + } -+ -+ static int mapGuiContainerSlotToPlayerSlot(int topSize, int containerSlot) { -+ return PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(topSize, containerSlot); -+ } -+ -+ private void appendRange( -+ List items, int sourceStart, int amount) { -+ for (int index = 0; index < amount; index++) { -+ items.add(item(sourceStart + index)); -+ } -+ } -+ +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/GlobalClickInterceptor.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/pipeline/GlobalClickInterceptor.java index ec3bd553181923362bece51aff5fec6983a8a52f..b5888370d5c9703c7487d4312df7e9c989e3940d 100644 From 415c2fd14c3b9c00ea9ebaaacc22359ef3b27eb2 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:38:44 +0200 Subject: [PATCH 34/50] refactor(packet): collapse the two-phase render plan into one Rendering built a plan of conversion operations, then walked those operations to build a second plan of outbound packets, then sent that. Both phases ran on the viewer thread in the same tick, so the split bought nothing - it was left over from an earlier attempt to convert items off-thread. It cost something, though. The two plans were the same (channel, windowId, generation, List) value object written twice, and canConvertPlan and canSendPlan were byte-identical five-clause guards forty lines apart, so a fix applied to one would silently miss the other. The phases had already drifted: addWindowItems keyed its packet on the plan window id while addPlayerSlotRepairs used the session one. There is now a single PacketGuiSendPlan that collects outbound packets as the render is built, and a single canSend guard, re-checked before each packet exactly as before. Item copies happen while building rather than during a separate conversion walk, which is the same thread and the same tick. --- ...0006-Add-internal-packet-GUI-backend.patch | 325 ++++-------------- 1 file changed, 76 insertions(+), 249 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 59c3c91..afaada2 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1228,10 +1228,10 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..560c745f8dbf173a43665de61cf47336e4e8ef87 +index 0000000000000000000000000000000000000000..02e6833d21f84f298584bbcae5b2b44261c4f98f --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1389 @@ +@@ -0,0 +1,1216 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1970,118 +1970,72 @@ index 0000000000000000000000000000000000000000..560c745f8dbf173a43665de61cf47336 + final boolean sendFullWindow = hardResync || reopen || previous == null; + + try { -+ final PlayerInventorySnapshot playerInventorySnapshot = sendFullWindow -+ ? snapshotPlayerInventory(session.player()) -+ : null; -+ final PacketGuiConversionPlan conversionPlan = new PacketGuiConversionPlan( -+ session.user(), -+ session.windowId(), -+ session.nextSendGeneration()); ++ final PlayerInventorySnapshot playerInventorySnapshot = ++ sendFullWindow ? snapshotPlayerInventory(session.player()) : null; ++ final PacketGuiSendPlan plan = new PacketGuiSendPlan( ++ session.user().getChannel(), session.windowId(), session.nextSendGeneration()); + + if (reopen) { -+ addOpenWindow(conversionPlan, session, render); ++ addOpenWindow(plan, session, render); + } + + if (sendFullWindow) { -+ addWindowItems(conversionPlan, session, render, playerInventorySnapshot); -+ addCursor(conversionPlan, playerInventorySnapshot.cursor()); ++ addWindowItems(plan, session, render, playerInventorySnapshot); ++ addCursor(plan, playerInventorySnapshot.cursor()); + } else { -+ addChangedTopSlots(conversionPlan, session, previous, render, forcedTopSlotRepairs); -+ addPlayerSlotRepairs(conversionPlan, session, render, playerSlotRepairs); -+ addCursor(conversionPlan, cloneItem(session.player().getItemOnCursor())); ++ addChangedTopSlots(plan, session, previous, render, forcedTopSlotRepairs); ++ addPlayerSlotRepairs(plan, session, render, playerSlotRepairs); ++ addCursor(plan, cloneItem(session.player().getItemOnCursor())); + } + + session.appliedRender(render); -+ convertAndSendRenderPlan(session, conversionPlan); ++ sendRenderPlan(session, plan); + } catch (final RuntimeException exception) { + owner.getLogger().log(Level.WARNING, "Failed to prepare packet GUI render", exception); + closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true); + } + } + -+ private void addOpenWindow( -+ PacketGuiConversionPlan conversionPlan, PacketGuiSession session, PacketGuiRender render) { ++ private void addOpenWindow(PacketGuiSendPlan plan, PacketGuiSession session, PacketGuiRender render) { + final ServerVersion version = PacketEvents.getAPI().getServerManager().getVersion(); -+ final boolean modernWindowType = version.isNewerThanOrEquals(ServerVersion.V_1_14); -+ final int windowId = session.windowId(); -+ final int rows = render.rows(); -+ final int size = render.size(); -+ final net.kyori.adventure.text.Component title = render.title(); -+ conversionPlan.addOperation((targetSession, packets) -> { -+ final WrapperPlayServerOpenWindow packet; -+ if (modernWindowType) { -+ packet = new WrapperPlayServerOpenWindow(windowId, rows - 1, title); -+ } else { -+ packet = new WrapperPlayServerOpenWindow(windowId, "minecraft:chest", title, size, 0); -+ } -+ packets.add((sender, player) -> targetSession.user().sendPacket(packet)); -+ return true; -+ }); ++ final WrapperPlayServerOpenWindow packet = version.isNewerThanOrEquals(ServerVersion.V_1_14) ++ ? new WrapperPlayServerOpenWindow(session.windowId(), render.rows() - 1, render.title()) ++ : new WrapperPlayServerOpenWindow( ++ session.windowId(), "minecraft:chest", render.title(), render.size(), 0); ++ ++ plan.add((sender, player) -> session.user().sendPacket(packet)); + } + + private void addWindowItems( -+ PacketGuiConversionPlan conversionPlan, ++ PacketGuiSendPlan plan, + PacketGuiSession session, + PacketGuiRender render, + PlayerInventorySnapshot playerInventorySnapshot) { + final int stateId = session.nextStateId(); -+ final ItemStack[] topItems = new ItemStack[render.size()]; ++ final List items = new ArrayList<>(render.size() + 36); + for (int slot = 0; slot < render.size(); slot++) { -+ topItems[slot] = render.bukkitItem(slot); ++ items.add(render.bukkitItem(slot)); + } -+ final ItemStack cursor = playerInventorySnapshot.cursor(); -+ -+ conversionPlan.addOperation((targetSession, packets) -> { -+ final List items = new ArrayList<>(topItems.length + 36); -+ for (final ItemStack topItem : topItems) { -+ if (!canConvertPlan(targetSession, conversionPlan)) { -+ return false; -+ } -+ items.add(cloneItem(topItem)); -+ } + -+ for (int slot = PacketInventoryConstants.ITEMS_START; -+ slot < PacketInventoryConstants.ITEMS_START + 27; -+ slot++) { -+ if (!addPlayerInventoryItem(targetSession, conversionPlan, playerInventorySnapshot, items, slot)) { -+ return false; -+ } -+ } -+ -+ for (int slot = PacketInventoryConstants.HOTBAR_START; -+ slot < PacketInventoryConstants.HOTBAR_START + 9; -+ slot++) { -+ if (!addPlayerInventoryItem(targetSession, conversionPlan, playerInventorySnapshot, items, slot)) { -+ return false; -+ } -+ } -+ -+ packets.add((sender, player) -> sender.sendContainerSetContent( -+ player, -+ conversionPlan.windowId(), -+ stateId, -+ items, -+ cloneItem(cursor))); -+ return true; -+ }); -+ } ++ for (int slot = PacketInventoryConstants.ITEMS_START; ++ slot < PacketInventoryConstants.ITEMS_START + 27; ++ slot++) { ++ items.add(cloneItem(playerInventorySnapshot.item(slot))); ++ } + -+ private boolean addPlayerInventoryItem( -+ PacketGuiSession session, -+ PacketGuiConversionPlan conversionPlan, -+ PlayerInventorySnapshot playerInventorySnapshot, -+ List target, -+ int playerWindowSlot) { -+ if (!canConvertPlan(session, conversionPlan)) { -+ return false; ++ for (int slot = PacketInventoryConstants.HOTBAR_START; ++ slot < PacketInventoryConstants.HOTBAR_START + 9; ++ slot++) { ++ items.add(cloneItem(playerInventorySnapshot.item(slot))); + } + -+ target.add(cloneItem(playerInventorySnapshot.item(playerWindowSlot))); -+ return true; ++ final ItemStack cursor = cloneItem(playerInventorySnapshot.cursor()); ++ plan.add((sender, player) -> ++ sender.sendContainerSetContent(player, plan.windowId(), stateId, items, cursor)); + } + + private void addChangedTopSlots( -+ PacketGuiConversionPlan conversionPlan, ++ PacketGuiSendPlan plan, + PacketGuiSession session, + PacketGuiRender previous, + PacketGuiRender render, @@ -2099,69 +2053,34 @@ index 0000000000000000000000000000000000000000..560c745f8dbf173a43665de61cf47336 + + final int currentStateId = stateId; + final int currentSlot = slot; -+ final ItemStack itemSnapshot = render.bukkitItem(currentSlot); -+ conversionPlan.addOperation((targetSession, packets) -> { -+ if (!canConvertPlan(targetSession, conversionPlan)) { -+ return false; -+ } -+ -+ packets.add((sender, player) -> sender.sendContainerSetSlot( -+ player, -+ conversionPlan.windowId(), -+ currentStateId, -+ currentSlot, -+ cloneItem(itemSnapshot))); -+ return true; -+ }); ++ final ItemStack item = render.bukkitItem(currentSlot); ++ plan.add((sender, player) -> ++ sender.sendContainerSetSlot(player, plan.windowId(), currentStateId, currentSlot, item)); + } + } + + private void addPlayerSlotRepairs( -+ PacketGuiConversionPlan conversionPlan, -+ PacketGuiSession session, -+ PacketGuiRender render, -+ int[] playerSlotRepairs) { -+ if (playerSlotRepairs.length == 0) { -+ return; -+ } -+ ++ PacketGuiSendPlan plan, PacketGuiSession session, PacketGuiRender render, int[] playerSlotRepairs) { + int guiStateId = -1; + for (final int playerSlot : playerSlotRepairs) { -+ final ItemStack itemSnapshot = cloneItem(playerInventoryItem(session.player(), playerSlot)); ++ final ItemStack item = cloneItem(playerInventoryItem(session.player(), playerSlot)); + final int openGuiSlot = mapPlayerWindowSlotToOpenGuiSlot(render.size(), playerSlot); -+ final int stateId; ++ + if (openGuiSlot >= 0) { + if (guiStateId < 0) { + guiStateId = session.nextStateId(); + } -+ stateId = guiStateId; -+ } else { -+ stateId = session.nextStateId(); -+ } -+ final int playerInventorySlot = PacketInventoryConstants.containerSlotToPlayerInventorySlot(playerSlot); + -+ conversionPlan.addOperation((targetSession, packets) -> { -+ if (!canConvertPlan(targetSession, conversionPlan)) { -+ return false; -+ } ++ final int stateId = guiStateId; ++ plan.add((sender, player) -> ++ sender.sendContainerSetSlot(player, plan.windowId(), stateId, openGuiSlot, item)); ++ continue; ++ } + -+ if (openGuiSlot >= 0) { -+ packets.add((sender, player) -> sender.sendContainerSetSlot( -+ player, -+ targetSession.windowId(), -+ stateId, -+ openGuiSlot, -+ cloneItem(itemSnapshot))); -+ } else { -+ packets.add((sender, player) -> sender.sendPlayerInventorySlot( -+ player, -+ playerInventorySlot, -+ playerSlot, -+ stateId, -+ cloneItem(itemSnapshot))); -+ } -+ return true; -+ }); ++ final int stateId = session.nextStateId(); ++ final int playerInventorySlot = PacketInventoryConstants.containerSlotToPlayerInventorySlot(playerSlot); ++ plan.add((sender, player) -> ++ sender.sendPlayerInventorySlot(player, playerInventorySlot, playerSlot, stateId, item)); + } + } + @@ -2261,89 +2180,36 @@ index 0000000000000000000000000000000000000000..560c745f8dbf173a43665de61cf47336 + sender.sendCursor(session.player(), cloneItem(session.player().getItemOnCursor())); + } + -+ private void addCursor(PacketGuiConversionPlan conversionPlan, ItemStack cursorSnapshot) { -+ conversionPlan.addOperation((targetSession, packets) -> { -+ if (!canConvertPlan(targetSession, conversionPlan)) { -+ return false; -+ } -+ -+ packets.add((sender, player) -> sender.sendCursor(player, cloneItem(cursorSnapshot))); -+ return true; -+ }); -+ } -+ -+ private void convertAndSendRenderPlan(PacketGuiSession session, PacketGuiConversionPlan conversionPlan) { -+ if (conversionPlan.isEmpty()) { -+ return; -+ } -+ -+ if (!isOnPlayerThread(session.player())) { -+ runOnPlayer(session.player(), () -> sendConvertedRenderPlan(session, conversionPlan)); -+ return; -+ } -+ -+ sendConvertedRenderPlan(session, conversionPlan); -+ } -+ -+ private void sendConvertedRenderPlan(PacketGuiSession session, PacketGuiConversionPlan conversionPlan) { -+ if (!canConvertPlan(session, conversionPlan)) { -+ return; -+ } -+ -+ try { -+ final List packets = new ArrayList<>(); -+ for (final PacketGuiConversionOperation operation : conversionPlan.operations()) { -+ if (!canConvertPlan(session, conversionPlan)) { -+ return; -+ } -+ -+ if (!operation.addPackets(session, packets)) { -+ return; -+ } -+ } -+ -+ sendRenderPlan(session, new PacketGuiSendPlan( -+ conversionPlan.channel(), -+ conversionPlan.windowId(), -+ conversionPlan.generation(), -+ packets)); -+ } catch (final RuntimeException exception) { -+ owner.getLogger().log(Level.WARNING, "Failed to prepare native packet GUI item packets", exception); -+ runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true)); -+ } -+ } -+ -+ private boolean canConvertPlan(PacketGuiSession session, PacketGuiConversionPlan plan) { -+ return isTracked(session) -+ && nativeOutbound != null -+ && !session.closeRequested() -+ && session.windowId() == plan.windowId() -+ && session.acceptsSendGeneration(plan.generation()) -+ && ChannelHelper.isOpen(plan.channel()); ++ private void addCursor(PacketGuiSendPlan plan, ItemStack cursorSnapshot) { ++ final ItemStack cursor = cloneItem(cursorSnapshot); ++ plan.add((sender, player) -> sender.sendCursor(player, cursor)); + } + ++ /** ++ * Sends a prepared render. ++ * ++ *

The guard is re-checked before every packet: a session can be closed, superseded by a newer render, or ++ * lose its channel part-way through, and stopping half way is better than addressing packets at a window ++ * the client no longer has. ++ */ + private void sendRenderPlan(PacketGuiSession session, PacketGuiSendPlan plan) { + if (plan.isEmpty()) { + return; + } + + if (!isOnPlayerThread(session.player())) { -+ runOnPlayer(session.player(), () -> sendRenderPlanNow(session, plan)); ++ runOnPlayer(session.player(), () -> sendRenderPlan(session, plan)); + return; + } + -+ sendRenderPlanNow(session, plan); -+ } -+ -+ private void sendRenderPlanNow(PacketGuiSession session, PacketGuiSendPlan plan) { + final PacketGuiNativeOutboundSender sender = nativeOutbound; -+ if (sender == null || !canSendPlan(session, plan)) { ++ if (sender == null) { + return; + } + + try { + for (final PacketGuiOutboundPacket packet : plan.packets()) { -+ if (!canSendPlan(session, plan)) { ++ if (!canSend(session, plan)) { + return; + } + @@ -2355,7 +2221,7 @@ index 0000000000000000000000000000000000000000..560c745f8dbf173a43665de61cf47336 + } + } + -+ private boolean canSendPlan(PacketGuiSession session, PacketGuiSendPlan plan) { ++ private boolean canSend(PacketGuiSession session, PacketGuiSendPlan plan) { + return isTracked(session) + && nativeOutbound != null + && !session.closeRequested() @@ -2511,54 +2377,11 @@ index 0000000000000000000000000000000000000000..560c745f8dbf173a43665de61cf47336 + return InventoryAction.NOTHING; + } + -+ private interface PacketGuiConversionOperation { -+ -+ boolean addPackets(PacketGuiSession session, List packets); -+ } -+ + private interface PacketGuiOutboundPacket { + + void send(PacketGuiNativeOutboundSender sender, Player player); + } + -+ private static final class PacketGuiConversionPlan { -+ -+ private final Object channel; -+ private final int windowId; -+ private final long generation; -+ private final List operations = new ArrayList<>(); -+ -+ private PacketGuiConversionPlan(User user, int windowId, long generation) { -+ this.channel = user.getChannel(); -+ this.windowId = windowId; -+ this.generation = generation; -+ } -+ -+ private void addOperation(PacketGuiConversionOperation operation) { -+ operations.add(operation); -+ } -+ -+ private Object channel() { -+ return channel; -+ } -+ -+ private int windowId() { -+ return windowId; -+ } -+ -+ private long generation() { -+ return generation; -+ } -+ -+ private List operations() { -+ return operations; -+ } -+ -+ private boolean isEmpty() { -+ return operations.isEmpty(); -+ } -+ } -+ + private static final class PlayerInventorySnapshot { + + private final ItemStack[] slots; @@ -2582,22 +2405,26 @@ index 0000000000000000000000000000000000000000..560c745f8dbf173a43665de61cf47336 + } + } + ++ /** ++ * One render worth of outbound packets, together with the session state they were built for. The window id, ++ * send generation and channel are captured up front so a plan that has been superseded can be recognised ++ * and dropped instead of being sent to a window that no longer exists. ++ */ + private static final class PacketGuiSendPlan { + + private final Object channel; + private final int windowId; + private final long generation; -+ private final List packets; ++ private final List packets = new ArrayList<>(); + -+ private PacketGuiSendPlan( -+ Object channel, -+ int windowId, -+ long generation, -+ List packets) { ++ private PacketGuiSendPlan(Object channel, int windowId, long generation) { + this.channel = channel; + this.windowId = windowId; + this.generation = generation; -+ this.packets = List.copyOf(packets); ++ } ++ ++ private void add(PacketGuiOutboundPacket packet) { ++ packets.add(packet); + } + + private Object channel() { From 2a4b95064f06a142c7355f5181f66c668e60d9c7 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:53:50 +0200 Subject: [PATCH 35/50] docs(packet): record the verification results from the live server run Fills in the twelve checklist rows that were actually exercised on SurfCanvas (Minecraft 26.2) while bringing this branch up, and names the source for each: some were watched on screen, some read off the debug-clicks log. The remaining fourteen rows are left empty on purpose rather than assumed. Two of them matter before this is called done - nobody has yet confirmed that no GUI display item can end up in a real inventory, which is the claim the whole backend exists to make, and the window id collision guard has only been unit-tested, never run end-to-end against a real chest. Also reorders the activation section: the sentence explaining what native=off does had ended up behind the debug-clicks block and read as if it described that instead. --- docs/packet-gui-backend.md | 66 ++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/docs/packet-gui-backend.md b/docs/packet-gui-backend.md index 534b033..9ca923d 100644 --- a/docs/packet-gui-backend.md +++ b/docs/packet-gui-backend.md @@ -26,6 +26,9 @@ suspected packet problem — start with: -Dinventory-framework.gui-backend.native=off ``` +That makes the sender report itself unavailable, which in turn falls the whole packet backend back to Bukkit +inventories. + When a click does not reach a view, log every inbound click and the routing decision taken for it: ``` @@ -35,9 +38,6 @@ When a click does not reach a view, log every inbound click and the routing deci Each click then produces one INFO line naming the window id, slot, button, click type, the computed repair scope and whether it was routed into the click pipeline or denied. -That makes the sender report itself unavailable, which in turn falls the whole packet backend back to Bukkit -inventories. - ## How availability is decided There is no Minecraft version allowlist. On startup `PacketGuiNativeOutboundSender.initialize()`: @@ -85,36 +85,60 @@ Startup log lines to look for: ## Verification checklist -Manual checklist from `AGENTS.md`. Fill in when validating a build on a real server. +Manual checklist from `AGENTS.md`, extended with the scenarios this backend added. Verified on the live +SurfCanvas server (Minecraft 26.2, `canvas-26.2-883`) with `-Dinventory-framework.gui-backend=packet`, against +surf-api `3.34.0` built from `1.0.5-packet-guis-SNAPSHOT`. + +Rows marked *(log)* were confirmed from the `-Dinventory-framework.gui-backend.debug-clicks=true` output rather +than by watching the screen. | Szenario | Erwartet | Geprüft am | Ergebnis | |---|---|---|---| -| Opening a simple GUI | Window opens with the configured title and size | | | -| Displaying all top slots | Every rendered slot shows its item | | | +| Packet mode activates | Startup logs `Packet mode enabled`, not the Bukkit fallback | 2026-07-28 | OK — probe reported Minecraft 26.2, PacketEvents V_26_2, native sender active | +| Opening a simple GUI | Window opens with the configured title and size | 2026-07-28 | OK — `/protect` and `/shop` open | +| Displaying all top slots | Every rendered slot shows its item | 2026-07-28 | OK | | Title rendering | Plain and Adventure component titles both render | | | -| Rows/size rendering | 1–6 row chests all open at the right size | | | -| Clicking a normal button | The view's click handler runs once | | | -| Refresh/rerender after click | Changed slots update, unchanged ones are not resent | | | -| Page/screen replacement | Navigating between views replaces the window cleanly | | | +| Rows/size rendering | 1–6 row chests all open at the right size | 2026-07-28 | OK for 3, 5 and 6 rows (topSize 27/45/54) *(log)*; 1, 2 and 4 rows not exercised | +| Clicking a normal button | The view's click handler runs once | 2026-07-28 | OK | +| Refresh/rerender after click | Changed slots update, unchanged ones are not resent | 2026-07-29 | OK — no visible problem after the render-plan rewrite | +| Page/screen replacement | Navigating between views replaces the window cleanly | 2026-07-28 | OK — chains of views navigated without a stuck window | +| Opening a GUI while another GUI is open | The new window replaces the old one; no command has to be repeated | 2026-07-29 | OK — was broken before `39a19c8`, fixed and re-tested | +| Outside click | Reaches the view; surf-api uses it for back navigation | 2026-07-28 | OK — was broken before `3bf6ef0`; client sends `THROW`/slot -999 | +| Bottom inventory click | Delivered to the view as an entity-container click | 2026-07-28 | OK — `slot=88, bottom=true, scope=PLAYER_INVENTORY, routed` *(log)* | +| Double-click denial | Denied, no callback, full resync | 2026-07-28 | OK — `PICKUP_ALL -> denied, full resync` *(log)* | +| PacketLore on inventory items | An enchanted item in the viewer's inventory shows its lore in the GUI, exactly once | 2026-07-29 | OK — decorated once, also after the mirror was removed in `891b7c0` | | Close handling | ESC closes the GUI and fires `onClose` exactly once | | | | Player quit cleanup | Session and viewer are removed, `onClose` fires once | | | | External inventory open cleanup | Opening a real chest finalizes the packet session | | | -| Shift-click denial | **Changed:** top-slot shift-clicks are *routed* to the view, not denied; the packet is still cancelled | | | -| Number-key denial | **Changed:** top-slot number-key swaps are *routed* to the view; the packet is still cancelled | | | +| Shift-click | **Changed:** top-slot shift-clicks are *routed* to the view, not denied; the packet is still cancelled | | | +| Number-key | **Changed:** top-slot number-key swaps are *routed* to the view; the packet is still cancelled | | | +| Offhand swap | **Changed:** routed to the view; the packet is still cancelled | | | | Drag denial | Denied, no callback, full resync | | | -| Double-click denial | Denied, no callback, full resync | | | -| Drop denial | Denied, no callback, full resync | | | -| Offhand swap denial | **Changed:** routed to the view; the packet is still cancelled | | | +| Drop-key denial | Denied, no callback, full resync | | | | Cursor ghost-item correction | No item sticks to the cursor after any click | | | -| Bottom inventory visual correctness | The player's own items render correctly and snap back when clicked | | | -| No GUI display items in real server inventory contents | `/invsee` or a dump shows no GUI icons in any real inventory | | | +| Bottom inventory visual correctness | The viewer's own items render correctly and snap back when clicked | | | +| No GUI display items in real server inventory contents | `/invsee` or an inventory dump shows no GUI icons in any real inventory | | | | Window id collision | Opening a GUI while a real chest is open closes the chest and never reuses its window id | | | | World change / respawn | Session is finalized, no stale viewer keeps receiving GUI packets | | | -The four rows marked **Changed** deviate from the original AGENTS.md expectation. Those click modes are -deliberately routed into the click API rather than denied, because the packet is already cancelled at the -listener, so nothing vanilla can mutate. What *is* denied is drag, drop, double-click and any unrecognised -mode. +The rows marked **Changed** deviate from the original AGENTS.md expectation. Those click modes are deliberately +routed into the click API rather than denied, because the packet is already cancelled at the listener, so +nothing vanilla can mutate. What *is* denied is drag, the drop key, double-click and any unrecognised mode. + +### Still open + +The empty rows have not been exercised. Two of them are worth clearing before this is considered done: + +- **No GUI display items in real server inventory contents.** This is the claim the whole backend exists to + make, and it is the one row nobody has checked. Open a GUI, then have a second player or a console command + dump the viewer's inventory and confirm no GUI icon appears in it. +- **Window id collision.** The guard in `PacketGuiWindowIds` is unit-tested, but the end-to-end path — open a + real chest, then open a GUI — has only been reasoned about, never run. + +The remaining gaps are the denial modes (drag, drop key, offhand swap, number key), lifecycle cleanup on quit +and world change, and cursor correction. Each is a single deliberate action on a server with +`-Dinventory-framework.gui-backend.debug-clicks=true` enabled, which prints the routing decision for every +click. ## Where the rendered items come from From 22cc67bd15a19cd55eebb34660e420ca876e70d2 Mon Sep 17 00:00:00 2001 From: Keviro <25409956+Keviro@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:01:12 +0200 Subject: [PATCH 36/50] fix(packet): send the close packet through the same transport as the cursor fix Closing a GUI sends a cursor correction and then a close packet. The correction went through the native connection, which queues, while the close was written straight to the channel by PacketEvents. With a backed-up queue the close can overtake the correction, and the client then applies it to its own inventory menu - reinstating exactly the ghost item the correction exists to clear. ClientboundContainerClosePacket takes nothing but the window id, so the native sender can build it without any chat-component conversion. It is resolved optionally and covered by the startup self-check; if a server does not expose it, the PacketEvents path is still used. The open-screen packet deliberately stays on PacketEvents. It carries a title component, so sending it natively would mean converting Adventure components to NMS ones, and it has no ordering hazard to fix: the content packets that follow it are queued behind the connection and cannot overtake a direct channel write. --- docs/packet-gui-backend.md | 8 +++- ...0006-Add-internal-packet-GUI-backend.patch | 38 ++++++++++++++++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/packet-gui-backend.md b/docs/packet-gui-backend.md index 9ca923d..8c9faa9 100644 --- a/docs/packet-gui-backend.md +++ b/docs/packet-gui-backend.md @@ -45,7 +45,13 @@ There is no Minecraft version allowlist. On startup `PacketGuiNativeOutboundSend 1. resolves every NMS class, constructor, field and method it needs, 2. probes both shapes of the `ClientboundContainerSetContentPacket` constructor (`NonNullList` and `List`), 3. runs a **self-check** that actually constructs one of every packet it will ever send — container content, - container slot, cursor and player inventory slot — without sending anything. + container slot, cursor, player inventory slot and container close — without sending anything. + +The close packet travels the native connection like everything else that follows a cursor correction, so the +two cannot arrive out of order. The open-screen packet is the one exception and still goes through +PacketEvents: it carries a chat component, which would mean converting an Adventure component into an NMS one, +and the ordering hazard does not apply to it — the content packets that follow it are queued behind the +connection, so they can never overtake a direct channel write. Only if all three succeed is packet mode enabled. Any mismatch produces a single warning naming the detected Minecraft version, and every GUI silently keeps using real Bukkit inventory items. diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index afaada2..e289f56 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1228,10 +1228,10 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..02e6833d21f84f298584bbcae5b2b44261c4f98f +index 0000000000000000000000000000000000000000..4539bd1242cdb1646d64ec2af6e3af5cb22993f1 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1216 @@ +@@ -0,0 +1,1224 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -2260,8 +2260,16 @@ index 0000000000000000000000000000000000000000..02e6833d21f84f298584bbcae5b2b442 + session.windowTracker().resetOpenWindow(); + if (sendClosePacket) { + try { ++ // Both packets must travel the same transport. The cursor correction goes through the native ++ // connection queue; a close written straight to the channel can overtake it, and the client ++ // then applies the correction to its own inventory menu - the ghost item it was meant to clear. ++ final PacketGuiNativeOutboundSender sender = nativeOutbound; + sendCursor(session); -+ session.user().sendPacket(new WrapperPlayServerCloseWindow(session.windowId())); ++ if (sender != null && sender.canSendCloseWindow()) { ++ sender.sendCloseWindow(session.player(), session.windowId()); ++ } else { ++ session.user().sendPacket(new WrapperPlayServerCloseWindow(session.windowId())); ++ } + } catch (final RuntimeException exception) { + owner.getLogger().log(Level.WARNING, "Failed to send packet GUI close packet", exception); + } @@ -2700,10 +2708,10 @@ index 0000000000000000000000000000000000000000..0da7f70a1658150890b2500d7251bef4 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java new file mode 100644 -index 0000000000000000000000000000000000000000..251f44ed68dfba2c9f0a36b7d779ec1263c35a41 +index 0000000000000000000000000000000000000000..196be89c5dab50da4ba15107d02df0f24d0babdc --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java -@@ -0,0 +1,484 @@ +@@ -0,0 +1,504 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.lang.reflect.Constructor; @@ -2740,6 +2748,7 @@ index 0000000000000000000000000000000000000000..251f44ed68dfba2c9f0a36b7d779ec12 + private final Constructor containerSetSlotConstructor; + private final Constructor setCursorItemConstructor; + private final Constructor setPlayerInventoryConstructor; ++ private final Constructor containerCloseConstructor; + private final ConcurrentMap, Field> connectionFields = new ConcurrentHashMap<>(); + private final ConcurrentMap, Method> sendMethods = new ConcurrentHashMap<>(); + @@ -2778,6 +2787,8 @@ index 0000000000000000000000000000000000000000..251f44ed68dfba2c9f0a36b7d779ec12 + "net.minecraft.network.protocol.game.ClientboundSetPlayerInventoryPacket", + int.class, + nmsItemStackClass); ++ this.containerCloseConstructor = optionalConstructor( ++ "net.minecraft.network.protocol.game.ClientboundContainerClosePacket", int.class); + } catch (final ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to initialize native packet GUI item sender", exception); + } @@ -2838,6 +2849,9 @@ index 0000000000000000000000000000000000000000..251f44ed68dfba2c9f0a36b7d779ec12 + if (setPlayerInventoryConstructor != null) { + construct(setPlayerInventoryConstructor, 0, emptyItemStack); + } ++ if (containerCloseConstructor != null) { ++ construct(containerCloseConstructor, 1); ++ } + } + + void sendContainerSetContent( @@ -2885,6 +2899,20 @@ index 0000000000000000000000000000000000000000..251f44ed68dfba2c9f0a36b7d779ec12 + send(player, construct(containerSetSlotConstructor, windowId, stateId, slot, toNmsItem(item))); + } + ++ /** ++ * Whether this sender can close a window itself. ++ * ++ *

When it can, the close travels the same queue as the cursor correction that precedes it. Sending the ++ * close through a different transport lets it overtake that correction and leave a ghost item behind. ++ */ ++ boolean canSendCloseWindow() { ++ return containerCloseConstructor != null; ++ } ++ ++ void sendCloseWindow(Player player, int windowId) { ++ send(player, construct(containerCloseConstructor, windowId)); ++ } ++ + void sendCursor(Player player, ItemStack item) { + final Object nmsItem = toNmsItem(item); + if (setCursorItemConstructor != null) { From 12b6e6ae27f447232ee398c46bd5cbcbd481de3f Mon Sep 17 00:00:00 2001 From: Keviro Date: Wed, 29 Jul 2026 01:51:35 +0200 Subject: [PATCH 37/50] build: release as 1.0.5 and verify pull requests in CI The version was still 1.0.4, the same value master carries, so merging would have republished an existing release coordinate with entirely different contents - either failing the release job or silently handing every consumer a different 1.0.4. Nothing verified pull requests either: publish.yml only triggers on pushes to master, so the packet GUI backend and its tests had never been built by anything but a workstation. The new workflow applies the patches, runs the target repository's tests through a delegating task, and fails if makePatches would produce a diff - a patch that no longer round-trips is the one mistake this fork layout makes easy. --- .github/workflows/verify.yml | 50 ++++++++++++++++++++++++++++++++++++ build.gradle.kts | 21 ++++++++++++--- gradle.properties | 2 +- 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/verify.yml diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..d911d9c --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,50 @@ +name: Verify + +on: + pull_request: + workflow_dispatch: + +concurrency: + group: verify-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 25 + + - uses: gradle/actions/setup-gradle@v4 + + # gitpatcher commits every patch into the generated target repository, which git refuses to do + # without an identity. + - name: Configure git identity + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Apply patches + run: ./gradlew applyPatches --stacktrace + + - name: Test + run: ./gradlew testPatched --stacktrace + + # A patch that no longer round-trips is the fork's easiest mistake: hand-editing patches/, or editing + # the target repository without regenerating. Both produce a tree that builds locally and cannot be + # reproduced from the committed patches. + - name: Check that the patches are up to date + run: | + ./gradlew makePatches --stacktrace + if ! git diff --quiet -- patches; then + echo "::error::patches/ is out of sync with the target repository. Run './gradlew makePatches' and commit the result." + git diff --stat -- patches + exit 1 + fi diff --git a/build.gradle.kts b/build.gradle.kts index d02ed2d..4b6bb3b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,14 +35,27 @@ val isWindows = System.getProperty("os.name") val targetDir = layout.projectDirectory.dir("inventory-framework") val gradlew = targetDir.file(if (isWindows) "gradlew.bat" else "gradlew").asFile.absolutePath -listOf("shadowJar", "publish", "publishToMavenLocal").forEach { taskName -> +// `test` is already taken by the `java` plugin on this project, so the delegating task carries a distinct +// name. It is what CI runs to verify a pull request. +val delegatedTasks = mapOf( + "shadowJar" to "shadowJar", + "publish" to "publish", + "publishToMavenLocal" to "publishToMavenLocal", + "testPatched" to "test", +) + +delegatedTasks.forEach { (taskName, delegate) -> tasks.register(taskName) { - group = if (taskName == "shadowJar") "build" else "publishing" - description = "Runs './gradlew $taskName' inside inventory-framework." + group = when (taskName) { + "shadowJar" -> "build" + "testPatched" -> "verification" + else -> "publishing" + } + description = "Runs './gradlew $delegate' inside inventory-framework." dependsOn("applyPatches") workingDir = targetDir.asFile - val args = listOf(taskName) + val args = listOf(delegate) if (isWindows) { commandLine("cmd", "/c", gradlew, *args.toTypedArray()) diff --git a/gradle.properties b/gradle.properties index 599d31a..e432e86 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ kotlin.code.style=official group=dev.slne.forks.inventoryframework -version=1.0.4 \ No newline at end of file +version=1.0.5 \ No newline at end of file From 42df066561a520c820ba8ce3db09bc37513158dc Mon Sep 17 00:00:00 2001 From: Keviro Date: Wed, 29 Jul 2026 01:54:55 +0200 Subject: [PATCH 38/50] fix(packet): allocate fake window ids outside vanilla's range The allocator cycled 1..127 and only refused the one real window id the viewer had open at the moment of allocation. Vanilla allocates real container ids from 1..100, so the ranges overlapped almost completely and the guard covered a single instant. The unprotected case was a real container opening while a packet GUI was already on screen. If the server's counter landed on the GUI's id, isGuiWindow() claimed the packet as the backend's own, handleExternalInventoryOpen never ran, the session stayed open on a window the real container now owned, and every click in that container was cancelled and routed into the stale GUI pipeline - a container the player could neither use nor escape. Fake ids now come from 101..127, which vanilla never hands out and which still fits in a signed byte. The forbidden-id skip is kept as defence against another plugin's fake window in the same range. --- ...0006-Add-internal-packet-GUI-backend.patch | 116 +++++++++++++----- 1 file changed, 87 insertions(+), 29 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index e289f56..f1fcac8 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1228,7 +1228,7 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..4539bd1242cdb1646d64ec2af6e3af5cb22993f1 +index 0000000000000000000000000000000000000000..0e5dc38e5d2c1f93c1d194c48187042dff656f49 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java @@ -0,0 +1,1224 @@ @@ -1306,7 +1306,7 @@ index 0000000000000000000000000000000000000000..4539bd1242cdb1646d64ec2af6e3af5c + private final BukkitGuiBackend fallbackBackend; + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); + private final ConcurrentMap windowTrackers = new ConcurrentHashMap<>(); -+ private final AtomicInteger nextWindowId = new AtomicInteger(1); ++ private final AtomicInteger nextWindowId = PacketGuiWindowIds.newCounter(); + private final Set reportedUnsupportedTypes = ConcurrentHashMap.newKeySet(); + + /** @@ -3218,10 +3218,10 @@ index 0000000000000000000000000000000000000000..196be89c5dab50da4ba15107d02df0f2 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java new file mode 100644 -index 0000000000000000000000000000000000000000..36f011f845d718429c5892e584489c7f2880422b +index 0000000000000000000000000000000000000000..e1a675ee337048beb08aa0c87f65017b8421be2e --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java -@@ -0,0 +1,129 @@ +@@ -0,0 +1,134 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.event.PacketListenerAbstract; @@ -3323,6 +3323,9 @@ index 0000000000000000000000000000000000000000..36f011f845d718429c5892e584489c7f + } + + if (packetType == PacketType.Play.Server.OPEN_WINDOW) { ++ // The backend opens its own windows through this same outbound path, so its packets come back ++ // through here. Filtering them out is what the id check is for - a real container can never carry ++ // a GUI window id, because PacketGuiWindowIds allocates outside the range vanilla uses. + final WrapperPlayServerOpenWindow packet = new WrapperPlayServerOpenWindow(event); + if (!backend.isGuiWindow(user, packet.getContainerId())) { + backend.handleExternalInventoryOpen(user, packet.getContainerId()); @@ -3331,6 +3334,8 @@ index 0000000000000000000000000000000000000000..36f011f845d718429c5892e584489c7f + } + + if (packetType == PacketType.Play.Server.OPEN_HORSE_WINDOW) { ++ // No id check here: the backend never sends a horse window, so there is nothing of its own to ++ // filter out. + final WrapperPlayServerOpenHorseWindow packet = new WrapperPlayServerOpenHorseWindow(event); + backend.handleExternalInventoryOpen(user, packet.getWindowId()); + return; @@ -3760,10 +3765,10 @@ index 0000000000000000000000000000000000000000..caf7e4bcdc4b93a38b5bb856499a69cd +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIds.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIds.java new file mode 100644 -index 0000000000000000000000000000000000000000..3552d4e9df03a9e574cd94461129301346da2195 +index 0000000000000000000000000000000000000000..d5e26cf2c65956f7477c21529c4a520487ac2aa8 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIds.java -@@ -0,0 +1,40 @@ +@@ -0,0 +1,65 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.util.concurrent.atomic.AtomicInteger; @@ -3771,26 +3776,46 @@ index 0000000000000000000000000000000000000000..3552d4e9df03a9e574cd944611293013 +/** + * Allocates the fake container ids used for packet GUI windows. + * -+ *

Vanilla hands out container ids for real menus from the same numeric range, and the client resolves -+ * window ownership by id alone. Reusing an id that a real container currently holds would make the client -+ * apply that container's updates to the GUI screen and route the player's clicks into the GUI pipeline, so -+ * the viewer's active external window id is always skipped. ++ *

The client resolves window ownership by id alone, so a fake window that shares an id with a real one ++ * makes the client apply that container's updates to the GUI screen and routes the player's clicks into the ++ * GUI pipeline. Fake ids are therefore taken from a range vanilla never uses: {@code ServerPlayer} allocates ++ * real container ids with {@code containerCounter = containerCounter % 100 + 1}, i.e. {@code 1..100}, which ++ * leaves {@code 101..127} free. The upper bound keeps the id inside a signed byte, so it is valid both on ++ * protocol versions that encode the container id as a byte and on those that use a VarInt. ++ * ++ *

The viewer's tracked external window is skipped on top of that. It is redundant against vanilla, but not ++ * against another plugin that opens its own fake window in the same range. + */ +final class PacketGuiWindowIds { + ++ /** ++ * The lowest fake window id, one past the highest id vanilla hands out for a real menu. ++ */ ++ static final int MIN_WINDOW_ID = 101; ++ ++ /** ++ * The highest fake window id that still fits in a signed byte. ++ */ + static final int MAX_WINDOW_ID = 127; + + private PacketGuiWindowIds() {} + + /** -+ * Returns the next usable window id. ++ * Creates the rolling counter {@link #allocate(AtomicInteger, int)} draws from. ++ */ ++ static AtomicInteger newCounter() { ++ return new AtomicInteger(MIN_WINDOW_ID - 1); ++ } ++ ++ /** ++ * Returns the next usable window id, always within {@code 101..127}. + * + * @param counter The rolling id counter shared by all viewers. + * @param forbiddenWindowId The window id the viewer currently has open for a real container, or + * {@code 0} when no external window is tracked. + */ + static int allocate(AtomicInteger counter, int forbiddenWindowId) { -+ for (int attempt = 0; attempt < MAX_WINDOW_ID; attempt++) { ++ for (int attempt = 0; attempt <= MAX_WINDOW_ID - MIN_WINDOW_ID; attempt++) { + final int candidate = next(counter); + if (candidate != forbiddenWindowId) { + return candidate; @@ -3800,8 +3825,13 @@ index 0000000000000000000000000000000000000000..3552d4e9df03a9e574cd944611293013 + return next(counter); + } + ++ /** ++ * Advances the counter, wrapping at both ends. Any out-of-range value - including the initial one - is ++ * normalized to {@link #MIN_WINDOW_ID}, so the allocator can never leak an id vanilla might also use. ++ */ + private static int next(AtomicInteger counter) { -+ return Math.max(1, counter.getAndUpdate(previous -> previous >= MAX_WINDOW_ID ? 1 : previous + 1)); ++ return counter.updateAndGet( ++ previous -> previous < MIN_WINDOW_ID || previous >= MAX_WINDOW_ID ? MIN_WINDOW_ID : previous + 1); + } +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstants.java @@ -4666,10 +4696,10 @@ index 0000000000000000000000000000000000000000..248745ce12a28003a4a137f68eb397a3 +} diff --git a/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIdsTest.java b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIdsTest.java new file mode 100644 -index 0000000000000000000000000000000000000000..7716d3a45577823a7728f8bfe4eb0150bd757af7 +index 0000000000000000000000000000000000000000..9d251743add80f827c025bc01f78c8b42f2d7e94 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIdsTest.java -@@ -0,0 +1,45 @@ +@@ -0,0 +1,73 @@ +package me.devnatan.inventoryframework.internal.packet; + +import static org.junit.jupiter.api.Assertions.assertEquals; @@ -4681,39 +4711,67 @@ index 0000000000000000000000000000000000000000..7716d3a45577823a7728f8bfe4eb0150 + +class PacketGuiWindowIdsTest { + ++ /** The highest container id vanilla can hand out for a real menu. */ ++ private static final int HIGHEST_VANILLA_WINDOW_ID = 100; ++ + @Test + void handsOutConsecutiveIdsWhenNothingIsForbidden() { -+ final AtomicInteger counter = new AtomicInteger(1); -+ assertEquals(1, PacketGuiWindowIds.allocate(counter, 0)); -+ assertEquals(2, PacketGuiWindowIds.allocate(counter, 0)); -+ assertEquals(3, PacketGuiWindowIds.allocate(counter, 0)); ++ final AtomicInteger counter = PacketGuiWindowIds.newCounter(); ++ assertEquals(101, PacketGuiWindowIds.allocate(counter, 0)); ++ assertEquals(102, PacketGuiWindowIds.allocate(counter, 0)); ++ assertEquals(103, PacketGuiWindowIds.allocate(counter, 0)); + } + + @Test + void skipsTheWindowIdTheViewerAlreadyHasOpen() { -+ final AtomicInteger counter = new AtomicInteger(5); -+ assertEquals(6, PacketGuiWindowIds.allocate(counter, 5)); ++ final AtomicInteger counter = PacketGuiWindowIds.newCounter(); ++ assertEquals(102, PacketGuiWindowIds.allocate(counter, 101)); + } + + @Test + void neverReturnsTheForbiddenIdAcrossTheWholeRange() { -+ final AtomicInteger counter = new AtomicInteger(1); ++ final AtomicInteger counter = PacketGuiWindowIds.newCounter(); + for (int i = 0; i < PacketGuiWindowIds.MAX_WINDOW_ID * 2; i++) { -+ assertNotEquals(42, PacketGuiWindowIds.allocate(counter, 42)); ++ assertNotEquals(110, PacketGuiWindowIds.allocate(counter, 110)); + } + } + + @Test -+ void rollsOverAtTheMaximumAndNeverReturnsZero() { -+ final AtomicInteger counter = new AtomicInteger(PacketGuiWindowIds.MAX_WINDOW_ID); ++ void rollsOverAtTheMaximum() { ++ final AtomicInteger counter = new AtomicInteger(PacketGuiWindowIds.MAX_WINDOW_ID - 1); + assertEquals(PacketGuiWindowIds.MAX_WINDOW_ID, PacketGuiWindowIds.allocate(counter, 0)); -+ assertEquals(1, PacketGuiWindowIds.allocate(counter, 0)); ++ assertEquals(PacketGuiWindowIds.MIN_WINDOW_ID, PacketGuiWindowIds.allocate(counter, 0)); ++ } + -+ final AtomicInteger fresh = new AtomicInteger(1); -+ for (int i = 0; i < PacketGuiWindowIds.MAX_WINDOW_ID * 3; i++) { -+ assertTrue(PacketGuiWindowIds.allocate(fresh, 0) >= 1); ++ /** ++ * The point of the whole class: a fake window id must never be one vanilla could also give a real ++ * container, because the client tells the two apart by id alone. ++ */ ++ @Test ++ void neverCollidesWithTheRangeVanillaUsesForRealContainers() { ++ assertTrue(PacketGuiWindowIds.MIN_WINDOW_ID > HIGHEST_VANILLA_WINDOW_ID); ++ ++ final AtomicInteger counter = PacketGuiWindowIds.newCounter(); ++ for (int i = 0; i < PacketGuiWindowIds.MAX_WINDOW_ID * 4; i++) { ++ final int windowId = PacketGuiWindowIds.allocate(counter, 0); ++ assertTrue( ++ windowId >= PacketGuiWindowIds.MIN_WINDOW_ID && windowId <= PacketGuiWindowIds.MAX_WINDOW_ID, ++ "window id out of range: " + windowId); + } + } ++ ++ /** ++ * Even a counter that was never initialized through {@link PacketGuiWindowIds#newCounter()} must not be ++ * able to produce an id in vanilla's range. ++ */ ++ @Test ++ void normalizesAnOutOfRangeCounter() { ++ assertEquals(PacketGuiWindowIds.MIN_WINDOW_ID, PacketGuiWindowIds.allocate(new AtomicInteger(0), 0)); ++ assertEquals(PacketGuiWindowIds.MIN_WINDOW_ID, PacketGuiWindowIds.allocate(new AtomicInteger(7), 0)); ++ assertEquals( ++ PacketGuiWindowIds.MIN_WINDOW_ID, ++ PacketGuiWindowIds.allocate(new AtomicInteger(Integer.MAX_VALUE), 0)); ++ } +} diff --git a/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstantsTest.java b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketInventoryConstantsTest.java new file mode 100644 From 8c692af342da3781732f89be0b99fdd719e1ca34 Mon Sep 17 00:00:00 2001 From: Keviro Date: Wed, 29 Jul 2026 01:58:05 +0200 Subject: [PATCH 39/50] fix(packet): make the opening guard re-entrancy safe and surface abandoned sessions openingViewers is a set, so a nested open() - which the close pipeline can trigger, since opening a view from onClose is a normal pattern - removed the marker in its own finally block while the outer invocation was still running. The remainder of the outer close pipeline then executed unguarded, and any Player#closeInventory it performed killed the session that had just been opened. That is the same failure 39a19c8 fixed, one level of nesting down. Only the outermost invocation lifts the marker now, and it is taken before closeRealInventory so it covers the whole open sequence. abandonSession dropped a live view at FINE level, which no production server logs, and never released the viewer, so the view kept listing someone who had no window and would never get one back. It now warns and detaches the viewer from the root. The CLOSE pipeline stays unrun on purpose: it executes developer code, and this path exists precisely because no thread will accept work for that viewer any more. --- ...0006-Add-internal-packet-GUI-backend.patch | 79 ++++++++++++++----- 1 file changed, 58 insertions(+), 21 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index f1fcac8..0578d28 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1228,10 +1228,10 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..0e5dc38e5d2c1f93c1d194c48187042dff656f49 +index 0000000000000000000000000000000000000000..25a27b4bac62fb099370198b1ea2238c00d73b37 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1224 @@ +@@ -0,0 +1,1261 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1252,6 +1252,7 @@ index 0000000000000000000000000000000000000000..0e5dc38e5d2c1f93c1d194c48187042d +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import me.devnatan.inventoryframework.BukkitViewer; ++import me.devnatan.inventoryframework.PlatformView; +import me.devnatan.inventoryframework.RootView; +import me.devnatan.inventoryframework.ViewContainer; +import me.devnatan.inventoryframework.ViewType; @@ -1483,25 +1484,31 @@ index 0000000000000000000000000000000000000000..0e5dc38e5d2c1f93c1d194c48187042d + return; + } + -+ final PacketViewerWindowTracker windowTracker = windowTrackerFor(player.getUniqueId()); -+ final int externalWindowId = windowTracker.openWindowId(); ++ // Everything from here on can emit an outbound close packet - closeRealInventory directly, the ++ // previous session's CLOSE pipeline indirectly, since arbitrary developer and framework code ++ // routinely ends up calling Player#closeInventory. None of it may be mistaken for the viewer closing ++ // the window being opened. ++ // ++ // The close pipeline may also re-enter open() for the same viewer. Only the outermost invocation may ++ // lift the marker: an inner one clearing it would leave the rest of the outer sequence unguarded, ++ // which is precisely the window this guard exists to cover. ++ final boolean outermostOpen = openingViewers.add(player.getUniqueId()); ++ try { ++ final PacketViewerWindowTracker windowTracker = windowTrackerFor(player.getUniqueId()); ++ final int externalWindowId = windowTracker.openWindowId(); + -+ // A real server-side menu must not stay open behind the fake window: the client would render the GUI -+ // while the server keeps ticking the real container, and closing the GUI would never release it. -+ closeRealInventory(player); ++ // A real server-side menu must not stay open behind the fake window: the client would render the ++ // GUI while the server keeps ticking the real container, and closing the GUI would never release ++ // it. ++ closeRealInventory(player); + -+ final PacketGuiSession session = new PacketGuiSession( -+ viewer, -+ user, -+ PacketGuiWindowIds.allocate(nextWindowId, externalWindowId), -+ container, -+ windowTracker); ++ final PacketGuiSession session = new PacketGuiSession( ++ viewer, ++ user, ++ PacketGuiWindowIds.allocate(nextWindowId, externalWindowId), ++ container, ++ windowTracker); + -+ // Tearing the previous session down runs its CLOSE pipeline, i.e. arbitrary developer and framework -+ // code. That code routinely ends up calling Player#closeInventory, whose outbound close packet must -+ // not be mistaken for the viewer closing the window we are in the middle of opening. -+ openingViewers.add(player.getUniqueId()); -+ try { + // Publish before the teardown: the close pipeline may re-enter open(), and the conditional remove + // inside closeSession keeps it from evicting this session. + final PacketGuiSession previous = sessions.put(player.getUniqueId(), session); @@ -1523,7 +1530,9 @@ index 0000000000000000000000000000000000000000..0e5dc38e5d2c1f93c1d194c48187042d + + renderSession(session, true, true); + } finally { -+ openingViewers.remove(player.getUniqueId()); ++ if (outermostOpen) { ++ openingViewers.remove(player.getUniqueId()); ++ } + } + } + @@ -2354,9 +2363,37 @@ index 0000000000000000000000000000000000000000..0e5dc38e5d2c1f93c1d194c48187042d + } + session.clearScheduledRender(); + session.windowTracker().resetOpenWindow(); ++ ++ // Not FINE: no production server runs at that level, and a viewer losing its GUI without any close ++ // handler running is exactly the kind of thing that otherwise gets reported as "sometimes the GUI ++ // just disappears" with nothing in the log to go on. + owner.getLogger() -+ .fine("Discarded packet GUI session for " + player.getName() -+ + ": the viewer's scheduler is no longer accepting tasks."); ++ .warning("Discarded the packet GUI session of " + player.getName() ++ + " without running its close handlers: the viewer's scheduler is no longer accepting" ++ + " tasks."); ++ ++ detachAbandonedViewer(session); ++ } ++ ++ /** ++ * Releases the framework's own hold on an abandoned viewer. ++ * ++ *

The CLOSE pipeline is deliberately not run: it executes developer code, and the reason this method ++ * exists at all is that no thread will accept work for the viewer any more. Without this, though, the ++ * view would keep listing a viewer that has no window and never gets one back, which leaks the context ++ * for as long as the view lives. ++ */ ++ @SuppressWarnings({"unchecked", "rawtypes"}) ++ private void detachAbandonedViewer(PacketGuiSession session) { ++ try { ++ final IFRenderContext context = session.context(); ++ final RootView root = context.getRoot(); ++ if (root instanceof PlatformView) { ++ ((PlatformView) root).removeAndTryInvalidateContext(session.viewer(), context); ++ } ++ } catch (final RuntimeException exception) { ++ owner.getLogger().log(Level.WARNING, "Failed to detach an abandoned packet GUI viewer", exception); ++ } + } + + private static InventoryType.SlotType slotTypeOf(PacketGuiClick click, int topSize) { From bbbdae6a93e9e7ca8b8619233e2523aee6422159 Mon Sep 17 00:00:00 2001 From: Keviro Date: Wed, 29 Jul 2026 02:03:36 +0200 Subject: [PATCH 40/50] fix(packet): give the synthesized click event Bukkit slot semantics PacketSlotClickEvent exists so that consumers written against the Bukkit backend keep working unchanged, and it got the two most-read accessors wrong. getSlot() returned the view-wide raw slot. Bukkit reports the index inside the clicked inventory, so a bottom click on a 54-slot GUI produced 88 where Bukkit produces 7, and player.getInventory().getItem(event.getSlot()) - the most common thing such a consumer does - read the wrong slot or threw. getClickedInventory() returned null for every click, and null is Bukkit's canonical "clicked outside the window" signal. The usual `if (getClickedInventory() == null) return;` guard therefore discarded every packet GUI click, and comparing it against the player inventory threw. Bottom clicks now report the viewer's inventory; top clicks keep returning null, because there genuinely is no Bukkit inventory behind them. An outside click also reported InventoryAction.PICKUP_ALL while its slot type said OUTSIDE. A packet GUI never fills the real cursor, so NOTHING is both truthful and what Bukkit reports for the same click. --- ...0006-Add-internal-packet-GUI-backend.patch | 87 ++++++++++++++++--- 1 file changed, 74 insertions(+), 13 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 0578d28..5f1f568 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -268,10 +268,10 @@ index 2d97a162afacb6aa9652d9d83079088761bbd35b..4dc75f811d1a888cee0851a70377ac76 // region Internals diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java new file mode 100644 -index 0000000000000000000000000000000000000000..72d6166df08878e2e7a53864427ab4141728061a +index 0000000000000000000000000000000000000000..8c389980937dfc38c7daf3b513a48a627d115343 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/BukkitSlotClickOrigin.java -@@ -0,0 +1,116 @@ +@@ -0,0 +1,121 @@ +package me.devnatan.inventoryframework.context; + +import org.bukkit.entity.Player; @@ -324,6 +324,11 @@ index 0000000000000000000000000000000000000000..72d6166df08878e2e7a53864427ab414 + } + + @Override ++ public int getConvertedSlot() { ++ return event.getSlot(); ++ } ++ ++ @Override + public @NotNull InventoryType.SlotType getSlotType() { + return event.getSlotType(); + } @@ -402,10 +407,10 @@ index d73ddb29859761e6505b001fb4d303f953cb99e3..686664b4211880e0dc7651cebda4e66c import org.jetbrains.annotations.UnmodifiableView; diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickEvent.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickEvent.java new file mode 100644 -index 0000000000000000000000000000000000000000..0bcf210d460ef6ec23a92fc658904b852e8f50d2 +index 0000000000000000000000000000000000000000..f7400c621a2afd63c280e2592057f04dfb6b408f --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickEvent.java -@@ -0,0 +1,118 @@ +@@ -0,0 +1,125 @@ +package me.devnatan.inventoryframework.context; + +import org.bukkit.entity.Player; @@ -485,9 +490,16 @@ index 0000000000000000000000000000000000000000..0bcf210d460ef6ec23a92fc658904b85 + return origin.getRawSlot(); + } + ++ /** ++ * The index inside the clicked inventory, not the view-wide raw slot. ++ * ++ *

Returning the raw slot here would be wrong for every bottom click - and ++ * {@code player.getInventory().getItem(event.getSlot())} is the single most common thing a consumer of ++ * this event does. ++ */ + @Override + public int getSlot() { -+ return origin.getRawSlot(); ++ return origin.getConvertedSlot(); + } + + @Override @@ -526,15 +538,16 @@ index 0000000000000000000000000000000000000000..0bcf210d460ef6ec23a92fc658904b85 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java new file mode 100644 -index 0000000000000000000000000000000000000000..4ee11f62518dbaf6e61c43d3ba5817249d330494 +index 0000000000000000000000000000000000000000..2335ec52e1b6d2ce0b0bf2927789c9d0c5eec4e9 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/PacketSlotClickOrigin.java -@@ -0,0 +1,154 @@ +@@ -0,0 +1,177 @@ +package me.devnatan.inventoryframework.context; + +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryType; ++import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; @@ -549,6 +562,8 @@ index 0000000000000000000000000000000000000000..4ee11f62518dbaf6e61c43d3ba581724 + private final InventoryAction action; + private final int hotbarButton; + private final int rawSlot; ++ private final int convertedSlot; ++ private final Inventory clickedInventory; + private final String clickIdentifier; + private final boolean leftClick; + private final boolean rightClick; @@ -570,6 +585,8 @@ index 0000000000000000000000000000000000000000..4ee11f62518dbaf6e61c43d3ba581724 + @NotNull InventoryAction action, + int hotbarButton, + int rawSlot, ++ int convertedSlot, ++ @Nullable Inventory clickedInventory, + @NotNull String clickIdentifier, + boolean leftClick, + boolean rightClick, @@ -584,6 +601,8 @@ index 0000000000000000000000000000000000000000..4ee11f62518dbaf6e61c43d3ba581724 + this.action = action; + this.hotbarButton = hotbarButton; + this.rawSlot = rawSlot; ++ this.convertedSlot = convertedSlot; ++ this.clickedInventory = clickedInventory; + this.clickIdentifier = clickIdentifier; + this.leftClick = leftClick; + this.rightClick = rightClick; @@ -620,6 +639,22 @@ index 0000000000000000000000000000000000000000..4ee11f62518dbaf6e61c43d3ba581724 + } + + @Override ++ public int getConvertedSlot() { ++ return convertedSlot; ++ } ++ ++ /** ++ * The viewer's own inventory for a bottom click, {@code null} otherwise. ++ * ++ *

The top rows of a packet GUI have no Bukkit {@link Inventory} behind them - that is the whole point ++ * of the backend - so a top click has nothing truthful to report here. ++ */ ++ @Override ++ public @Nullable Inventory getClickedInventory() { ++ return clickedInventory; ++ } ++ ++ @Override + public @NotNull InventoryType.SlotType getSlotType() { + return slotType; + } @@ -930,10 +965,10 @@ index c0bb94198697239a1dd0e1cf986f8fdcf62143e1..92eb5a88f4fbe962ed36ca2f80bf8daa } diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java new file mode 100644 -index 0000000000000000000000000000000000000000..8046cc16d595b3eafebc38ecec3a99d871bddcd4 +index 0000000000000000000000000000000000000000..7b6fb15141e24a98bbaa7a91416fea13042a3a8a --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/context/SlotClickOrigin.java -@@ -0,0 +1,81 @@ +@@ -0,0 +1,92 @@ +package me.devnatan.inventoryframework.context; + +import org.bukkit.entity.Player; @@ -972,6 +1007,17 @@ index 0000000000000000000000000000000000000000..8046cc16d595b3eafebc38ecec3a99d8 + int getRawSlot(); + + /** ++ * The index of the clicked slot inside {@link #getClickedInventory()}, i.e. what Bukkit's ++ * {@link org.bukkit.event.inventory.InventoryClickEvent#getSlot()} reports. ++ * ++ *

Only equal to {@link #getRawSlot()} for slots of the top container; a click in the viewer's own ++ * inventory carries a view-wide raw slot but a much smaller inventory index. ++ */ ++ default int getConvertedSlot() { ++ return getRawSlot(); ++ } ++ ++ /** + * The Bukkit slot type of the clicked slot. + */ + @NotNull @@ -1228,10 +1274,10 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..25a27b4bac62fb099370198b1ea2238c00d73b37 +index 0000000000000000000000000000000000000000..c19f114c8f0a37f485ba640a6a4ba5d59950ac49 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1261 @@ +@@ -0,0 +1,1276 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1871,13 +1917,23 @@ index 0000000000000000000000000000000000000000..25a27b4bac62fb099370198b1ea2238c + clickedContainer = context.getContainer(); + } + ++ // Bukkit reports getSlot() relative to the clicked inventory and getRawSlot() relative to the whole ++ // view. They only coincide for the top container, so a bottom click has to carry the player ++ // inventory index alongside the raw slot - consumers of the synthesized InventoryClickEvent read it ++ // to address the viewer's own inventory. ++ final int convertedSlot = playerInventoryClick ++ ? PacketInventoryConstants.containerSlotToPlayerInventorySlot(mappedPlayerSlot) ++ : click.slot(); ++ + final PacketSlotClickOrigin origin = new PacketSlotClickOrigin( + session.player(), + currentItem, + slotTypeOf(click, topSize), -+ actionOf(click), ++ actionOf(click, outsideClick), + click.isKeyboardClick() && !click.isOffhandSwapClick() ? click.button() : -1, + click.slot(), ++ convertedSlot, ++ playerInventoryClick ? session.player().getInventory() : null, + click.clickIdentifier(), + click.isLeftClick(), + click.isRightClick(), @@ -2412,7 +2468,12 @@ index 0000000000000000000000000000000000000000..25a27b4bac62fb099370198b1ea2238c + * Best-effort mapping from the wire click to a Bukkit action. The packet protocol carries less information + * than Bukkit's action enum, so this is a label for consumers, not a contract. + */ -+ private static InventoryAction actionOf(PacketGuiClick click) { ++ private static InventoryAction actionOf(PacketGuiClick click, boolean outsideClick) { ++ // A packet GUI never puts anything on the real cursor, so an outside click drops nothing. Bukkit ++ // reports NOTHING for the same click with an empty cursor; without this, the left-button form would ++ // surface as PICKUP_ALL and contradict the OUTSIDE slot type. ++ if (outsideClick) return InventoryAction.NOTHING; ++ + if (click.isShiftClick()) return InventoryAction.MOVE_TO_OTHER_INVENTORY; + if (click.isKeyboardClick()) return InventoryAction.HOTBAR_SWAP; + if (click.isMiddleClick()) return InventoryAction.CLONE_STACK; From 846d399452a5a209b8e008762ac4bd52fec9be81 Mon Sep 17 00:00:00 2001 From: Keviro Date: Wed, 29 Jul 2026 02:07:50 +0200 Subject: [PATCH 41/50] perf(packet): mirror the player inventory with one packet and keep the window tracker honest The mirror pass sent one slot packet per mirrored slot - 36 of them for every player#updateInventory that happened while a GUI was open, which the backend itself performs on close and other plugins call routinely. It now sends the single container content packet that path already had available. PacketViewerWindowTracker only ever learned about a real window closing when the server initiated it. Vanilla does not echo a close packet for a window the client closed, so pressing escape on a chest left the tracker naming a window that no longer existed. The inbound close packet is already intercepted, so the non-GUI branch now clears the tracker there. Also drops a line in handleWindowClose that could never fire - it asked the tracker to close the fake GUI id, which the tracker never holds - and removes the session monitor from the accessors of immutable fields, which netty threads read on every inbound packet. --- ...0006-Add-internal-packet-GUI-backend.patch | 136 ++++++++++++------ 1 file changed, 91 insertions(+), 45 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 5f1f568..8bc9889 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1274,10 +1274,10 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..c19f114c8f0a37f485ba640a6a4ba5d59950ac49 +index 0000000000000000000000000000000000000000..a44e3f3cf88652fa1d12f91ea33d38a197473c78 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1276 @@ +@@ -0,0 +1,1314 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1686,10 +1686,25 @@ index 0000000000000000000000000000000000000000..c19f114c8f0a37f485ba640a6a4ba5d5 + if (!isTracked(session) || session.windowId() != windowId) { + return; + } -+ session.windowTracker().closeWindow(windowId); ++ + runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_CLIENT, true, true)); + } + ++ /** ++ * Records that the viewer closed a real container. ++ * ++ *

Vanilla does not echo a close packet when the client closes a window, so the inbound packet ++ * is the only place this can be observed. Without it the tracker keeps naming a window the viewer left ++ * long ago and {@link PacketGuiWindowIds} skips an id that is free. ++ */ ++ void handleExternalInventoryClose(User user, int windowId) { ++ if (user == null || user.getUUID() == null) { ++ return; ++ } ++ ++ closeTrackedWindow(user.getUUID(), windowId); ++ } ++ + void handleExternalInventoryOpen(User user, int windowId) { + if (user == null || user.getUUID() == null) { + return; @@ -1739,7 +1754,18 @@ index 0000000000000000000000000000000000000000..c19f114c8f0a37f485ba640a6a4ba5d5 + return; + } + -+ windowTrackerFor(user.getUUID()).closeWindow(windowId); ++ closeTrackedWindow(user.getUUID(), windowId); ++ } ++ ++ /** ++ * Clears the tracked external window without creating a tracker for a viewer that has none - this runs ++ * for every player on the server, not just those with a packet GUI open. ++ */ ++ private void closeTrackedWindow(UUID viewerId, int windowId) { ++ final PacketViewerWindowTracker tracker = windowTrackers.get(viewerId); ++ if (tracker != null) { ++ tracker.closeWindow(windowId); ++ } + } + + void handleDisconnect(UUID viewerId) { @@ -1807,20 +1833,6 @@ index 0000000000000000000000000000000000000000..c19f114c8f0a37f485ba640a6a4ba5d5 + return; + } + -+ final List guiSlots = new ArrayList<>(36); -+ final List playerSlots = new ArrayList<>(36); -+ for (int slot = PacketInventoryConstants.ITEMS_START; -+ slot < PacketInventoryConstants.HOTBAR_START + 9; -+ slot++) { -+ final int guiSlot = mapPlayerWindowSlotToOpenGuiSlot(session.container().getSize(), slot); -+ if (guiSlot < 0) { -+ continue; -+ } -+ -+ guiSlots.add(guiSlot); -+ playerSlots.add(slot); -+ } -+ + runOnPlayer(session.player(), () -> { + final PacketGuiNativeOutboundSender sender = nativeOutbound; + if (sender == null || !isTracked(session) || session.closeRequested()) { @@ -1828,15 +1840,15 @@ index 0000000000000000000000000000000000000000..c19f114c8f0a37f485ba640a6a4ba5d5 + } + + try { -+ final int stateId = session.nextStateId(); -+ for (int index = 0; index < guiSlots.size(); index++) { -+ sender.sendContainerSetSlot( -+ session.player(), -+ session.windowId(), -+ stateId, -+ guiSlots.get(index), -+ cloneItem(playerInventoryItem(session.player(), playerSlots.get(index)))); -+ } ++ // One content packet rather than 36 slot packets. The trigger is any player#updateInventory ++ // while a GUI is open, which the backend itself performs on close and which other plugins ++ // call routinely, so the difference is not academic. ++ sender.sendContainerSetContent( ++ session.player(), ++ session.windowId(), ++ session.nextStateId(), ++ currentWindowContents(session), ++ cloneItem(session.player().getItemOnCursor())); + } catch (final RuntimeException exception) { + owner.getLogger() + .log(Level.WARNING, "Failed to mirror the player inventory into a packet GUI", exception); @@ -1845,6 +1857,32 @@ index 0000000000000000000000000000000000000000..c19f114c8f0a37f485ba640a6a4ba5d5 + }); + } + ++ /** ++ * The full slot list of an open GUI window: the rendered top rows followed by the viewer's live main ++ * inventory and hotbar, in the order the container content packet expects them. ++ * ++ *

The top rows come from the last applied render so a mirror pass cannot roll the window back to ++ * whatever the container held at some other point in time. ++ */ ++ private static List currentWindowContents(PacketGuiSession session) { ++ final int topSize = session.container().getSize(); ++ final PacketGuiRender render = session.appliedRender(); ++ final boolean useRender = render != null && render.size() == topSize; ++ ++ final List items = new ArrayList<>(topSize + 36); ++ for (int slot = 0; slot < topSize; slot++) { ++ items.add(useRender ? render.bukkitItem(slot) : session.container().item(slot)); ++ } ++ ++ for (int slot = PacketInventoryConstants.ITEMS_START; ++ slot < PacketInventoryConstants.HOTBAR_START + 9; ++ slot++) { ++ items.add(cloneItem(playerInventoryItem(session.player(), slot))); ++ } ++ ++ return items; ++ } ++ + private void mirrorPlayerInventorySlot(UUID viewerId, int playerWindowSlot) { + final PacketGuiSession session = sessions.get(viewerId); + if (!isTracked(session) || session.closeRequested()) { @@ -3316,10 +3354,10 @@ index 0000000000000000000000000000000000000000..196be89c5dab50da4ba15107d02df0f2 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java new file mode 100644 -index 0000000000000000000000000000000000000000..e1a675ee337048beb08aa0c87f65017b8421be2e +index 0000000000000000000000000000000000000000..7b618da100d97fbc4796f78783e465842fa8d1ed --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java -@@ -0,0 +1,134 @@ +@@ -0,0 +1,137 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.event.PacketListenerAbstract; @@ -3376,12 +3414,15 @@ index 0000000000000000000000000000000000000000..e1a675ee337048beb08aa0c87f65017b + + if (packetType == PacketType.Play.Client.CLOSE_WINDOW) { + final WrapperPlayClientCloseWindow packet = new WrapperPlayClientCloseWindow(event); -+ if (!backend.isGuiWindow(event.getUser(), packet.getWindowId())) { ++ if (backend.isGuiWindow(event.getUser(), packet.getWindowId())) { ++ event.setCancelled(false); ++ backend.handleWindowClose(event.getUser(), packet.getWindowId()); + return; + } + -+ event.setCancelled(false); -+ backend.handleWindowClose(event.getUser(), packet.getWindowId()); ++ // The server never echoes a close packet for a window the client closed itself, so this is the ++ // only chance to notice that a real container is gone. ++ backend.handleExternalInventoryClose(event.getUser(), packet.getWindowId()); + } + } + @@ -3584,10 +3625,10 @@ index 0000000000000000000000000000000000000000..862304c2a30d0bae12cb416567801644 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java new file mode 100644 -index 0000000000000000000000000000000000000000..caf7e4bcdc4b93a38b5bb856499a69cdfe2ecec3 +index 0000000000000000000000000000000000000000..87f177266bd5be45723fe11ff9a2c6d7b2d618d7 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java -@@ -0,0 +1,273 @@ +@@ -0,0 +1,278 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.protocol.player.User; @@ -3601,9 +3642,10 @@ index 0000000000000000000000000000000000000000..caf7e4bcdc4b93a38b5bb856499a69cd + * One viewer's open packet GUI: window id, container state id, the last applied render and the pending + * render request. + * -+ *

Touched from netty threads (inbound clicks) and the viewer's thread (rendering), so every accessor is -+ * synchronized on the session. Render requests are coalesced through a latch so a burst of slot changes -+ * produces a single render pass. ++ *

Touched from netty threads (inbound clicks) and the viewer's thread (rendering), so every accessor of ++ * mutable state is synchronized on the session; the immutable identity of the session is readable without a ++ * lock. Render requests are coalesced through a latch so a burst of slot changes produces a single render ++ * pass. + */ +final class PacketGuiSession { + @@ -3642,35 +3684,39 @@ index 0000000000000000000000000000000000000000..caf7e4bcdc4b93a38b5bb856499a69cd + this.windowTracker = windowTracker; + } + -+ synchronized UUID viewerId() { ++ // The identity of a session never changes, so these accessors take no lock. That matters because they ++ // are read from netty threads on every inbound packet, which must not be able to block behind a render ++ // holding the session monitor. ++ ++ UUID viewerId() { + return viewerId; + } + -+ synchronized BukkitViewer viewer() { ++ BukkitViewer viewer() { + return viewer; + } + -+ synchronized Player player() { ++ Player player() { + return player; + } + -+ synchronized User user() { ++ User user() { + return user; + } + -+ synchronized int windowId() { ++ int windowId() { + return windowId; + } + -+ synchronized PacketViewContainer container() { ++ PacketViewContainer container() { + return container; + } + -+ synchronized IFRenderContext context() { ++ IFRenderContext context() { + return container.getContext(); + } + -+ synchronized PacketViewerWindowTracker windowTracker() { ++ PacketViewerWindowTracker windowTracker() { + return windowTracker; + } + From 713dd71479d4c5ea64a48a61afce84dc90a7e79b Mon Sep 17 00:00:00 2001 From: Keviro Date: Wed, 29 Jul 2026 02:12:40 +0200 Subject: [PATCH 42/50] test(packet): extract the render coalescing state machine and cover it The latch and the repair merging were the most state-machine-shaped code in the backend and the only part of it with no tests: a lost update there shows up as a window that silently stops matching what the view thinks it shows. They could not be tested where they were, because PacketGuiSession needs a viewer, a PacketEvents user and a final PacketViewContainer that Mockito 4 cannot mock. PacketGuiRenderRequests now owns that logic and needs nothing but the top size. The session delegates to it. Ten tests pin the behaviour the rest of the backend relies on: the latch only schedules once and reopens after a consume, clear() releases it without discarding the pending frame, flags only ever merge upwards, a full resync drops targeted repairs, and slots outside the window are ignored. --- ...0006-Add-internal-packet-GUI-backend.patch | 549 +++++++++++++----- 1 file changed, 400 insertions(+), 149 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 8bc9889..0e9157a 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1274,7 +1274,7 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..a44e3f3cf88652fa1d12f91ea33d38a197473c78 +index 0000000000000000000000000000000000000000..a633305b82d9027fd1a3bfc7206441e78bc13236 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java @@ -0,0 +1,1314 @@ @@ -2027,7 +2027,7 @@ index 0000000000000000000000000000000000000000..a44e3f3cf88652fa1d12f91ea33d38a1 + } + + runOnPlayerNextTick(session.player(), () -> { -+ final PacketGuiSession.RenderRequest request = session.consumeRenderRequest(); ++ final PacketGuiRenderRequests.RenderRequest request = session.consumeRenderRequest(); + renderSession(session, request); + }); + } @@ -2036,7 +2036,7 @@ index 0000000000000000000000000000000000000000..a44e3f3cf88652fa1d12f91ea33d38a1 + renderSession(session, forceReopen, hardResync, new boolean[0], new int[0]); + } + -+ private void renderSession(PacketGuiSession session, PacketGuiSession.RenderRequest request) { ++ private void renderSession(PacketGuiSession session, PacketGuiRenderRequests.RenderRequest request) { + renderSession( + session, + request.forceReopen(), @@ -3578,6 +3578,193 @@ index 0000000000000000000000000000000000000000..b08e2dc2684aa5ca04844b5f496a0a4e + return Component.text(String.valueOf(title)); + } +} +diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRenderRequests.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRenderRequests.java +new file mode 100644 +index 0000000000000000000000000000000000000000..c8a180b34efab4abc2e0c7dea01484c3f718a8ba +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRenderRequests.java +@@ -0,0 +1,181 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import java.util.Arrays; ++ ++/** ++ * Coalesces the render requests of one packet GUI session. ++ * ++ *

A single click can dirty several slots and a view can rewrite its whole model in one pass, so requests ++ * are merged into one pending frame instead of producing a render each. The latch is what makes that work: ++ * the first request after a consume schedules the render task, every request until that task runs only adds ++ * to the frame it will see. ++ * ++ *

Merging is deliberately one-way. Two requests never cancel each other out - a full resync wins over ++ * targeted repairs, a reopen wins over a resync - because the cost of resending too much is a packet, and the ++ * cost of resending too little is a window that no longer matches what the view thinks it shows. ++ * ++ *

Requests arrive from netty threads and are consumed on the viewer's thread, so every method is ++ * synchronized. ++ */ ++final class PacketGuiRenderRequests { ++ ++ private final int topSize; ++ private boolean scheduled; ++ private boolean forceReopen; ++ private boolean hardResync; ++ private boolean[] topSlotRepairs = new boolean[0]; ++ private final boolean[] playerSlotRepairs = new boolean[PacketInventoryConstants.INVENTORY_SIZE]; ++ private int playerSlotRepairCount; ++ ++ PacketGuiRenderRequests(int topSize) { ++ this.topSize = topSize; ++ } ++ ++ /** ++ * Merges a request into the pending frame. ++ * ++ * @return {@code true} when the caller has to schedule the render task, i.e. when no frame was pending. ++ */ ++ synchronized boolean schedule( ++ boolean forceReopen, boolean hardResync, PacketGuiRepairScope repairScope, PacketGuiClick click) { ++ final PacketGuiRepairScope scope = repairScope == null ? PacketGuiRepairScope.NONE : repairScope; ++ this.forceReopen |= forceReopen; ++ this.hardResync |= hardResync || scope.fullWindow(); ++ ++ // Targeted repairs are pointless once the frame resends everything anyway. ++ if (!this.hardResync && click != null) { ++ scheduleTargetedRepairs(scope, click); ++ } ++ ++ if (scheduled) { ++ return false; ++ } ++ ++ scheduled = true; ++ return true; ++ } ++ ++ /** ++ * Takes the pending frame and releases the latch, so requests arriving while the render runs schedule a ++ * new task rather than being folded into a frame nobody will read. ++ */ ++ synchronized RenderRequest consume() { ++ final RenderRequest request = new RenderRequest( ++ forceReopen, hardResync, topSlotRepairs.length == 0 ? new boolean[0] : topSlotRepairs.clone(), playerSlotRepairs()); ++ scheduled = false; ++ forceReopen = false; ++ hardResync = false; ++ topSlotRepairs = new boolean[0]; ++ Arrays.fill(playerSlotRepairs, false); ++ playerSlotRepairCount = 0; ++ return request; ++ } ++ ++ /** ++ * Releases the latch without producing a request. Used when the task that was supposed to consume the ++ * frame can never run, so a later {@link #schedule} is not swallowed. ++ */ ++ synchronized void clear() { ++ scheduled = false; ++ } ++ ++ private void scheduleTargetedRepairs(PacketGuiRepairScope scope, PacketGuiClick click) { ++ if (scope.repairsClickedPlayerSlot()) { ++ schedulePlayerSlotRepair(PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(topSize, click.slot())); ++ scheduleChangedPlayerSlotRepairs(click); ++ return; ++ } ++ ++ if (scope.repairsTopSlot()) { ++ scheduleTopSlotRepair(click.slot()); ++ } ++ ++ if (scope.repairsChangedPlayerSlots()) { ++ scheduleChangedPlayerSlotRepairs(click); ++ } ++ ++ if (scope.repairsOffhand()) { ++ schedulePlayerSlotRepair(PacketInventoryConstants.SLOT_OFFHAND); ++ } ++ } ++ ++ private void scheduleTopSlotRepair(int slot) { ++ if (slot < 0 || slot >= topSize) { ++ return; ++ } ++ ++ if (topSlotRepairs.length != topSize) { ++ topSlotRepairs = new boolean[topSize]; ++ } ++ topSlotRepairs[slot] = true; ++ } ++ ++ private void scheduleChangedPlayerSlotRepairs(PacketGuiClick click) { ++ for (final int changedSlot : click.changedSlots()) { ++ schedulePlayerSlotRepair(PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(topSize, changedSlot)); ++ } ++ ++ schedulePlayerSlotRepair(click.swappedPlayerWindowSlot()); ++ } ++ ++ private void schedulePlayerSlotRepair(int playerSlot) { ++ if (playerSlot < 0 || playerSlot >= PacketInventoryConstants.INVENTORY_SIZE) { ++ return; ++ } ++ ++ if (!playerSlotRepairs[playerSlot]) { ++ playerSlotRepairs[playerSlot] = true; ++ playerSlotRepairCount++; ++ } ++ } ++ ++ private int[] playerSlotRepairs() { ++ if (playerSlotRepairCount == 0) { ++ return new int[0]; ++ } ++ ++ final int[] repairs = new int[playerSlotRepairCount]; ++ int index = 0; ++ for (int slot = 0; slot < playerSlotRepairs.length; slot++) { ++ if (playerSlotRepairs[slot]) { ++ repairs[index++] = slot; ++ } ++ } ++ return repairs; ++ } ++ ++ /** ++ * One coalesced frame: what to resend and how much of it. ++ */ ++ static final class RenderRequest { ++ ++ private final boolean forceReopen; ++ private final boolean hardResync; ++ private final boolean[] forcedTopSlotRepairs; ++ private final int[] playerSlotRepairs; ++ ++ private RenderRequest( ++ boolean forceReopen, boolean hardResync, boolean[] forcedTopSlotRepairs, int[] playerSlotRepairs) { ++ this.forceReopen = forceReopen; ++ this.hardResync = hardResync; ++ this.forcedTopSlotRepairs = forcedTopSlotRepairs; ++ this.playerSlotRepairs = playerSlotRepairs; ++ } ++ ++ boolean forceReopen() { ++ return forceReopen; ++ } ++ ++ boolean hardResync() { ++ return hardResync; ++ } ++ ++ boolean[] forcedTopSlotRepairs() { ++ return forcedTopSlotRepairs; ++ } ++ ++ int[] playerSlotRepairs() { ++ return playerSlotRepairs; ++ } ++ } ++} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRepairScope.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRepairScope.java new file mode 100644 index 0000000000000000000000000000000000000000..862304c2a30d0bae12cb4165678016441db8c7e6 @@ -3625,14 +3812,13 @@ index 0000000000000000000000000000000000000000..862304c2a30d0bae12cb416567801644 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java new file mode 100644 -index 0000000000000000000000000000000000000000..87f177266bd5be45723fe11ff9a2c6d7b2d618d7 +index 0000000000000000000000000000000000000000..b3e40b1dfd7e088a7bf11be24e8c3ffef965b231 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java -@@ -0,0 +1,278 @@ +@@ -0,0 +1,143 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.protocol.player.User; -+import java.util.Arrays; +import java.util.UUID; +import me.devnatan.inventoryframework.BukkitViewer; +import me.devnatan.inventoryframework.context.IFRenderContext; @@ -3656,13 +3842,8 @@ index 0000000000000000000000000000000000000000..87f177266bd5be45723fe11ff9a2c6d7 + private final int windowId; + private final PacketViewContainer container; + private final PacketViewerWindowTracker windowTracker; ++ private final PacketGuiRenderRequests renderRequests; + private PacketGuiRender appliedRender; -+ private boolean renderScheduled; -+ private boolean scheduledForceReopen; -+ private boolean scheduledHardResync; -+ private boolean[] scheduledTopSlotRepairs = new boolean[0]; -+ private final boolean[] scheduledPlayerSlotRepairs = new boolean[PacketInventoryConstants.INVENTORY_SIZE]; -+ private int scheduledPlayerSlotRepairCount; + private boolean closed; + private boolean closeRequested; + private int stateId = 1; @@ -3682,6 +3863,7 @@ index 0000000000000000000000000000000000000000..87f177266bd5be45723fe11ff9a2c6d7 + this.windowId = windowId; + this.container = container; + this.windowTracker = windowTracker; ++ this.renderRequests = new PacketGuiRenderRequests(container.getSize()); + } + + // The identity of a session never changes, so these accessors take no lock. That matters because they @@ -3760,151 +3942,21 @@ index 0000000000000000000000000000000000000000..87f177266bd5be45723fe11ff9a2c6d7 + return generation == sendGeneration && generation > invalidatedSendGeneration; + } + -+ synchronized boolean scheduleRender( -+ boolean forceReopen, -+ boolean hardResync, -+ PacketGuiRepairScope repairScope, -+ PacketGuiClick click) { -+ final PacketGuiRepairScope scope = repairScope == null ? PacketGuiRepairScope.NONE : repairScope; -+ scheduledForceReopen |= forceReopen; -+ scheduledHardResync |= hardResync || scope.fullWindow(); -+ -+ if (!scheduledHardResync && click != null) { -+ scheduleTargetedRepairs(scope, click); -+ } -+ -+ if (renderScheduled) { -+ return false; -+ } -+ -+ renderScheduled = true; -+ return true; ++ boolean scheduleRender( ++ boolean forceReopen, boolean hardResync, PacketGuiRepairScope repairScope, PacketGuiClick click) { ++ return renderRequests.schedule(forceReopen, hardResync, repairScope, click); + } + -+ synchronized RenderRequest consumeRenderRequest() { -+ final RenderRequest request = new RenderRequest( -+ scheduledForceReopen, -+ scheduledHardResync, -+ scheduledTopSlotRepairs.length == 0 ? new boolean[0] : scheduledTopSlotRepairs.clone(), -+ scheduledPlayerSlotRepairs()); -+ renderScheduled = false; -+ scheduledForceReopen = false; -+ scheduledHardResync = false; -+ scheduledTopSlotRepairs = new boolean[0]; -+ Arrays.fill(scheduledPlayerSlotRepairs, false); -+ scheduledPlayerSlotRepairCount = 0; -+ return request; ++ PacketGuiRenderRequests.RenderRequest consumeRenderRequest() { ++ return renderRequests.consume(); + } + + /** + * Releases the scheduled-render latch without producing a request. Used when the task that was supposed to + * consume the request can never run, so a later {@link #scheduleRender} is not swallowed. + */ -+ synchronized void clearScheduledRender() { -+ renderScheduled = false; -+ } -+ -+ private void scheduleTargetedRepairs(PacketGuiRepairScope scope, PacketGuiClick click) { -+ if (scope.repairsClickedPlayerSlot()) { -+ schedulePlayerSlotRepair( -+ PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(container.getSize(), click.slot())); -+ scheduleChangedPlayerSlotRepairs(click); -+ return; -+ } -+ -+ if (scope.repairsTopSlot()) { -+ scheduleTopSlotRepair(click.slot()); -+ } -+ -+ if (scope.repairsChangedPlayerSlots()) { -+ scheduleChangedPlayerSlotRepairs(click); -+ } -+ -+ if (scope.repairsOffhand()) { -+ schedulePlayerSlotRepair(PacketInventoryConstants.SLOT_OFFHAND); -+ } -+ } -+ -+ private void scheduleTopSlotRepair(int slot) { -+ if (slot < 0 || slot >= container.getSize()) { -+ return; -+ } -+ -+ if (scheduledTopSlotRepairs.length != container.getSize()) { -+ scheduledTopSlotRepairs = new boolean[container.getSize()]; -+ } -+ scheduledTopSlotRepairs[slot] = true; -+ } -+ -+ private void scheduleChangedPlayerSlotRepairs(PacketGuiClick click) { -+ final int topSize = container.getSize(); -+ for (final int changedSlot : click.changedSlots()) { -+ final int playerSlot = PacketInventoryConstants.guiContainerSlotToPlayerWindowSlot(topSize, changedSlot); -+ schedulePlayerSlotRepair(playerSlot); -+ } -+ -+ schedulePlayerSlotRepair(click.swappedPlayerWindowSlot()); -+ } -+ -+ private void schedulePlayerSlotRepair(int playerSlot) { -+ if (playerSlot < 0 || playerSlot >= PacketInventoryConstants.INVENTORY_SIZE) { -+ return; -+ } -+ -+ if (!scheduledPlayerSlotRepairs[playerSlot]) { -+ scheduledPlayerSlotRepairs[playerSlot] = true; -+ scheduledPlayerSlotRepairCount++; -+ } -+ } -+ -+ private int[] scheduledPlayerSlotRepairs() { -+ if (scheduledPlayerSlotRepairCount == 0) { -+ return new int[0]; -+ } -+ -+ final int[] repairs = new int[scheduledPlayerSlotRepairCount]; -+ int index = 0; -+ for (int slot = 0; slot < scheduledPlayerSlotRepairs.length; slot++) { -+ if (scheduledPlayerSlotRepairs[slot]) { -+ repairs[index++] = slot; -+ } -+ } -+ return repairs; -+ } -+ -+ static final class RenderRequest { -+ -+ private final boolean forceReopen; -+ private final boolean hardResync; -+ private final boolean[] forcedTopSlotRepairs; -+ private final int[] playerSlotRepairs; -+ -+ private RenderRequest( -+ boolean forceReopen, -+ boolean hardResync, -+ boolean[] forcedTopSlotRepairs, -+ int[] playerSlotRepairs) { -+ this.forceReopen = forceReopen; -+ this.hardResync = hardResync; -+ this.forcedTopSlotRepairs = forcedTopSlotRepairs; -+ this.playerSlotRepairs = playerSlotRepairs; -+ } -+ -+ boolean forceReopen() { -+ return forceReopen; -+ } -+ -+ boolean hardResync() { -+ return hardResync; -+ } -+ -+ boolean[] forcedTopSlotRepairs() { -+ return forcedTopSlotRepairs; -+ } -+ -+ int[] playerSlotRepairs() { -+ return playerSlotRepairs; -+ } ++ void clearScheduledRender() { ++ renderRequests.clear(); + } +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIds.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIds.java @@ -4838,6 +4890,205 @@ index 0000000000000000000000000000000000000000..248745ce12a28003a4a137f68eb397a3 + } + } +} +diff --git a/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRenderRequestsTest.java b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRenderRequestsTest.java +new file mode 100644 +index 0000000000000000000000000000000000000000..643b57b48087df6f350b5f22f58176f6b358d0aa +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRenderRequestsTest.java +@@ -0,0 +1,193 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import static org.junit.jupiter.api.Assertions.assertArrayEquals; ++import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.junit.jupiter.api.Assertions.assertFalse; ++import static org.junit.jupiter.api.Assertions.assertTrue; ++ ++import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientClickWindow.WindowClickType; ++import org.junit.jupiter.api.Test; ++ ++class PacketGuiRenderRequestsTest { ++ ++ private static final int TOP_SIZE = 27; ++ ++ /** GUI slot 27 is the first slot of the player's main inventory, i.e. player window slot 9. */ ++ private static final int FIRST_BOTTOM_GUI_SLOT = TOP_SIZE; ++ ++ @Test ++ void latchesSoOnlyTheFirstRequestSchedulesATask() { ++ final PacketGuiRenderRequests requests = new PacketGuiRenderRequests(TOP_SIZE); ++ ++ assertTrue(requests.schedule(false, false, PacketGuiRepairScope.NONE, null)); ++ assertFalse(requests.schedule(false, false, PacketGuiRepairScope.NONE, null)); ++ assertFalse(requests.schedule(false, false, PacketGuiRepairScope.NONE, null)); ++ } ++ ++ @Test ++ void latchReopensAfterTheFrameIsConsumed() { ++ final PacketGuiRenderRequests requests = new PacketGuiRenderRequests(TOP_SIZE); ++ ++ assertTrue(requests.schedule(false, false, PacketGuiRepairScope.NONE, null)); ++ requests.consume(); ++ assertTrue(requests.schedule(false, false, PacketGuiRepairScope.NONE, null)); ++ } ++ ++ /** ++ * The whole point of {@code clear()}: a render task that can never run must not leave the latch closed, ++ * or every later request is swallowed and the window silently stops updating. ++ */ ++ @Test ++ void clearReleasesTheLatchWithoutProducingAFrame() { ++ final PacketGuiRenderRequests requests = new PacketGuiRenderRequests(TOP_SIZE); ++ ++ assertTrue(requests.schedule(true, true, PacketGuiRepairScope.FULL_WINDOW, null)); ++ requests.clear(); ++ ++ assertTrue(requests.schedule(false, false, PacketGuiRepairScope.NONE, null)); ++ ++ // The frame itself survives the clear - only the latch is released. ++ final PacketGuiRenderRequests.RenderRequest request = requests.consume(); ++ assertTrue(request.forceReopen()); ++ assertTrue(request.hardResync()); ++ } ++ ++ @Test ++ void mergesFlagsOneWayAcrossRequests() { ++ final PacketGuiRenderRequests requests = new PacketGuiRenderRequests(TOP_SIZE); ++ ++ requests.schedule(false, false, PacketGuiRepairScope.NONE, null); ++ requests.schedule(true, false, PacketGuiRepairScope.NONE, null); ++ requests.schedule(false, true, PacketGuiRepairScope.NONE, null); ++ requests.schedule(false, false, PacketGuiRepairScope.NONE, null); ++ ++ final PacketGuiRenderRequests.RenderRequest request = requests.consume(); ++ assertTrue(request.forceReopen(), "a reopen may never be downgraded by a later plain request"); ++ assertTrue(request.hardResync(), "a resync may never be downgraded by a later plain request"); ++ } ++ ++ @Test ++ void aFullWindowScopeImpliesAHardResync() { ++ final PacketGuiRenderRequests requests = new PacketGuiRenderRequests(TOP_SIZE); ++ ++ requests.schedule(false, false, PacketGuiRepairScope.FULL_WINDOW, click(0, WindowClickType.PICKUP)); ++ ++ assertTrue(requests.consume().hardResync()); ++ } ++ ++ @Test ++ void consumingResetsTheFrame() { ++ final PacketGuiRenderRequests requests = new PacketGuiRenderRequests(TOP_SIZE); ++ ++ requests.schedule(true, true, PacketGuiRepairScope.FULL_WINDOW, null); ++ requests.consume(); ++ ++ requests.schedule(false, false, PacketGuiRepairScope.NONE, null); ++ final PacketGuiRenderRequests.RenderRequest second = requests.consume(); ++ assertFalse(second.forceReopen()); ++ assertFalse(second.hardResync()); ++ assertEquals(0, second.forcedTopSlotRepairs().length); ++ assertEquals(0, second.playerSlotRepairs().length); ++ } ++ ++ @Test ++ void recordsTheClickedTopSlotForATargetedRepair() { ++ final PacketGuiRenderRequests requests = new PacketGuiRenderRequests(TOP_SIZE); ++ ++ requests.schedule(false, false, PacketGuiRepairScope.TOP_SLOT_AND_CURSOR, click(4, WindowClickType.PICKUP)); ++ ++ final boolean[] repairs = requests.consume().forcedTopSlotRepairs(); ++ assertEquals(TOP_SIZE, repairs.length); ++ assertTrue(repairs[4]); ++ assertFalse(repairs[3]); ++ assertFalse(repairs[5]); ++ } ++ ++ @Test ++ void mapsAClickedBottomSlotToItsPlayerWindowSlot() { ++ final PacketGuiRenderRequests requests = new PacketGuiRenderRequests(TOP_SIZE); ++ ++ requests.schedule( ++ false, ++ false, ++ PacketGuiRepairScope.PLAYER_INVENTORY, ++ click(FIRST_BOTTOM_GUI_SLOT, WindowClickType.PICKUP)); ++ ++ assertArrayEquals( ++ new int[] {PacketInventoryConstants.ITEMS_START}, requests.consume().playerSlotRepairs()); ++ } ++ ++ @Test ++ void recordsTheOffhandForAnOffhandSwap() { ++ final PacketGuiRenderRequests requests = new PacketGuiRenderRequests(TOP_SIZE); ++ ++ requests.schedule( ++ false, ++ false, ++ PacketGuiRepairScope.TOP_SLOT_CURSOR_AND_OFFHAND, ++ PacketGuiClick.of(1, 2, 40, WindowClickType.SWAP)); ++ ++ final PacketGuiRenderRequests.RenderRequest request = requests.consume(); ++ assertTrue(request.forcedTopSlotRepairs()[2]); ++ assertArrayEquals(new int[] {PacketInventoryConstants.SLOT_OFFHAND}, request.playerSlotRepairs()); ++ } ++ ++ @Test ++ void collectsPlayerSlotRepairsFromSeveralClicksWithoutDuplicates() { ++ final PacketGuiRenderRequests requests = new PacketGuiRenderRequests(TOP_SIZE); ++ ++ requests.schedule( ++ false, ++ false, ++ PacketGuiRepairScope.PLAYER_INVENTORY, ++ click(FIRST_BOTTOM_GUI_SLOT, WindowClickType.PICKUP)); ++ requests.schedule( ++ false, ++ false, ++ PacketGuiRepairScope.PLAYER_INVENTORY, ++ click(FIRST_BOTTOM_GUI_SLOT + 1, WindowClickType.PICKUP)); ++ requests.schedule( ++ false, ++ false, ++ PacketGuiRepairScope.PLAYER_INVENTORY, ++ click(FIRST_BOTTOM_GUI_SLOT, WindowClickType.PICKUP)); ++ ++ assertArrayEquals( ++ new int[] {PacketInventoryConstants.ITEMS_START, PacketInventoryConstants.ITEMS_START + 1}, ++ requests.consume().playerSlotRepairs()); ++ } ++ ++ /** ++ * Once the frame resends the whole window, tracking individual slots would only cost work - and would ++ * leave stale entries behind if a later request downgraded the scope. ++ */ ++ @Test ++ void dropsTargetedRepairsOnceTheFrameResyncsEverything() { ++ final PacketGuiRenderRequests requests = new PacketGuiRenderRequests(TOP_SIZE); ++ ++ requests.schedule(false, true, PacketGuiRepairScope.NONE, null); ++ requests.schedule(false, false, PacketGuiRepairScope.TOP_SLOT_AND_CURSOR, click(4, WindowClickType.PICKUP)); ++ ++ final PacketGuiRenderRequests.RenderRequest request = requests.consume(); ++ assertTrue(request.hardResync()); ++ assertEquals(0, request.forcedTopSlotRepairs().length); ++ assertEquals(0, request.playerSlotRepairs().length); ++ } ++ ++ @Test ++ void ignoresSlotsOutsideTheWindow() { ++ final PacketGuiRenderRequests requests = new PacketGuiRenderRequests(TOP_SIZE); ++ ++ requests.schedule(false, false, PacketGuiRepairScope.TOP_SLOT_AND_CURSOR, click(-999, WindowClickType.THROW)); ++ requests.schedule( ++ false, false, PacketGuiRepairScope.TOP_SLOT_AND_CURSOR, click(TOP_SIZE + 99, WindowClickType.PICKUP)); ++ ++ final PacketGuiRenderRequests.RenderRequest request = requests.consume(); ++ assertEquals(0, request.forcedTopSlotRepairs().length); ++ assertEquals(0, request.playerSlotRepairs().length); ++ } ++ ++ private static PacketGuiClick click(int slot, WindowClickType clickType) { ++ return PacketGuiClick.of(1, slot, 0, clickType); ++ } ++} diff --git a/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIdsTest.java b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketGuiWindowIdsTest.java new file mode 100644 index 0000000000000000000000000000000000000000..9d251743add80f827c025bc01f78c8b42f2d7e94 From 84103738ed8b1b3635c357f6964e930f040d4c87 Mon Sep 17 00:00:00 2001 From: Keviro Date: Wed, 29 Jul 2026 02:14:48 +0200 Subject: [PATCH 43/50] docs(packet): correct the click contract and document the window id range The bottom-click write-back was described as opt-in, requiring setCancelled(false). The click context starts uncancelled, exactly like the Bukkit backend, so it is opt-out - a handler that changes currentItem for display purposes only has to cancel, and the old wording would have led someone to destroy an item. Also records what the synthesized InventoryClickEvent now reports for getSlot() and getClickedInventory(), why fake window ids live in 101-127, and that a discarded session is a WARNING that means onClose did not run. --- docs/packet-gui-backend.md | 49 +++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/docs/packet-gui-backend.md b/docs/packet-gui-backend.md index 8c9faa9..a446a99 100644 --- a/docs/packet-gui-backend.md +++ b/docs/packet-gui-backend.md @@ -70,9 +70,11 @@ Startup log lines to look for: such a type is encountered it is logged once at INFO. - **The viewer's own inventory is effectively read-only while a packet GUI is open.** Click packets are cancelled before vanilla sees them, so nothing moves by itself. A view can still act on bottom-inventory - clicks: the click is delivered as an entity-container click, and a handler that calls - `setCancelled(false)` **and** changes `clickOrigin.currentItem` gets that item written back to the real - slot. Vanilla pickup/swap/quick-move semantics are deliberately not emulated. + clicks: the click is delivered as an entity-container click, and whatever `clickOrigin.currentItem` holds + when the handler returns is written back to the real slot — **unless the handler cancels the click**. Like + the Bukkit backend, the click context starts *uncancelled*, so the write-back is opt-out, not opt-in. For a + handler that leaves `currentItem` alone it is a no-op; a handler that changes it for display purposes only + must call `setCancelled(true)`. Vanilla pickup/swap/quick-move semantics are deliberately not emulated. - **Drag, double-click, the drop key and unknown click modes are denied.** They are cancelled and answered with a full resync; no view callback runs for them. - **Outside clicks are routed.** The protocol has two wire forms for them, and both are accepted on a negative @@ -84,8 +86,15 @@ Startup log lines to look for: - **`RenderContext#getInventory()` throws** `UnsupportedOperationException` in packet mode. Probe with `RenderContext#isBackedByRealInventory()` first. - **`SlotClickContext#getClickOrigin()` returns a synthesized `InventoryClickEvent`.** Item access, - cancellation, slot, slot type, action, hotbar button and click type are served from the packet click. The - inherited `getInventory()`, `getCursor()` and `setCursor(...)` are left intact so existing plugins keep + cancellation, raw slot, slot, slot type, action, hotbar button, click type and the clicked inventory are + served from the packet click, with Bukkit's own semantics: `getSlot()` is the index *inside* the clicked + inventory (so `player.getInventory().getItem(event.getSlot())` works for a bottom click), while + `getRawSlot()` stays view-wide. `getClickedInventory()` returns the viewer's inventory for a bottom click + and `null` for a top one — the top rows have no Bukkit inventory behind them, which is the entire point of + the backend, so a consumer that treats `null` as "outside" will read a top click as an outside click. + Use `SlotClickContext#isOutsideClick()` to tell them apart. + + The inherited `getInventory()`, `getCursor()` and `setCursor(...)` are left intact so existing plugins keep compiling and running, but they resolve against the player's *own* inventory view. Reading them is harmless; writing through them reaches real server-side state and must not be done from a packet GUI handler. @@ -124,7 +133,7 @@ than by watching the screen. | Cursor ghost-item correction | No item sticks to the cursor after any click | | | | Bottom inventory visual correctness | The viewer's own items render correctly and snap back when clicked | | | | No GUI display items in real server inventory contents | `/invsee` or an inventory dump shows no GUI icons in any real inventory | | | -| Window id collision | Opening a GUI while a real chest is open closes the chest and never reuses its window id | | | +| Window id collision | Opening a GUI while a real chest is open closes the chest and never reuses its window id | | Range guard unit-tested (ids 101–127 vs vanilla's 1–100); end-to-end path not run | | World change / respawn | Session is finalized, no stale viewer keeps receiving GUI packets | | | The rows marked **Changed** deviate from the original AGENTS.md expectation. Those click modes are deliberately @@ -138,8 +147,9 @@ The empty rows have not been exercised. Two of them are worth clearing before th - **No GUI display items in real server inventory contents.** This is the claim the whole backend exists to make, and it is the one row nobody has checked. Open a GUI, then have a second player or a console command dump the viewer's inventory and confirm no GUI icon appears in it. -- **Window id collision.** The guard in `PacketGuiWindowIds` is unit-tested, but the end-to-end path — open a - real chest, then open a GUI — has only been reasoned about, never run. +- **Window id collision.** `PacketGuiWindowIds` now allocates outside vanilla's range and that is unit-tested, + so the collision class is closed by construction. The end-to-end path — open a real chest, then open a GUI — + has still only been reasoned about, never run. The remaining gaps are the denial modes (drag, drop key, offhand swap, number key), lifecycle cleanup on quit and world change, and cursor correction. Each is a single deliberate action on a server with @@ -155,9 +165,21 @@ contents. The bottom rows are read live from the viewer's Bukkit inventory and s That last point matters for surf-api's PacketLore: because the packets travel the server's normal outbound path, an enchanted item in the viewer's inventory shows its enchantment lore inside a packet GUI exactly as it does anywhere else, decorated once. The backend deliberately keeps **no** mirror of the viewer's items — items -captured from already-intercepted outbound packets would be decorated a second time when resent. The only -packet-side viewer state is `PacketViewerWindowTracker`, which remembers the id of the real container window so -a fake window id never collides with it. +captured from already-intercepted outbound packets would be decorated a second time when resent. + +The only packet-side viewer state is `PacketViewerWindowTracker`, which remembers the id of the real container +window the viewer has open. + +## Window ids + +The client tells windows apart by id alone, so a fake window that shares an id with a real container makes the +client apply that container's updates to the GUI screen and the backend route the player's clicks into the +wrong pipeline. Fake ids are therefore allocated from **101–127**, a range vanilla never uses: +`ServerPlayer#nextContainerCounter()` is `containerCounter % 100 + 1`, i.e. 1–100. 127 is the upper bound so +the id still fits in a signed byte, which older protocol versions require. + +`PacketViewerWindowTracker` is skipped on top of that. Against vanilla that is redundant; it still matters +against another plugin that opens its own fake window in the same range. ## Scheduling @@ -171,6 +193,11 @@ If the scheduler refuses a task, the viewer is gone: the session is discarded ra thread. That matters for the next-tick path in particular, because a dropped render task would otherwise leave the session's render request latched and swallow every later one. +Discarding a session is logged at `WARNING` and detaches the viewer from the view, but does **not** run the +CLOSE pipeline — that executes developer code, and this path exists precisely because nothing will accept work +for that viewer any more. A `Discarded the packet GUI session of …` line therefore means `onClose` did not run +for that viewer. + ## Build note `spotlessCheck` and `spotlessApply` do not run under this project's JDK 25 toolchain — From 192fc1fbb4af8693003768857ddcd8f2502c11a6 Mon Sep 17 00:00:00 2001 From: Keviro Date: Wed, 29 Jul 2026 02:28:37 +0200 Subject: [PATCH 44/50] Revert "perf(packet): mirror the player inventory with one packet" Sending one container content packet instead of 36 slot packets was not an equivalent optimization. The protocol has no "set these slots" form: a content packet addresses the whole window, so the client redraws the top rows as well. A refresh that was meant to repaint only the viewer's own rows came out as a full window redraw. Reverts only the mirror change from 846d399; the window tracker fix, the dead line removal and the accessor cleanup in that commit stay. --- docs/packet-gui-backend.md | 4 ++ ...0006-Add-internal-packet-GUI-backend.patch | 71 +++++++++---------- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/docs/packet-gui-backend.md b/docs/packet-gui-backend.md index a446a99..e4587e0 100644 --- a/docs/packet-gui-backend.md +++ b/docs/packet-gui-backend.md @@ -162,6 +162,10 @@ The top rows come from the view's own render model in `PacketViewContainer`; the contents. The bottom rows are read live from the viewer's Bukkit inventory and sent through `PacketGuiNativeOutboundSender`, i.e. the same outbound path vanilla uses. +Those bottom rows are repainted with one slot packet each, never with a container content packet. A content +packet addresses the whole window, so the client redraws the top rows too — visually a full refresh of a GUI +the server only meant to touch the bottom of. The packet count is the deliberate price for that. + That last point matters for surf-api's PacketLore: because the packets travel the server's normal outbound path, an enchanted item in the viewer's inventory shows its enchantment lore inside a packet GUI exactly as it does anywhere else, decorated once. The backend deliberately keeps **no** mirror of the viewer's items — items diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 0e9157a..a70102f 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1274,10 +1274,10 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..a633305b82d9027fd1a3bfc7206441e78bc13236 +index 0000000000000000000000000000000000000000..35fca35c357d6746dcc44f04e4cab7893a08aa86 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1314 @@ +@@ -0,0 +1,1311 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1827,12 +1827,35 @@ index 0000000000000000000000000000000000000000..a633305b82d9027fd1a3bfc7206441e7 + return user != null && user.getUUID() != null && isTracked(sessions.get(user.getUUID())); + } + ++ /** ++ * Repaints the bottom rows of an open GUI after the server resent the viewer's own inventory. ++ * ++ *

Deliberately one slot packet per mirrored slot rather than a single container content packet. The ++ * protocol has no "set these slots" form: the alternative addresses the whole window, which the client ++ * treats as a full resync and redraws accordingly. Trading 36 packets for a visible repaint of rows the ++ * server never meant to touch is the wrong way round. ++ */ + private void mirrorPlayerInventoryWindow(UUID viewerId) { + final PacketGuiSession session = sessions.get(viewerId); + if (!isTracked(session) || session.closeRequested()) { + return; + } + ++ final int topSize = session.container().getSize(); ++ final List guiSlots = new ArrayList<>(36); ++ final List playerSlots = new ArrayList<>(36); ++ for (int slot = PacketInventoryConstants.ITEMS_START; ++ slot < PacketInventoryConstants.HOTBAR_START + 9; ++ slot++) { ++ final int guiSlot = mapPlayerWindowSlotToOpenGuiSlot(topSize, slot); ++ if (guiSlot < 0) { ++ continue; ++ } ++ ++ guiSlots.add(guiSlot); ++ playerSlots.add(slot); ++ } ++ + runOnPlayer(session.player(), () -> { + final PacketGuiNativeOutboundSender sender = nativeOutbound; + if (sender == null || !isTracked(session) || session.closeRequested()) { @@ -1840,15 +1863,15 @@ index 0000000000000000000000000000000000000000..a633305b82d9027fd1a3bfc7206441e7 + } + + try { -+ // One content packet rather than 36 slot packets. The trigger is any player#updateInventory -+ // while a GUI is open, which the backend itself performs on close and which other plugins -+ // call routinely, so the difference is not academic. -+ sender.sendContainerSetContent( -+ session.player(), -+ session.windowId(), -+ session.nextStateId(), -+ currentWindowContents(session), -+ cloneItem(session.player().getItemOnCursor())); ++ final int stateId = session.nextStateId(); ++ for (int index = 0; index < guiSlots.size(); index++) { ++ sender.sendContainerSetSlot( ++ session.player(), ++ session.windowId(), ++ stateId, ++ guiSlots.get(index), ++ cloneItem(playerInventoryItem(session.player(), playerSlots.get(index)))); ++ } + } catch (final RuntimeException exception) { + owner.getLogger() + .log(Level.WARNING, "Failed to mirror the player inventory into a packet GUI", exception); @@ -1857,32 +1880,6 @@ index 0000000000000000000000000000000000000000..a633305b82d9027fd1a3bfc7206441e7 + }); + } + -+ /** -+ * The full slot list of an open GUI window: the rendered top rows followed by the viewer's live main -+ * inventory and hotbar, in the order the container content packet expects them. -+ * -+ *

The top rows come from the last applied render so a mirror pass cannot roll the window back to -+ * whatever the container held at some other point in time. -+ */ -+ private static List currentWindowContents(PacketGuiSession session) { -+ final int topSize = session.container().getSize(); -+ final PacketGuiRender render = session.appliedRender(); -+ final boolean useRender = render != null && render.size() == topSize; -+ -+ final List items = new ArrayList<>(topSize + 36); -+ for (int slot = 0; slot < topSize; slot++) { -+ items.add(useRender ? render.bukkitItem(slot) : session.container().item(slot)); -+ } -+ -+ for (int slot = PacketInventoryConstants.ITEMS_START; -+ slot < PacketInventoryConstants.HOTBAR_START + 9; -+ slot++) { -+ items.add(cloneItem(playerInventoryItem(session.player(), slot))); -+ } -+ -+ return items; -+ } -+ + private void mirrorPlayerInventorySlot(UUID viewerId, int playerWindowSlot) { + final PacketGuiSession session = sessions.get(viewerId); + if (!isTracked(session) || session.closeRequested()) { From a8f69a3e8d9bd15844e5068cda60b0e01288609c Mon Sep 17 00:00:00 2001 From: Keviro Date: Wed, 29 Jul 2026 02:43:25 +0200 Subject: [PATCH 45/50] fix(packet): reuse the window when a view replaces one of the same shape InventoryFramework builds a new context and a new container on every open, including when a view reopens itself to refresh. The backend answered each of those with a freshly allocated window id and an open-screen packet, so the client tore the screen down and built a new one: cursor back to the centre, the whole window flashing, for what the view intended as an in-place update. In surf-shop the refresh button made that obvious - every click produced a new window id. A new session now takes over the window of the one it replaces when the row count matches, adopting its applied render and container state id. renderSession then diffs against what the client is actually showing and sends only the changed slots; a different title or row count still fails sameWindow() and produces a real reopen. Outbound close packets are dropped while a viewer is inside open(). They were already ignored, but ignoring is not enough once no open-screen packet follows: the client does not check which window a close belongs to, it closes whatever is on screen. --- docs/packet-gui-backend.md | 17 +++++ ...0006-Add-internal-packet-GUI-backend.patch | 75 +++++++++++++++---- 2 files changed, 77 insertions(+), 15 deletions(-) diff --git a/docs/packet-gui-backend.md b/docs/packet-gui-backend.md index e4587e0..fb76cf5 100644 --- a/docs/packet-gui-backend.md +++ b/docs/packet-gui-backend.md @@ -185,6 +185,23 @@ the id still fits in a signed byte, which older protocol versions require. `PacketViewerWindowTracker` is skipped on top of that. Against vanilla that is redundant; it still matters against another plugin that opens its own fake window in the same range. +### Window reuse + +InventoryFramework builds a new context and a new container on every open, including when a view reopens +itself to refresh. Giving each of those a new window id would make the client tear the screen down and build a +new one — the cursor jumps back to the centre and the window flashes — for what the view meant as an in-place +update. + +A new session therefore takes over the window of the session it replaces whenever the row count matches. The +first render then diffs against the render the client is actually showing and sends only the slots that +changed. A different title or row count still fails `PacketGuiRender#sameWindow`, which produces a real +reopen. The container state id travels with the window rather than the session, because the client echoes it +back and it has to keep increasing for as long as the window lives. + +While a viewer is inside `open()`, outbound close packets are dropped rather than forwarded. The client does +not check which window a close belongs to — it closes whatever is on screen — and a session that reuses a +window sends no open-screen packet afterwards that would put it back. + ## Scheduling All GUI work runs on the thread that owns the viewer, scheduled through FoliaLib's `PlatformScheduler`, which diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index a70102f..a9707e6 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1274,10 +1274,10 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..35fca35c357d6746dcc44f04e4cab7893a08aa86 +index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845e160f65f --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1311 @@ +@@ -0,0 +1,1338 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1548,12 +1548,30 @@ index 0000000000000000000000000000000000000000..35fca35c357d6746dcc44f04e4cab789 + // it. + closeRealInventory(player); + ++ // Replacing a view means a brand new context and container - IFs open pipeline builds both every ++ // time, including when a view reopens itself to refresh. Allocating a new window for that would ++ // make the client tear down the screen and build a new one: the cursor jumps back to the middle ++ // and the whole window flashes, for what the view intended as an in-place update. ++ // ++ // So the new session takes over the window of the one it replaces whenever the shape allows it. ++ // renderSession then diffs against the adopted render and only sends what actually changed; a ++ // different title or row count still fails sameWindow() and produces a real reopen. ++ final PacketGuiSession replaced = sessions.get(player.getUniqueId()); ++ final boolean reuseWindow = isTracked(replaced) ++ && !replaced.closeRequested() ++ && replaced.container().getSize() == container.getSize(); ++ + final PacketGuiSession session = new PacketGuiSession( + viewer, + user, -+ PacketGuiWindowIds.allocate(nextWindowId, externalWindowId), ++ reuseWindow ++ ? replaced.windowId() ++ : PacketGuiWindowIds.allocate(nextWindowId, externalWindowId), + container, + windowTracker); ++ if (reuseWindow) { ++ session.adoptWindowStateFrom(replaced); ++ } + + // Publish before the teardown: the close pipeline may re-enter open(), and the conditional remove + // inside closeSession keeps it from evicting this session. @@ -1574,7 +1592,7 @@ index 0000000000000000000000000000000000000000..35fca35c357d6746dcc44f04e4cab789 + return; + } + -+ renderSession(session, true, true); ++ renderSession(session, !reuseWindow, !reuseWindow); + } finally { + if (outermostOpen) { + openingViewers.remove(player.getUniqueId()); @@ -1720,15 +1738,23 @@ index 0000000000000000000000000000000000000000..35fca35c357d6746dcc44f04e4cab789 + } + } + -+ void handleInventoryClosePacket(User user, int windowId) { ++ /** ++ * Handles an outbound close packet. ++ * ++ * @return {@code true} when the packet has to be dropped instead of forwarded to the client. ++ */ ++ boolean handleInventoryClosePacket(User user, int windowId) { + if (user == null || user.getUUID() == null) { -+ return; ++ return false; + } + + if (openingViewers.contains(user.getUUID())) { -+ // Emitted by our own open sequence while the previous view was being closed, not by the viewer -+ // closing the window we are opening. -+ return; ++ // Emitted by our own open sequence - by closing a real menu, or by the replaced view's close ++ // handlers - not by the viewer closing the window being opened. The client does not check which ++ // window a close belongs to, it closes whatever is on screen, so this must not reach it: a view ++ // that takes over the window of the one it replaces sends no open-screen packet afterwards that ++ // would put the screen back. ++ return true; + } + + final PacketGuiSession session = sessions.get(user.getUUID()); @@ -1751,10 +1777,11 @@ index 0000000000000000000000000000000000000000..35fca35c357d6746dcc44f04e4cab789 + // in its own inventory menu and does not get discarded. + runOnPlayerNextTick(player, player::updateInventory); + }); -+ return; ++ return false; + } + + closeTrackedWindow(user.getUUID(), windowId); ++ return false; + } + + /** @@ -3351,10 +3378,10 @@ index 0000000000000000000000000000000000000000..196be89c5dab50da4ba15107d02df0f2 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java new file mode 100644 -index 0000000000000000000000000000000000000000..7b618da100d97fbc4796f78783e465842fa8d1ed +index 0000000000000000000000000000000000000000..10b7b7b1ac84e6a8466e77a213dd60c86b879722 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java -@@ -0,0 +1,137 @@ +@@ -0,0 +1,139 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.event.PacketListenerAbstract; @@ -3479,7 +3506,9 @@ index 0000000000000000000000000000000000000000..7b618da100d97fbc4796f78783e46584 + + if (packetType == PacketType.Play.Server.CLOSE_WINDOW) { + final WrapperPlayServerCloseWindow packet = new WrapperPlayServerCloseWindow(event); -+ backend.handleInventoryClosePacket(user, packet.getWindowId()); ++ if (backend.handleInventoryClosePacket(user, packet.getWindowId())) { ++ event.setCancelled(true); ++ } + } + } + @@ -3809,10 +3838,10 @@ index 0000000000000000000000000000000000000000..862304c2a30d0bae12cb416567801644 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java new file mode 100644 -index 0000000000000000000000000000000000000000..b3e40b1dfd7e088a7bf11be24e8c3ffef965b231 +index 0000000000000000000000000000000000000000..68864c7b5fe8a109c1cbaebc5815022a933e5677 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiSession.java -@@ -0,0 +1,143 @@ +@@ -0,0 +1,159 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.protocol.player.User; @@ -3927,6 +3956,22 @@ index 0000000000000000000000000000000000000000..b3e40b1dfd7e088a7bf11be24e8c3ffe + return stateId++; + } + ++ synchronized int currentStateId() { ++ return stateId; ++ } ++ ++ /** ++ * Adopts the client-visible state of the session whose window this one is taking over. ++ * ++ *

The applied render so the first render diffs against what the client actually shows instead of ++ * resending everything, and the container state id because the client echoes it back and it has to keep ++ * increasing for as long as the window lives - it belongs to the window, not to the session. ++ */ ++ synchronized void adoptWindowStateFrom(PacketGuiSession previous) { ++ this.appliedRender = previous.appliedRender(); ++ this.stateId = previous.currentStateId(); ++ } ++ + synchronized long nextSendGeneration() { + return ++sendGeneration; + } From 73a037f11a51799a0938388f7c1de17db3eb784b Mon Sep 17 00:00:00 2001 From: Keviro Date: Wed, 29 Jul 2026 11:35:19 +0200 Subject: [PATCH 46/50] fix(packet): settle the open-in-progress guard by ordering, not by timing The guard was set and cleared on the viewer's thread but is read on netty threads. A close packet emitted during open() - by closeRealInventory, or by the replaced view's close handlers - is written from the viewer's thread and observed on the connection's event loop, because the server defers sends made off that loop. Clearing the marker in a finally block let that observation land after the marker was already gone, and the close was then read as the viewer closing the window that had just been opened. That race was survivable while every open ended in an open-screen packet, which put the screen back. Window reuse removed that safety net, so a miss now loses the GUI outright. The marker is released through the same event loop instead. The loop is FIFO per channel, so everything the open sequence queued is observed first - the ordering holds by construction rather than by luck. Disconnect, quit and unregister clear the marker too, because a channel that dies mid-open would otherwise leave it behind, and a stuck marker silently drops close packets. abandonSession also removed whatever session the viewer happened to be on rather than the one whose task was refused. Scheduler fallbacks fire asynchronously and can arrive long after the task was queued, so this could destroy a live window and detach the viewer from a view it had just opened. It now takes the session and removes it conditionally, the way closeSession already did. Call sites without a session log instead of guessing at one. --- ...0006-Add-internal-packet-GUI-backend.patch | 124 +++++++++++++++--- 1 file changed, 103 insertions(+), 21 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index a9707e6..8742259 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1274,10 +1274,10 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845e160f65f +index 0000000000000000000000000000000000000000..fefbba64f8ebb494f7258ee62c7e8aa6c434427f --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1338 @@ +@@ -0,0 +1,1420 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1358,7 +1358,11 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + + /** + * Viewers currently inside {@link #open}. Outbound close packets seen while a viewer is in this set were -+ * caused by our own open sequence tearing down the previous view, not by the viewer closing the new one. ++ * caused by our own open sequence tearing down the previous view, not by the viewer closing the new one, ++ * and are dropped rather than forwarded. ++ * ++ *

Read from netty threads, so membership deliberately outlives {@code open()} by one turn of the ++ * connection's event loop - see {@link #releaseOpeningMarker}. + */ + private final Set openingViewers = ConcurrentHashMap.newKeySet(); + @@ -1479,6 +1483,7 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + } + sessions.clear(); + windowTrackers.clear(); ++ openingViewers.clear(); + nativeOutbound = null; + } + @@ -1491,6 +1496,7 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + // whether this backend really finalized the session. + final boolean closed = closeSession(session, false, CLOSE_ORIGIN_QUIT, true, true); + windowTrackers.remove(player.getUniqueId()); ++ openingViewers.remove(player.getUniqueId()); + return closed; + } + @@ -1595,12 +1601,42 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + renderSession(session, !reuseWindow, !reuseWindow); + } finally { + if (outermostOpen) { -+ openingViewers.remove(player.getUniqueId()); ++ releaseOpeningMarker(player, user); + } + } + } + + /** ++ * Lifts the open-in-progress marker, but not before the client connection has processed everything ++ * {@link #open} queued on it. ++ * ++ *

A close packet emitted during the open sequence is written from the viewer's thread and observed on ++ * the connection's event loop, because the server defers sends made off that loop. Clearing the marker ++ * inline would let the observation land after the marker is already gone, and the close would then be read ++ * as the viewer closing the window that was just opened - the failure {@code 39a19c8} fixed, back again as ++ * a race. Since the window-reuse path sends no open-screen packet afterwards, nothing would repair it. ++ * ++ *

Queueing the removal on the same event loop settles the order by construction rather than by timing: ++ * the loop is FIFO per channel, so every packet the open sequence queued is seen first. ++ */ ++ private void releaseOpeningMarker(Player player, User user) { ++ final UUID viewerId = player.getUniqueId(); ++ final Object channel = user.getChannel(); ++ if (channel == null || !ChannelHelper.isOpen(channel)) { ++ openingViewers.remove(viewerId); ++ return; ++ } ++ ++ try { ++ ChannelHelper.runInEventLoop(channel, () -> openingViewers.remove(viewerId)); ++ } catch (final RuntimeException exception) { ++ // Never leave the marker behind: while it is set, outbound close packets for this viewer are ++ // dropped, so a stuck marker would make the viewer impossible to close out of anything. ++ openingViewers.remove(viewerId); ++ } ++ } ++ ++ /** + * Finalizes a real server-side inventory before a packet GUI takes over the client's window. + */ + private void closeRealInventory(Player player) { @@ -1692,7 +1728,7 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + return; + } + -+ runOnPlayer(session.player(), () -> handlePacketClick(session, click, repairScope)); ++ runOnPlayer(session, () -> handlePacketClick(session, click, repairScope)); + } + + void handleWindowClose(User user, int windowId) { @@ -1705,7 +1741,7 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + return; + } + -+ runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_CLIENT, true, true)); ++ runOnPlayer(session, () -> closeSession(session, false, CLOSE_ORIGIN_CLIENT, true, true)); + } + + /** @@ -1734,7 +1770,7 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + + final PacketGuiSession session = sessions.get(user.getUUID()); + if (session != null && !isGuiWindow(user, windowId)) { -+ runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_EXTERNAL_OPEN, true, true)); ++ runOnPlayer(session, () -> closeSession(session, false, CLOSE_ORIGIN_EXTERNAL_OPEN, true, true)); + } + } + @@ -1766,7 +1802,7 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + && (windowId == session.windowId() || windowId == PacketInventoryConstants.PLAYER_WINDOW_ID); + if (closesThisSession) { + session.closeRequested(true); -+ runOnPlayer(session.player(), () -> { ++ runOnPlayer(session, () -> { + final Player player = session.player(); + if (!closeSession(session, false, CLOSE_ORIGIN_SERVER, true, false)) { + return; @@ -1805,6 +1841,10 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + closeSession(session, false, CLOSE_ORIGIN_QUIT, false, false); + } + windowTrackers.remove(viewerId); ++ ++ // The open marker is normally lifted through the connection's event loop. A channel that dies while ++ // an open is in flight would leave it behind, and a stuck marker silently drops close packets. ++ openingViewers.remove(viewerId); + } + + /** @@ -1883,7 +1923,7 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + playerSlots.add(slot); + } + -+ runOnPlayer(session.player(), () -> { ++ runOnPlayer(session, () -> { + final PacketGuiNativeOutboundSender sender = nativeOutbound; + if (sender == null || !isTracked(session) || session.closeRequested()) { + return; @@ -1918,7 +1958,7 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + return; + } + -+ runOnPlayer(session.player(), () -> { ++ runOnPlayer(session, () -> { + final PacketGuiNativeOutboundSender sender = nativeOutbound; + if (sender == null || !isTracked(session) || session.closeRequested()) { + return; @@ -2050,7 +2090,7 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + return; + } + -+ runOnPlayerNextTick(session.player(), () -> { ++ runOnPlayerNextTick(session, () -> { + final PacketGuiRenderRequests.RenderRequest request = session.consumeRenderRequest(); + renderSession(session, request); + }); @@ -2077,7 +2117,7 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + int[] playerSlotRepairs) { + if (!isOnPlayerThread(session.player())) { + runOnPlayer( -+ session.player(), ++ session, + () -> renderSession( + session, + forceReopen, @@ -2325,7 +2365,7 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + } + + if (!isOnPlayerThread(session.player())) { -+ runOnPlayer(session.player(), () -> sendRenderPlan(session, plan)); ++ runOnPlayer(session, () -> sendRenderPlan(session, plan)); + return; + } + @@ -2344,7 +2384,7 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + } + } catch (final RuntimeException exception) { + owner.getLogger().log(Level.WARNING, "Failed to send native packet GUI render", exception); -+ runOnPlayer(session.player(), () -> closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true)); ++ runOnPlayer(session, () -> closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true)); + } + } + @@ -2369,7 +2409,7 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + + if (!isOnPlayerThread(session.player()) && (sendClosePacket || callClose)) { + runOnPlayer( -+ session.player(), ++ session, + () -> closeSession(session, sendClosePacket, origin, callClose, syncInventory)); + return true; + } @@ -2439,13 +2479,32 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + *

Runs inline when already on that thread: several call sites depend on the work having happened by the + * time they return, most importantly {@code open()}, which publishes the session before rendering it. + */ ++ /** ++ * Runs on the viewer's thread, inline when already there. ++ * ++ *

If the scheduler refuses the task the session is discarded: it can never be rendered or closed ++ * properly again, and leaving it in the map would keep the viewer bound to a window nobody can update. ++ */ ++ private void runOnPlayer(PacketGuiSession session, Runnable task) { ++ runOnPlayer(session.player(), task, () -> abandonSession(session)); ++ } ++ ++ /** ++ * Runs on the viewer's thread without a session to tie the task to. A refused task is only logged - there ++ * is nothing to discard, and guessing at whatever session the viewer happens to have would take down an ++ * unrelated one. ++ */ + private void runOnPlayer(Player player, Runnable task) { ++ runOnPlayer(player, task, () -> logRefusedTask(player)); ++ } ++ ++ private void runOnPlayer(Player player, Runnable task, Runnable refused) { + if (isOnPlayerThread(player)) { + task.run(); + return; + } + -+ scheduler.runAtEntityWithFallback(player, ignored -> task.run(), () -> abandonSession(player)); ++ scheduler.runAtEntityWithFallback(player, ignored -> task.run(), refused); + } + + /** @@ -2454,12 +2513,26 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + *

A {@code null} task means the scheduler refused the work because the viewer is gone; the session is + * then discarded so a latched render request cannot swallow every later one. + */ ++ private void runOnPlayerNextTick(PacketGuiSession session, Runnable task) { ++ runOnPlayerNextTick(session.player(), task, () -> abandonSession(session)); ++ } ++ + private void runOnPlayerNextTick(Player player, Runnable task) { -+ if (scheduler.runAtEntityLater(player, task, () -> abandonSession(player), 1L) == null) { -+ abandonSession(player); ++ runOnPlayerNextTick(player, task, () -> logRefusedTask(player)); ++ } ++ ++ private void runOnPlayerNextTick(Player player, Runnable task, Runnable refused) { ++ if (scheduler.runAtEntityLater(player, task, refused, 1L) == null) { ++ refused.run(); + } + } + ++ private void logRefusedTask(Player player) { ++ owner.getLogger() ++ .warning("Dropped a packet GUI task for " + player.getName() ++ + ": the viewer's scheduler is no longer accepting tasks."); ++ } ++ + private boolean isOnPlayerThread(Player player) { + return scheduler.isOwnedByCurrentRegion(player); + } @@ -2468,12 +2541,21 @@ index 0000000000000000000000000000000000000000..c282054e15bf024237037d4d0a576845 + * Drops a session whose viewer can no longer be scheduled on. No close packet and no close pipeline: the + * client is already gone or has been moved, and running developer code here would be off-thread. + */ -+ private void abandonSession(Player player) { -+ final PacketGuiSession session = sessions.remove(player.getUniqueId()); -+ if (session == null) { ++ /** ++ * Discards a session whose viewer no longer accepts scheduled work. ++ * ++ *

The removal is conditional on purpose. A scheduler fallback fires asynchronously and can arrive long ++ * after the task was queued, by which time the viewer may already be on a different session - taking that ++ * one down would kill a live window and detach the viewer from a view it had just opened. ++ */ ++ private void abandonSession(PacketGuiSession session) { ++ if (!sessions.remove(session.viewerId(), session)) { ++ // Already replaced or closed through the normal path, which did the full teardown. ++ session.clearScheduledRender(); + return; + } + ++ final Player player = session.player(); + synchronized (session) { + session.invalidatePendingSends(); + session.closeRequested(true); From 89f629b1e6b0ebb1059cb144313b178bb7536c0b Mon Sep 17 00:00:00 2001 From: Keviro Date: Wed, 29 Jul 2026 11:38:37 +0200 Subject: [PATCH 47/50] refactor(packet): stop the outbound sender from restyling items it does not own toNmsItem normalized every item it converted, and it converts everything the backend sends - GUI icons, but also the viewer's own items in the bottom rows, on the cursor and in player-inventory slot updates. Styling items the backend does not own is not the transport layer's job, so it now converts verbatim and the normalization happens where GUI icons are produced, in PacketGuiRender#bukkitItem. No behaviour change was observed from this. An anvil-renamed item renders italic inside a GUI both before and after, so the case this was expected to fix - a name that leaves its italic state unspecified, which vanilla renders in italics - does not appear to reach the normalization on a live server. Why is not established; the reflection resolves (adventure.text is not relocated by the shaded build), so the likeliest explanation is that Paper does not hand out such a name with the state unspecified in the first place. Recorded as a hypothesis, not a finding. What is left is the structural argument plus one measurable saving: the clone/getItemMeta/setItemMeta round trip no longer runs for the 36 mirrored slots on every player#updateInventory while a GUI is open. Six tests pin the distinction the normalization rests on: an unspecified italic state is pinned to false, an explicit one - either way - is returned untouched. --- ...0006-Add-internal-packet-GUI-backend.patch | 141 ++++++++++++++++-- 1 file changed, 132 insertions(+), 9 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 8742259..1e6598b 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -2950,10 +2950,10 @@ index 0000000000000000000000000000000000000000..0da7f70a1658150890b2500d7251bef4 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java new file mode 100644 -index 0000000000000000000000000000000000000000..196be89c5dab50da4ba15107d02df0f24d0babdc +index 0000000000000000000000000000000000000000..e5116d3176619755c4c961b0e51175e37136b185 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java -@@ -0,0 +1,504 @@ +@@ -0,0 +1,507 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.lang.reflect.Constructor; @@ -3191,7 +3191,10 @@ index 0000000000000000000000000000000000000000..196be89c5dab50da4ba15107d02df0f2 + } + + try { -+ final Object converted = asNmsCopy.invoke(null, PacketItemConverter.normalizedCopy(item)); ++ // No styling here. This method converts everything the backend sends, including the viewer's own ++ // items in the bottom rows and on the cursor; normalizing those would restyle items the backend ++ // does not own. GUI icons are normalized where they are produced, in PacketGuiRender#bukkitItem. ++ final Object converted = asNmsCopy.invoke(null, item); + return converted == null ? emptyItemStack : converted; + } catch (final ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to convert Bukkit item to native item stack", exception); @@ -3605,10 +3608,10 @@ index 0000000000000000000000000000000000000000..10b7b7b1ac84e6a8466e77a213dd60c8 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java new file mode 100644 -index 0000000000000000000000000000000000000000..b08e2dc2684aa5ca04844b5f496a0a4e1c5174d5 +index 0000000000000000000000000000000000000000..edd29c3d67e3f5e7921f1ff9487f3c2d40333e70 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiRender.java -@@ -0,0 +1,77 @@ +@@ -0,0 +1,84 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.util.Objects; @@ -3657,9 +3660,16 @@ index 0000000000000000000000000000000000000000..b08e2dc2684aa5ca04844b5f496a0a4e + return topItems.length; + } + ++ /** ++ * The item to draw in a top slot. ++ * ++ *

Normalized here rather than in the outbound sender. The sender also carries the viewer's own items ++ * into the bottom rows, and restyling those would make an item look different inside a GUI than in the ++ * player's own inventory. Only items this render owns are GUI icons. ++ */ + ItemStack bukkitItem(int slot) { + final ItemStack item = topItems[slot]; -+ return item == null ? null : item.clone(); ++ return item == null ? null : PacketItemConverter.normalizedCopy(item); + } + + boolean sameWindow(PacketGuiRender other) { @@ -4261,10 +4271,10 @@ index 0000000000000000000000000000000000000000..c6ed7053e89d189987f66763d4ecacdb +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java new file mode 100644 -index 0000000000000000000000000000000000000000..64a961f8ad57fff4c73976a3fc111953d2d317b4 +index 0000000000000000000000000000000000000000..b0c8652ec892db77b5e3f591f016c902a882b106 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverter.java -@@ -0,0 +1,203 @@ +@@ -0,0 +1,211 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.lang.reflect.Method; @@ -4460,7 +4470,15 @@ index 0000000000000000000000000000000000000000..64a961f8ad57fff4c73976a3fc111953 + *

Only the root style is touched, and only when it does not already carry an explicit decision: children + * inherit the root's decoration, so rewriting them would destroy italics a view set on purpose. + */ -+ private static Component forceNonItalic(Component component) { ++ /** ++ * Pins the italic decoration to {@code false} when, and only when, the component leaves it unspecified. ++ * ++ *

Vanilla renders an unspecified custom name in italics, which is not what a GUI icon wants. An explicit ++ * italic state - either way - is somebody's decision and is left alone. Package-private so the distinction ++ * can be tested without a server; it is what separates "give GUI icons a sane default" from "restyle ++ * items". ++ */ ++ static Component forceNonItalic(Component component) { + if (component.decoration(TextDecoration.ITALIC) != TextDecoration.State.NOT_SET) { + return component; + } @@ -5385,6 +5403,111 @@ index 0000000000000000000000000000000000000000..662e20f77195cda3a67631e605c9ec60 + } + } +} +diff --git a/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverterTest.java b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverterTest.java +new file mode 100644 +index 0000000000000000000000000000000000000000..f98d396a699338ec2433ac0be110cb224cc4766e +--- /dev/null ++++ b/inventory-framework-platform-bukkit/src/test/java/me/devnatan/inventoryframework/internal/packet/PacketItemConverterTest.java +@@ -0,0 +1,99 @@ ++package me.devnatan.inventoryframework.internal.packet; ++ ++import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.junit.jupiter.api.Assertions.assertFalse; ++import static org.junit.jupiter.api.Assertions.assertSame; ++import static org.junit.jupiter.api.Assertions.assertTrue; ++import static org.mockito.Mockito.mock; ++import static org.mockito.Mockito.when; ++ ++import net.kyori.adventure.text.Component; ++import net.kyori.adventure.text.format.TextDecoration; ++import org.bukkit.Material; ++import org.bukkit.inventory.ItemStack; ++import org.junit.jupiter.api.Test; ++ ++/** ++ * Pins down exactly which names {@link PacketItemConverter#forceNonItalic} restyles. ++ * ++ *

It matters where that line falls. The normalization exists so a GUI icon does not inherit vanilla's ++ * default italics for a custom name, but the same conversion path also carries the viewer's own items into the ++ * bottom rows of a packet GUI - anything it changes there makes an item look different inside a GUI than in ++ * the player's own inventory. ++ */ ++class PacketItemConverterTest { ++ ++ /** ++ * The case the normalization exists for: vanilla renders a name that does not specify its italic state in ++ * italics, which is not what a GUI icon wants. ++ */ ++ @Test ++ void pinsItalicToFalseWhenTheNameLeavesItUnspecified() { ++ final Component name = Component.text("Renamed Sword"); ++ assertEquals( ++ TextDecoration.State.NOT_SET, ++ name.decoration(TextDecoration.ITALIC), ++ "precondition: a plain text component leaves italic unspecified"); ++ ++ final Component normalized = PacketItemConverter.forceNonItalic(name); ++ ++ assertEquals(TextDecoration.State.FALSE, normalized.decoration(TextDecoration.ITALIC)); ++ } ++ ++ /** ++ * A deliberately italic name must survive untouched - whoever set it meant it. This is the difference ++ * between defaulting and restyling. ++ */ ++ @Test ++ void keepsAnExplicitlyItalicNameItalic() { ++ final Component name = Component.text("Fancy Sword").decoration(TextDecoration.ITALIC, true); ++ ++ final Component normalized = PacketItemConverter.forceNonItalic(name); ++ ++ assertSame(name, normalized, "an explicit italic state must not be rewritten at all"); ++ assertEquals(TextDecoration.State.TRUE, normalized.decoration(TextDecoration.ITALIC)); ++ } ++ ++ @Test ++ void keepsAnExplicitlyNonItalicNameUntouched() { ++ final Component name = Component.text("Plain Sword").decoration(TextDecoration.ITALIC, false); ++ ++ assertSame(name, PacketItemConverter.forceNonItalic(name)); ++ } ++ ++ /** ++ * Only the component the decoration is read from matters - a child that specifies nothing still inherits, ++ * so the parent's pinned state is what the client ends up rendering. ++ */ ++ @Test ++ void pinsTheRootOfANestedNameThatLeavesItalicUnspecified() { ++ final Component name = Component.text("Sword ").append(Component.text("of Testing")); ++ ++ final Component normalized = PacketItemConverter.forceNonItalic(name); ++ ++ assertEquals(TextDecoration.State.FALSE, normalized.decoration(TextDecoration.ITALIC)); ++ } ++ ++ @Test ++ void treatsNullAirAndEmptyStacksAsEmpty() { ++ assertTrue(PacketItemConverter.isEmpty(null)); ++ assertTrue(PacketItemConverter.isEmpty(stack(Material.AIR, 1))); ++ assertTrue(PacketItemConverter.isEmpty(stack(Material.STONE, 0))); ++ assertFalse(PacketItemConverter.isEmpty(stack(Material.STONE, 1))); ++ } ++ ++ @Test ++ void comparesTypeAndAmountBeforeAnythingElse() { ++ assertTrue(PacketItemConverter.sameDisplayItem(null, stack(Material.AIR, 1))); ++ assertFalse(PacketItemConverter.sameDisplayItem(stack(Material.STONE, 1), stack(Material.DIRT, 1))); ++ assertFalse(PacketItemConverter.sameDisplayItem(stack(Material.STONE, 1), stack(Material.STONE, 2))); ++ assertFalse(PacketItemConverter.sameDisplayItem(null, stack(Material.STONE, 1))); ++ } ++ ++ private static ItemStack stack(Material type, int amount) { ++ final ItemStack item = mock(ItemStack.class); ++ when(item.getType()).thenReturn(type); ++ when(item.getAmount()).thenReturn(amount); ++ return item; ++ } ++} diff --git a/settings.gradle.kts b/settings.gradle.kts index 05585a2bd587532365bfe98563ecc18047386144..f26ceb55f94d93d4eaf30706bf82998bab48f278 100644 --- a/settings.gradle.kts From 262d210fbaac3c3022da03d1664d2b460dd3ecc7 Mon Sep 17 00:00:00 2001 From: Keviro Date: Wed, 29 Jul 2026 11:59:27 +0200 Subject: [PATCH 48/50] fix(packet): honour the graceful-degradation contract on load and on send Two gaps in the same promise: a mismatch is supposed to warn once and leave every GUI on real Bukkit inventories. register() and the native sender's initialize() caught only RuntimeException, but both resolve NMS classes and run their static initializers. A class that is present yet cannot be linked raises NoClassDefFoundError, NoSuchMethodError or ExceptionInInitializerError - all Errors, all escaping the catch, aborting the owning plugin's enable rather than falling back. The presence probe in GuiBackendFactory uses Class.forName without initializing, so this is reachable whenever PacketEvents is on the classpath but broken. Both now also catch LinkageError, which is exactly that family; Throwable is deliberately not caught so OutOfMemoryError still propagates. The send path was never probed at all, because reaching a player's connection needs a live player - so a server whose connection field or send method does not match reported "Packet mode enabled" and then failed every single GUI open, forever, with the Bukkit fallback already discarded. selfCheck now resolves as much of that chain as it can statically and reports what it cannot, without refusing the server over it: an obfuscated runtime may name the field differently and still work through the field-scanning fallback. If the chain does turn out to be unusable, the first failure now disables packet mode and releases every open session back to real inventories, instead of retrying per GUI. A dedicated exception type keeps that apart from a single packet failing to build, which still only takes its own session down. --- docs/packet-gui-backend.md | 11 +- ...0006-Add-internal-packet-GUI-backend.patch | 144 +++++++++++++++--- 2 files changed, 134 insertions(+), 21 deletions(-) diff --git a/docs/packet-gui-backend.md b/docs/packet-gui-backend.md index fb76cf5..bd4e3b5 100644 --- a/docs/packet-gui-backend.md +++ b/docs/packet-gui-backend.md @@ -54,7 +54,16 @@ and the ordering hazard does not apply to it — the content packets that follow connection, so they can never overtake a direct channel write. Only if all three succeed is packet mode enabled. Any mismatch produces a single warning naming the detected -Minecraft version, and every GUI silently keeps using real Bukkit inventory items. +Minecraft version, and every GUI silently keeps using real Bukkit inventory items. That includes mismatches +that surface as `Error` rather than exception — a class that is present but cannot be linked or initialized — +because those would otherwise abort the owning plugin's enable instead of falling back. + +One hop cannot be fully verified at startup: reaching the player's connection needs a live player. The probe +resolves as much of it as it can statically (the native player class, its `connection` field, the send method +on that field's type) and reports what it could not, but it deliberately does **not** refuse a server over it — +an obfuscated runtime may name the field differently and still work, because the connection is discovered by +scanning fields on first use. If the chain then turns out to be unusable, the **first** failure disables packet +mode, releases every open session back to real inventories, and logs one warning. It does not retry per GUI. Startup log lines to look for: diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index 1e6598b..c810ab2 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1274,10 +1274,10 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..fefbba64f8ebb494f7258ee62c7e8aa6c434427f +index 0000000000000000000000000000000000000000..9caa85bcb6bb70bc27d522fdefd5c4df750d19df --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1420 @@ +@@ -0,0 +1,1467 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1452,7 +1452,12 @@ index 0000000000000000000000000000000000000000..fefbba64f8ebb494f7258ee62c7e8aa6 + + "Native outbound sender is active for Minecraft " + + nativeSender.minecraftVersion() + + ". This is the recommended mode for preventing GUI item duplication."); -+ } catch (final RuntimeException exception) { ++ } catch (final RuntimeException | LinkageError exception) { ++ // LinkageError as well: the presence probe in GuiBackendFactory uses Class.forName without ++ // initializing, so PacketEvents can be findable and still fail to link or initialize here. That ++ // surfaces as NoClassDefFoundError or ExceptionInInitializerError, which are Errors - letting them ++ // escape would take down the owning plugin's enable, and this backend's whole contract is to fall ++ // back to Bukkit inventories instead. Not Throwable: OutOfMemoryError must still propagate. + available = false; + owner.getLogger() + .log( @@ -1471,7 +1476,9 @@ index 0000000000000000000000000000000000000000..fefbba64f8ebb494f7258ee62c7e8aa6 + if (listener != null) { + try { + PacketEvents.getAPI().getEventManager().unregisterListener(listener); -+ } catch (final RuntimeException exception) { ++ } catch (final RuntimeException | LinkageError exception) { ++ // Runs during plugin disable, where PacketEvents may already be unloading. Failing here must ++ // not stop the rest of the teardown below. + owner.getLogger().log(Level.WARNING, "Failed to unregister packet GUI listener", exception); + } finally { + listener = null; @@ -1940,9 +1947,7 @@ index 0000000000000000000000000000000000000000..fefbba64f8ebb494f7258ee62c7e8aa6 + cloneItem(playerInventoryItem(session.player(), playerSlots.get(index)))); + } + } catch (final RuntimeException exception) { -+ owner.getLogger() -+ .log(Level.WARNING, "Failed to mirror the player inventory into a packet GUI", exception); -+ closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true); ++ handleSendFailure(session, "Failed to mirror the player inventory into a packet GUI", exception); + } + }); + } @@ -1972,9 +1977,7 @@ index 0000000000000000000000000000000000000000..fefbba64f8ebb494f7258ee62c7e8aa6 + guiSlot, + cloneItem(playerInventoryItem(session.player(), playerWindowSlot))); + } catch (final RuntimeException exception) { -+ owner.getLogger() -+ .log(Level.WARNING, "Failed to mirror a player inventory slot into a packet GUI", exception); -+ closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true); ++ handleSendFailure(session, "Failed to mirror a player inventory slot into a packet GUI", exception); + } + }); + } @@ -2383,8 +2386,52 @@ index 0000000000000000000000000000000000000000..fefbba64f8ebb494f7258ee62c7e8aa6 + packet.send(sender, session.player()); + } + } catch (final RuntimeException exception) { -+ owner.getLogger().log(Level.WARNING, "Failed to send native packet GUI render", exception); -+ runOnPlayer(session, () -> closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true)); ++ handleSendFailure(session, "Failed to send native packet GUI render", exception); ++ } ++ } ++ ++ /** ++ * Reacts to a failure while sending to a viewer. ++ * ++ *

A packet that failed to build takes its session down and nothing more. A broken outbound chain takes ++ * packet mode down with it: the alternative is failing every GUI open for the rest of the server's life, ++ * where the documented behaviour is to fall back to real Bukkit inventories. The send path is the one part ++ * of the native sender that cannot be fully verified at startup, because it needs a live player. ++ */ ++ private void handleSendFailure(PacketGuiSession session, String message, RuntimeException exception) { ++ if (exception instanceof PacketGuiNativeOutboundSender.SendPathUnusableException) { ++ disablePacketMode(exception); ++ return; ++ } ++ ++ owner.getLogger().log(Level.WARNING, message, exception); ++ runOnPlayer(session, () -> closeSession(session, false, CLOSE_ORIGIN_SERVER, true, true)); ++ } ++ ++ /** ++ * Turns packet mode off for the rest of this server's life and hands every open session back to real ++ * inventories. New containers route to {@link BukkitGuiBackend} through the {@code available} check in ++ * {@link #createContainer}. ++ */ ++ private void disablePacketMode(Throwable failure) { ++ if (!available) { ++ return; ++ } ++ available = false; ++ ++ owner.getLogger() ++ .log( ++ Level.WARNING, ++ "[IF] GUI backend: disabling packet mode after the native outbound path proved " ++ + "unusable. Inventory GUIs fall back to real Bukkit inventory items.", ++ failure); ++ ++ for (final PacketGuiSession open : List.copyOf(sessions.values())) { ++ try { ++ runOnPlayer(open, () -> closeSession(open, false, CLOSE_ORIGIN_SERVER, true, true)); ++ } catch (final RuntimeException ignored) { ++ // Best effort: one viewer failing to close must not stop the others from being released. ++ } + } + } + @@ -2950,10 +2997,10 @@ index 0000000000000000000000000000000000000000..0da7f70a1658150890b2500d7251bef4 +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java new file mode 100644 -index 0000000000000000000000000000000000000000..e5116d3176619755c4c961b0e51175e37136b185 +index 0000000000000000000000000000000000000000..e4bea5df501492e2bdb4e4e7f9235afa21a3627d --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiNativeOutboundSender.java -@@ -0,0 +1,507 @@ +@@ -0,0 +1,564 @@ +package me.devnatan.inventoryframework.internal.packet; + +import java.lang.reflect.Constructor; @@ -3052,13 +3099,23 @@ index 0000000000000000000000000000000000000000..e5116d3176619755c4c961b0e51175e3 + try { + final PacketGuiNativeOutboundSender sender = new PacketGuiNativeOutboundSender(); + sender.selfCheck(); ++ final String unverifiedSendPath = sender.probeSendPath(); + return InitializationResult.available( + sender, + minecraftVersion, + bukkitVersion, + serverPackage, -+ "Native packet GUI outbound sender initialized for Minecraft " + minecraftVersion + "."); -+ } catch (final RuntimeException exception) { ++ "Native packet GUI outbound sender initialized for Minecraft " + minecraftVersion + "." ++ + (unverifiedSendPath == null ++ ? "" ++ : " Send path not fully verified: " + unverifiedSendPath + ".")); ++ } catch (final RuntimeException | LinkageError exception) { ++ // LinkageError, not just RuntimeException: resolving the server's packet classes runs their static ++ // initializers, and a class that is present but cannot be linked - a renamed field, a changed ++ // signature, a failed - raises NoClassDefFoundError, NoSuchMethodError or ++ // ExceptionInInitializerError. Those are Errors, and letting them escape would abort the owning ++ // plugin's enable instead of falling back to Bukkit inventories. Deliberately not Throwable, so a ++ // genuinely fatal condition such as OutOfMemoryError still propagates. + return InitializationResult.unavailable( + minecraftVersion, + bukkitVersion, @@ -3216,7 +3273,26 @@ index 0000000000000000000000000000000000000000..e5116d3176619755c4c961b0e51175e3 + try { + send.invoke(connection, packet); + } catch (final ReflectiveOperationException exception) { -+ throw new IllegalStateException("Failed to send native packet GUI item packet", exception); ++ throw new SendPathUnusableException("Failed to send native packet GUI item packet", exception); ++ } ++ } ++ ++ /** ++ * Thrown when the outbound machinery itself cannot be used - the player handle, the connection or the send ++ * method could not be reached - as opposed to a single packet failing to build. ++ * ++ *

The distinction is what lets the backend turn packet mode off and fall back to Bukkit inventories, ++ * rather than failing every GUI open for the rest of the server's life. This is the one part of the native ++ * sender that {@link #selfCheck()} cannot fully verify, because it needs a live player. ++ */ ++ static final class SendPathUnusableException extends IllegalStateException { ++ ++ SendPathUnusableException(String message) { ++ super(message); ++ } ++ ++ SendPathUnusableException(String message, Throwable cause) { ++ super(message, cause); + } + } + @@ -3224,7 +3300,7 @@ index 0000000000000000000000000000000000000000..e5116d3176619755c4c961b0e51175e3 + try { + return getHandle.invoke(player); + } catch (final ReflectiveOperationException exception) { -+ throw new IllegalStateException("Failed to access native player handle", exception); ++ throw new SendPathUnusableException("Failed to access native player handle", exception); + } + } + @@ -3266,7 +3342,7 @@ index 0000000000000000000000000000000000000000..e5116d3176619755c4c961b0e51175e3 + type = type.getSuperclass(); + } + -+ throw new IllegalStateException("Failed to discover native player connection"); ++ throw new SendPathUnusableException("Failed to discover native player connection"); + } + + private Method sendMethod(Class connectionClass) { @@ -3275,7 +3351,7 @@ index 0000000000000000000000000000000000000000..e5116d3176619755c4c961b0e51175e3 + // instead of one entry per concrete packet class. + final Method method = findSendMethod(type, packetClass); + if (method == null) { -+ throw new IllegalStateException("Failed to find native player connection send method"); ++ throw new SendPathUnusableException("Failed to find native player connection send method"); + } + return method; + }); @@ -3302,6 +3378,34 @@ index 0000000000000000000000000000000000000000..e5116d3176619755c4c961b0e51175e3 + return null; + } + ++ /** ++ * Resolves the outbound chain as far as it can be resolved without a player. ++ * ++ *

Deliberately tolerant. An obfuscated runtime may name the field differently and still work, because ++ * {@link #connection(Object)} falls back to scanning fields on first use; rejecting the server here would ++ * trade a working setup for the Bukkit fallback. So an unresolved chain is reported, not treated as fatal. ++ * ++ * @return {@code null} when the whole chain resolved, otherwise what stayed unverified. ++ */ ++ private String probeSendPath() { ++ final Class nativePlayerClass = getHandle.getReturnType(); ++ ++ final Field connectionField; ++ try { ++ connectionField = field(nativePlayerClass, "connection"); ++ } catch (final NoSuchFieldException ignored) { ++ return "the connection field of " + nativePlayerClass.getName() ++ + " could not be resolved by name and will be discovered on the first GUI open"; ++ } ++ ++ if (findSendMethod(connectionField.getType(), packetClass) == null) { ++ return "no packet send method was found on " + connectionField.getType().getName() ++ + "; it will be resolved on the first GUI open"; ++ } ++ ++ return null; ++ } ++ + private static Class craftItemStackClass() throws ClassNotFoundException { + return craftClass("inventory.CraftItemStack"); + } From 7f808d91479296330fc4aee43ebabb1cf6a6df6e Mon Sep 17 00:00:00 2001 From: Keviro Date: Wed, 29 Jul 2026 12:03:28 +0200 Subject: [PATCH 49/50] fix(packet): stop leaking viewers and stop touching state from the wrong thread Three findings on the same theme - work happening where it should not. detachAbandonedViewer mutated the context's viewer list, the framework's global viewer map and the root's context list inline. Those are plain collections the main thread iterates, and the method is reached from a scheduler fallback that can fire on a netty thread. It now hops to the global tick thread, which is where framework state belongs - not the viewer's own scheduler, since that one refusing work is what led here. open() returned without doing anything when PacketEvents had no user for the player. By then IF has already registered the viewer with the framework and the context and committed the container, so the viewer stayed bound to a window that did not exist and that nothing could finalize: no close packet, quit handler or external open reaches a session that was never created. The likeliest cause is a connection PacketEvents has not registered yet, so it retries once on the next tick and releases the view properly if that still fails. The inbound close packet for a GUI window was force-uncancelled, which overrode whatever another listener had decided and let vanilla run its close handling for a container the server does not have open - firing an InventoryCloseEvent for the player's own inventory on every packet GUI close. It is now cancelled, like the click packet next to it. --- ...0006-Add-internal-packet-GUI-backend.patch | 91 ++++++++++++++++--- 1 file changed, 77 insertions(+), 14 deletions(-) diff --git a/patches/0006-Add-internal-packet-GUI-backend.patch b/patches/0006-Add-internal-packet-GUI-backend.patch index c810ab2..c4a518b 100644 --- a/patches/0006-Add-internal-packet-GUI-backend.patch +++ b/patches/0006-Add-internal-packet-GUI-backend.patch @@ -1274,10 +1274,10 @@ index 0000000000000000000000000000000000000000..38305f83a335cebcdd6e9a1a77442c0b +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java new file mode 100644 -index 0000000000000000000000000000000000000000..9caa85bcb6bb70bc27d522fdefd5c4df750d19df +index 0000000000000000000000000000000000000000..9fc0c7b9f5bebc8388c9b808c13359be85770404 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiBackend.java -@@ -0,0 +1,1467 @@ +@@ -0,0 +1,1525 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.PacketEvents; @@ -1529,17 +1529,29 @@ index 0000000000000000000000000000000000000000..9caa85bcb6bb70bc27d522fdefd5c4df + } + + void open(@NotNull BukkitViewer viewer, @NotNull PacketViewContainer container) { ++ open(viewer, container, true); ++ } ++ ++ private void open(BukkitViewer viewer, PacketViewContainer container, boolean mayRetry) { + if (!isOnPlayerThread(viewer.getPlayer())) { -+ runOnPlayer(viewer.getPlayer(), () -> open(viewer, container)); ++ runOnPlayer(viewer.getPlayer(), () -> open(viewer, container, mayRetry)); + return; + } + + final Player player = viewer.getPlayer(); + final User user = user(player.getUniqueId()); + if (user == null) { ++ if (mayRetry) { ++ // Most likely a player whose connection PacketEvents has not registered yet, which one tick ++ // resolves. A single retry covers that without risking a loop. ++ runOnPlayerNextTick(player, () -> open(viewer, container, false)); ++ return; ++ } ++ + owner.getLogger() + .warning("Unable to open packet GUI for " + player.getName() -+ + ": PacketEvents has no user for this player."); ++ + ": PacketEvents has no user for this player. Releasing the view."); ++ releaseUnopenedView(viewer, container); + return; + } + @@ -2626,20 +2638,66 @@ index 0000000000000000000000000000000000000000..9caa85bcb6bb70bc27d522fdefd5c4df + * Releases the framework's own hold on an abandoned viewer. + * + *

The CLOSE pipeline is deliberately not run: it executes developer code, and the reason this method -+ * exists at all is that no thread will accept work for the viewer any more. Without this, though, the -+ * view would keep listing a viewer that has no window and never gets one back, which leaks the context -+ * for as long as the view lives. ++ * exists at all is that no thread will accept work for the viewer any more. Without this, though, the view ++ * would keep listing a viewer that has no window and never gets one back, which leaks the context for as ++ * long as the view lives. ++ * ++ *

Hopped to the global tick thread instead of done inline. The removal mutates the context's viewer ++ * list, the framework's global viewer map and the root's context list - plain collections the main thread ++ * iterates - and this runs from a scheduler fallback that can fire on a netty thread. Deliberately not the ++ * viewer's own scheduler: that one refusing work is what brought us here. + */ -+ @SuppressWarnings({"unchecked", "rawtypes"}) + private void detachAbandonedViewer(PacketGuiSession session) { ++ detachViewer(session.viewer(), session.context()); ++ } ++ ++ /** ++ * Releases a view the backend was asked to open but never could. ++ * ++ *

By this point IF has already registered the viewer with the framework and with the context, and ++ * committed the container as a packet container. Simply returning would leave the viewer bound to a window ++ * that does not exist and that nothing can ever finalize - no close packet, quit handler or external open ++ * reaches a session that was never created. ++ */ ++ private void releaseUnopenedView(BukkitViewer viewer, PacketViewContainer container) { ++ detachViewer(viewer, container.getContext()); ++ } ++ ++ /** ++ * Hands a viewer back to the framework, on the global tick thread. ++ * ++ *

The removal mutates the context's viewer list, the framework's global viewer map and the root's ++ * context list - plain collections the main thread iterates - while the callers reach this from a scheduler ++ * fallback that can fire on a netty thread. Deliberately not the viewer's own scheduler: in the abandoned ++ * case, that one refusing work is what brought us here. ++ */ ++ private void detachViewer(BukkitViewer viewer, IFRenderContext context) { ++ if (scheduler.isGlobalTickThread()) { ++ detachViewerNow(viewer, context); ++ return; ++ } ++ ++ try { ++ scheduler.runNextTick(ignored -> detachViewerNow(viewer, context)); ++ } catch (final RuntimeException exception) { ++ owner.getLogger() ++ .log( ++ Level.WARNING, ++ "Could not schedule the detach of a packet GUI viewer; the view keeps listing it " ++ + "until it is invalidated", ++ exception); ++ } ++ } ++ ++ @SuppressWarnings({"unchecked", "rawtypes"}) ++ private void detachViewerNow(BukkitViewer viewer, IFRenderContext context) { + try { -+ final IFRenderContext context = session.context(); + final RootView root = context.getRoot(); + if (root instanceof PlatformView) { -+ ((PlatformView) root).removeAndTryInvalidateContext(session.viewer(), context); ++ ((PlatformView) root).removeAndTryInvalidateContext(viewer, context); + } + } catch (final RuntimeException exception) { -+ owner.getLogger().log(Level.WARNING, "Failed to detach an abandoned packet GUI viewer", exception); ++ owner.getLogger().log(Level.WARNING, "Failed to detach a packet GUI viewer", exception); + } + } + @@ -3567,10 +3625,10 @@ index 0000000000000000000000000000000000000000..e4bea5df501492e2bdb4e4e7f9235afa +} diff --git a/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java new file mode 100644 -index 0000000000000000000000000000000000000000..10b7b7b1ac84e6a8466e77a213dd60c86b879722 +index 0000000000000000000000000000000000000000..d30e76b1540243b24cc0fb5f1ba05a5c5668f524 --- /dev/null +++ b/inventory-framework-platform-bukkit/src/main/java/me/devnatan/inventoryframework/internal/packet/PacketGuiPacketListener.java -@@ -0,0 +1,139 @@ +@@ -0,0 +1,144 @@ +package me.devnatan.inventoryframework.internal.packet; + +import com.github.retrooper.packetevents.event.PacketListenerAbstract; @@ -3628,7 +3686,12 @@ index 0000000000000000000000000000000000000000..10b7b7b1ac84e6a8466e77a213dd60c8 + if (packetType == PacketType.Play.Client.CLOSE_WINDOW) { + final WrapperPlayClientCloseWindow packet = new WrapperPlayClientCloseWindow(event); + if (backend.isGuiWindow(event.getUser(), packet.getWindowId())) { -+ event.setCancelled(false); ++ // Cancelled, like the click above, and no longer force-uncancelled. The server has no container ++ // open for a fake window, so there is nothing for vanilla to reset - all its close handling ++ // would do is fire an InventoryCloseEvent for the player's *own* inventory on every packet GUI ++ // close, which other plugins see and misread. Un-cancelling also overrode whatever another ++ // listener had decided about this packet. ++ event.setCancelled(true); + backend.handleWindowClose(event.getUser(), packet.getWindowId()); + return; + } From bd4e06b66d5c1ba784e2299bb76cb3fb571d1b81 Mon Sep 17 00:00:00 2001 From: Keviro Date: Wed, 29 Jul 2026 12:20:31 +0200 Subject: [PATCH 50/50] docs(packet): record that no inventory open/close event fires for a packet GUI The observable consequence of cancelling the inbound close packet: a plugin listening for InventoryCloseEvent will not hear about a packet GUI closing. A view's own onClose is unaffected. Also notes what the event used to describe, since it was not the GUI - vanilla ran its close handling against the player's own inventory menu, so anything that relied on it was reacting to a misleading event. --- docs/packet-gui-backend.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/packet-gui-backend.md b/docs/packet-gui-backend.md index bd4e3b5..df77f10 100644 --- a/docs/packet-gui-backend.md +++ b/docs/packet-gui-backend.md @@ -92,6 +92,16 @@ Startup log lines to look for: The negative slot is what separates these from their in-window meaning — `THROW` on a real slot is the drop key and stays denied, and drag (`QUICK_CRAFT`) carries a negative slot too but is never treated as a click. Consumers see such a click as `ClickType.LEFT`/`RIGHT` with `SlotType.OUTSIDE`, matching the Bukkit backend. +- **No Bukkit `InventoryOpenEvent` or `InventoryCloseEvent` is fired for a packet GUI.** There is no real + inventory to fire them for, and the inbound close packet is cancelled before vanilla sees it. A view's own + `onClose` is unaffected — the framework's CLOSE pipeline runs exactly as before — but a plugin that listens + for `InventoryCloseEvent` to notice a GUI closing will not hear about packet GUIs. Use the framework's own + close callback instead. + + Up to `7f808d9` the close packet was let through, which made vanilla run its close handling against the + player's *own* inventory menu; the resulting `InventoryCloseEvent` therefore described the player's inventory + rather than any GUI. Anything that relied on it was reacting to a misleading event, not a useful one. + - **`RenderContext#getInventory()` throws** `UnsupportedOperationException` in packet mode. Probe with `RenderContext#isBackedByRealInventory()` first. - **`SlotClickContext#getClickOrigin()` returns a synthesized `InventoryClickEvent`.** Item access,