diff --git a/README.md b/README.md
index b17eede96..65a1261bd 100644
--- a/README.md
+++ b/README.md
@@ -34,20 +34,10 @@ Take a look [here](https://github.com/GeyserMC/Geyser/wiki#Setup) for how to set
 - Test Server: `test.geysermc.org` port `25565` for Java and `19132` for Bedrock
 
 ## What's Left to be Added/Fixed
-- Lecterns
 - Near-perfect movement (to the point where anticheat on large servers is unlikely to ban you)
 - Resource pack conversion/CustomModelData
 - Some Entity Flags
-- The Following Inventories 
-  - Enchantment Table (as a proper GUI)
-  - Beacon
-  - Cartography Table
-  - Stonecutter
-  - Structure Block
-  - Horse Inventory
-  - Loom
-  - Smithing Table
-  - Grindstone
+- Structure block UI
 
 ## What can't be fixed
 The following things can't be fixed because of Bedrock limitations. They might be fixable in the future, but not as of now.
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/GeyserSpigotPlugin.java b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/GeyserSpigotPlugin.java
index e9e6a2cad..0d99f89cc 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/GeyserSpigotPlugin.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/GeyserSpigotPlugin.java
@@ -43,6 +43,7 @@ import org.geysermc.geyser.adapters.spigot.SpigotAdapters;
 import org.geysermc.platform.spigot.command.GeyserSpigotCommandExecutor;
 import org.geysermc.platform.spigot.command.GeyserSpigotCommandManager;
 import org.geysermc.platform.spigot.command.SpigotCommandSender;
+import org.geysermc.platform.spigot.world.GeyserSpigot1_11CraftingListener;
 import org.geysermc.platform.spigot.world.GeyserSpigotBlockPlaceListener;
 import org.geysermc.platform.spigot.world.manager.*;
 import us.myles.ViaVersion.api.Pair;
@@ -154,8 +155,9 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
             geyserLogger.debug("Legacy version of Minecraft (1.15.2 or older) detected; not using 3D biomes.");
         }
 
+        boolean isPre1_12 = !isCompatible(Bukkit.getServer().getVersion(), "1.12.0");
         // Set if we need to use a different method for getting a player's locale
-        SpigotCommandSender.setUseLegacyLocaleMethod(!isCompatible(Bukkit.getServer().getVersion(), "1.12.0"));
+        SpigotCommandSender.setUseLegacyLocaleMethod(isPre1_12);
 
         if (connector.getConfig().isUseAdapters()) {
             try {
@@ -165,14 +167,14 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
                 if (isViaVersion && isViaVersionNeeded()) {
                     if (isLegacy) {
                         // Pre-1.13
-                        this.geyserWorldManager = new GeyserSpigot1_12NativeWorldManager();
+                        this.geyserWorldManager = new GeyserSpigot1_12NativeWorldManager(this);
                     } else {
                         // Post-1.13
                         this.geyserWorldManager = new GeyserSpigotLegacyNativeWorldManager(this, use3dBiomes);
                     }
                 } else {
                     // No ViaVersion
-                    this.geyserWorldManager = new GeyserSpigotNativeWorldManager(use3dBiomes);
+                    this.geyserWorldManager = new GeyserSpigotNativeWorldManager(this, use3dBiomes);
                 }
                 geyserLogger.debug("Using NMS adapter: " + this.geyserWorldManager.getClass() + ", " + nmsVersion);
             } catch (Exception e) {
@@ -188,20 +190,24 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
             // No NMS adapter
             if (isLegacy && isViaVersion) {
                 // Use ViaVersion for converting pre-1.13 block states
-                this.geyserWorldManager = new GeyserSpigot1_12WorldManager();
+                this.geyserWorldManager = new GeyserSpigot1_12WorldManager(this);
             } else if (isLegacy) {
                 // Not sure how this happens - without ViaVersion, we don't know any block states, so just assume everything is air
-                this.geyserWorldManager = new GeyserSpigotFallbackWorldManager();
+                this.geyserWorldManager = new GeyserSpigotFallbackWorldManager(this);
             } else {
                 // Post-1.13
-                this.geyserWorldManager = new GeyserSpigotWorldManager(use3dBiomes);
+                this.geyserWorldManager = new GeyserSpigotWorldManager(this, use3dBiomes);
             }
             geyserLogger.debug("Using default world manager: " + this.geyserWorldManager.getClass());
         }
         GeyserSpigotBlockPlaceListener blockPlaceListener = new GeyserSpigotBlockPlaceListener(connector, this.geyserWorldManager);
-
         Bukkit.getServer().getPluginManager().registerEvents(blockPlaceListener, this);
 
+        if (isPre1_12) {
+            // Register events needed to send all recipes to the client
+            Bukkit.getServer().getPluginManager().registerEvents(new GeyserSpigot1_11CraftingListener(connector), this);
+        }
+
         this.getCommand("geyser").setExecutor(new GeyserSpigotCommandExecutor(connector));
     }
 
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/GeyserSpigot1_11CraftingListener.java b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/GeyserSpigot1_11CraftingListener.java
new file mode 100644
index 000000000..2ee6457ac
--- /dev/null
+++ b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/GeyserSpigot1_11CraftingListener.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.platform.spigot.world;
+
+import com.github.steveice10.mc.protocol.MinecraftConstants;
+import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
+import com.github.steveice10.mc.protocol.data.game.recipe.Ingredient;
+import com.github.steveice10.mc.protocol.data.game.recipe.RecipeType;
+import com.github.steveice10.mc.protocol.data.game.recipe.data.ShapedRecipeData;
+import com.github.steveice10.mc.protocol.data.game.recipe.data.ShapelessRecipeData;
+import com.nukkitx.protocol.bedrock.data.inventory.CraftingData;
+import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
+import com.nukkitx.protocol.bedrock.packet.CraftingDataPacket;
+import org.bukkit.Bukkit;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.inventory.Recipe;
+import org.bukkit.inventory.ShapedRecipe;
+import org.bukkit.inventory.ShapelessRecipe;
+import org.geysermc.connector.GeyserConnector;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.item.ItemTranslator;
+import org.geysermc.connector.network.translators.item.RecipeRegistry;
+import us.myles.ViaVersion.api.Pair;
+import us.myles.ViaVersion.api.data.MappingData;
+import us.myles.ViaVersion.api.protocol.Protocol;
+import us.myles.ViaVersion.api.protocol.ProtocolRegistry;
+import us.myles.ViaVersion.api.protocol.ProtocolVersion;
+import us.myles.ViaVersion.protocols.protocol1_13to1_12_2.Protocol1_13To1_12_2;
+
+import java.util.*;
+
+/**
+ * Used to send all available recipes from the server to the client, as a valid recipe book packet won't be sent by the server.
+ * Requires ViaVersion.
+ */
+public class GeyserSpigot1_11CraftingListener implements Listener {
+
+    private final GeyserConnector connector;
+    /**
+     * Specific mapping data for 1.12 to 1.13. Used to convert the 1.12 item into 1.13.
+     */
+    private final MappingData mappingData1_12to1_13;
+    /**
+     * The list of all protocols from the client's version to 1.13.
+     */
+    private final List<Pair<Integer, Protocol>> protocolList;
+
+    public GeyserSpigot1_11CraftingListener(GeyserConnector connector) {
+        this.connector = connector;
+        this.mappingData1_12to1_13 = ProtocolRegistry.getProtocol(Protocol1_13To1_12_2.class).getMappingData();
+        this.protocolList = ProtocolRegistry.getProtocolPath(MinecraftConstants.PROTOCOL_VERSION,
+                ProtocolVersion.v1_13.getVersion());
+    }
+
+    @EventHandler
+    public void onPlayerJoin(PlayerJoinEvent event) {
+        GeyserSession session = null;
+        for (GeyserSession otherSession : connector.getPlayers()) {
+            if (otherSession.getName().equals(event.getPlayer().getName())) {
+                session = otherSession;
+                break;
+            }
+        }
+        if (session == null) {
+            return;
+        }
+
+        sendServerRecipes(session);
+    }
+
+    public void sendServerRecipes(GeyserSession session) {
+        int netId = RecipeRegistry.LAST_RECIPE_NET_ID;
+
+        CraftingDataPacket craftingDataPacket = new CraftingDataPacket();
+        craftingDataPacket.setCleanRecipes(true);
+
+        Iterator<Recipe> recipeIterator = Bukkit.getServer().recipeIterator();
+        while (recipeIterator.hasNext()) {
+            Recipe recipe = recipeIterator.next();
+
+            Pair<ItemStack, ItemData> outputs = translateToBedrock(session, recipe.getResult());
+            ItemStack javaOutput = outputs.getKey();
+            ItemData output = outputs.getValue();
+            if (output.getId() == 0) continue; // If items make air we don't want that
+
+            boolean isNotAllAir = false; // Check for all-air recipes
+            if (recipe instanceof ShapedRecipe) {
+                ShapedRecipe shapedRecipe = (ShapedRecipe) recipe;
+                int size = shapedRecipe.getShape().length * shapedRecipe.getShape()[0].length();
+                Ingredient[] ingredients = new Ingredient[size];
+                ItemData[] input = new ItemData[size];
+                for (int i = 0; i < input.length; i++) {
+                    // Index is converting char to integer, adding i then converting back to char based on ASCII code
+                    Pair<ItemStack, ItemData> result = translateToBedrock(session, shapedRecipe.getIngredientMap().get((char) ('a' + i)));
+                    ingredients[i] = new Ingredient(new ItemStack[]{result.getKey()});
+                    input[i] = result.getValue();
+                    isNotAllAir |= input[i].getId() != 0;
+                }
+
+                if (!isNotAllAir) continue;
+                UUID uuid = UUID.randomUUID();
+                // Add recipe to our internal cache
+                ShapedRecipeData data = new ShapedRecipeData(shapedRecipe.getShape()[0].length(), shapedRecipe.getShape().length,
+                        "", ingredients, javaOutput);
+                session.getCraftingRecipes().put(netId,
+                        new com.github.steveice10.mc.protocol.data.game.recipe.Recipe(RecipeType.CRAFTING_SHAPED, uuid.toString(), data));
+
+                // Add recipe for Bedrock
+                craftingDataPacket.getCraftingData().add(CraftingData.fromShaped(uuid.toString(),
+                        shapedRecipe.getShape()[0].length(), shapedRecipe.getShape().length, Arrays.asList(input),
+                        Collections.singletonList(output), uuid, "crafting_table", 0, netId++));
+            } else if (recipe instanceof ShapelessRecipe) {
+                ShapelessRecipe shapelessRecipe = (ShapelessRecipe) recipe;
+                Ingredient[] ingredients = new Ingredient[shapelessRecipe.getIngredientList().size()];
+                ItemData[] input = new ItemData[shapelessRecipe.getIngredientList().size()];
+
+                for (int i = 0; i < input.length; i++) {
+                    Pair<ItemStack, ItemData> result = translateToBedrock(session, shapelessRecipe.getIngredientList().get(i));
+                    ingredients[i] = new Ingredient(new ItemStack[]{result.getKey()});
+                    input[i] = result.getValue();
+                    isNotAllAir |= input[i].getId() != 0;
+                }
+
+                if (!isNotAllAir) continue;
+                UUID uuid = UUID.randomUUID();
+                // Add recipe to our internal cache
+                ShapelessRecipeData data = new ShapelessRecipeData("", ingredients, javaOutput);
+                session.getCraftingRecipes().put(netId,
+                        new com.github.steveice10.mc.protocol.data.game.recipe.Recipe(RecipeType.CRAFTING_SHAPELESS, uuid.toString(), data));
+
+                // Add recipe for Bedrock
+                craftingDataPacket.getCraftingData().add(CraftingData.fromShapeless(uuid.toString(),
+                        Arrays.asList(input), Collections.singletonList(output), uuid, "crafting_table", 0, netId++));
+            }
+        }
+
+        session.sendUpstreamPacket(craftingDataPacket);
+    }
+
+    @SuppressWarnings("deprecation")
+    private Pair<ItemStack, ItemData> translateToBedrock(GeyserSession session, org.bukkit.inventory.ItemStack itemStack) {
+        if (itemStack != null && itemStack.getData() != null) {
+            if (itemStack.getType().getId() == 0) {
+                return new Pair<>(null, ItemData.AIR);
+            }
+
+            int legacyId = (itemStack.getType().getId() << 4) | (itemStack.getData().getData() & 0xFFFF);
+
+            if (itemStack.getType().getId() == 355 && itemStack.getData().getData() == (byte) 0) { // Handle bed color since the server will always be pre-1.12
+                legacyId = (itemStack.getType().getId() << 4) | ((byte) 14 & 0xFFFF);
+            }
+
+            // old version -> 1.13 -> 1.13.1 -> 1.14 -> 1.15 -> 1.16 and so on
+            int itemId;
+            if (mappingData1_12to1_13.getItemMappings().containsKey(legacyId)) {
+                itemId = mappingData1_12to1_13.getNewItemId(legacyId);
+            } else if (mappingData1_12to1_13.getItemMappings().containsKey((itemStack.getType().getId() << 4) | (0))) {
+                itemId = mappingData1_12to1_13.getNewItemId((itemStack.getType().getId() << 4) | (0));
+            } else {
+                // No ID found, just send back air
+                return new Pair<>(null, ItemData.AIR);
+            }
+
+            for (int i = protocolList.size() - 1; i >= 0; i--) {
+                MappingData mappingData = protocolList.get(i).getValue().getMappingData();
+                if (mappingData != null) {
+                    itemId = mappingData.getNewItemId(itemId);
+                }
+            }
+
+            ItemStack mcItemStack = new ItemStack(itemId, itemStack.getAmount());
+            ItemData finalData = ItemTranslator.translateToBedrock(session, mcItemStack);
+            return new Pair<>(mcItemStack, finalData);
+        }
+
+        // Empty slot, most likely
+        return new Pair<>(null, ItemData.AIR);
+    }
+
+}
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigot1_12NativeWorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigot1_12NativeWorldManager.java
index 2cbec3273..02347f5de 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigot1_12NativeWorldManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigot1_12NativeWorldManager.java
@@ -27,6 +27,7 @@ package org.geysermc.platform.spigot.world.manager;
 
 import org.bukkit.Bukkit;
 import org.bukkit.entity.Player;
+import org.bukkit.plugin.Plugin;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.world.block.BlockTranslator;
 import org.geysermc.geyser.adapters.spigot.SpigotAdapters;
@@ -40,7 +41,8 @@ import us.myles.ViaVersion.protocols.protocol1_13to1_12_2.storage.BlockStorage;
 public class GeyserSpigot1_12NativeWorldManager extends GeyserSpigot1_12WorldManager {
     private final SpigotWorldAdapter adapter;
 
-    public GeyserSpigot1_12NativeWorldManager() {
+    public GeyserSpigot1_12NativeWorldManager(Plugin plugin) {
+        super(plugin);
         this.adapter = SpigotAdapters.getWorldAdapter();
         // Unlike post-1.13, we can't build up a cache of block states, because block entities need some special conversion
     }
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigot1_12WorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigot1_12WorldManager.java
index 423156c97..a28eef5b4 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigot1_12WorldManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigot1_12WorldManager.java
@@ -30,6 +30,7 @@ import org.bukkit.Bukkit;
 import org.bukkit.World;
 import org.bukkit.block.Block;
 import org.bukkit.entity.Player;
+import org.bukkit.plugin.Plugin;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.world.block.BlockTranslator;
 import us.myles.ViaVersion.api.Pair;
@@ -61,8 +62,8 @@ public class GeyserSpigot1_12WorldManager extends GeyserSpigotWorldManager {
      */
     private final List<Pair<Integer, Protocol>> protocolList;
 
-    public GeyserSpigot1_12WorldManager() {
-        super(false);
+    public GeyserSpigot1_12WorldManager(Plugin plugin) {
+        super(plugin, false);
         this.mappingData1_12to1_13 = ProtocolRegistry.getProtocol(Protocol1_13To1_12_2.class).getMappingData();
         this.protocolList = ProtocolRegistry.getProtocolPath(CLIENT_PROTOCOL_VERSION,
                 ProtocolVersion.v1_13.getVersion());
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotFallbackWorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotFallbackWorldManager.java
index f2ae8a641..4cac791a0 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotFallbackWorldManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotFallbackWorldManager.java
@@ -26,6 +26,7 @@
 package org.geysermc.platform.spigot.world.manager;
 
 import com.github.steveice10.mc.protocol.data.game.chunk.Chunk;
+import org.bukkit.plugin.Plugin;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.world.block.BlockTranslator;
 
@@ -35,9 +36,9 @@ import org.geysermc.connector.network.translators.world.block.BlockTranslator;
  * If this occurs to you somehow, please let us know!!
  */
 public class GeyserSpigotFallbackWorldManager extends GeyserSpigotWorldManager {
-    public GeyserSpigotFallbackWorldManager() {
+    public GeyserSpigotFallbackWorldManager(Plugin plugin) {
         // Since this is pre-1.13 (and thus pre-1.15), there will never be 3D biomes.
-        super(false);
+        super(plugin, false);
     }
 
     @Override
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotLegacyNativeWorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotLegacyNativeWorldManager.java
index 8ed1b3883..8f407de0a 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotLegacyNativeWorldManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotLegacyNativeWorldManager.java
@@ -47,7 +47,7 @@ public class GeyserSpigotLegacyNativeWorldManager extends GeyserSpigotNativeWorl
     private final Int2IntMap oldToNewBlockId;
 
     public GeyserSpigotLegacyNativeWorldManager(GeyserSpigotPlugin plugin, boolean use3dBiomes) {
-        super(use3dBiomes);
+        super(plugin, use3dBiomes);
         IntList allBlockStates = adapter.getAllBlockStates();
         oldToNewBlockId = new Int2IntOpenHashMap(allBlockStates.size());
         ProtocolVersion serverVersion = plugin.getServerProtocolVersion();
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotNativeWorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotNativeWorldManager.java
index 469a38f17..cc9d5bddc 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotNativeWorldManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotNativeWorldManager.java
@@ -27,6 +27,7 @@ package org.geysermc.platform.spigot.world.manager;
 
 import org.bukkit.Bukkit;
 import org.bukkit.entity.Player;
+import org.bukkit.plugin.Plugin;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.world.block.BlockTranslator;
 import org.geysermc.geyser.adapters.spigot.SpigotAdapters;
@@ -35,8 +36,8 @@ import org.geysermc.geyser.adapters.spigot.SpigotWorldAdapter;
 public class GeyserSpigotNativeWorldManager extends GeyserSpigotWorldManager {
     protected final SpigotWorldAdapter adapter;
 
-    public GeyserSpigotNativeWorldManager(boolean use3dBiomes) {
-        super(use3dBiomes);
+    public GeyserSpigotNativeWorldManager(Plugin plugin, boolean use3dBiomes) {
+        super(plugin, use3dBiomes);
         adapter = SpigotAdapters.getWorldAdapter();
     }
 
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotWorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotWorldManager.java
index 748d0f1ef..13f696fd5 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotWorldManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/platform/spigot/world/manager/GeyserSpigotWorldManager.java
@@ -28,23 +28,35 @@ package org.geysermc.platform.spigot.world.manager;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.github.steveice10.mc.protocol.MinecraftConstants;
 import com.github.steveice10.mc.protocol.data.game.chunk.Chunk;
+import com.nukkitx.math.vector.Vector3i;
+import com.nukkitx.nbt.NbtMap;
+import com.nukkitx.nbt.NbtMapBuilder;
+import com.nukkitx.nbt.NbtType;
 import it.unimi.dsi.fastutil.ints.Int2IntMap;
 import it.unimi.dsi.fastutil.ints.Int2IntOpenHashMap;
 import org.bukkit.Bukkit;
 import org.bukkit.World;
 import org.bukkit.block.Biome;
 import org.bukkit.block.Block;
+import org.bukkit.block.Lectern;
 import org.bukkit.block.data.BlockData;
 import org.bukkit.entity.Player;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.BookMeta;
+import org.bukkit.plugin.Plugin;
 import org.geysermc.connector.GeyserConnector;
 import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.translators.LecternInventoryTranslator;
 import org.geysermc.connector.network.translators.world.GeyserWorldManager;
 import org.geysermc.connector.network.translators.world.block.BlockTranslator;
+import org.geysermc.connector.utils.BlockEntityUtils;
 import org.geysermc.connector.utils.FileUtils;
 import org.geysermc.connector.utils.GameRule;
 import org.geysermc.connector.utils.LanguageUtils;
 
 import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
 
 /**
  * The base world manager to use when there is no supported NMS revision
@@ -72,8 +84,11 @@ public class GeyserSpigotWorldManager extends GeyserWorldManager {
      */
     private final Int2IntMap biomeToIdMap = new Int2IntOpenHashMap(Biome.values().length);
 
-    public GeyserSpigotWorldManager(boolean use3dBiomes) {
+    private final Plugin plugin;
+
+    public GeyserSpigotWorldManager(Plugin plugin, boolean use3dBiomes) {
         this.use3dBiomes = use3dBiomes;
+        this.plugin = plugin;
 
         // Load the values into the biome-to-ID map
         InputStream biomeStream = FileUtils.getResource("biomes.json");
@@ -132,9 +147,6 @@ public class GeyserSpigotWorldManager extends GeyserWorldManager {
     @Override
     @SuppressWarnings("deprecation")
     public int[] getBiomeDataAt(GeyserSession session, int x, int z) {
-        if (session.getPlayerEntity() == null) {
-            return new int[1024];
-        }
         int[] biomeData = new int[1024];
         World world = Bukkit.getPlayer(session.getPlayerEntity().getUsername()).getWorld();
         int chunkX = x << 4;
@@ -167,6 +179,76 @@ public class GeyserSpigotWorldManager extends GeyserWorldManager {
         return biomeData;
     }
 
+    @Override
+    public NbtMap getLecternDataAt(GeyserSession session, int x, int y, int z, boolean isChunkLoad) {
+        // Run as a task to prevent async issues
+        Runnable lecternInfoGet = () -> {
+            Player bukkitPlayer;
+            if ((bukkitPlayer = Bukkit.getPlayer(session.getPlayerEntity().getUsername())) == null) {
+                return;
+            }
+
+            Block block = bukkitPlayer.getWorld().getBlockAt(x, y, z);
+            if (!(block.getState() instanceof Lectern)) {
+                session.getConnector().getLogger().error("Lectern expected at: " + Vector3i.from(x, y, z).toString() + " but was not! " + block.toString());
+                return;
+            }
+
+            Lectern lectern = (Lectern) block.getState();
+            ItemStack itemStack = lectern.getInventory().getItem(0);
+            if (itemStack == null || !(itemStack.getItemMeta() instanceof BookMeta)) {
+                if (!isChunkLoad) {
+                    // We need to update the lectern since it's not going to be updated otherwise
+                    BlockEntityUtils.updateBlockEntity(session, LecternInventoryTranslator.getBaseLecternTag(x, y, z, 0).build(), Vector3i.from(x, y, z));
+                }
+                // We don't care; return
+                return;
+            }
+
+            BookMeta bookMeta = (BookMeta) itemStack.getItemMeta();
+            // On the count: allow the book to show/open even there are no pages. We know there is a book here, after all, and this matches Java behavior
+            boolean hasBookPages = bookMeta.getPageCount() > 0;
+            NbtMapBuilder lecternTag = LecternInventoryTranslator.getBaseLecternTag(x, y, z, hasBookPages ? bookMeta.getPageCount() : 1);
+            lecternTag.putInt("page", lectern.getPage() / 2);
+            NbtMapBuilder bookTag = NbtMap.builder()
+                    .putByte("Count", (byte) itemStack.getAmount())
+                    .putShort("Damage", (short) 0)
+                    .putString("Name", "minecraft:writable_book");
+            List<NbtMap> pages = new ArrayList<>(bookMeta.getPageCount());
+            if (hasBookPages) {
+                for (String page : bookMeta.getPages()) {
+                    NbtMapBuilder pageBuilder = NbtMap.builder()
+                            .putString("photoname", "")
+                            .putString("text", page);
+                    pages.add(pageBuilder.build());
+                }
+            } else {
+                // Empty page
+                NbtMapBuilder pageBuilder = NbtMap.builder()
+                        .putString("photoname", "")
+                        .putString("text", "");
+                pages.add(pageBuilder.build());
+            }
+            
+            bookTag.putCompound("tag", NbtMap.builder().putList("pages", NbtType.COMPOUND, pages).build());
+            lecternTag.putCompound("book", bookTag.build());
+            NbtMap blockEntityTag = lecternTag.build();
+            BlockEntityUtils.updateBlockEntity(session, blockEntityTag, Vector3i.from(x, y, z));
+        };
+        if (isChunkLoad) {
+            // Delay to ensure the chunk is sent first, and then the lectern data
+            Bukkit.getScheduler().runTaskLater(this.plugin, lecternInfoGet, 5);
+        } else {
+            Bukkit.getScheduler().runTask(this.plugin, lecternInfoGet);
+        }
+        return LecternInventoryTranslator.getBaseLecternTag(x, y, z, 0).build(); // Will be updated later
+    }
+
+    @Override
+    public boolean shouldExpectLecternHandled() {
+        return true;
+    }
+
     public Boolean getGameRuleBool(GeyserSession session, GameRule gameRule) {
         return Boolean.parseBoolean(Bukkit.getPlayer(session.getPlayerEntity().getUsername()).getWorld().getGameRuleValue(gameRule.getJavaID()));
     }
diff --git a/connector/src/main/java/org/geysermc/connector/entity/Entity.java b/connector/src/main/java/org/geysermc/connector/entity/Entity.java
index 3ba3c9730..741f5fcd0 100644
--- a/connector/src/main/java/org/geysermc/connector/entity/Entity.java
+++ b/connector/src/main/java/org/geysermc/connector/entity/Entity.java
@@ -50,6 +50,7 @@ import org.geysermc.connector.entity.attribute.AttributeType;
 import org.geysermc.connector.entity.living.ArmorStandEntity;
 import org.geysermc.connector.entity.player.PlayerEntity;
 import org.geysermc.connector.entity.type.EntityType;
+import org.geysermc.connector.inventory.PlayerInventory;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.item.ItemRegistry;
 import org.geysermc.connector.utils.AttributeUtils;
@@ -280,11 +281,12 @@ public class Entity {
 
                     // Shield code
                     if (session.getPlayerEntity().getEntityId() == entityId && metadata.getFlags().getFlag(EntityFlag.SNEAKING)) {
-                        if ((session.getInventory().getItemInHand() != null && session.getInventory().getItemInHand().getId() == ItemRegistry.SHIELD.getJavaId()) ||
-                                (session.getInventoryCache().getPlayerInventory().getItem(45) != null && session.getInventoryCache().getPlayerInventory().getItem(45).getId() == ItemRegistry.SHIELD.getJavaId())) {
+                        PlayerInventory playerInv = session.getPlayerInventory();
+                        if ((playerInv.getItemInHand().getJavaId() == ItemRegistry.SHIELD.getJavaId()) ||
+                                (playerInv.getOffhand().getJavaId() == ItemRegistry.SHIELD.getJavaId())) {
                             ClientPlayerUseItemPacket useItemPacket;
                             metadata.getFlags().setFlag(EntityFlag.BLOCKING, true);
-                            if (session.getInventory().getItemInHand() != null && session.getInventory().getItemInHand().getId() == ItemRegistry.SHIELD.getJavaId()) {
+                            if (playerInv.getItemInHand().getJavaId() == ItemRegistry.SHIELD.getJavaId()) {
                                 useItemPacket = new ClientPlayerUseItemPacket(Hand.MAIN_HAND);
                             }
                             // Else we just assume it's the offhand, to simplify logic and to assure the packet gets sent
diff --git a/connector/src/main/java/org/geysermc/connector/entity/living/animal/horse/AbstractHorseEntity.java b/connector/src/main/java/org/geysermc/connector/entity/living/animal/horse/AbstractHorseEntity.java
index 628beff1b..41073246e 100644
--- a/connector/src/main/java/org/geysermc/connector/entity/living/animal/horse/AbstractHorseEntity.java
+++ b/connector/src/main/java/org/geysermc/connector/entity/living/animal/horse/AbstractHorseEntity.java
@@ -30,6 +30,7 @@ import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import com.nukkitx.protocol.bedrock.data.entity.EntityEventType;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
 import com.nukkitx.protocol.bedrock.packet.EntityEventPacket;
 import org.geysermc.connector.entity.living.animal.AnimalEntity;
 import org.geysermc.connector.entity.type.EntityType;
@@ -40,6 +41,9 @@ public class AbstractHorseEntity extends AnimalEntity {
 
     public AbstractHorseEntity(long entityId, long geyserId, EntityType entityType, Vector3f position, Vector3f motion, Vector3f rotation) {
         super(entityId, geyserId, entityType, position, motion, rotation);
+
+        // Specifies the size of the entity's inventory. Required to place slots in the entity.
+        metadata.put(EntityData.CONTAINER_BASE_SIZE, 2);
     }
 
     @Override
@@ -75,6 +79,9 @@ public class AbstractHorseEntity extends AnimalEntity {
                 entityEventPacket.setData(ItemRegistry.WHEAT.getBedrockId() << 16);
                 session.sendUpstreamPacket(entityEventPacket);
             }
+
+            // Set container type if tamed
+            metadata.put(EntityData.CONTAINER_TYPE, ((xd & 0x02) == 0x02) ? (byte) ContainerType.HORSE.getId() : (byte) 0);
         }
 
         // Needed to control horses
diff --git a/connector/src/main/java/org/geysermc/connector/entity/living/animal/horse/ChestedHorseEntity.java b/connector/src/main/java/org/geysermc/connector/entity/living/animal/horse/ChestedHorseEntity.java
index f67567c90..461d636bd 100644
--- a/connector/src/main/java/org/geysermc/connector/entity/living/animal/horse/ChestedHorseEntity.java
+++ b/connector/src/main/java/org/geysermc/connector/entity/living/animal/horse/ChestedHorseEntity.java
@@ -27,6 +27,7 @@ package org.geysermc.connector.entity.living.animal.horse;
 
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.EntityMetadata;
 import com.nukkitx.math.vector.Vector3f;
+import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.connector.entity.type.EntityType;
 import org.geysermc.connector.network.session.GeyserSession;
@@ -35,6 +36,8 @@ public class ChestedHorseEntity extends AbstractHorseEntity {
 
     public ChestedHorseEntity(long entityId, long geyserId, EntityType entityType, Vector3f position, Vector3f motion, Vector3f rotation) {
         super(entityId, geyserId, entityType, position, motion, rotation);
+
+        metadata.put(EntityData.CONTAINER_BASE_SIZE, 16);
     }
 
     @Override
diff --git a/connector/src/main/java/org/geysermc/connector/entity/living/animal/horse/LlamaEntity.java b/connector/src/main/java/org/geysermc/connector/entity/living/animal/horse/LlamaEntity.java
index a04539dca..5fdde5272 100644
--- a/connector/src/main/java/org/geysermc/connector/entity/living/animal/horse/LlamaEntity.java
+++ b/connector/src/main/java/org/geysermc/connector/entity/living/animal/horse/LlamaEntity.java
@@ -38,6 +38,8 @@ public class LlamaEntity extends ChestedHorseEntity {
 
     public LlamaEntity(long entityId, long geyserId, EntityType entityType, Vector3f position, Vector3f motion, Vector3f rotation) {
         super(entityId, geyserId, entityType, position, motion, rotation);
+
+        metadata.put(EntityData.CONTAINER_STRENGTH_MODIFIER, 3); // Presumably 3 slots for every 1 strength
     }
 
     @Override
@@ -56,7 +58,7 @@ public class LlamaEntity extends ChestedHorseEntity {
                 // The damage value is the dye color that Java sends us
                 // Always going to be a carpet so we can hardcode 171 in BlockTranslator
                 // The int then short conversion is required or we get a ClassCastException
-                equipmentPacket.setChestplate(ItemData.of(BlockTranslator.CARPET, (short)((int) entityMetadata.getValue()), 1));
+                equipmentPacket.setChestplate(ItemData.of(BlockTranslator.CARPET, (short) ((int) entityMetadata.getValue()), 1));
             } else {
                 equipmentPacket.setChestplate(ItemData.AIR);
             }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/action/Click.java b/connector/src/main/java/org/geysermc/connector/inventory/AnvilContainer.java
similarity index 78%
rename from connector/src/main/java/org/geysermc/connector/network/translators/inventory/action/Click.java
rename to connector/src/main/java/org/geysermc/connector/inventory/AnvilContainer.java
index fdfc2d57b..71b5cbda9 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/action/Click.java
+++ b/connector/src/main/java/org/geysermc/connector/inventory/AnvilContainer.java
@@ -23,16 +23,15 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.connector.network.translators.inventory.action;
+package org.geysermc.connector.inventory;
 
-import com.github.steveice10.mc.protocol.data.game.window.ClickItemParam;
-import com.github.steveice10.mc.protocol.data.game.window.WindowActionParam;
-import lombok.AllArgsConstructor;
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
 
-@AllArgsConstructor
-enum Click {
-    LEFT(ClickItemParam.LEFT_CLICK),
-    RIGHT(ClickItemParam.RIGHT_CLICK);
-
-    public final WindowActionParam actionParam;
+/**
+ * Used to determine if rename packets should be sent.
+ */
+public class AnvilContainer extends Container {
+    public AnvilContainer(String title, int id, int size, WindowType windowType, PlayerInventory playerInventory) {
+        super(title, id, size, windowType, playerInventory);
+    }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/inventory/BeaconContainer.java b/connector/src/main/java/org/geysermc/connector/inventory/BeaconContainer.java
new file mode 100644
index 000000000..3798d9009
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/inventory/BeaconContainer.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.inventory;
+
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class BeaconContainer extends Container {
+    private int primaryId;
+    private int secondaryId;
+
+    public BeaconContainer(String title, int id, int size, WindowType windowType, PlayerInventory playerInventory) {
+        super(title, id, size, windowType, playerInventory);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/inventory/CartographyContainer.java b/connector/src/main/java/org/geysermc/connector/inventory/CartographyContainer.java
new file mode 100644
index 000000000..0ac93b431
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/inventory/CartographyContainer.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.inventory;
+
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+
+public class CartographyContainer extends Container {
+    public CartographyContainer(String title, int id, int size, WindowType windowType, PlayerInventory playerInventory) {
+        super(title, id, size, windowType, playerInventory);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/inventory/Container.java b/connector/src/main/java/org/geysermc/connector/inventory/Container.java
new file mode 100644
index 000000000..d61b2b71d
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/inventory/Container.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.inventory;
+
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import lombok.Getter;
+import lombok.NonNull;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
+
+/**
+ * Combination of {@link Inventory} and {@link PlayerInventory}
+ */
+@Getter
+public class Container extends Inventory {
+    private final PlayerInventory playerInventory;
+    private final int containerSize;
+
+    /**
+     * Whether we are using a real block when opening this inventory.
+     */
+    private boolean isUsingRealBlock = false;
+
+    public Container(String title, int id, int size, WindowType windowType, PlayerInventory playerInventory) {
+        super(title, id, size, windowType);
+        this.playerInventory = playerInventory;
+        this.containerSize = this.size + InventoryTranslator.PLAYER_INVENTORY_SIZE;
+    }
+
+    @Override
+    public GeyserItemStack getItem(int slot) {
+        if (slot < this.size) {
+            return super.getItem(slot);
+        } else {
+            return playerInventory.getItem(slot - this.size + InventoryTranslator.PLAYER_INVENTORY_OFFSET);
+        }
+    }
+
+    @Override
+    public void setItem(int slot, @NonNull GeyserItemStack newItem, GeyserSession session) {
+        if (slot < this.size) {
+            super.setItem(slot, newItem, session);
+        } else {
+            playerInventory.setItem(slot - this.size + InventoryTranslator.PLAYER_INVENTORY_OFFSET, newItem, session);
+        }
+    }
+
+    @Override
+    public int getSize() {
+        return this.containerSize;
+    }
+
+    /**
+     * Will be overwritten for droppers.
+     *
+     * @param usingRealBlock whether this container is using a real container or not
+     * @param javaBlockId the Java block string of the block, if real
+     */
+    public void setUsingRealBlock(boolean usingRealBlock, String javaBlockId) {
+        isUsingRealBlock = usingRealBlock;
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/inventory/EnchantingContainer.java b/connector/src/main/java/org/geysermc/connector/inventory/EnchantingContainer.java
new file mode 100644
index 000000000..e8c935649
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/inventory/EnchantingContainer.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.inventory;
+
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import com.nukkitx.protocol.bedrock.data.inventory.EnchantOptionData;
+import lombok.Getter;
+
+public class EnchantingContainer extends Container {
+    /**
+     * A cache of what Bedrock sees
+     */
+    @Getter
+    private final EnchantOptionData[] enchantOptions;
+    /**
+     * A mutable cache of what the server sends us
+     */
+    @Getter
+    private final GeyserEnchantOption[] geyserEnchantOptions;
+
+    public EnchantingContainer(String title, int id, int size, WindowType windowType, PlayerInventory playerInventory) {
+        super(title, id, size, windowType, playerInventory);
+
+        enchantOptions = new EnchantOptionData[3];
+        geyserEnchantOptions = new GeyserEnchantOption[3];
+        for (int i = 0; i < geyserEnchantOptions.length; i++) {
+            geyserEnchantOptions[i] = new GeyserEnchantOption(i);
+            // Options cannot be null, so we build initial options
+            // GeyserSession can be safely null here because it's only needed for net IDs
+            enchantOptions[i] = geyserEnchantOptions[i].build(null);
+        }
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/inventory/Generic3X3Container.java b/connector/src/main/java/org/geysermc/connector/inventory/Generic3X3Container.java
new file mode 100644
index 000000000..8c89cdeb6
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/inventory/Generic3X3Container.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.inventory;
+
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import lombok.Getter;
+
+public class Generic3X3Container extends Container {
+    /**
+     * Whether we need to set the container type as {@link com.nukkitx.protocol.bedrock.data.inventory.ContainerType#DROPPER}
+     */
+    @Getter
+    private boolean isDropper = false;
+
+    public Generic3X3Container(String title, int id, int size, WindowType windowType, PlayerInventory playerInventory) {
+        super(title, id, size, windowType, playerInventory);
+    }
+
+    @Override
+    public void setUsingRealBlock(boolean usingRealBlock, String javaBlockId) {
+        super.setUsingRealBlock(usingRealBlock, javaBlockId);
+        if (usingRealBlock) {
+            isDropper = javaBlockId.startsWith("minecraft:dropper");
+        }
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/inventory/GeyserEnchantOption.java b/connector/src/main/java/org/geysermc/connector/inventory/GeyserEnchantOption.java
new file mode 100644
index 000000000..e9ad81a6a
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/inventory/GeyserEnchantOption.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.inventory;
+
+import com.nukkitx.protocol.bedrock.data.inventory.EnchantData;
+import com.nukkitx.protocol.bedrock.data.inventory.EnchantOptionData;
+import lombok.Getter;
+import lombok.Setter;
+import org.geysermc.connector.network.session.GeyserSession;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A mutable "wrapper" around {@link EnchantOptionData}
+ */
+@Setter
+public class GeyserEnchantOption {
+    private static final List<EnchantData> EMPTY = Collections.emptyList();
+    /**
+     * This: https://cdn.discordapp.com/attachments/613168850925649981/791030657169227816/unknown.png
+     * is controlled by the server.
+     * So, of course, we have to throw in some easter eggs. ;)
+     */
+    private static final List<String> ENCHANT_NAMES = Arrays.asList("tougher armor", "lukeeey", "fall better",
+            "explode less", "camo toy", "breathe better", "rtm five one six", "armor stab", "water walk", "you are elsa",
+            "tim two zero three", "fast walk nether", "oof ouch owie", "enemy on fire", "spider sad", "aj ferguson", "redned",
+            "more items thx", "long sword reach", "fast tool", "give me block", "less breaky break", "cube craft",
+            "strong arrow", "fist arrow", "spicy arrow", "many many arrows", "geyser", "come here fish", "i like this",
+            "stabby stab", "supreme mortal", "avatar i guess", "more arrows", "fly finder seventeen", "in and out",
+            "xp heals tools", "dragon proxy waz here");
+
+    @Getter
+    private final int javaIndex;
+
+    private int xpCost = 0;
+    private int javaEnchantIndex = -1;
+    private int bedrockEnchantIndex = -1;
+    private int enchantLevel = -1;
+
+    public GeyserEnchantOption(int javaIndex) {
+        this.javaIndex = javaIndex;
+    }
+
+    public EnchantOptionData build(GeyserSession session) {
+        return new EnchantOptionData(xpCost, javaIndex + 16, EMPTY,
+                enchantLevel == -1 ? EMPTY : Collections.singletonList(new EnchantData(bedrockEnchantIndex, enchantLevel)), EMPTY,
+                javaEnchantIndex == -1 ? "unknown" : ENCHANT_NAMES.get(javaEnchantIndex), enchantLevel == -1 ? 0 : session.getNextItemNetId());
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/inventory/GeyserItemStack.java b/connector/src/main/java/org/geysermc/connector/inventory/GeyserItemStack.java
new file mode 100644
index 000000000..7cdaf1801
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/inventory/GeyserItemStack.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.inventory;
+
+import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
+import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
+import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
+import lombok.Data;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.item.ItemEntry;
+import org.geysermc.connector.network.translators.item.ItemRegistry;
+import org.geysermc.connector.network.translators.item.ItemTranslator;
+
+@Data
+public class GeyserItemStack {
+    public static final GeyserItemStack EMPTY = new GeyserItemStack(0, 0, null);
+
+    private final int javaId;
+    private int amount;
+    private CompoundTag nbt;
+    private int netId;
+
+    public GeyserItemStack(int javaId) {
+        this(javaId, 1);
+    }
+
+    public GeyserItemStack(int javaId, int amount) {
+        this(javaId, amount, null);
+    }
+
+    public GeyserItemStack(int javaId, int amount, CompoundTag nbt) {
+        this(javaId, amount, nbt, 1);
+    }
+
+    public GeyserItemStack(int javaId, int amount, CompoundTag nbt, int netId) {
+        this.javaId = javaId;
+        this.amount = amount;
+        this.nbt = nbt;
+        this.netId = netId;
+    }
+
+    public int getJavaId() {
+        return isEmpty() ? 0 : javaId;
+    }
+
+    public int getAmount() {
+        return isEmpty() ? 0 : amount;
+    }
+
+    public CompoundTag getNbt() {
+        return isEmpty() ? null : nbt;
+    }
+
+    public void setNetId(int netId) {
+        this.netId = netId;
+    }
+
+    public int getNetId() {
+        return isEmpty() ? 0 : netId;
+    }
+
+    public void add(int add) {
+        amount += add;
+    }
+
+    public void sub(int sub) {
+        amount -= sub;
+    }
+
+    public static GeyserItemStack from(ItemStack itemStack) {
+        return itemStack == null ? EMPTY : new GeyserItemStack(itemStack.getId(), itemStack.getAmount(), itemStack.getNbt());
+    }
+
+    public ItemStack getItemStack() {
+        return getItemStack(amount);
+    }
+
+    public ItemStack getItemStack(int newAmount) {
+        return isEmpty() ? null : new ItemStack(javaId, newAmount, nbt);
+    }
+
+    public ItemData getItemData(GeyserSession session) {
+        ItemData itemData = ItemTranslator.translateToBedrock(session, getItemStack());
+        itemData.setNetId(getNetId());
+        return itemData;
+    }
+
+    public ItemEntry getItemEntry() {
+        return ItemRegistry.ITEM_ENTRIES.get(getJavaId());
+    }
+
+    public boolean isEmpty() {
+        return amount <= 0 || javaId == 0;
+    }
+
+    public GeyserItemStack copy() {
+        return copy(amount);
+    }
+
+    public GeyserItemStack copy(int newAmount) {
+        return isEmpty() ? EMPTY : new GeyserItemStack(javaId, newAmount, nbt == null ? null : nbt.clone(), netId);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/inventory/Inventory.java b/connector/src/main/java/org/geysermc/connector/inventory/Inventory.java
index 41ae994f5..11a0034ad 100644
--- a/connector/src/main/java/org/geysermc/connector/inventory/Inventory.java
+++ b/connector/src/main/java/org/geysermc/connector/inventory/Inventory.java
@@ -25,36 +25,39 @@
 
 package org.geysermc.connector.inventory;
 
-import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
 import com.github.steveice10.mc.protocol.data.game.window.WindowType;
 import com.nukkitx.math.vector.Vector3i;
 import lombok.Getter;
+import lombok.NonNull;
 import lombok.Setter;
+import org.geysermc.connector.GeyserConnector;
+import org.geysermc.connector.network.session.GeyserSession;
 
-import java.util.concurrent.atomic.AtomicInteger;
+import java.util.Arrays;
 
 public class Inventory {
 
     @Getter
-    protected int id;
-
-    @Getter
-    @Setter
-    protected boolean open;
-
-    @Getter
-    protected WindowType windowType;
+    protected final int id;
 
     @Getter
     protected final int size;
 
+    /**
+     * Used for smooth transitions between two windows of the same type.
+     */
+    @Getter
+    protected final WindowType windowType;
+
     @Getter
     @Setter
     protected String title;
 
-    @Setter
-    protected ItemStack[] items;
+    protected GeyserItemStack[] items;
 
+    /**
+     * The location of the inventory block. Will either be a fake block above the player's head, or the actual block location
+     */
     @Getter
     @Setter
     protected Vector3i holderPosition = Vector3i.ZERO;
@@ -64,27 +67,67 @@ public class Inventory {
     protected long holderId = -1;
 
     @Getter
-    protected AtomicInteger transactionId = new AtomicInteger(1);
+    protected short transactionId = 0;
 
-    public Inventory(int id, WindowType windowType, int size) {
-        this("Inventory", id, windowType, size);
+    @Getter
+    @Setter
+    private boolean pending = false;
+
+    protected Inventory(int id, int size, WindowType windowType) {
+        this("Inventory", id, size, windowType);
     }
 
-    public Inventory(String title, int id, WindowType windowType, int size) {
+    protected Inventory(String title, int id, int size, WindowType windowType) {
         this.title = title;
         this.id = id;
-        this.windowType = windowType;
         this.size = size;
-        this.items = new ItemStack[size];
+        this.windowType = windowType;
+        this.items = new GeyserItemStack[size];
+        Arrays.fill(items, GeyserItemStack.EMPTY);
     }
 
-    public ItemStack getItem(int slot) {
+    public GeyserItemStack getItem(int slot) {
+        if (slot > this.size) {
+            GeyserConnector.getInstance().getLogger().debug("Tried to get an item out of bounds! " + this.toString());
+            return GeyserItemStack.EMPTY;
+        }
         return items[slot];
     }
 
-    public void setItem(int slot, ItemStack item) {
-        if (item != null && (item.getId() == 0 || item.getAmount() < 1))
-            item = null;
-        items[slot] = item;
+    public void setItem(int slot, @NonNull GeyserItemStack newItem, GeyserSession session) {
+        if (slot > this.size) {
+            session.getConnector().getLogger().debug("Tried to set an item out of bounds! " + this.toString());
+            return;
+        }
+        GeyserItemStack oldItem = items[slot];
+        updateItemNetId(oldItem, newItem, session);
+        items[slot] = newItem;
+    }
+
+    protected static void updateItemNetId(GeyserItemStack oldItem, GeyserItemStack newItem, GeyserSession session) {
+        if (!newItem.isEmpty()) {
+            if (newItem.getItemData(session).equals(oldItem.getItemData(session), false, false, false)) {
+                newItem.setNetId(oldItem.getNetId());
+            } else {
+                newItem.setNetId(session.getNextItemNetId());
+            }
+        }
+    }
+
+    public short getNextTransactionId() {
+        return ++transactionId;
+    }
+
+    @Override
+    public String toString() {
+        return "Inventory{" +
+                "id=" + id +
+                ", size=" + size +
+                ", title='" + title + '\'' +
+                ", items=" + Arrays.toString(items) +
+                ", holderPosition=" + holderPosition +
+                ", holderId=" + holderId +
+                ", transactionId=" + transactionId +
+                '}';
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/inventory/LecternContainer.java b/connector/src/main/java/org/geysermc/connector/inventory/LecternContainer.java
new file mode 100644
index 000000000..be1b8b34b
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/inventory/LecternContainer.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.inventory;
+
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import com.nukkitx.math.vector.Vector3i;
+import com.nukkitx.nbt.NbtMap;
+import lombok.Getter;
+import lombok.Setter;
+
+public class LecternContainer extends Container {
+    @Getter @Setter
+    private int currentBedrockPage = 0;
+    @Getter @Setter
+    private NbtMap blockEntityTag;
+    @Getter @Setter
+    private Vector3i position;
+
+    public LecternContainer(String title, int id, int size, WindowType windowType, PlayerInventory playerInventory) {
+        super(title, id, size, windowType, playerInventory);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/inventory/MerchantContainer.java b/connector/src/main/java/org/geysermc/connector/inventory/MerchantContainer.java
new file mode 100644
index 000000000..5941b2a7d
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/inventory/MerchantContainer.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.inventory;
+
+import com.github.steveice10.mc.protocol.data.game.window.VillagerTrade;
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import lombok.Getter;
+import lombok.Setter;
+import org.geysermc.connector.entity.Entity;
+
+@Getter
+@Setter
+public class MerchantContainer extends Container {
+    private Entity villager;
+    private VillagerTrade[] villagerTrades;
+
+    public MerchantContainer(String title, int id, int size, WindowType windowType, PlayerInventory playerInventory) {
+        super(title, id, size, windowType, playerInventory);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/inventory/PlayerInventory.java b/connector/src/main/java/org/geysermc/connector/inventory/PlayerInventory.java
index 4816e3c3a..76b2e5fbe 100644
--- a/connector/src/main/java/org/geysermc/connector/inventory/PlayerInventory.java
+++ b/connector/src/main/java/org/geysermc/connector/inventory/PlayerInventory.java
@@ -25,9 +25,11 @@
 
 package org.geysermc.connector.inventory;
 
-import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
 import lombok.Getter;
+import lombok.NonNull;
 import lombok.Setter;
+import org.geysermc.connector.GeyserConnector;
+import org.geysermc.connector.network.session.GeyserSession;
 
 public class PlayerInventory extends Inventory {
 
@@ -40,20 +42,36 @@ public class PlayerInventory extends Inventory {
     private int heldItemSlot;
 
     @Getter
-    private ItemStack cursor;
+    @NonNull
+    private GeyserItemStack cursor = GeyserItemStack.EMPTY;
 
     public PlayerInventory() {
-        super(0, null, 46);
+        super(0, 46, null);
         heldItemSlot = 0;
     }
 
-    public void setCursor(ItemStack stack) {
-        if (stack != null && (stack.getId() == 0 || stack.getAmount() < 1))
-            stack = null;
-        cursor = stack;
+    public void setCursor(@NonNull GeyserItemStack newCursor, GeyserSession session) {
+        updateItemNetId(cursor, newCursor, session);
+        cursor = newCursor;
     }
 
-    public ItemStack getItemInHand() {
+    public GeyserItemStack getItemInHand() {
+        if (36 + heldItemSlot > this.size) {
+            GeyserConnector.getInstance().getLogger().debug("Held item slot was larger than expected!");
+            return GeyserItemStack.EMPTY;
+        }
         return items[36 + heldItemSlot];
     }
+
+    public void setItemInHand(@NonNull GeyserItemStack item) {
+        if (36 + heldItemSlot > this.size) {
+            GeyserConnector.getInstance().getLogger().debug("Held item slot was larger than expected!");
+            return;
+        }
+        items[36 + heldItemSlot] = item;
+    }
+
+    public GeyserItemStack getOffhand() {
+        return items[45];
+    }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/session/cache/InventoryCache.java b/connector/src/main/java/org/geysermc/connector/inventory/StonecutterContainer.java
similarity index 61%
rename from connector/src/main/java/org/geysermc/connector/network/session/cache/InventoryCache.java
rename to connector/src/main/java/org/geysermc/connector/inventory/StonecutterContainer.java
index 3ead687fc..d558fab34 100644
--- a/connector/src/main/java/org/geysermc/connector/network/session/cache/InventoryCache.java
+++ b/connector/src/main/java/org/geysermc/connector/inventory/StonecutterContainer.java
@@ -23,39 +23,32 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.connector.network.session.cache;
+package org.geysermc.connector.inventory;
 
-import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
-import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
 import lombok.Getter;
+import lombok.NonNull;
 import lombok.Setter;
-import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
 
-public class InventoryCache {
-
-    private GeyserSession session;
-
+public class StonecutterContainer extends Container {
+    /**
+     * The button that has currently been pressed Java-side
+     */
     @Getter
     @Setter
-    private Inventory openInventory;
+    private int stonecutterButton = -1;
 
-    @Getter
-    private Int2ObjectMap<Inventory> inventories = new Int2ObjectOpenHashMap<>();
-
-    public InventoryCache(GeyserSession session) {
-        this.session = session;
+    public StonecutterContainer(String title, int id, int size, WindowType windowType, PlayerInventory playerInventory) {
+        super(title, id, size, windowType, playerInventory);
     }
 
-    public Inventory getPlayerInventory() {
-        return inventories.get(0);
-    }
-
-    public void cacheInventory(Inventory inventory) {
-        inventories.put(inventory.getId(), inventory);
-    }
-
-    public void uncacheInventory(int id) {
-        inventories.remove(id);
+    @Override
+    public void setItem(int slot, @NonNull GeyserItemStack newItem, GeyserSession session) {
+        if (slot == 0 && newItem.getJavaId() != items[slot].getJavaId()) {
+            // The pressed stonecutter button output resets whenever the input item changes
+            this.stonecutterButton = -1;
+        }
+        super.setItem(slot, newItem, session);
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/session/GeyserSession.java b/connector/src/main/java/org/geysermc/connector/network/session/GeyserSession.java
index 589214652..ee2a63e6f 100644
--- a/connector/src/main/java/org/geysermc/connector/network/session/GeyserSession.java
+++ b/connector/src/main/java/org/geysermc/connector/network/session/GeyserSession.java
@@ -36,8 +36,8 @@ import com.github.steveice10.mc.protocol.MinecraftConstants;
 import com.github.steveice10.mc.protocol.MinecraftProtocol;
 import com.github.steveice10.mc.protocol.data.SubProtocol;
 import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
+import com.github.steveice10.mc.protocol.data.game.recipe.Recipe;
 import com.github.steveice10.mc.protocol.data.game.statistic.Statistic;
-import com.github.steveice10.mc.protocol.data.game.window.VillagerTrade;
 import com.github.steveice10.mc.protocol.packet.handshake.client.HandshakePacket;
 import com.github.steveice10.mc.protocol.packet.ingame.client.player.ClientPlayerPositionPacket;
 import com.github.steveice10.mc.protocol.packet.ingame.client.player.ClientPlayerPositionRotationPacket;
@@ -58,12 +58,15 @@ import com.nukkitx.protocol.bedrock.data.command.CommandPermission;
 import com.nukkitx.protocol.bedrock.packet.*;
 import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
 import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
+import it.unimi.dsi.fastutil.ints.IntList;
 import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
 import it.unimi.dsi.fastutil.longs.Long2ObjectMaps;
 import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
 import it.unimi.dsi.fastutil.objects.Object2LongMap;
 import it.unimi.dsi.fastutil.objects.Object2LongOpenHashMap;
 import it.unimi.dsi.fastutil.objects.ObjectIterator;
+import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet;
+import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.NonNull;
 import lombok.Setter;
@@ -74,6 +77,7 @@ import org.geysermc.connector.entity.Entity;
 import org.geysermc.connector.entity.Tickable;
 import org.geysermc.connector.entity.player.SessionPlayerEntity;
 import org.geysermc.connector.entity.player.SkullPlayerEntity;
+import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.inventory.PlayerInventory;
 import org.geysermc.connector.network.remote.RemoteServer;
 import org.geysermc.connector.network.session.auth.AuthData;
@@ -84,7 +88,7 @@ import org.geysermc.connector.network.translators.EntityIdentifierRegistry;
 import org.geysermc.connector.network.translators.PacketTranslatorRegistry;
 import org.geysermc.connector.network.translators.chat.MessageTranslator;
 import org.geysermc.connector.network.translators.collision.CollisionManager;
-import org.geysermc.connector.network.translators.inventory.EnchantmentInventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
 import org.geysermc.connector.network.translators.item.ItemRegistry;
 import org.geysermc.connector.network.translators.world.block.BlockTranslator;
 import org.geysermc.connector.skin.FloodgateSkinUploader;
@@ -100,9 +104,8 @@ import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.nio.charset.StandardCharsets;
 import java.util.*;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.TimeUnit;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicInteger;
 
 @Getter
 public class GeyserSession implements CommandSender {
@@ -121,18 +124,39 @@ public class GeyserSession implements CommandSender {
     private boolean microsoftAccount;
 
     private final SessionPlayerEntity playerEntity;
-    private PlayerInventory inventory;
 
     private AdvancementsCache advancementsCache;
     private BookEditCache bookEditCache;
     private ChunkCache chunkCache;
     private EntityCache entityCache;
     private EntityEffectCache effectCache;
-    private InventoryCache inventoryCache;
     private WorldCache worldCache;
     private FormCache formCache;
     private final Int2ObjectMap<TeleportCache> teleportMap = new Int2ObjectOpenHashMap<>();
 
+    private final PlayerInventory playerInventory;
+    @Setter
+    private Inventory openInventory;
+    @Setter
+    private boolean closingInventory;
+
+    @Setter
+    private InventoryTranslator inventoryTranslator = InventoryTranslator.PLAYER_INVENTORY_TRANSLATOR;
+
+    /**
+     * Use {@link #getNextItemNetId()} instead for consistency
+     */
+    @Getter(AccessLevel.NONE)
+    private final AtomicInteger itemNetId = new AtomicInteger(2);
+
+    @Getter(AccessLevel.NONE)
+    private final Object inventoryLock = new Object();
+    @Getter(AccessLevel.NONE)
+    private CompletableFuture<Void> inventoryFuture;
+
+    @Setter
+    private ScheduledFuture<?> craftingGridFuture;
+
     /**
      * Stores session collision
      */
@@ -153,6 +177,16 @@ public class GeyserSession implements CommandSender {
      */
     private final Object2LongMap<Vector3i> itemFrameCache = new Object2LongOpenHashMap<>();
 
+    /**
+     * Stores a list of all lectern locations and their block entity tags.
+     * See {@link org.geysermc.connector.network.translators.world.WorldManager#getLecternDataAt(GeyserSession, int, int, int, boolean)}
+     * for more information.
+     */
+    private final Set<Vector3i> lecternCache = new ObjectOpenHashSet<>();
+
+    @Setter
+    private boolean droppingLecternBook;
+
     @Setter
     private Vector2i lastChunkPosition = null;
     private int renderDistance;
@@ -209,26 +243,30 @@ public class GeyserSession implements CommandSender {
      * Initialized as (0, 0, 0) so it is always not-null.
      */
     @Setter
-    private Vector3i lastInteractionPosition = Vector3i.ZERO;
+    private Vector3i lastInteractionBlockPosition = Vector3i.ZERO;
+
+    /**
+     * Stores the position of the player the last time they interacted.
+     * Used to verify that the player did not move since their last interaction. <br>
+     * Initialized as (0, 0, 0) so it is always not-null.
+     */
+    @Setter
+    private Vector3f lastInteractionPlayerPosition = Vector3f.ZERO;
 
     @Setter
     private Entity ridingVehicleEntity;
 
     @Setter
-    private int craftSlot = 0;
-
-    @Setter
-    private long lastWindowCloseTime = 0;
-
-    @Setter
-    private VillagerTrade[] villagerTrades;
-    @Setter
-    private long lastInteractedVillagerEid;
+    private Int2ObjectMap<Recipe> craftingRecipes;
+    private final Set<String> unlockedRecipes;
+    private final AtomicInteger lastRecipeNetId;
 
     /**
-     * Stores the enchantment information the client has received if they are in an enchantment table GUI
+     * Saves a list of all stonecutter recipes, for use in a stonecutter inventory.
+     * The key is the Java ID of the item; the values are all the possible outputs' Java IDs sorted by their string identifier
      */
-    private final EnchantmentInventoryTranslator.EnchantmentSlotData[] enchantmentSlotData = new EnchantmentInventoryTranslator.EnchantmentSlotData[3];
+    @Setter
+    private Int2ObjectMap<IntList> stonecutterRecipes;
 
     /**
      * The current attack speed of the player. Used for sending proper cooldown timings.
@@ -364,20 +402,22 @@ public class GeyserSession implements CommandSender {
         this.chunkCache = new ChunkCache(this);
         this.entityCache = new EntityCache(this);
         this.effectCache = new EntityEffectCache();
-        this.inventoryCache = new InventoryCache(this);
         this.worldCache = new WorldCache(this);
         this.formCache = new FormCache(this);
 
         this.collisionManager = new CollisionManager(this);
-
         this.playerEntity = new SessionPlayerEntity(this);
-        this.inventory = new PlayerInventory();
+
+        this.playerInventory = new PlayerInventory();
+        this.openInventory = null;
+        this.inventoryFuture = CompletableFuture.completedFuture(null);
+        this.craftingRecipes = new Int2ObjectOpenHashMap<>();
+        this.unlockedRecipes = new ObjectOpenHashSet<>();
+        this.lastRecipeNetId = new AtomicInteger(1);
 
         this.spawned = false;
         this.loggedIn = false;
 
-        this.inventoryCache.getInventories().put(0, inventory);
-
         // Make a copy to prevent ConcurrentModificationException
         final List<GeyserSession> tmpPlayers = new ArrayList<>(connector.getPlayers());
         tmpPlayers.forEach(player -> this.emotes.addAll(player.getEmotes()));
@@ -702,7 +742,6 @@ public class GeyserSession implements CommandSender {
         this.entityCache = null;
         this.effectCache = null;
         this.worldCache = null;
-        this.inventoryCache = null;
         this.formCache = null;
 
         closed = true;
@@ -842,6 +881,7 @@ public class GeyserSession implements CommandSender {
         startGamePacket.setMultiplayerCorrelationId("");
         startGamePacket.setItemEntries(ItemRegistry.ITEMS);
         startGamePacket.setVanillaVersion("*");
+        startGamePacket.setInventoriesServerAuthoritative(true);
         startGamePacket.setAuthoritativeMovementMode(AuthoritativeMovementMode.CLIENT); // can be removed once 1.16.200 support is dropped
 
         SyncedPlayerMovementSettings settings = new SyncedPlayerMovementSettings();
@@ -853,6 +893,46 @@ public class GeyserSession implements CommandSender {
         upstream.sendPacket(startGamePacket);
     }
 
+    /**
+     * Adds a new inventory task.
+     * Inventory tasks are executed one at a time, in order.
+     *
+     * @param task the task to run
+     */
+    public void addInventoryTask(Runnable task) {
+        synchronized (inventoryLock) {
+            inventoryFuture = inventoryFuture.thenRun(task).exceptionally(throwable -> {
+                GeyserConnector.getInstance().getLogger().error("Error processing inventory task", throwable.getCause());
+                return null;
+            });
+        }
+    }
+
+    /**
+     * Adds a new inventory task with a delay.
+     * The delay is achieved by scheduling with the Geyser general thread pool.
+     * Inventory tasks are executed one at a time, in order.
+     *
+     * @param task the delayed task to run
+     * @param delayMillis delay in milliseconds
+     */
+    public void addInventoryTask(Runnable task, long delayMillis) {
+        synchronized (inventoryLock) {
+            Executor delayedExecutor = command -> GeyserConnector.getInstance().getGeneralThreadPool().schedule(command, delayMillis, TimeUnit.MILLISECONDS);
+            inventoryFuture = inventoryFuture.thenRunAsync(task, delayedExecutor).exceptionally(throwable -> {
+                GeyserConnector.getInstance().getLogger().error("Error processing inventory task", throwable.getCause());
+                return null;
+            });
+        }
+    }
+
+    /**
+     * @return the next Bedrock item network ID to use for a new item
+     */
+    public int getNextItemNetId() {
+        return itemNetId.getAndIncrement();
+    }
+
     public void addTeleport(TeleportCache teleportCache) {
         teleportMap.put(teleportCache.getTeleportConfirmId(), teleportCache);
 
diff --git a/connector/src/main/java/org/geysermc/connector/network/session/cache/BookEditCache.java b/connector/src/main/java/org/geysermc/connector/network/session/cache/BookEditCache.java
index f81a9fdf9..c82645dbf 100644
--- a/connector/src/main/java/org/geysermc/connector/network/session/cache/BookEditCache.java
+++ b/connector/src/main/java/org/geysermc/connector/network/session/cache/BookEditCache.java
@@ -25,9 +25,9 @@
 
 package org.geysermc.connector.network.session.cache;
 
-import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
 import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientEditBookPacket;
 import lombok.Setter;
+import org.geysermc.connector.inventory.GeyserItemStack;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.item.ItemRegistry;
 
@@ -63,8 +63,8 @@ public class BookEditCache {
             return;
         }
         // Don't send the update if the player isn't not holding a book, shouldn't happen if we catch all interactions
-        ItemStack itemStack = session.getInventory().getItemInHand();
-        if (itemStack == null || itemStack.getId() != ItemRegistry.WRITABLE_BOOK.getJavaId()) {
+        GeyserItemStack itemStack = session.getPlayerInventory().getItemInHand();
+        if (itemStack == null || itemStack.getJavaId() != ItemRegistry.WRITABLE_BOOK.getJavaId()) {
             packet = null;
             return;
         }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockBookEditTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockBookEditTranslator.java
index dd5d08a2c..8e2d77df7 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockBookEditTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockBookEditTranslator.java
@@ -26,17 +26,16 @@
 package org.geysermc.connector.network.translators.bedrock;
 
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
-import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
 import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientEditBookPacket;
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.github.steveice10.opennbt.tag.builtin.ListTag;
 import com.github.steveice10.opennbt.tag.builtin.StringTag;
 import com.github.steveice10.opennbt.tag.builtin.Tag;
 import com.nukkitx.protocol.bedrock.packet.BookEditPacket;
+import org.geysermc.connector.inventory.GeyserItemStack;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
-import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
 
 import java.util.Collections;
 import java.util.LinkedList;
@@ -47,58 +46,55 @@ public class BedrockBookEditTranslator extends PacketTranslator<BookEditPacket>
 
     @Override
     public void translate(BookEditPacket packet, GeyserSession session) {
-        ItemStack itemStack = session.getInventory().getItemInHand();
+        GeyserItemStack itemStack = session.getPlayerInventory().getItemInHand();
         if (itemStack != null) {
             CompoundTag tag = itemStack.getNbt() != null ? itemStack.getNbt() : new CompoundTag("");
-            ItemStack bookItem = new ItemStack(itemStack.getId(), itemStack.getAmount(), tag);
+            ItemStack bookItem = new ItemStack(itemStack.getJavaId(), itemStack.getAmount(), tag);
             List<Tag> pages = tag.contains("pages") ? new LinkedList<>(((ListTag) tag.get("pages")).getValue()) : new LinkedList<>();
 
             int page = packet.getPageNumber();
-            // Creative edits the NBT for us
-            if (session.getGameMode() != GameMode.CREATIVE) {
-                switch (packet.getAction()) {
-                    case ADD_PAGE: {
+            switch (packet.getAction()) {
+                case ADD_PAGE: {
+                    // Add empty pages in between
+                    for (int i = pages.size(); i < page; i++) {
+                        pages.add(i, new StringTag("", ""));
+                    }
+                    pages.add(page, new StringTag("", packet.getText()));
+                    break;
+                }
+                // Called whenever a page is modified
+                case REPLACE_PAGE: {
+                    if (page < pages.size()) {
+                        pages.set(page, new StringTag("", packet.getText()));
+                    } else {
                         // Add empty pages in between
                         for (int i = pages.size(); i < page; i++) {
                             pages.add(i, new StringTag("", ""));
                         }
                         pages.add(page, new StringTag("", packet.getText()));
-                        break;
                     }
-                    // Called whenever a page is modified
-                    case REPLACE_PAGE: {
-                        if (page < pages.size()) {
-                            pages.set(page, new StringTag("", packet.getText()));
-                        } else {
-                            // Add empty pages in between
-                            for (int i = pages.size(); i < page; i++) {
-                                pages.add(i, new StringTag("", ""));
-                            }
-                            pages.add(page, new StringTag("", packet.getText()));
-                        }
-                        break;
-                    }
-                    case DELETE_PAGE: {
-                        if (page < pages.size()) {
-                            pages.remove(page);
-                        }
-                        break;
-                    }
-                    case SWAP_PAGES: {
-                        int page2 = packet.getSecondaryPageNumber();
-                        if (page < pages.size() && page2 < pages.size()) {
-                            Collections.swap(pages, page, page2);
-                        }
-                        break;
-                    }
-                    case SIGN_BOOK: {
-                        tag.put(new StringTag("author", packet.getAuthor()));
-                        tag.put(new StringTag("title", packet.getTitle()));
-                        break;
-                    }
-                    default:
-                        return;
+                    break;
                 }
+                case DELETE_PAGE: {
+                    if (page < pages.size()) {
+                        pages.remove(page);
+                    }
+                    break;
+                }
+                case SWAP_PAGES: {
+                    int page2 = packet.getSecondaryPageNumber();
+                    if (page < pages.size() && page2 < pages.size()) {
+                        Collections.swap(pages, page, page2);
+                    }
+                    break;
+                }
+                case SIGN_BOOK: {
+                    tag.put(new StringTag("author", packet.getAuthor()));
+                    tag.put(new StringTag("title", packet.getTitle()));
+                    break;
+                }
+                default:
+                    return;
             }
             // Remove empty pages at the end
             while (pages.size() > 0) {
@@ -110,10 +106,10 @@ public class BedrockBookEditTranslator extends PacketTranslator<BookEditPacket>
                 }
             }
             tag.put(new ListTag("pages", pages));
-            session.getInventory().setItem(36 + session.getInventory().getHeldItemSlot(), bookItem);
-            InventoryTranslator.INVENTORY_TRANSLATORS.get(null).updateInventory(session, session.getInventory());
+            session.getPlayerInventory().setItem(36 + session.getPlayerInventory().getHeldItemSlot(), GeyserItemStack.from(bookItem), session);
+            session.getInventoryTranslator().updateInventory(session, session.getPlayerInventory());
 
-            session.getBookEditCache().setPacket(new ClientEditBookPacket(bookItem, packet.getAction() == BookEditPacket.Action.SIGN_BOOK, session.getInventory().getHeldItemSlot()));
+            session.getBookEditCache().setPacket(new ClientEditBookPacket(bookItem, packet.getAction() == BookEditPacket.Action.SIGN_BOOK, session.getPlayerInventory().getHeldItemSlot()));
             // There won't be any more book updates after this, so we can try sending the edit packet immediately
             if (packet.getAction() == BookEditPacket.Action.SIGN_BOOK) {
                 session.getBookEditCache().checkForSend();
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockContainerCloseTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockContainerCloseTranslator.java
index 21eb73be0..21bc1e437 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockContainerCloseTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockContainerCloseTranslator.java
@@ -28,6 +28,7 @@ package org.geysermc.connector.network.translators.bedrock;
 import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientCloseWindowPacket;
 import com.nukkitx.protocol.bedrock.packet.ContainerClosePacket;
 import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.MerchantContainer;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
@@ -38,24 +39,29 @@ public class BedrockContainerCloseTranslator extends PacketTranslator<ContainerC
 
     @Override
     public void translate(ContainerClosePacket packet, GeyserSession session) {
-        session.setLastWindowCloseTime(0);
-        byte windowId = packet.getId();
-        Inventory openInventory = session.getInventoryCache().getOpenInventory();
-        if (windowId == -1) { //player inventory or crafting table
-            if (openInventory != null) {
-                windowId = (byte) openInventory.getId();
-            } else {
-                windowId = 0;
+        session.addInventoryTask(() -> {
+            byte windowId = packet.getId();
+
+            //Client wants close confirmation
+            session.sendUpstreamPacket(packet);
+            session.setClosingInventory(false);
+
+            if (windowId == -1 && session.getOpenInventory() instanceof MerchantContainer) {
+                // 1.16.200 - window ID is always -1 sent from Bedrock
+                windowId = (byte) session.getOpenInventory().getId();
             }
-        }
 
-        if (windowId == 0 || (openInventory != null && openInventory.getId() == windowId)) {
-            ClientCloseWindowPacket closeWindowPacket = new ClientCloseWindowPacket(windowId);
-            session.getDownstream().getSession().send(closeWindowPacket);
-            InventoryUtils.closeInventory(session, windowId);
-        }
-
-        //Client wants close confirmation
-        session.sendUpstreamPacket(packet);
+            Inventory openInventory = session.getOpenInventory();
+            if (openInventory != null) {
+                if (windowId == openInventory.getId()) {
+                    ClientCloseWindowPacket closeWindowPacket = new ClientCloseWindowPacket(windowId);
+                    session.sendDownstreamPacket(closeWindowPacket);
+                    InventoryUtils.closeInventory(session, windowId, false);
+                } else if (openInventory.isPending()) {
+                    InventoryUtils.displayInventory(session, openInventory);
+                    openInventory.setPending(false);
+                }
+            }
+        });
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockFilterTextTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockFilterTextTranslator.java
index db01df150..3b017dfbd 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockFilterTextTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockFilterTextTranslator.java
@@ -25,7 +25,10 @@
 
 package org.geysermc.connector.network.translators.bedrock;
 
+import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientRenameItemPacket;
 import com.nukkitx.protocol.bedrock.packet.FilterTextPacket;
+import org.geysermc.connector.inventory.AnvilContainer;
+import org.geysermc.connector.inventory.CartographyContainer;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
@@ -39,7 +42,17 @@ public class BedrockFilterTextTranslator extends PacketTranslator<FilterTextPack
 
     @Override
     public void translate(FilterTextPacket packet, GeyserSession session) {
+        if (session.getOpenInventory() instanceof CartographyContainer) {
+            // We don't want to be able to rename in the cartography table
+            return;
+        }
         packet.setFromServer(true);
         session.sendUpstreamPacket(packet);
+
+        if (session.getOpenInventory() instanceof AnvilContainer) {
+            // Java Edition sends a packet every time an item is renamed even slightly in GUI. Fortunately, this works out for us now
+            ClientRenameItemPacket renameItemPacket = new ClientRenameItemPacket(packet.getText());
+            session.sendDownstreamPacket(renameItemPacket);
+        }
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockInventoryTransactionTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockInventoryTransactionTranslator.java
index 9b8b5b668..5258219ba 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockInventoryTransactionTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockInventoryTransactionTranslator.java
@@ -25,7 +25,6 @@
 
 package org.geysermc.connector.network.translators.bedrock;
 
-import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.Position;
 import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
 import com.github.steveice10.mc.protocol.data.game.entity.player.Hand;
@@ -43,23 +42,22 @@ import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlags;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerId;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
+import com.nukkitx.protocol.bedrock.data.inventory.InventoryActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.InventorySource;
 import com.nukkitx.protocol.bedrock.packet.*;
 import org.geysermc.connector.entity.CommandBlockMinecartEntity;
 import org.geysermc.connector.entity.Entity;
 import org.geysermc.connector.entity.ItemFrameEntity;
-import org.geysermc.connector.entity.living.merchant.AbstractMerchantEntity;
 import org.geysermc.connector.entity.type.EntityType;
-import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.GeyserItemStack;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
-import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
 import org.geysermc.connector.network.translators.item.ItemEntry;
 import org.geysermc.connector.network.translators.item.ItemRegistry;
 import org.geysermc.connector.network.translators.sound.EntitySoundInteractionHandler;
 import org.geysermc.connector.network.translators.world.block.BlockTranslator;
 import org.geysermc.connector.utils.BlockUtils;
-import org.geysermc.connector.utils.InventoryUtils;
 
 import java.util.concurrent.TimeUnit;
 
@@ -82,15 +80,35 @@ public class BedrockInventoryTransactionTranslator extends PacketTranslator<Inve
 
         switch (packet.getTransactionType()) {
             case NORMAL:
-                Inventory inventory = session.getInventoryCache().getOpenInventory();
-                if (inventory == null) inventory = session.getInventory();
-                InventoryTranslator.INVENTORY_TRANSLATORS.get(inventory.getWindowType()).translateActions(session, inventory, packet.getActions());
+                if (packet.getActions().size() == 2) {
+                    InventoryActionData worldAction = packet.getActions().get(0);
+                    InventoryActionData containerAction = packet.getActions().get(1);
+                    if (worldAction.getSource().getType() == InventorySource.Type.WORLD_INTERACTION
+                            && worldAction.getSource().getFlag() == InventorySource.Flag.DROP_ITEM) {
+                        session.addInventoryTask(() -> {
+                            if (session.getPlayerInventory().getHeldItemSlot() != containerAction.getSlot() ||
+                                    session.getPlayerInventory().getItemInHand().isEmpty()) {
+                                return;
+                            }
+
+                            boolean dropAll = worldAction.getToItem().getCount() > 1;
+                            ClientPlayerActionPacket dropAllPacket = new ClientPlayerActionPacket(
+                                    dropAll ? PlayerAction.DROP_ITEM_STACK : PlayerAction.DROP_ITEM,
+                                    new Position(0, 0, 0),
+                                    BlockFace.DOWN
+                            );
+                            session.sendDownstreamPacket(dropAllPacket);
+
+                            if (dropAll) {
+                                session.getPlayerInventory().setItemInHand(GeyserItemStack.EMPTY);
+                            } else {
+                                session.getPlayerInventory().getItemInHand().sub(1);
+                            }
+                        });
+                    }
+                }
                 break;
             case INVENTORY_MISMATCH:
-                Inventory inv = session.getInventoryCache().getOpenInventory();
-                if (inv == null) inv = session.getInventory();
-                InventoryTranslator.INVENTORY_TRANSLATORS.get(inv.getWindowType()).updateInventory(session, inv);
-                InventoryUtils.updateCursor(session);
                 break;
             case ITEM_USE:
                 switch (packet.getActionType()) {
@@ -98,8 +116,9 @@ public class BedrockInventoryTransactionTranslator extends PacketTranslator<Inve
                         // Check to make sure the client isn't spamming interaction
                         // Based on Nukkit 1.0, with changes to ensure holding down still works
                         boolean hasAlreadyClicked = System.currentTimeMillis() - session.getLastInteractionTime() < 110.0 &&
-                                packet.getBlockPosition().distanceSquared(session.getLastInteractionPosition()) < 0.00001;
-                        session.setLastInteractionPosition(packet.getBlockPosition());
+                                packet.getBlockPosition().distanceSquared(session.getLastInteractionBlockPosition()) < 0.00001;
+                        session.setLastInteractionBlockPosition(packet.getBlockPosition());
+                        session.setLastInteractionPlayerPosition(session.getPlayerEntity().getPosition());
                         if (hasAlreadyClicked) {
                             break;
                         } else {
@@ -215,9 +234,8 @@ public class BedrockInventoryTransactionTranslator extends PacketTranslator<Inve
                         session.setInteracting(true);
                         break;
                     case 1:
-                        ItemStack shieldSlot = session.getInventory().getItem(session.getInventory().getHeldItemSlot() + 36);
                         // Handled in Entity.java
-                        if (shieldSlot != null && shieldSlot.getId() == ItemRegistry.SHIELD.getJavaId()) {
+                        if (session.getPlayerInventory().getItemInHand().getJavaId() == ItemRegistry.SHIELD.getJavaId()) {
                             break;
                         }
 
@@ -308,10 +326,6 @@ public class BedrockInventoryTransactionTranslator extends PacketTranslator<Inve
                         session.sendDownstreamPacket(interactAtPacket);
 
                         EntitySoundInteractionHandler.handleEntityInteraction(session, vector, entity);
-
-                        if (entity instanceof AbstractMerchantEntity) {
-                            session.setLastInteractedVillagerEid(packet.getRuntimeEntityId());
-                        }
                         break;
                     case 1: //Attack
                         if (entity.getEntityType() == EntityType.ENDER_DRAGON) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockItemStackRequestTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockItemStackRequestTranslator.java
new file mode 100644
index 000000000..bdbb88eee
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockItemStackRequestTranslator.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.bedrock;
+
+import com.nukkitx.protocol.bedrock.packet.ItemStackRequestPacket;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.PacketTranslator;
+import org.geysermc.connector.network.translators.Translator;
+import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
+
+/**
+ * The packet sent for server-authoritative-style inventory transactions.
+ */
+@Translator(packet = ItemStackRequestPacket.class)
+public class BedrockItemStackRequestTranslator extends PacketTranslator<ItemStackRequestPacket> {
+
+    @Override
+    public void translate(ItemStackRequestPacket packet, GeyserSession session) {
+        Inventory inventory = session.getOpenInventory();
+        if (inventory == null)
+            return;
+
+        InventoryTranslator translator = session.getInventoryTranslator();
+        session.addInventoryTask(() -> translator.translateRequests(session, inventory, packet.getRequests()));
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockLecternUpdateTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockLecternUpdateTranslator.java
new file mode 100644
index 000000000..99dcebed9
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockLecternUpdateTranslator.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.bedrock;
+
+import com.github.steveice10.mc.protocol.data.game.entity.metadata.Position;
+import com.github.steveice10.mc.protocol.data.game.entity.player.Hand;
+import com.github.steveice10.mc.protocol.data.game.world.block.BlockFace;
+import com.github.steveice10.mc.protocol.packet.ingame.client.player.ClientPlayerPlaceBlockPacket;
+import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientClickWindowButtonPacket;
+import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientCloseWindowPacket;
+import com.nukkitx.protocol.bedrock.packet.LecternUpdatePacket;
+import org.geysermc.connector.inventory.LecternContainer;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.PacketTranslator;
+import org.geysermc.connector.network.translators.Translator;
+import org.geysermc.connector.utils.InventoryUtils;
+
+/**
+ * Used to translate moving pages, or closing the inventory
+ */
+@Translator(packet = LecternUpdatePacket.class)
+public class BedrockLecternUpdateTranslator extends PacketTranslator<LecternUpdatePacket> {
+
+    @Override
+    public void translate(LecternUpdatePacket packet, GeyserSession session) {
+        if (packet.isDroppingBook()) {
+            // Bedrock drops the book outside of the GUI. Java drops it in the GUI
+            // So, we enter the GUI and then drop it! :)
+            session.setDroppingLecternBook(true);
+
+            // Emulate an interact packet
+            ClientPlayerPlaceBlockPacket blockPacket = new ClientPlayerPlaceBlockPacket(
+                    new Position(packet.getBlockPosition().getX(), packet.getBlockPosition().getY(), packet.getBlockPosition().getZ()),
+                    BlockFace.values()[0],
+                    Hand.MAIN_HAND,
+                    packet.getBlockPosition().getX(), packet.getBlockPosition().getY(), packet.getBlockPosition().getZ(), //TODO
+                    false);
+            session.sendDownstreamPacket(blockPacket);
+        } else {
+            // Bedrock wants to either move a page or exit
+            if (!(session.getOpenInventory() instanceof LecternContainer)) {
+                session.getConnector().getLogger().debug("Expected lectern but it wasn't open!");
+                return;
+            }
+            LecternContainer lecternContainer = (LecternContainer) session.getOpenInventory();
+            if (lecternContainer.getCurrentBedrockPage() == packet.getPage()) {
+                // The same page means Bedrock is closing the window
+                ClientCloseWindowPacket closeWindowPacket = new ClientCloseWindowPacket(lecternContainer.getId());
+                session.sendDownstreamPacket(closeWindowPacket);
+                InventoryUtils.closeInventory(session, lecternContainer.getId(), false);
+            } else {
+                // Each "page" Bedrock gives to us actually represents two pages (think opening a book and seeing two pages)
+                // Each "page" on Java is just one page (think a spiral notebook folded back to only show one page)
+                int newJavaPage = (packet.getPage() * 2);
+                int currentJavaPage = (lecternContainer.getCurrentBedrockPage() * 2);
+                // Send as many click button packets as we need to
+                // Java has the option to specify exact page numbers by adding 100 to the number, but buttonId variable
+                // is a byte and therefore this stops us at 128
+                if (newJavaPage > currentJavaPage) {
+                    for (int i = currentJavaPage; i < newJavaPage; i++) {
+                        ClientClickWindowButtonPacket clickButtonPacket = new ClientClickWindowButtonPacket(session.getOpenInventory().getId(), 2);
+                        session.sendDownstreamPacket(clickButtonPacket);
+                    }
+                } else {
+                    for (int i = currentJavaPage; i > newJavaPage; i--) {
+                        ClientClickWindowButtonPacket clickButtonPacket = new ClientClickWindowButtonPacket(session.getOpenInventory().getId(), 1);
+                        session.sendDownstreamPacket(clickButtonPacket);
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockMobEquipmentTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockMobEquipmentTranslator.java
index 47fb97027..3ffc2a8f3 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockMobEquipmentTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockMobEquipmentTranslator.java
@@ -40,7 +40,7 @@ public class BedrockMobEquipmentTranslator extends PacketTranslator<MobEquipment
     @Override
     public void translate(MobEquipmentPacket packet, GeyserSession session) {
         if (!session.isSpawned() || packet.getHotbarSlot() > 8 ||
-                packet.getContainerId() != ContainerId.INVENTORY || session.getInventory().getHeldItemSlot() == packet.getHotbarSlot()) {
+                packet.getContainerId() != ContainerId.INVENTORY || session.getPlayerInventory().getHeldItemSlot() == packet.getHotbarSlot()) {
             // For the last condition - Don't update the slot if the slot is the same - not Java Edition behavior and messes with plugins such as Grief Prevention
             return;
         }
@@ -48,7 +48,7 @@ public class BedrockMobEquipmentTranslator extends PacketTranslator<MobEquipment
         // Send book update before switching hotbar slot
         session.getBookEditCache().checkForSend();
 
-        session.getInventory().setHeldItemSlot(packet.getHotbarSlot());
+        session.getPlayerInventory().setHeldItemSlot(packet.getHotbarSlot());
 
         ClientPlayerChangeHeldItemPacket changeHeldItemPacket = new ClientPlayerChangeHeldItemPacket(packet.getHotbarSlot());
         session.sendDownstreamPacket(changeHeldItemPacket);
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/entity/BedrockEntityEventTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/entity/BedrockEntityEventTranslator.java
index a89bfdfb4..6267597fd 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/entity/BedrockEntityEventTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/entity/BedrockEntityEventTranslator.java
@@ -26,12 +26,13 @@
 package org.geysermc.connector.network.translators.bedrock.entity;
 
 import com.github.steveice10.mc.protocol.data.game.window.VillagerTrade;
-import com.github.steveice10.mc.protocol.data.game.window.WindowType;
 import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientSelectTradePacket;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import com.nukkitx.protocol.bedrock.packet.EntityEventPacket;
 import org.geysermc.connector.entity.Entity;
+import org.geysermc.connector.inventory.GeyserItemStack;
 import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.MerchantContainer;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
@@ -47,20 +48,25 @@ public class BedrockEntityEventTranslator extends PacketTranslator<EntityEventPa
                 session.sendUpstreamPacket(packet);
                 return;
             case COMPLETE_TRADE:
-                ClientSelectTradePacket selectTradePacket = new ClientSelectTradePacket(packet.getData());
-                session.sendDownstreamPacket(selectTradePacket);
+                session.addInventoryTask(() -> {
+                    ClientSelectTradePacket selectTradePacket = new ClientSelectTradePacket(packet.getData());
+                    session.sendDownstreamPacket(selectTradePacket);
+                });
 
-                Entity villager = session.getPlayerEntity();
-                Inventory openInventory = session.getInventoryCache().getOpenInventory();
-                if (openInventory != null && openInventory.getWindowType() == WindowType.MERCHANT) {
-                    VillagerTrade[] trades = session.getVillagerTrades();
-                    if (trades != null && packet.getData() >= 0 && packet.getData() < trades.length) {
-                        VillagerTrade trade = session.getVillagerTrades()[packet.getData()];
-                        openInventory.setItem(2, trade.getOutput());
-                        villager.getMetadata().put(EntityData.TRADE_XP, trade.getXp() + villager.getMetadata().getInt(EntityData.TRADE_XP));
-                        villager.updateBedrockMetadata(session);
+                session.addInventoryTask(() -> {
+                    Entity villager = session.getPlayerEntity();
+                    Inventory openInventory = session.getOpenInventory();
+                    if (openInventory instanceof MerchantContainer) {
+                        MerchantContainer merchantInventory = (MerchantContainer) openInventory;
+                        VillagerTrade[] trades = merchantInventory.getVillagerTrades();
+                        if (trades != null && packet.getData() >= 0 && packet.getData() < trades.length) {
+                            VillagerTrade trade = merchantInventory.getVillagerTrades()[packet.getData()];
+                            openInventory.setItem(2, GeyserItemStack.from(trade.getOutput()), session);
+                            villager.getMetadata().put(EntityData.TRADE_XP, trade.getXp() + villager.getMetadata().getInt(EntityData.TRADE_XP));
+                            villager.updateBedrockMetadata(session);
+                        }
                     }
-                }
+                }, 100);
                 return;
         }
         session.getConnector().getLogger().debug("Did not translate incoming EntityEventPacket: " + packet.toString());
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/entity/player/BedrockActionTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/entity/player/BedrockActionTranslator.java
index e837a036a..f0bbbeada 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/entity/player/BedrockActionTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/entity/player/BedrockActionTranslator.java
@@ -25,7 +25,6 @@
 
 package org.geysermc.connector.network.translators.bedrock.entity.player;
 
-import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.Position;
 import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
 import com.github.steveice10.mc.protocol.data.game.entity.player.PlayerAction;
@@ -44,12 +43,12 @@ import com.nukkitx.protocol.bedrock.packet.LevelEventPacket;
 import com.nukkitx.protocol.bedrock.packet.PlayStatusPacket;
 import com.nukkitx.protocol.bedrock.packet.PlayerActionPacket;
 import org.geysermc.connector.entity.Entity;
+import org.geysermc.connector.inventory.GeyserItemStack;
 import org.geysermc.connector.inventory.PlayerInventory;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
 import org.geysermc.connector.network.translators.item.ItemEntry;
-import org.geysermc.connector.network.translators.item.ItemRegistry;
 import org.geysermc.connector.network.translators.world.block.BlockTranslator;
 import org.geysermc.connector.utils.BlockUtils;
 
@@ -144,12 +143,12 @@ public class BedrockActionTranslator extends PacketTranslator<PlayerActionPacket
                         LevelEventPacket startBreak = new LevelEventPacket();
                         startBreak.setType(LevelEventType.BLOCK_START_BREAK);
                         startBreak.setPosition(vector.toFloat());
-                        PlayerInventory inventory = session.getInventory();
-                        ItemStack item = inventory.getItemInHand();
+                        PlayerInventory inventory = session.getPlayerInventory();
+                        GeyserItemStack item = inventory.getItemInHand();
                         ItemEntry itemEntry = null;
                         CompoundTag nbtData = new CompoundTag("");
                         if (item != null) {
-                            itemEntry = ItemRegistry.getItem(item);
+                            itemEntry = item.getItemEntry();
                             nbtData = item.getNbt();
                         }
                         double breakTime = Math.ceil(BlockUtils.getBreakTime(blockHardness, blockState, itemEntry, nbtData, session) * 20);
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/entity/player/BedrockInteractTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/entity/player/BedrockInteractTranslator.java
index c08a2f311..ca71a1975 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/entity/player/BedrockInteractTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/entity/player/BedrockInteractTranslator.java
@@ -38,7 +38,10 @@ import com.nukkitx.protocol.bedrock.packet.ContainerOpenPacket;
 import com.nukkitx.protocol.bedrock.packet.InteractPacket;
 import lombok.Getter;
 import org.geysermc.connector.entity.Entity;
+import org.geysermc.connector.entity.living.animal.horse.AbstractHorseEntity;
+import org.geysermc.connector.entity.living.animal.horse.HorseEntity;
 import org.geysermc.connector.entity.type.EntityType;
+import org.geysermc.connector.inventory.GeyserItemStack;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
@@ -98,7 +101,7 @@ public class BedrockInteractTranslator extends PacketTranslator<InteractPacket>
 
         switch (packet.getAction()) {
             case INTERACT:
-                if (session.getInventory().getItem(session.getInventory().getHeldItemSlot() + 36).getId() == ItemRegistry.SHIELD.getJavaId()) {
+                if (session.getPlayerInventory().getItemInHand().getJavaId() == ItemRegistry.SHIELD.getJavaId()) {
                     break;
                 }
                 ClientPlayerInteractEntityPacket interactPacket = new ClientPlayerInteractEntityPacket((int) entity.getEntityId(),
@@ -122,7 +125,7 @@ public class BedrockInteractTranslator extends PacketTranslator<InteractPacket>
                     if (interactEntity == null)
                         return;
                     EntityDataMap entityMetadata = interactEntity.getMetadata();
-                    ItemEntry itemEntry = session.getInventory().getItemInHand() == null ? ItemEntry.AIR : ItemRegistry.getItem(session.getInventory().getItemInHand());
+                    ItemEntry itemEntry = session.getPlayerInventory().getItemInHand() == GeyserItemStack.EMPTY ? ItemEntry.AIR : ItemRegistry.getItem(session.getPlayerInventory().getItemInHand().getItemStack());
                     String javaIdentifierStripped = itemEntry.getJavaIdentifier().replace("minecraft:", "");
 
                     // TODO - in the future, update these in the metadata? So the client doesn't have to wiggle their cursor around for it to happen
@@ -136,8 +139,8 @@ public class BedrockInteractTranslator extends PacketTranslator<InteractPacket>
                             interactEntity.getEntityType() == EntityType.PIG || interactEntity.getEntityType() == EntityType.STRIDER)) {
                         // Entity can be saddled and the conditions meet (entity can be saddled and, if needed, is tamed)
                         interactiveTag = InteractiveTag.SADDLE;
-                    } else if (javaIdentifierStripped.equals("name_tag") && session.getInventory().getItemInHand().getNbt() != null &&
-                        session.getInventory().getItemInHand().getNbt().contains("display")) {
+                    } else if (javaIdentifierStripped.equals("name_tag") && session.getPlayerInventory().getItemInHand().getNbt() != null &&
+                        session.getPlayerInventory().getItemInHand().getNbt().contains("display")) {
                         // Holding a named name tag
                         interactiveTag = InteractiveTag.NAME;
                     } else if (javaIdentifierStripped.equals("lead") && LEASHABLE_MOB_TYPES.contains(interactEntity.getEntityType()) &&
@@ -210,6 +213,11 @@ public class BedrockInteractTranslator extends PacketTranslator<InteractPacket>
                             case SKELETON_HORSE:
                             case TRADER_LLAMA:
                             case ZOMBIE_HORSE:
+                                boolean tamed = entityMetadata.getFlags().getFlag(EntityFlag.TAMED);
+                                if (session.isSneaking() && tamed && (interactEntity instanceof HorseEntity || entityMetadata.getFlags().getFlag(EntityFlag.CHESTED))) {
+                                    interactiveTag = InteractiveTag.OPEN_CONTAINER;
+                                    break;
+                                }
                                 // have another switch statement as, while these share mount attributes they don't share food
                                 switch (interactEntity.getEntityType()) {
                                     case LLAMA:
@@ -228,9 +236,9 @@ public class BedrockInteractTranslator extends PacketTranslator<InteractPacket>
                                 }
                                 if (!entityMetadata.getFlags().getFlag(EntityFlag.BABY)) {
                                     // Can't ride a baby
-                                    if (entityMetadata.getFlags().getFlag(EntityFlag.TAMED)) {
+                                    if (tamed) {
                                         interactiveTag = InteractiveTag.RIDE_HORSE;
-                                    } else if (!entityMetadata.getFlags().getFlag(EntityFlag.TAMED) && itemEntry.equals(ItemEntry.AIR)) {
+                                    } else if (itemEntry.equals(ItemEntry.AIR)) {
                                         // Can't hide an untamed entity without having your hand empty
                                         interactiveTag = InteractiveTag.MOUNT;
                                     }
@@ -349,20 +357,30 @@ public class BedrockInteractTranslator extends PacketTranslator<InteractPacket>
                 } else {
                     if (!session.getPlayerEntity().getMetadata().getString(EntityData.INTERACTIVE_TAG).isEmpty()) {
                         // No interactive tag should be sent
-                        session.getPlayerEntity().getMetadata().remove(EntityData.INTERACTIVE_TAG);
+                        session.getPlayerEntity().getMetadata().put(EntityData.INTERACTIVE_TAG, "");
                         session.getPlayerEntity().updateBedrockMetadata(session);
                     }
                 }
                 break;
             case OPEN_INVENTORY:
-                if (!session.getInventory().isOpen()) {
-                    ContainerOpenPacket containerOpenPacket = new ContainerOpenPacket();
-                    containerOpenPacket.setId((byte) 0);
-                    containerOpenPacket.setType(ContainerType.INVENTORY);
-                    containerOpenPacket.setUniqueEntityId(-1);
-                    containerOpenPacket.setBlockPosition(entity.getPosition().toInt());
-                    session.sendUpstreamPacket(containerOpenPacket);
-                    session.getInventory().setOpen(true);
+                if (session.getOpenInventory() == null) {
+                    Entity ridingEntity = session.getRidingVehicleEntity();
+                    if (ridingEntity instanceof AbstractHorseEntity) {
+                        if (ridingEntity.getMetadata().getFlags().getFlag(EntityFlag.TAMED)) {
+                            // We should request to open the horse inventory instead
+                            ClientPlayerStatePacket openHorseWindowPacket = new ClientPlayerStatePacket((int)session.getPlayerEntity().getEntityId(), PlayerState.OPEN_HORSE_INVENTORY);
+                            session.sendDownstreamPacket(openHorseWindowPacket);
+                        }
+                    } else {
+                        session.setOpenInventory(session.getPlayerInventory());
+
+                        ContainerOpenPacket containerOpenPacket = new ContainerOpenPacket();
+                        containerOpenPacket.setId((byte) 0);
+                        containerOpenPacket.setType(ContainerType.INVENTORY);
+                        containerOpenPacket.setUniqueEntityId(-1);
+                        containerOpenPacket.setBlockPosition(entity.getPosition().toInt());
+                        session.sendUpstreamPacket(containerOpenPacket);
+                    }
                 }
                 break;
         }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/AnvilInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/AnvilInventoryTranslator.java
deleted file mode 100644
index fb487bea2..000000000
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/AnvilInventoryTranslator.java
+++ /dev/null
@@ -1,167 +0,0 @@
-/*
- * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- *
- * @author GeyserMC
- * @link https://github.com/GeyserMC/Geyser
- */
-
-package org.geysermc.connector.network.translators.inventory;
-
-import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
-import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientRenameItemPacket;
-import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
-import com.google.gson.JsonSyntaxException;
-import com.nukkitx.nbt.NbtMap;
-import com.nukkitx.protocol.bedrock.data.inventory.*;
-import net.kyori.adventure.text.Component;
-import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
-import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
-import org.geysermc.connector.inventory.Inventory;
-import org.geysermc.connector.network.session.GeyserSession;
-import org.geysermc.connector.network.translators.inventory.updater.CursorInventoryUpdater;
-
-import java.util.List;
-import java.util.stream.Collectors;
-
-public class AnvilInventoryTranslator extends BlockInventoryTranslator {
-    public AnvilInventoryTranslator() {
-        super(3, "minecraft:anvil[facing=north]", ContainerType.ANVIL, new CursorInventoryUpdater());
-    }
-
-    @Override
-    public int bedrockSlotToJava(InventoryActionData action) {
-        if (action.getSource().getContainerId() == ContainerId.UI) {
-            switch (action.getSlot()) {
-                case 1:
-                    return 0;
-                case 2:
-                    return 1;
-                case 50:
-                    return 2;
-            }
-        }
-        if (action.getSource().getContainerId() == ContainerId.ANVIL_RESULT) {
-            return 2;
-        }
-        return super.bedrockSlotToJava(action);
-    }
-
-    @Override
-    public int javaSlotToBedrock(int slot) {
-        switch (slot) {
-            case 0:
-                return 1;
-            case 1:
-                return 2;
-            case 2:
-                return 50;
-        }
-        return super.javaSlotToBedrock(slot);
-    }
-
-    @Override
-    public SlotType getSlotType(int javaSlot) {
-        if (javaSlot == 2)
-            return SlotType.OUTPUT;
-        return SlotType.NORMAL;
-    }
-
-    @Override
-    public void translateActions(GeyserSession session, Inventory inventory, List<InventoryActionData> actions) {
-        InventoryActionData anvilResult = null;
-        InventoryActionData anvilInput = null;
-        for (InventoryActionData action : actions) {
-            if (action.getSource().getContainerId() == ContainerId.ANVIL_MATERIAL) {
-                //useless packet
-                return;
-            } else if (action.getSource().getContainerId() == ContainerId.ANVIL_RESULT) {
-                anvilResult = action;
-            } else if (bedrockSlotToJava(action) == 0) {
-                anvilInput = action;
-            }
-        }
-        ItemData itemName = null;
-        if (anvilResult != null) {
-            itemName = anvilResult.getFromItem();
-        } else if (anvilInput != null) {
-            itemName = anvilInput.getToItem();
-        }
-        if (itemName != null) {
-            String rename;
-            NbtMap tag = itemName.getTag();
-            if (tag != null && tag.containsKey("display")) {
-                String name = tag.getCompound("display").getString("Name");
-                try {
-                    Component component = GsonComponentSerializer.gson().deserialize(name);
-                    rename = LegacyComponentSerializer.legacySection().serialize(component);
-                } catch (JsonSyntaxException e) {
-                    rename = name;
-                }
-            } else {
-                rename = "";
-            }
-            ClientRenameItemPacket renameItemPacket = new ClientRenameItemPacket(rename);
-            session.sendDownstreamPacket(renameItemPacket);
-        }
-        if (anvilResult != null) {
-            //Strip unnecessary actions
-            List<InventoryActionData> strippedActions = actions.stream()
-                    .filter(action -> action.getSource().getContainerId() == ContainerId.ANVIL_RESULT
-                            || (action.getSource().getType() == InventorySource.Type.CONTAINER
-                            && !(action.getSource().getContainerId() == ContainerId.UI && action.getSlot() != 0)))
-                    .collect(Collectors.toList());
-            super.translateActions(session, inventory, strippedActions);
-            return;
-        }
-
-        super.translateActions(session, inventory, actions);
-    }
-
-    @Override
-    public void updateSlot(GeyserSession session, Inventory inventory, int slot) {
-        if (slot == 0) {
-            ItemStack item = inventory.getItem(slot);
-            if (item != null) {
-                String rename;
-                CompoundTag tag = item.getNbt();
-                if (tag != null) {
-                    CompoundTag displayTag = tag.get("display");
-                    if (displayTag != null && displayTag.contains("Name")) {
-                        String itemName = displayTag.get("Name").getValue().toString();
-                        try {
-                            Component component = GsonComponentSerializer.gson().deserialize(itemName);
-                            rename = LegacyComponentSerializer.legacySection().serialize(component);
-                        } catch (JsonSyntaxException e) {
-                            rename = itemName;
-                        }
-                    } else {
-                        rename = "";
-                    }
-                } else {
-                    rename = "";
-                }
-                ClientRenameItemPacket renameItemPacket = new ClientRenameItemPacket(rename);
-                session.sendDownstreamPacket(renameItemPacket);
-            }
-        }
-        super.updateSlot(session, inventory, slot);
-    }
-}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/BedrockContainerSlot.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/BedrockContainerSlot.java
new file mode 100644
index 000000000..47d1f0709
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/BedrockContainerSlot.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory;
+
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import lombok.Value;
+
+@Value
+public class BedrockContainerSlot {
+    ContainerSlotType container;
+    int slot;
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/EnchantmentInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/EnchantmentInventoryTranslator.java
deleted file mode 100644
index b7b98bf73..000000000
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/EnchantmentInventoryTranslator.java
+++ /dev/null
@@ -1,264 +0,0 @@
-/*
- * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- *
- * @author GeyserMC
- * @link https://github.com/GeyserMC/Geyser
- */
-
-package org.geysermc.connector.network.translators.inventory;
-
-import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientClickWindowButtonPacket;
-import com.nukkitx.nbt.NbtMap;
-import com.nukkitx.nbt.NbtMapBuilder;
-import com.nukkitx.nbt.NbtType;
-import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
-import com.nukkitx.protocol.bedrock.data.inventory.InventoryActionData;
-import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
-import com.nukkitx.protocol.bedrock.packet.InventoryContentPacket;
-import com.nukkitx.protocol.bedrock.packet.InventorySlotPacket;
-import lombok.Getter;
-import lombok.NoArgsConstructor;
-import lombok.Setter;
-import lombok.ToString;
-import org.geysermc.connector.common.ChatColor;
-import org.geysermc.connector.inventory.Inventory;
-import org.geysermc.connector.network.session.GeyserSession;
-import org.geysermc.connector.network.translators.inventory.updater.InventoryUpdater;
-import org.geysermc.connector.network.translators.item.ItemRegistry;
-import org.geysermc.connector.network.translators.item.ItemTranslator;
-import org.geysermc.connector.utils.InventoryUtils;
-import org.geysermc.connector.utils.LocaleUtils;
-
-import java.util.*;
-
-/**
- * A temporary reconstruction of the enchantment table UI until our inventory rewrite is complete.
- * The enchantment table on Bedrock without server authoritative inventories doesn't tell us which button is pressed
- * when selecting an enchantment.
- */
-public class EnchantmentInventoryTranslator extends BlockInventoryTranslator {
-
-    private static final int DYE_ID = ItemRegistry.getItemEntry("minecraft:lapis_lazuli").getBedrockId();
-    private static final int ENCHANTED_BOOK_ID = ItemRegistry.getItemEntry("minecraft:enchanted_book").getBedrockId();
-
-    public EnchantmentInventoryTranslator(InventoryUpdater updater) {
-        super(2, "minecraft:hopper[enabled=false,facing=down]", ContainerType.HOPPER, updater);
-    }
-
-    @Override
-    public void translateActions(GeyserSession session, Inventory inventory, List<InventoryActionData> actions) {
-        for (InventoryActionData action : actions) {
-            if (action.getSource().getContainerId() == inventory.getId()) {
-                // This is the hopper UI
-                switch (action.getSlot()) {
-                    case 1:
-                        // Don't allow the slot to be put through if the item isn't lapis
-                        if ((action.getToItem().getId() != DYE_ID) && action.getToItem() != ItemData.AIR) {
-                            updateInventory(session, inventory);
-                            InventoryUtils.updateCursor(session);
-                            return;
-                        }
-                        break;
-                    case 2:
-                    case 3:
-                    case 4:
-                        // The books here act as buttons
-                        ClientClickWindowButtonPacket packet = new ClientClickWindowButtonPacket(inventory.getId(), action.getSlot() - 2);
-                        session.sendDownstreamPacket(packet);
-                        updateInventory(session, inventory);
-                        InventoryUtils.updateCursor(session);
-                        return;
-                    default:
-                        break;
-                }
-            }
-        }
-
-        super.translateActions(session, inventory, actions);
-    }
-
-    @Override
-    public void updateInventory(GeyserSession session, Inventory inventory) {
-        super.updateInventory(session, inventory);
-        List<ItemData> items = new ArrayList<>(5);
-        items.add(ItemTranslator.translateToBedrock(session, inventory.getItem(0)));
-        items.add(ItemTranslator.translateToBedrock(session, inventory.getItem(1)));
-        for (int i = 0; i < 3; i++) {
-            items.add(session.getEnchantmentSlotData()[i].getItem() != null ? session.getEnchantmentSlotData()[i].getItem() : createEnchantmentBook());
-        }
-
-        InventoryContentPacket contentPacket = new InventoryContentPacket();
-        contentPacket.setContainerId(inventory.getId());
-        contentPacket.setContents(items);
-        session.sendUpstreamPacket(contentPacket);
-    }
-
-    @Override
-    public void updateProperty(GeyserSession session, Inventory inventory, int key, int value) {
-        int bookSlotToUpdate;
-        switch (key) {
-            case 0:
-            case 1:
-            case 2:
-                // Experience required
-                bookSlotToUpdate = key;
-                session.getEnchantmentSlotData()[bookSlotToUpdate].setExperienceRequired(value);
-                break;
-            case 4:
-            case 5:
-            case 6:
-                // Enchantment name
-                bookSlotToUpdate = key - 4;
-                if (value != -1) {
-                    session.getEnchantmentSlotData()[bookSlotToUpdate].setEnchantmentType(EnchantmentTableEnchantments.values()[value - 1]);
-                } else {
-                    // -1 means no enchantment specified
-                    session.getEnchantmentSlotData()[bookSlotToUpdate].setEnchantmentType(null);
-                }
-                break;
-            case 7:
-            case 8:
-            case 9:
-                // Enchantment level
-                bookSlotToUpdate = key - 7;
-                session.getEnchantmentSlotData()[bookSlotToUpdate].setEnchantmentLevel(value);
-                break;
-            default:
-                return;
-        }
-        updateEnchantmentBook(session, inventory, bookSlotToUpdate);
-    }
-
-    @Override
-    public void openInventory(GeyserSession session, Inventory inventory) {
-        super.openInventory(session, inventory);
-        for (int i = 0; i < session.getEnchantmentSlotData().length; i++) {
-            session.getEnchantmentSlotData()[i] = new EnchantmentSlotData();
-        }
-    }
-
-    @Override
-    public void closeInventory(GeyserSession session, Inventory inventory) {
-        super.closeInventory(session, inventory);
-        Arrays.fill(session.getEnchantmentSlotData(), null);
-    }
-
-    private ItemData createEnchantmentBook() {
-        NbtMapBuilder root = NbtMap.builder();
-        NbtMapBuilder display = NbtMap.builder();
-
-        display.putString("Name", ChatColor.RESET + "No Enchantment");
-
-        root.put("display", display.build());
-        return ItemData.of(ENCHANTED_BOOK_ID, (short) 0, 1, root.build());
-    }
-
-    private void updateEnchantmentBook(GeyserSession session, Inventory inventory, int slot) {
-        NbtMapBuilder root = NbtMap.builder();
-        NbtMapBuilder display = NbtMap.builder();
-        EnchantmentSlotData data = session.getEnchantmentSlotData()[slot];
-        if (data.getEnchantmentType() != null) {
-            display.putString("Name", ChatColor.ITALIC + data.getEnchantmentType().toString(session) +
-                    (data.getEnchantmentLevel() != -1 ? " " + toRomanNumeral(session, data.getEnchantmentLevel()) : "") + "?");
-        } else {
-            display.putString("Name", ChatColor.RESET + "No Enchantment");
-        }
-
-        display.putList("Lore", NbtType.STRING, Collections.singletonList(ChatColor.DARK_GRAY + data.getExperienceRequired() + "xp"));
-        root.put("display", display.build());
-        ItemData book = ItemData.of(ENCHANTED_BOOK_ID, (short) 0, 1, root.build());
-
-        InventorySlotPacket slotPacket = new InventorySlotPacket();
-        slotPacket.setContainerId(inventory.getId());
-        slotPacket.setSlot(slot + 2);
-        slotPacket.setItem(book);
-        session.sendUpstreamPacket(slotPacket);
-        data.setItem(book);
-    }
-
-    private String toRomanNumeral(GeyserSession session, int level) {
-        return LocaleUtils.getLocaleString("enchantment.level." + level,
-                session.getLocale());
-    }
-
-    /**
-     * Stores the data of each slot in an enchantment table
-     */
-    @NoArgsConstructor
-    @Getter
-    @Setter
-    @ToString
-    public static class EnchantmentSlotData {
-        private EnchantmentTableEnchantments enchantmentType = null;
-        private int enchantmentLevel = 0;
-        private int experienceRequired = 0;
-        private ItemData item;
-    }
-
-    /**
-     * Classifies enchantments by Java order
-     */
-    public enum EnchantmentTableEnchantments {
-        PROTECTION,
-        FIRE_PROTECTION,
-        FEATHER_FALLING,
-        BLAST_PROTECTION,
-        PROJECTILE_PROTECTION,
-        RESPIRATION,
-        AQUA_AFFINITY,
-        THORNS,
-        DEPTH_STRIDER,
-        FROST_WALKER,
-        BINDING_CURSE,
-        SHARPNESS,
-        SMITE,
-        BANE_OF_ARTHROPODS,
-        KNOCKBACK,
-        FIRE_ASPECT,
-        LOOTING,
-        SWEEPING,
-        EFFICIENCY,
-        SILK_TOUCH,
-        UNBREAKING,
-        FORTUNE,
-        POWER,
-        PUNCH,
-        FLAME,
-        INFINITY,
-        LUCK_OF_THE_SEA,
-        LURE,
-        LOYALTY,
-        IMPALING,
-        RIPTIDE,
-        CHANNELING,
-        MENDING,
-        VANISHING_CURSE, // After this is not documented
-        MULTISHOT,
-        PIERCING,
-        QUICK_CHARGE,
-        SOUL_SPEED;
-
-        public String toString(GeyserSession session) {
-            return LocaleUtils.getLocaleString("enchantment.minecraft." + this.toString().toLowerCase(),
-                    session.getLocale());
-        }
-    }
-}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/InventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/InventoryTranslator.java
index f8ef0f7ce..1eef679f5 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/InventoryTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/InventoryTranslator.java
@@ -25,52 +25,80 @@
 
 package org.geysermc.connector.network.translators.inventory;
 
+import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
+import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
+import com.github.steveice10.mc.protocol.data.game.recipe.Ingredient;
+import com.github.steveice10.mc.protocol.data.game.recipe.Recipe;
+import com.github.steveice10.mc.protocol.data.game.recipe.data.ShapedRecipeData;
+import com.github.steveice10.mc.protocol.data.game.recipe.data.ShapelessRecipeData;
 import com.github.steveice10.mc.protocol.data.game.window.WindowType;
-import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
-import com.nukkitx.protocol.bedrock.data.inventory.InventoryActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.ItemStackRequest;
+import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.*;
+import com.nukkitx.protocol.bedrock.packet.ItemStackResponsePacket;
+import it.unimi.dsi.fastutil.ints.*;
 import lombok.AllArgsConstructor;
-import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.*;
 import org.geysermc.connector.network.session.GeyserSession;
-import org.geysermc.connector.network.translators.inventory.updater.ContainerInventoryUpdater;
-import org.geysermc.connector.network.translators.inventory.updater.InventoryUpdater;
+import org.geysermc.connector.network.translators.inventory.click.Click;
+import org.geysermc.connector.network.translators.inventory.click.ClickPlan;
+import org.geysermc.connector.network.translators.inventory.translators.*;
+import org.geysermc.connector.network.translators.inventory.translators.chest.DoubleChestInventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.translators.chest.SingleChestInventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.translators.furnace.BlastFurnaceInventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.translators.furnace.FurnaceInventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.translators.furnace.SmokerInventoryTranslator;
+import org.geysermc.connector.utils.InventoryUtils;
 
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 
 @AllArgsConstructor
 public abstract class InventoryTranslator {
 
+    public static final InventoryTranslator PLAYER_INVENTORY_TRANSLATOR = new PlayerInventoryTranslator();
     public static final Map<WindowType, InventoryTranslator> INVENTORY_TRANSLATORS = new HashMap<WindowType, InventoryTranslator>() {
         {
-            put(null, new PlayerInventoryTranslator()); //player inventory
+            /* Player Inventory */
+            put(null, PLAYER_INVENTORY_TRANSLATOR);
+
+            /* Chest UIs */
             put(WindowType.GENERIC_9X1, new SingleChestInventoryTranslator(9));
             put(WindowType.GENERIC_9X2, new SingleChestInventoryTranslator(18));
             put(WindowType.GENERIC_9X3, new SingleChestInventoryTranslator(27));
             put(WindowType.GENERIC_9X4, new DoubleChestInventoryTranslator(36));
             put(WindowType.GENERIC_9X5, new DoubleChestInventoryTranslator(45));
             put(WindowType.GENERIC_9X6, new DoubleChestInventoryTranslator(54));
-            put(WindowType.BREWING_STAND, new BrewingInventoryTranslator());
+
+            /* Furnaces */
+            put(WindowType.FURNACE, new FurnaceInventoryTranslator());
+            put(WindowType.BLAST_FURNACE, new BlastFurnaceInventoryTranslator());
+            put(WindowType.SMOKER, new SmokerInventoryTranslator());
+
+            /* Specific Inventories */
             put(WindowType.ANVIL, new AnvilInventoryTranslator());
+            put(WindowType.BEACON, new BeaconInventoryTranslator());
+            put(WindowType.BREWING_STAND, new BrewingInventoryTranslator());
+            put(WindowType.CARTOGRAPHY, new CartographyInventoryTranslator());
             put(WindowType.CRAFTING, new CraftingInventoryTranslator());
-            //put(WindowType.GRINDSTONE, new GrindstoneInventoryTranslator()); //FIXME
+            put(WindowType.ENCHANTMENT, new EnchantingInventoryTranslator());
+            put(WindowType.HOPPER, new HopperInventoryTranslator());
+            put(WindowType.GENERIC_3X3, new Generic3X3InventoryTranslator());
+            put(WindowType.GRINDSTONE, new GrindstoneInventoryTranslator());
+            put(WindowType.LOOM, new LoomInventoryTranslator());
             put(WindowType.MERCHANT, new MerchantInventoryTranslator());
-            //put(WindowType.SMITHING, new SmithingInventoryTranslator()); //TODO for server authoritative inventories
+            put(WindowType.SHULKER_BOX, new ShulkerInventoryTranslator());
+            put(WindowType.SMITHING, new SmithingInventoryTranslator());
+            put(WindowType.STONECUTTER, new StonecutterInventoryTranslator());
 
-            InventoryTranslator furnace = new FurnaceInventoryTranslator();
-            put(WindowType.FURNACE, furnace);
-            put(WindowType.BLAST_FURNACE, furnace);
-            put(WindowType.SMOKER, furnace);
-
-            InventoryUpdater containerUpdater = new ContainerInventoryUpdater();
-            put(WindowType.ENCHANTMENT, new EnchantmentInventoryTranslator(containerUpdater)); //TODO
-            put(WindowType.GENERIC_3X3, new BlockInventoryTranslator(9, "minecraft:dispenser[facing=north,triggered=false]", ContainerType.DISPENSER, containerUpdater));
-            put(WindowType.HOPPER, new BlockInventoryTranslator(5, "minecraft:hopper[enabled=false,facing=down]", ContainerType.HOPPER, containerUpdater));
-            put(WindowType.SHULKER_BOX, new BlockInventoryTranslator(27, "minecraft:shulker_box[facing=north]", ContainerType.CONTAINER, containerUpdater));
-            //put(WindowType.BEACON, new BlockInventoryTranslator(1, "minecraft:beacon", ContainerType.BEACON)); //TODO
+            /* Lectern */
+            put(WindowType.LECTERN, new LecternInventoryTranslator());
         }
     };
 
+    public static final int PLAYER_INVENTORY_SIZE = 36;
+    public static final int PLAYER_INVENTORY_OFFSET = 9;
+
     public final int size;
 
     public abstract void prepareInventory(GeyserSession session, Inventory inventory);
@@ -79,8 +107,739 @@ public abstract class InventoryTranslator {
     public abstract void updateProperty(GeyserSession session, Inventory inventory, int key, int value);
     public abstract void updateInventory(GeyserSession session, Inventory inventory);
     public abstract void updateSlot(GeyserSession session, Inventory inventory, int slot);
-    public abstract int bedrockSlotToJava(InventoryActionData action);
-    public abstract int javaSlotToBedrock(int slot);
+    public abstract int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData);
+    public abstract int javaSlotToBedrock(int javaSlot); //TODO
+    public abstract BedrockContainerSlot javaSlotToBedrockContainer(int javaSlot); //TODO
     public abstract SlotType getSlotType(int javaSlot);
-    public abstract void translateActions(GeyserSession session, Inventory inventory, List<InventoryActionData> actions);
+    public abstract Inventory createInventory(String name, int windowId, WindowType windowType, PlayerInventory playerInventory);
+
+    /**
+     * Should be overwritten in cases where specific inventories should reject an item being in a specific spot.
+     * For examples, looms use this to reject items that are dyes in Bedrock but not in Java.
+     *
+     * The source/destination slot will be -1 if the cursor is the slot
+     *
+     * @return true if this transfer should be rejected
+     */
+    public boolean shouldRejectItemPlace(GeyserSession session, Inventory inventory, ContainerSlotType bedrockSourceContainer,
+                                         int javaSourceSlot, ContainerSlotType bedrockDestinationContainer, int javaDestinationSlot) {
+        return false;
+    }
+
+    /**
+     * Should be overrided if this request matches a certain criteria and shouldn't be treated normally.
+     * E.G. anvil renaming or enchanting
+     */
+    public boolean shouldHandleRequestFirst(StackRequestActionData action, Inventory inventory) {
+        return false;
+    }
+
+    /**
+     * If {@link #shouldHandleRequestFirst(StackRequestActionData, Inventory)} returns true, this will be called
+     */
+    public ItemStackResponsePacket.Response translateSpecialRequest(GeyserSession session, Inventory inventory, ItemStackRequest request) {
+        return null;
+    }
+
+    public void translateRequests(GeyserSession session, Inventory inventory, List<ItemStackRequest> requests) {
+        boolean refresh = false;
+        ItemStackResponsePacket responsePacket = new ItemStackResponsePacket();
+        for (ItemStackRequest request : requests) {
+            ItemStackResponsePacket.Response response;
+            if (request.getActions().length > 0) {
+                StackRequestActionData firstAction = request.getActions()[0];
+                if (shouldHandleRequestFirst(firstAction, inventory)) {
+                    // Some special request that shouldn't be processed normally
+                    response = translateSpecialRequest(session, inventory, request);
+                } else if (firstAction.getType() == StackRequestActionType.CRAFT_RECIPE) {
+                    response = translateCraftingRequest(session, inventory, request);
+                } else if (firstAction.getType() == StackRequestActionType.CRAFT_RECIPE_AUTO) {
+                    response = translateAutoCraftingRequest(session, inventory, request);
+                } else if (firstAction.getType() == StackRequestActionType.CRAFT_CREATIVE) {
+                    // This is also used for pulling items out of creative
+                    response = translateCreativeRequest(session, inventory, request);
+                } else {
+                    response = translateRequest(session, inventory, request);
+                }
+            } else {
+                response = rejectRequest(request);
+            }
+            if (response.getResult() == ItemStackResponsePacket.ResponseStatus.ERROR) {
+                refresh = true;
+            }
+            responsePacket.getEntries().add(response);
+        }
+        session.sendUpstreamPacket(responsePacket);
+
+        if (refresh) {
+            InventoryUtils.updateCursor(session);
+            updateInventory(session, inventory);
+        }
+    }
+
+    public ItemStackResponsePacket.Response translateRequest(GeyserSession session, Inventory inventory, ItemStackRequest request) {
+        ClickPlan plan = new ClickPlan(session, this, inventory);
+        IntSet affectedSlots = new IntOpenHashSet();
+        for (StackRequestActionData action : request.getActions()) {
+            GeyserItemStack cursor = session.getPlayerInventory().getCursor();
+            switch (action.getType()) {
+                case TAKE:
+                case PLACE: {
+                    TransferStackRequestActionData transferAction = (TransferStackRequestActionData) action;
+                    if (!(checkNetId(session, inventory, transferAction.getSource()) && checkNetId(session, inventory, transferAction.getDestination()))) {
+                        if (session.getGameMode().equals(GameMode.CREATIVE) && transferAction.getSource().getContainer() == ContainerSlotType.CRAFTING_INPUT &&
+                                transferAction.getSource().getSlot() >= 28 && transferAction.getSource().getSlot() <= 31) {
+                            return rejectRequest(request, false);
+                        }
+                        session.getConnector().getLogger().error("DEBUG: About to reject TAKE/PLACE request made by " + session.getName());
+                        session.getConnector().getLogger().error("Source: " + transferAction.getSource().toString() + " Result: " + checkNetId(session, inventory, transferAction.getSource()));
+                        session.getConnector().getLogger().error("Destination: " + transferAction.getDestination().toString() + " Result: " + checkNetId(session, inventory, transferAction.getDestination()));
+                        session.getConnector().getLogger().error("Geyser's record of source slot: " + inventory.getItem(bedrockSlotToJava(transferAction.getSource())));
+                        session.getConnector().getLogger().error("Geyser's record of destination slot: " + inventory.getItem(bedrockSlotToJava(transferAction.getDestination())));
+                        return rejectRequest(request);
+                    }
+
+                    int sourceSlot = bedrockSlotToJava(transferAction.getSource());
+                    int destSlot = bedrockSlotToJava(transferAction.getDestination());
+
+                    if (shouldRejectItemPlace(session, inventory, transferAction.getSource().getContainer(),
+                            isCursor(transferAction.getSource()) ? -1 : sourceSlot,
+                            transferAction.getDestination().getContainer(), isCursor(transferAction.getDestination()) ? -1 : destSlot)) {
+                        // This item would not be here in Java
+                        return rejectRequest(request, false);
+                    }
+
+                    if (isCursor(transferAction.getSource()) && isCursor(transferAction.getDestination())) { //???
+                        return rejectRequest(request);
+                    } else if (isCursor(transferAction.getSource())) { //releasing cursor
+                        int sourceAmount = cursor.getAmount();
+                        if (transferAction.getCount() == sourceAmount) { //release all
+                            plan.add(Click.LEFT, destSlot);
+                        } else { //release some
+                            for (int i = 0; i < transferAction.getCount(); i++) {
+                                plan.add(Click.RIGHT, destSlot);
+                            }
+                        }
+                    } else if (isCursor(transferAction.getDestination())) { //picking up into cursor
+                        GeyserItemStack sourceItem = plan.getItem(sourceSlot);
+                        int sourceAmount = sourceItem.getAmount();
+                        if (cursor.isEmpty()) { //picking up into empty cursor
+                            if (transferAction.getCount() == sourceAmount) { //pickup all
+                                plan.add(Click.LEFT, sourceSlot);
+                            } else if (transferAction.getCount() == sourceAmount - (sourceAmount / 2)) { //larger half; simple right click
+                                plan.add(Click.RIGHT, sourceSlot);
+                            } else { //pickup some; not a simple right click
+                                plan.add(Click.LEFT, sourceSlot); //first pickup all
+                                for (int i = 0; i < sourceAmount - transferAction.getCount(); i++) {
+                                    plan.add(Click.RIGHT, sourceSlot); //release extra items back into source slot
+                                }
+                            }
+                        } else { //pickup into non-empty cursor
+                            if (!InventoryUtils.canStack(cursor, plan.getItem(sourceSlot))) { //doesn't make sense, reject
+                                return rejectRequest(request);
+                            }
+                            if (transferAction.getCount() != sourceAmount) {
+                                int tempSlot = findTempSlot(inventory, cursor, false, sourceSlot);
+                                if (tempSlot == -1) {
+                                    return rejectRequest(request);
+                                }
+                                plan.add(Click.LEFT, tempSlot); //place cursor into temp slot
+                                plan.add(Click.LEFT, sourceSlot); //pickup source items into cursor
+                                for (int i = 0; i < transferAction.getCount(); i++) {
+                                    plan.add(Click.RIGHT, tempSlot); //partially transfer source items into temp slot (original cursor)
+                                }
+                                plan.add(Click.LEFT, sourceSlot); //return remaining source items
+                                plan.add(Click.LEFT, tempSlot); //retrieve original cursor items from temp slot
+                            } else {
+                                if (getSlotType(sourceSlot).equals(SlotType.NORMAL)) {
+                                    plan.add(Click.LEFT, sourceSlot); //release cursor onto source slot
+                                }
+                                plan.add(Click.LEFT, sourceSlot); //pickup combined cursor and source
+                            }
+                        }
+                    } else { //transfer from one slot to another
+                        int tempSlot = -1;
+                        if (!plan.getCursor().isEmpty()) {
+                            tempSlot = findTempSlot(inventory, cursor, false, sourceSlot, destSlot);
+                            if (tempSlot == -1) {
+                                return rejectRequest(request);
+                            }
+                            plan.add(Click.LEFT, tempSlot); //place cursor into temp slot
+                        }
+
+                        transferSlot(plan, sourceSlot, destSlot, transferAction.getCount());
+
+                        if (tempSlot != -1) {
+                            plan.add(Click.LEFT, tempSlot); //retrieve original cursor
+                        }
+                    }
+                    break;
+                }
+                case SWAP: {
+                    SwapStackRequestActionData swapAction = (SwapStackRequestActionData) action;
+                    if (!(checkNetId(session, inventory, swapAction.getSource()) && checkNetId(session, inventory, swapAction.getDestination()))) {
+                        session.getConnector().getLogger().error("DEBUG: About to reject SWAP request made by " + session.getName());
+                        session.getConnector().getLogger().error("Source: " + swapAction.getSource().toString() + " Result: " + checkNetId(session, inventory, swapAction.getSource()));
+                        session.getConnector().getLogger().error("Destination: " + swapAction.getDestination().toString() + " Result: " + checkNetId(session, inventory, swapAction.getDestination()));
+                        session.getConnector().getLogger().error("Geyser's record of source slot: " + inventory.getItem(bedrockSlotToJava(swapAction.getSource())));
+                        session.getConnector().getLogger().error("Geyser's record of destination slot: " + inventory.getItem(bedrockSlotToJava(swapAction.getDestination())));
+                        return rejectRequest(request);
+                    }
+
+                    int sourceSlot = bedrockSlotToJava(swapAction.getSource());
+                    int destSlot = bedrockSlotToJava(swapAction.getDestination());
+                    boolean isSourceCursor = isCursor(swapAction.getSource());
+                    boolean isDestCursor = isCursor(swapAction.getDestination());
+
+                    if (shouldRejectItemPlace(session, inventory, swapAction.getSource().getContainer(),
+                            isSourceCursor ? -1 : sourceSlot,
+                            swapAction.getDestination().getContainer(), isDestCursor ? -1 : destSlot)) {
+                        // This item would not be here in Java
+                        return rejectRequest(request, false);
+                    }
+
+                    if (isSourceCursor && isDestCursor) { //???
+                        return rejectRequest(request);
+                    } else if (isSourceCursor) { //swap cursor
+                        if (InventoryUtils.canStack(cursor, plan.getItem(destSlot))) { //TODO: cannot simply swap if cursor stacks with slot (temp slot)
+                            return rejectRequest(request);
+                        }
+                        plan.add(Click.LEFT, destSlot);
+                    } else if (isDestCursor) { //swap cursor
+                        if (InventoryUtils.canStack(cursor, plan.getItem(sourceSlot))) { //TODO
+                            return rejectRequest(request);
+                        }
+                        plan.add(Click.LEFT, sourceSlot);
+                    } else {
+                        if (!cursor.isEmpty()) { //TODO: (temp slot)
+                            return rejectRequest(request);
+                        }
+                        if (sourceSlot == destSlot) { //doesn't make sense
+                            return rejectRequest(request);
+                        }
+                        if (InventoryUtils.canStack(plan.getItem(sourceSlot), plan.getItem(destSlot))) { //TODO: (temp slot)
+                            return rejectRequest(request);
+                        }
+                        plan.add(Click.LEFT, sourceSlot); //pickup source into cursor
+                        plan.add(Click.LEFT, destSlot); //swap cursor with dest slot
+                        plan.add(Click.LEFT, sourceSlot); //release cursor onto source
+                    }
+                    break;
+                }
+                case DROP: {
+                    DropStackRequestActionData dropAction = (DropStackRequestActionData) action;
+                    if (!checkNetId(session, inventory, dropAction.getSource()))
+                        return rejectRequest(request);
+
+                    if (isCursor(dropAction.getSource())) { //clicking outside of window
+                        int sourceAmount = plan.getCursor().getAmount();
+                        if (dropAction.getCount() == sourceAmount) { //drop all
+                            plan.add(Click.LEFT_OUTSIDE, Click.OUTSIDE_SLOT);
+                        } else { //drop some
+                            for (int i = 0; i < dropAction.getCount(); i++) {
+                                plan.add(Click.RIGHT_OUTSIDE, Click.OUTSIDE_SLOT); //drop one until goal is met
+                            }
+                        }
+                    } else { //dropping from inventory
+                        int sourceSlot = bedrockSlotToJava(dropAction.getSource());
+                        int sourceAmount = plan.getItem(sourceSlot).getAmount();
+                        if (dropAction.getCount() == sourceAmount && sourceAmount > 1) { //dropping all? (prefer DROP_ONE if only one)
+                            plan.add(Click.DROP_ALL, sourceSlot);
+                        } else { //drop some
+                            for (int i = 0; i < dropAction.getCount(); i++) {
+                                plan.add(Click.DROP_ONE, sourceSlot); //drop one until goal is met
+                            }
+                        }
+                    }
+                    break;
+                }
+                case CONSUME: { // Tends to be called for UI inventories
+                    if (inventory instanceof CartographyContainer) {
+                        // TODO add this for more inventories? Only seems to glitch out the cartography table, though.
+                        ConsumeStackRequestActionData consumeData = (ConsumeStackRequestActionData) action;
+                        int sourceSlot = bedrockSlotToJava(consumeData.getSource());
+                        if (sourceSlot == 0 && inventory.getItem(1).isEmpty()) {
+                            // Java doesn't allow an item to be renamed; this is why CARTOGRAPHY_ADDITIONAL could remain empty for Bedrock
+                            // We check this during slot 0 since setting the inventory slots here messes up shouldRejectItemPlace
+                            return rejectRequest(request, false);
+                        }
+
+                        GeyserItemStack item = inventory.getItem(sourceSlot);
+                        item.setAmount(item.getAmount() - consumeData.getCount());
+                        if (item.isEmpty()) {
+                            inventory.setItem(sourceSlot, GeyserItemStack.EMPTY, session);
+                        }
+                        affectedSlots.add(sourceSlot);
+                    }
+                    break;
+                }
+                case CRAFT_RECIPE_AUTO: // Called by villagers
+                case CRAFT_NON_IMPLEMENTED_DEPRECATED: // Tends to be called for UI inventories
+                case CRAFT_RESULTS_DEPRECATED: // Tends to be called for UI inventories
+                case CRAFT_RECIPE_OPTIONAL: { // Anvils and cartography tables will handle this
+                    break;
+                }
+                default:
+                    return rejectRequest(request);
+            }
+        }
+        plan.execute(false);
+        affectedSlots.addAll(plan.getAffectedSlots());
+        return acceptRequest(request, makeContainerEntries(session, inventory, affectedSlots));
+    }
+    
+    public ItemStackResponsePacket.Response translateCraftingRequest(GeyserSession session, Inventory inventory, ItemStackRequest request) {
+        int resultSize = 0;
+        int timesCrafted;
+        CraftState craftState = CraftState.START;
+
+        int leftover = 0;
+        ClickPlan plan = new ClickPlan(session, this, inventory);
+        for (StackRequestActionData action : request.getActions()) {
+            switch (action.getType()) {
+                case CRAFT_RECIPE: {
+                    if (craftState != CraftState.START) {
+                        return rejectRequest(request);
+                    }
+                    craftState = CraftState.RECIPE_ID;
+                    break;
+                }
+                case CRAFT_RESULTS_DEPRECATED: {
+                    CraftResultsDeprecatedStackRequestActionData deprecatedCraftAction = (CraftResultsDeprecatedStackRequestActionData) action;
+                    if (craftState != CraftState.RECIPE_ID) {
+                        return rejectRequest(request);
+                    }
+                    craftState = CraftState.DEPRECATED;
+
+                    if (deprecatedCraftAction.getResultItems().length != 1) {
+                        return rejectRequest(request);
+                    }
+                    resultSize = deprecatedCraftAction.getResultItems()[0].getCount();
+                    timesCrafted = deprecatedCraftAction.getTimesCrafted();
+                    if (resultSize <= 0 || timesCrafted <= 0) {
+                        return rejectRequest(request);
+                    }
+                    break;
+                }
+                case CONSUME: {
+                    if (craftState != CraftState.DEPRECATED && craftState != CraftState.INGREDIENTS) {
+                        return rejectRequest(request);
+                    }
+                    craftState = CraftState.INGREDIENTS;
+                    break;
+                }
+                case TAKE:
+                case PLACE: {
+                    TransferStackRequestActionData transferAction = (TransferStackRequestActionData) action;
+                    if (craftState != CraftState.INGREDIENTS && craftState != CraftState.TRANSFER) {
+                        return rejectRequest(request);
+                    }
+                    craftState = CraftState.TRANSFER;
+
+                    if (transferAction.getSource().getContainer() != ContainerSlotType.CREATIVE_OUTPUT) {
+                        return rejectRequest(request);
+                    }
+                    if (transferAction.getCount() <= 0) {
+                        return rejectRequest(request);
+                    }
+
+                    int sourceSlot = bedrockSlotToJava(transferAction.getSource());
+                    int destSlot = bedrockSlotToJava(transferAction.getDestination());
+
+                    if (isCursor(transferAction.getDestination())) {
+                        plan.add(Click.LEFT, sourceSlot);
+                        craftState = CraftState.DONE;
+                    } else {
+                        if (leftover != 0) {
+                            if (transferAction.getCount() > leftover) {
+                                return rejectRequest(request);
+                            }
+                            if (transferAction.getCount() == leftover) {
+                                plan.add(Click.LEFT, destSlot);
+                            } else {
+                                for (int i = 0; i < transferAction.getCount(); i++) {
+                                    plan.add(Click.RIGHT, destSlot);
+                                }
+                            }
+                            leftover -= transferAction.getCount();
+                            break;
+                        }
+
+                        int remainder = transferAction.getCount() % resultSize;
+                        int timesToCraft = transferAction.getCount() / resultSize;
+                        for (int i = 0; i < timesToCraft; i++) {
+                            plan.add(Click.LEFT, sourceSlot);
+                            plan.add(Click.LEFT, destSlot);
+                        }
+                        if (remainder > 0) {
+                            plan.add(Click.LEFT, 0);
+                            for (int i = 0; i < remainder; i++) {
+                                plan.add(Click.RIGHT, destSlot);
+                            }
+                            leftover = resultSize - remainder;
+                        }
+                    }
+                    break;
+                }
+                default:
+                    return rejectRequest(request);
+            }
+        }
+        plan.execute(false);
+        return acceptRequest(request, makeContainerEntries(session, inventory, plan.getAffectedSlots()));
+    }
+
+    public ItemStackResponsePacket.Response translateAutoCraftingRequest(GeyserSession session, Inventory inventory, ItemStackRequest request) {
+        int gridSize;
+        int gridDimensions;
+        if (this instanceof PlayerInventoryTranslator) {
+            gridSize = 4;
+            gridDimensions = 2;
+        } else if (this instanceof CraftingInventoryTranslator) {
+            gridSize = 9;
+            gridDimensions = 3;
+        } else {
+            return rejectRequest(request);
+        }
+
+        Recipe recipe;
+        Ingredient[] ingredients = new Ingredient[0];
+        ItemStack output = null;
+        int recipeWidth = 0;
+        int ingRemaining = 0;
+        int ingredientIndex = -1;
+
+        Int2IntMap consumedSlots = new Int2IntOpenHashMap();
+        int prioritySlot = -1;
+        int tempSlot;
+
+        int resultSize;
+        int timesCrafted = 0;
+        Int2ObjectMap<Int2IntMap> ingredientMap = new Int2ObjectOpenHashMap<>();
+        CraftState craftState = CraftState.START;
+
+        ClickPlan plan = new ClickPlan(session, this, inventory);
+        requestLoop:
+        for (StackRequestActionData action : request.getActions()) {
+            switch (action.getType()) {
+                case CRAFT_RECIPE_AUTO: {
+                    AutoCraftRecipeStackRequestActionData autoCraftAction = (AutoCraftRecipeStackRequestActionData) action;
+                    if (craftState != CraftState.START) {
+                        return rejectRequest(request);
+                    }
+                    craftState = CraftState.RECIPE_ID;
+
+                    int recipeId = autoCraftAction.getRecipeNetworkId();
+                    recipe = session.getCraftingRecipes().get(recipeId);
+                    if (recipe == null) {
+                        return rejectRequest(request);
+                    }
+                    if (!plan.getCursor().isEmpty()) {
+                        return rejectRequest(request);
+                    }
+                    //reject if crafting grid is not clear
+                    for (int i = 1; i <= gridSize; i++) {
+                        if (!inventory.getItem(i).isEmpty()) {
+                            return rejectRequest(request);
+                        }
+                    }
+
+                    switch (recipe.getType()) {
+                        case CRAFTING_SHAPED:
+                            ShapedRecipeData shapedData = (ShapedRecipeData) recipe.getData();
+                            ingredients = shapedData.getIngredients();
+                            recipeWidth = shapedData.getWidth();
+                            output = shapedData.getResult();
+                            if (shapedData.getWidth() > gridDimensions || shapedData.getHeight() > gridDimensions) {
+                                return rejectRequest(request);
+                            }
+                            break;
+                        case CRAFTING_SHAPELESS:
+                            ShapelessRecipeData shapelessData = (ShapelessRecipeData) recipe.getData();
+                            ingredients = shapelessData.getIngredients();
+                            recipeWidth = gridDimensions;
+                            output = shapelessData.getResult();
+                            if (ingredients.length > gridSize) {
+                                return rejectRequest(request);
+                            }
+                            break;
+                    }
+                    break;
+                }
+                case CRAFT_RESULTS_DEPRECATED: {
+                    CraftResultsDeprecatedStackRequestActionData deprecatedCraftAction = (CraftResultsDeprecatedStackRequestActionData) action;
+                    if (craftState != CraftState.RECIPE_ID) {
+                        return rejectRequest(request);
+                    }
+                    craftState = CraftState.DEPRECATED;
+
+                    if (deprecatedCraftAction.getResultItems().length != 1) {
+                        return rejectRequest(request);
+                    }
+                    resultSize = deprecatedCraftAction.getResultItems()[0].getCount();
+                    timesCrafted = deprecatedCraftAction.getTimesCrafted();
+                    if (resultSize <= 0 || timesCrafted <= 0) {
+                        return rejectRequest(request);
+                    }
+                    break;
+                }
+                case CONSUME: {
+                    ConsumeStackRequestActionData consumeAction = (ConsumeStackRequestActionData) action;
+                    if (craftState != CraftState.DEPRECATED && craftState != CraftState.INGREDIENTS) {
+                        return rejectRequest(request);
+                    }
+                    craftState = CraftState.INGREDIENTS;
+
+                    if (ingRemaining == 0) {
+                        while (++ingredientIndex < ingredients.length) {
+                            if (ingredients[ingredientIndex].getOptions().length != 0) {
+                                ingRemaining = timesCrafted;
+                                break;
+                            }
+                        }
+                    }
+
+                    ingRemaining -= consumeAction.getCount();
+                    if (ingRemaining < 0)
+                        return rejectRequest(request);
+
+                    int javaSlot = bedrockSlotToJava(consumeAction.getSource());
+                    consumedSlots.merge(javaSlot, consumeAction.getCount(), Integer::sum);
+
+                    int gridSlot = 1 + ingredientIndex + ((ingredientIndex / recipeWidth) * (gridDimensions - recipeWidth));
+                    Int2IntMap sources = ingredientMap.computeIfAbsent(gridSlot, k -> new Int2IntOpenHashMap());
+                    sources.put(javaSlot, consumeAction.getCount());
+                    break;
+                }
+                case TAKE:
+                case PLACE: {
+                    TransferStackRequestActionData transferAction = (TransferStackRequestActionData) action;
+                    if (craftState != CraftState.INGREDIENTS && craftState != CraftState.TRANSFER) {
+                        return rejectRequest(request);
+                    }
+                    craftState = CraftState.TRANSFER;
+
+                    if (transferAction.getSource().getContainer() != ContainerSlotType.CREATIVE_OUTPUT) {
+                        return rejectRequest(request);
+                    }
+                    if (transferAction.getCount() <= 0) {
+                        return rejectRequest(request);
+                    }
+
+                    int javaSlot = bedrockSlotToJava(transferAction.getDestination());
+                    if (isCursor(transferAction.getDestination())) { //TODO
+                        if (timesCrafted > 1) {
+                            tempSlot = findTempSlot(inventory, GeyserItemStack.from(output), true);
+                            if (tempSlot == -1) {
+                                return rejectRequest(request);
+                            }
+                        }
+                        break requestLoop;
+                    } else if (inventory.getItem(javaSlot).getAmount() == consumedSlots.get(javaSlot)) {
+                        prioritySlot = bedrockSlotToJava(transferAction.getDestination());
+                        break requestLoop;
+                    }
+                    break;
+                }
+                default:
+                    return rejectRequest(request);
+            }
+        }
+
+        final int maxLoops = Math.min(64, timesCrafted);
+        for (int loops = 0; loops < maxLoops; loops++) {
+            boolean done = true;
+            for (Int2ObjectMap.Entry<Int2IntMap> entry : ingredientMap.int2ObjectEntrySet()) {
+                Int2IntMap sources = entry.getValue();
+                if (sources.isEmpty())
+                    continue;
+
+                done = false;
+                int gridSlot = entry.getIntKey();
+                if (!plan.getItem(gridSlot).isEmpty())
+                    continue;
+
+                int sourceSlot;
+                if (loops == 0 && sources.containsKey(prioritySlot)) {
+                    sourceSlot = prioritySlot;
+                } else {
+                    sourceSlot = sources.keySet().iterator().nextInt();
+                }
+                int transferAmount = sources.remove(sourceSlot);
+                transferSlot(plan, sourceSlot, gridSlot, transferAmount);
+            }
+
+            if (!done) {
+                //TODO: sometimes the server does not agree on this slot?
+                plan.add(Click.LEFT_SHIFT, 0, true);
+            } else {
+                break;
+            }
+        }
+
+        inventory.setItem(0, GeyserItemStack.from(output), session);
+        plan.execute(true);
+        return acceptRequest(request, makeContainerEntries(session, inventory, plan.getAffectedSlots()));
+    }
+
+    public ItemStackResponsePacket.Response translateCreativeRequest(GeyserSession session, Inventory inventory, ItemStackRequest request) {
+        // Handled in PlayerInventoryTranslator
+        return rejectRequest(request);
+    }
+
+    private void transferSlot(ClickPlan plan, int sourceSlot, int destSlot, int transferAmount) {
+        boolean tempSwap = !plan.getCursor().isEmpty();
+        int sourceAmount = plan.getItem(sourceSlot).getAmount();
+        if (transferAmount == sourceAmount) { //transfer all
+            plan.add(Click.LEFT, sourceSlot); //pickup source
+            plan.add(Click.LEFT, destSlot); //let go of all items and done
+        } else { //transfer some
+            //try to transfer items with least clicks possible
+            int halfSource = sourceAmount - (sourceAmount / 2); //larger half
+            int holding;
+            if (!tempSwap && transferAmount <= halfSource) { //faster to take only half. CURSOR MUST BE EMPTY
+                plan.add(Click.RIGHT, sourceSlot);
+                holding = halfSource;
+            } else { //need all
+                plan.add(Click.LEFT, sourceSlot);
+                holding = sourceAmount;
+            }
+            if (!tempSwap && transferAmount > holding / 2) { //faster to release extra items onto source or dest slot?
+                for (int i = 0; i < holding - transferAmount; i++) {
+                    plan.add(Click.RIGHT, sourceSlot); //prepare cursor
+                }
+                plan.add(Click.LEFT, destSlot); //release cursor onto dest slot
+            } else {
+                for (int i = 0; i < transferAmount; i++) {
+                    plan.add(Click.RIGHT, destSlot); //right click until transfer goal is met
+                }
+                plan.add(Click.LEFT, sourceSlot); //return extra items to source slot
+            }
+        }
+    }
+
+    public static ItemStackResponsePacket.Response acceptRequest(ItemStackRequest request, List<ItemStackResponsePacket.ContainerEntry> containerEntries) {
+        return new ItemStackResponsePacket.Response(ItemStackResponsePacket.ResponseStatus.OK, request.getRequestId(), containerEntries);
+    }
+
+    /**
+     * Reject an incorrect ItemStackRequest.
+     */
+    public static ItemStackResponsePacket.Response rejectRequest(ItemStackRequest request) {
+        return rejectRequest(request, true);
+    }
+
+    /**
+     * Reject an incorrect ItemStackRequest.
+     *
+     * @param throwError whether this request was truly erroneous (true), or known as an outcome and should not be treated
+     *                   as bad (false).
+     */
+    public static ItemStackResponsePacket.Response rejectRequest(ItemStackRequest request, boolean throwError) {
+        if (throwError) {
+            // Currently for debugging, but might be worth it to keep in the future if something goes terribly wrong.
+            new Throwable("DEBUGGING: ItemStackRequest rejected " + request.toString()).printStackTrace();
+        }
+        return new ItemStackResponsePacket.Response(ItemStackResponsePacket.ResponseStatus.ERROR, request.getRequestId(), Collections.emptyList());
+    }
+
+    public boolean checkNetId(GeyserSession session, Inventory inventory, StackRequestSlotInfoData slotInfoData) {
+        int netId = slotInfoData.getStackNetworkId();
+        // "In my testing, sometimes the client thinks the netId of an item in the crafting grid is 1, even though we never said it was.
+        // I think it only happens when we manually set the grid but that was my quick fix"
+        if (netId < 0 || netId == 1)
+            return true;
+
+        GeyserItemStack currentItem = isCursor(slotInfoData) ? session.getPlayerInventory().getCursor() : inventory.getItem(bedrockSlotToJava(slotInfoData));
+        return currentItem.getNetId() == netId;
+    }
+
+    /**
+     * Try to find a slot that can temporarily store the given item.
+     * Only looks in the main inventory and hotbar (excluding offhand).
+     * Only slots that are empty or contain a different type of item are valid.
+     *
+     * @return java id for the temporary slot, or -1 if no viable slot was found
+     */
+    //TODO: compatibility for simulated inventory (ClickPlan)
+    private static int findTempSlot(Inventory inventory, GeyserItemStack item, boolean emptyOnly, int... slotBlacklist) {
+        int offset = inventory.getId() == 0 ? 1 : 0; //offhand is not a viable temp slot
+        HashSet<GeyserItemStack> itemBlacklist = new HashSet<>(slotBlacklist.length + 1);
+        itemBlacklist.add(item);
+
+        IntSet potentialSlots = new IntOpenHashSet(36);
+        for (int i = inventory.getSize() - (36 + offset); i < inventory.getSize() - offset; i++) {
+            potentialSlots.add(i);
+        }
+        for (int i : slotBlacklist) {
+            potentialSlots.remove(i);
+            GeyserItemStack blacklistedItem = inventory.getItem(i);
+            if (!blacklistedItem.isEmpty()) {
+                itemBlacklist.add(blacklistedItem);
+            }
+        }
+
+        for (int i : potentialSlots) {
+            GeyserItemStack testItem = inventory.getItem(i);
+            if ((emptyOnly && !testItem.isEmpty())) {
+                continue;
+            }
+
+            boolean viable = true;
+            for (GeyserItemStack blacklistedItem : itemBlacklist) {
+                if (InventoryUtils.canStack(testItem, blacklistedItem)) {
+                    viable = false;
+                    break;
+                }
+            }
+            if (!viable) {
+                continue;
+            }
+            return i;
+        }
+        //could not find a viable temp slot
+        return -1;
+    }
+
+    public List<ItemStackResponsePacket.ContainerEntry> makeContainerEntries(GeyserSession session, Inventory inventory, Set<Integer> affectedSlots) {
+        Map<ContainerSlotType, List<ItemStackResponsePacket.ItemEntry>> containerMap = new HashMap<>();
+        for (int slot : affectedSlots) {
+            BedrockContainerSlot bedrockSlot = javaSlotToBedrockContainer(slot);
+            List<ItemStackResponsePacket.ItemEntry> list = containerMap.computeIfAbsent(bedrockSlot.getContainer(), k -> new ArrayList<>());
+            list.add(makeItemEntry(bedrockSlot.getSlot(), inventory.getItem(slot)));
+        }
+
+        List<ItemStackResponsePacket.ContainerEntry> containerEntries = new ArrayList<>();
+        for (Map.Entry<ContainerSlotType, List<ItemStackResponsePacket.ItemEntry>> entry : containerMap.entrySet()) {
+            containerEntries.add(new ItemStackResponsePacket.ContainerEntry(entry.getKey(), entry.getValue()));
+        }
+
+        ItemStackResponsePacket.ItemEntry cursorEntry = makeItemEntry(0, session.getPlayerInventory().getCursor());
+        containerEntries.add(new ItemStackResponsePacket.ContainerEntry(ContainerSlotType.CURSOR, Collections.singletonList(cursorEntry)));
+
+        return containerEntries;
+    }
+
+    public static ItemStackResponsePacket.ItemEntry makeItemEntry(int bedrockSlot, GeyserItemStack itemStack) {
+        ItemStackResponsePacket.ItemEntry itemEntry;
+        if (!itemStack.isEmpty()) {
+            itemEntry = new ItemStackResponsePacket.ItemEntry((byte) bedrockSlot, (byte) bedrockSlot, (byte) itemStack.getAmount(), itemStack.getNetId(), "", 0);
+        } else {
+            itemEntry = new ItemStackResponsePacket.ItemEntry((byte) bedrockSlot, (byte) bedrockSlot, (byte) 0, 0, "", 0);
+        }
+        return itemEntry;
+    }
+
+    protected static boolean isCursor(StackRequestSlotInfoData slotInfoData) {
+        return slotInfoData.getContainer() == ContainerSlotType.CURSOR;
+    }
+
+    protected enum CraftState {
+        START,
+        RECIPE_ID,
+        DEPRECATED,
+        INGREDIENTS,
+        TRANSFER,
+        DONE
+    }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/PlayerInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/PlayerInventoryTranslator.java
deleted file mode 100644
index 0ff20772b..000000000
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/PlayerInventoryTranslator.java
+++ /dev/null
@@ -1,248 +0,0 @@
-/*
- * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- *
- * @author GeyserMC
- * @link https://github.com/GeyserMC/Geyser
- */
-
-package org.geysermc.connector.network.translators.inventory;
-
-import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
-import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
-import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientCreativeInventoryActionPacket;
-import com.nukkitx.protocol.bedrock.data.inventory.ContainerId;
-import com.nukkitx.protocol.bedrock.data.inventory.InventoryActionData;
-import com.nukkitx.protocol.bedrock.data.inventory.InventorySource;
-import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
-import com.nukkitx.protocol.bedrock.packet.InventoryContentPacket;
-import com.nukkitx.protocol.bedrock.packet.InventorySlotPacket;
-import org.geysermc.connector.inventory.Inventory;
-import org.geysermc.connector.network.session.GeyserSession;
-import org.geysermc.connector.network.translators.inventory.action.InventoryActionDataTranslator;
-import org.geysermc.connector.network.translators.item.ItemTranslator;
-import org.geysermc.connector.utils.InventoryUtils;
-import org.geysermc.connector.utils.LanguageUtils;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
-public class PlayerInventoryTranslator extends InventoryTranslator {
-    private static final ItemData UNUSUABLE_CRAFTING_SPACE_BLOCK = InventoryUtils.createUnusableSpaceBlock(LanguageUtils.getLocaleStringLog("geyser.inventory.unusable_item.creative"));
-
-    public PlayerInventoryTranslator() {
-        super(46);
-    }
-
-    @Override
-    public void updateInventory(GeyserSession session, Inventory inventory) {
-        updateCraftingGrid(session, inventory);
-
-        InventoryContentPacket inventoryContentPacket = new InventoryContentPacket();
-        inventoryContentPacket.setContainerId(ContainerId.INVENTORY);
-        ItemData[] contents = new ItemData[36];
-        // Inventory
-        for (int i = 9; i < 36; i++) {
-            contents[i] = ItemTranslator.translateToBedrock(session, inventory.getItem(i));
-        }
-        // Hotbar
-        for (int i = 36; i < 45; i++) {
-            contents[i - 36] = ItemTranslator.translateToBedrock(session, inventory.getItem(i));
-        }
-        inventoryContentPacket.setContents(Arrays.asList(contents));
-        session.sendUpstreamPacket(inventoryContentPacket);
-
-        // Armor
-        InventoryContentPacket armorContentPacket = new InventoryContentPacket();
-        armorContentPacket.setContainerId(ContainerId.ARMOR);
-        contents = new ItemData[4];
-        for (int i = 5; i < 9; i++) {
-            contents[i - 5] = ItemTranslator.translateToBedrock(session, inventory.getItem(i));
-        }
-        armorContentPacket.setContents(Arrays.asList(contents));
-        session.sendUpstreamPacket(armorContentPacket);
-
-        // Offhand
-        InventoryContentPacket offhandPacket = new InventoryContentPacket();
-        offhandPacket.setContainerId(ContainerId.OFFHAND);
-        offhandPacket.setContents(Collections.singletonList(ItemTranslator.translateToBedrock(session, inventory.getItem(45))));
-        session.sendUpstreamPacket(offhandPacket);
-    }
-
-    /**
-     * Update the crafting grid for the player to hide/show the barriers in the creative inventory
-     * @param session Session of the player
-     * @param inventory Inventory of the player
-     */
-    public static void updateCraftingGrid(GeyserSession session, Inventory inventory) {
-        // Crafting grid
-        for (int i = 1; i < 5; i++) {
-            InventorySlotPacket slotPacket = new InventorySlotPacket();
-            slotPacket.setContainerId(ContainerId.UI);
-            slotPacket.setSlot(i + 27);
-
-            if (session.getGameMode() == GameMode.CREATIVE) {
-                slotPacket.setItem(UNUSUABLE_CRAFTING_SPACE_BLOCK);
-            }else{
-                slotPacket.setItem(ItemTranslator.translateToBedrock(session, inventory.getItem(i)));
-            }
-
-            session.sendUpstreamPacket(slotPacket);
-        }
-    }
-
-    @Override
-    public void updateSlot(GeyserSession session, Inventory inventory, int slot) {
-        if (slot >= 1 && slot <= 44) {
-            InventorySlotPacket slotPacket = new InventorySlotPacket();
-            if (slot >= 9) {
-                slotPacket.setContainerId(ContainerId.INVENTORY);
-                if (slot >= 36) {
-                    slotPacket.setSlot(slot - 36);
-                } else {
-                    slotPacket.setSlot(slot);
-                }
-            } else if (slot >= 5) {
-                slotPacket.setContainerId(ContainerId.ARMOR);
-                slotPacket.setSlot(slot - 5);
-            } else {
-                slotPacket.setContainerId(ContainerId.UI);
-                slotPacket.setSlot(slot + 27);
-            }
-            slotPacket.setItem(ItemTranslator.translateToBedrock(session, inventory.getItem(slot)));
-            session.sendUpstreamPacket(slotPacket);
-        } else if (slot == 45) {
-            InventoryContentPacket offhandPacket = new InventoryContentPacket();
-            offhandPacket.setContainerId(ContainerId.OFFHAND);
-            offhandPacket.setContents(Collections.singletonList(ItemTranslator.translateToBedrock(session, inventory.getItem(slot))));
-            session.sendUpstreamPacket(offhandPacket);
-        }
-    }
-
-    @Override
-    public int bedrockSlotToJava(InventoryActionData action) {
-        int slotnum = action.getSlot();
-        switch (action.getSource().getContainerId()) {
-            case ContainerId.INVENTORY:
-                // Inventory
-                if (slotnum >= 9 && slotnum <= 35) {
-                    return slotnum;
-                }
-                // Hotbar
-                if (slotnum >= 0 && slotnum <= 8) {
-                    return slotnum + 36;
-                }
-                break;
-            case ContainerId.ARMOR:
-                if (slotnum >= 0 && slotnum <= 3) {
-                    return slotnum + 5;
-                }
-                break;
-            case ContainerId.OFFHAND:
-                return 45;
-            case ContainerId.UI:
-                if (slotnum >= 28 && 31 >= slotnum) {
-                    return slotnum - 27;
-                }
-                break;
-            case ContainerId.CRAFTING_RESULT:
-                return 0;
-        }
-        return slotnum;
-    }
-
-    @Override
-    public int javaSlotToBedrock(int slot) {
-        return slot;
-    }
-
-    @Override
-    public SlotType getSlotType(int javaSlot) {
-        if (javaSlot == 0)
-            return SlotType.OUTPUT;
-        return SlotType.NORMAL;
-    }
-
-    @Override
-    public void translateActions(GeyserSession session, Inventory inventory, List<InventoryActionData> actions) {
-        if (session.getGameMode() == GameMode.CREATIVE) {
-            //crafting grid is not visible in creative mode in java edition
-            for (InventoryActionData action : actions) {
-                if (action.getSource().getContainerId() == ContainerId.UI && (action.getSlot() >= 28 && 31 >= action.getSlot())) {
-                    updateInventory(session, inventory);
-                    InventoryUtils.updateCursor(session);
-                    return;
-                }
-            }
-
-            ItemStack javaItem;
-            for (InventoryActionData action : actions) {
-                switch (action.getSource().getContainerId()) {
-                    case ContainerId.INVENTORY:
-                    case ContainerId.ARMOR:
-                    case ContainerId.OFFHAND:
-                        int javaSlot = bedrockSlotToJava(action);
-                        if (action.getToItem().getId() == 0) {
-                            javaItem = new ItemStack(-1, 0, null);
-                        } else {
-                            javaItem = ItemTranslator.translateToJava(action.getToItem());
-                        }
-                        ClientCreativeInventoryActionPacket creativePacket = new ClientCreativeInventoryActionPacket(javaSlot, javaItem);
-                        session.sendDownstreamPacket(creativePacket);
-                        inventory.setItem(javaSlot, javaItem);
-                        break;
-                    case ContainerId.UI:
-                        if (action.getSlot() == 0) {
-                            session.getInventory().setCursor(ItemTranslator.translateToJava(action.getToItem()));
-                        }
-                        break;
-                    case ContainerId.NONE:
-                        if (action.getSource().getType() == InventorySource.Type.WORLD_INTERACTION
-                                && action.getSource().getFlag() == InventorySource.Flag.DROP_ITEM) {
-                            javaItem = ItemTranslator.translateToJava(action.getToItem());
-                            ClientCreativeInventoryActionPacket creativeDropPacket = new ClientCreativeInventoryActionPacket(-1, javaItem);
-                            session.sendDownstreamPacket(creativeDropPacket);
-                        }
-                        break;
-                }
-            }
-            return;
-        }
-
-        InventoryActionDataTranslator.translate(this, session, inventory, actions);
-    }
-
-    @Override
-    public void prepareInventory(GeyserSession session, Inventory inventory) {
-    }
-
-    @Override
-    public void openInventory(GeyserSession session, Inventory inventory) {
-    }
-
-    @Override
-    public void closeInventory(GeyserSession session, Inventory inventory) {
-    }
-
-    @Override
-    public void updateProperty(GeyserSession session, Inventory inventory, int key, int value) {
-    }
-}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/action/ClickPlan.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/action/ClickPlan.java
deleted file mode 100644
index c72954bf3..000000000
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/action/ClickPlan.java
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- *
- * @author GeyserMC
- * @link https://github.com/GeyserMC/Geyser
- */
-
-package org.geysermc.connector.network.translators.inventory.action;
-
-import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
-import com.github.steveice10.mc.protocol.data.game.window.WindowAction;
-import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientConfirmTransactionPacket;
-import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientWindowActionPacket;
-import org.geysermc.connector.inventory.Inventory;
-import org.geysermc.connector.inventory.PlayerInventory;
-import org.geysermc.connector.network.session.GeyserSession;
-import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
-import org.geysermc.connector.network.translators.inventory.SlotType;
-import org.geysermc.connector.utils.InventoryUtils;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.ListIterator;
-
-class ClickPlan {
-    private final List<ClickAction> plan = new ArrayList<>();
-
-    public void add(Click click, int slot) {
-        plan.add(new ClickAction(click, slot));
-    }
-
-    public void execute(GeyserSession session, InventoryTranslator translator, Inventory inventory, boolean refresh) {
-        PlayerInventory playerInventory = session.getInventory();
-        ListIterator<ClickAction> planIter = plan.listIterator();
-        while (planIter.hasNext()) {
-            final ClickAction action = planIter.next();
-            final ItemStack cursorItem = playerInventory.getCursor();
-            final ItemStack clickedItem = inventory.getItem(action.slot);
-            final short actionId = (short) inventory.getTransactionId().getAndIncrement();
-
-            //TODO: stop relying on refreshing the inventory for crafting to work properly
-            if (translator.getSlotType(action.slot) != SlotType.NORMAL)
-                refresh = true;
-
-            ClientWindowActionPacket clickPacket = new ClientWindowActionPacket(inventory.getId(),
-                    actionId, action.slot, !planIter.hasNext() && refresh ? InventoryUtils.REFRESH_ITEM : clickedItem,
-                    WindowAction.CLICK_ITEM, action.click.actionParam);
-
-            if (translator.getSlotType(action.slot) == SlotType.OUTPUT) {
-                if (cursorItem == null && clickedItem != null) {
-                    playerInventory.setCursor(clickedItem);
-                } else if (InventoryUtils.canStack(cursorItem, clickedItem)) {
-                    playerInventory.setCursor(new ItemStack(cursorItem.getId(),
-                            cursorItem.getAmount() + clickedItem.getAmount(), cursorItem.getNbt()));
-                }
-            } else {
-                switch (action.click) {
-                    case LEFT:
-                        if (!InventoryUtils.canStack(cursorItem, clickedItem)) {
-                            playerInventory.setCursor(clickedItem);
-                            inventory.setItem(action.slot, cursorItem);
-                        } else {
-                            playerInventory.setCursor(null);
-                            inventory.setItem(action.slot, new ItemStack(clickedItem.getId(),
-                                    clickedItem.getAmount() + cursorItem.getAmount(), clickedItem.getNbt()));
-                        }
-                        break;
-                    case RIGHT:
-                        if (cursorItem == null && clickedItem != null) {
-                            ItemStack halfItem = new ItemStack(clickedItem.getId(),
-                                    clickedItem.getAmount() / 2, clickedItem.getNbt());
-                            inventory.setItem(action.slot, halfItem);
-                            playerInventory.setCursor(new ItemStack(clickedItem.getId(),
-                                    clickedItem.getAmount() - halfItem.getAmount(), clickedItem.getNbt()));
-                        } else if (cursorItem != null && clickedItem == null) {
-                            playerInventory.setCursor(new ItemStack(cursorItem.getId(),
-                                    cursorItem.getAmount() - 1, cursorItem.getNbt()));
-                            inventory.setItem(action.slot, new ItemStack(cursorItem.getId(),
-                                    1, cursorItem.getNbt()));
-                        } else if (InventoryUtils.canStack(cursorItem, clickedItem)) {
-                            playerInventory.setCursor(new ItemStack(cursorItem.getId(),
-                                    cursorItem.getAmount() - 1, cursorItem.getNbt()));
-                            inventory.setItem(action.slot, new ItemStack(clickedItem.getId(),
-                                    clickedItem.getAmount() + 1, clickedItem.getNbt()));
-                        }
-                        break;
-                }
-            }
-            session.sendDownstreamPacket(clickPacket);
-            session.sendDownstreamPacket(new ClientConfirmTransactionPacket(inventory.getId(), actionId, true));
-        }
-
-        /*if (refresh) {
-            translator.updateInventory(session, inventory);
-            InventoryUtils.updateCursor(session);
-        }*/
-    }
-
-    private static class ClickAction {
-        final Click click;
-        final int slot;
-        ClickAction(Click click, int slot) {
-            this.click = click;
-            this.slot = slot;
-        }
-    }
-}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/action/InventoryActionDataTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/action/InventoryActionDataTranslator.java
deleted file mode 100644
index c313e3669..000000000
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/action/InventoryActionDataTranslator.java
+++ /dev/null
@@ -1,338 +0,0 @@
-/*
- * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- *
- * @author GeyserMC
- * @link https://github.com/GeyserMC/Geyser
- */
-
-package org.geysermc.connector.network.translators.inventory.action;
-
-import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
-import com.github.steveice10.mc.protocol.data.game.entity.metadata.Position;
-import com.github.steveice10.mc.protocol.data.game.entity.player.PlayerAction;
-import com.github.steveice10.mc.protocol.data.game.window.*;
-import com.github.steveice10.mc.protocol.data.game.world.block.BlockFace;
-import com.github.steveice10.mc.protocol.packet.ingame.client.player.ClientPlayerActionPacket;
-import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientWindowActionPacket;
-import com.nukkitx.protocol.bedrock.data.inventory.ContainerId;
-import com.nukkitx.protocol.bedrock.data.inventory.InventoryActionData;
-import com.nukkitx.protocol.bedrock.data.inventory.InventorySource;
-import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
-import org.geysermc.connector.inventory.Inventory;
-import org.geysermc.connector.network.session.GeyserSession;
-import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
-import org.geysermc.connector.network.translators.inventory.SlotType;
-import org.geysermc.connector.network.translators.item.ItemTranslator;
-import org.geysermc.connector.utils.InventoryUtils;
-
-import java.util.*;
-
-public class InventoryActionDataTranslator {
-    public static void translate(InventoryTranslator translator, GeyserSession session, Inventory inventory, List<InventoryActionData> actions) {
-        if (actions.size() != 2)
-            return;
-
-        InventoryActionData worldAction = null;
-        InventoryActionData cursorAction = null;
-        InventoryActionData containerAction = null;
-        boolean refresh = false;
-        for (InventoryActionData action : actions) {
-            if (action.getSource().getContainerId() == ContainerId.CRAFTING_USE_INGREDIENT) {
-                return;
-            } else if (action.getSource().getType() == InventorySource.Type.WORLD_INTERACTION) {
-                worldAction = action;
-            } else if (action.getSource().getContainerId() == ContainerId.UI && action.getSlot() == 0) {
-                cursorAction = action;
-                ItemData translatedCursor = ItemTranslator.translateToBedrock(session, session.getInventory().getCursor());
-                if (!translatedCursor.equals(action.getFromItem())) {
-                    refresh = true;
-                }
-            } else {
-                containerAction = action;
-                ItemData translatedItem = ItemTranslator.translateToBedrock(session, inventory.getItem(translator.bedrockSlotToJava(action)));
-                if (!translatedItem.equals(action.getFromItem())) {
-                    refresh = true;
-                }
-            }
-        }
-
-        final int craftSlot = session.getCraftSlot();
-        session.setCraftSlot(0);
-
-        if (worldAction != null) {
-            InventoryActionData sourceAction;
-            if (cursorAction != null) {
-                sourceAction = cursorAction;
-            } else {
-                sourceAction = containerAction;
-            }
-
-            if (sourceAction != null) {
-                if (worldAction.getSource().getFlag() == InventorySource.Flag.DROP_ITEM) {
-                    //quick dropping from hotbar?
-                    if (session.getInventoryCache().getOpenInventory() == null && sourceAction.getSource().getContainerId() == ContainerId.INVENTORY) {
-                        int heldSlot = session.getInventory().getHeldItemSlot();
-                        if (sourceAction.getSlot() == heldSlot) {
-                            ClientPlayerActionPacket actionPacket = new ClientPlayerActionPacket(
-                                    sourceAction.getToItem().getCount() == 0 ? PlayerAction.DROP_ITEM_STACK : PlayerAction.DROP_ITEM,
-                                    new Position(0, 0, 0), BlockFace.DOWN);
-                            session.sendDownstreamPacket(actionPacket);
-                            ItemStack item = session.getInventory().getItem(heldSlot);
-                            if (item != null) {
-                                session.getInventory().setItem(heldSlot, new ItemStack(item.getId(), item.getAmount() - 1, item.getNbt()));
-                            }
-                            return;
-                        }
-                    }
-                    int dropAmount = sourceAction.getFromItem().getCount() - sourceAction.getToItem().getCount();
-                    if (sourceAction != cursorAction) { //dropping directly from inventory
-                        int javaSlot = translator.bedrockSlotToJava(sourceAction);
-                        if (dropAmount == sourceAction.getFromItem().getCount()) {
-                            ClientWindowActionPacket dropPacket = new ClientWindowActionPacket(inventory.getId(),
-                                    inventory.getTransactionId().getAndIncrement(),
-                                    javaSlot, null, WindowAction.DROP_ITEM,
-                                    DropItemParam.DROP_SELECTED_STACK);
-                            session.sendDownstreamPacket(dropPacket);
-                        } else {
-                            for (int i = 0; i < dropAmount; i++) {
-                                ClientWindowActionPacket dropPacket = new ClientWindowActionPacket(inventory.getId(),
-                                        inventory.getTransactionId().getAndIncrement(),
-                                        javaSlot, null, WindowAction.DROP_ITEM,
-                                        DropItemParam.DROP_FROM_SELECTED);
-                                session.sendDownstreamPacket(dropPacket);
-                            }
-                        }
-                        ItemStack item = inventory.getItem(javaSlot);
-                        if (item != null) {
-                            inventory.setItem(javaSlot, new ItemStack(item.getId(), item.getAmount() - dropAmount, item.getNbt()));
-                        }
-                        return;
-                    } else { //clicking outside of inventory
-                        ClientWindowActionPacket dropPacket = new ClientWindowActionPacket(inventory.getId(), inventory.getTransactionId().getAndIncrement(),
-                                -999, null, WindowAction.CLICK_ITEM,
-                                dropAmount > 1 ? ClickItemParam.LEFT_CLICK : ClickItemParam.RIGHT_CLICK);
-                        session.sendDownstreamPacket(dropPacket);
-                        ItemStack cursor = session.getInventory().getCursor();
-                        if (cursor != null) {
-                            session.getInventory().setCursor(new ItemStack(cursor.getId(), dropAmount > 1 ? 0 : cursor.getAmount() - 1, cursor.getNbt()));
-                        }
-                        return;
-                    }
-                }
-            }
-        } else if (cursorAction != null && containerAction != null) {
-            //left/right click
-            ClickPlan plan = new ClickPlan();
-            int javaSlot = translator.bedrockSlotToJava(containerAction);
-            if (cursorAction.getFromItem().equals(containerAction.getToItem())
-                    && containerAction.getFromItem().equals(cursorAction.getToItem())
-                    && !InventoryUtils.canStack(cursorAction.getFromItem(), containerAction.getFromItem())) { //simple swap
-                plan.add(Click.LEFT, javaSlot);
-            } else if (cursorAction.getFromItem().getCount() > cursorAction.getToItem().getCount()) { //release
-                if (cursorAction.getToItem().getCount() == 0) {
-                    plan.add(Click.LEFT, javaSlot);
-                } else {
-                    int difference = cursorAction.getFromItem().getCount() - cursorAction.getToItem().getCount();
-                    for (int i = 0; i < difference; i++) {
-                        plan.add(Click.RIGHT, javaSlot);
-                    }
-                }
-            } else { //pickup
-                if (cursorAction.getFromItem().getCount() == 0) {
-                    if (containerAction.getToItem().getCount() == 0) { //pickup all
-                        plan.add(Click.LEFT, javaSlot);
-                    } else { //pickup some
-                        if (translator.getSlotType(javaSlot) == SlotType.FURNACE_OUTPUT
-                                || containerAction.getToItem().getCount() == containerAction.getFromItem().getCount() / 2) { //right click
-                            plan.add(Click.RIGHT, javaSlot);
-                        } else {
-                            plan.add(Click.LEFT, javaSlot);
-                            int difference = containerAction.getFromItem().getCount() - cursorAction.getToItem().getCount();
-                            for (int i = 0; i < difference; i++) {
-                                plan.add(Click.RIGHT, javaSlot);
-                            }
-                        }
-                    }
-                } else { //pickup into non-empty cursor
-                    if (translator.getSlotType(javaSlot) == SlotType.FURNACE_OUTPUT) {
-                        if (containerAction.getToItem().getCount() == 0) {
-                            plan.add(Click.LEFT, javaSlot);
-                        } else {
-                            ClientWindowActionPacket shiftClickPacket = new ClientWindowActionPacket(inventory.getId(),
-                                    inventory.getTransactionId().getAndIncrement(),
-                                    javaSlot, InventoryUtils.REFRESH_ITEM, WindowAction.SHIFT_CLICK_ITEM,
-                                    ShiftClickItemParam.LEFT_CLICK);
-                            session.sendDownstreamPacket(shiftClickPacket);
-                            translator.updateInventory(session, inventory);
-                            return;
-                        }
-                    } else if (translator.getSlotType(javaSlot) == SlotType.OUTPUT) {
-                        plan.add(Click.LEFT, javaSlot);
-                    } else {
-                        int cursorSlot = findTempSlot(inventory, session.getInventory().getCursor(), Collections.singletonList(javaSlot), false);
-                        if (cursorSlot != -1) {
-                            plan.add(Click.LEFT, cursorSlot);
-                        } else {
-                            translator.updateInventory(session, inventory);
-                            InventoryUtils.updateCursor(session);
-                            return;
-                        }
-                        plan.add(Click.LEFT, javaSlot);
-                        int difference = cursorAction.getToItem().getCount() - cursorAction.getFromItem().getCount();
-                        for (int i = 0; i < difference; i++) {
-                            plan.add(Click.RIGHT, cursorSlot);
-                        }
-                        plan.add(Click.LEFT, javaSlot);
-                        plan.add(Click.LEFT, cursorSlot);
-                    }
-                }
-            }
-            plan.execute(session, translator, inventory, refresh);
-            return;
-        } else {
-            ClickPlan plan = new ClickPlan();
-            InventoryActionData fromAction;
-            InventoryActionData toAction;
-            if (actions.get(0).getFromItem().getCount() >= actions.get(0).getToItem().getCount()) {
-                fromAction = actions.get(0);
-                toAction = actions.get(1);
-            } else {
-                fromAction = actions.get(1);
-                toAction = actions.get(0);
-            }
-            int fromSlot = translator.bedrockSlotToJava(fromAction);
-            int toSlot = translator.bedrockSlotToJava(toAction);
-
-            if (translator.getSlotType(fromSlot) == SlotType.OUTPUT) {
-                if ((craftSlot != 0 && craftSlot != -2) && (inventory.getItem(toSlot) == null
-                        || InventoryUtils.canStack(session.getInventory().getCursor(), inventory.getItem(toSlot)))) {
-                    if (fromAction.getToItem().getCount() == 0) {
-                        refresh = true;
-                        plan.add(Click.LEFT, toSlot);
-                        if (craftSlot != -1) {
-                            plan.add(Click.LEFT, craftSlot);
-                        }
-                    } else {
-                        int difference = toAction.getToItem().getCount() - toAction.getFromItem().getCount();
-                        for (int i = 0; i < difference; i++) {
-                            plan.add(Click.RIGHT, toSlot);
-                        }
-                        session.setCraftSlot(craftSlot);
-                    }
-                    plan.execute(session, translator, inventory, refresh);
-                    return;
-                } else {
-                    session.setCraftSlot(-2);
-                }
-            }
-
-            int cursorSlot = -1;
-            if (session.getInventory().getCursor() != null) { //move cursor contents to a temporary slot
-                cursorSlot = findTempSlot(inventory,
-                        session.getInventory().getCursor(),
-                        Arrays.asList(fromSlot, toSlot),
-                        translator.getSlotType(fromSlot) == SlotType.OUTPUT);
-                if (cursorSlot != -1) {
-                    plan.add(Click.LEFT, cursorSlot);
-                } else {
-                    translator.updateInventory(session, inventory);
-                    InventoryUtils.updateCursor(session);
-                    return;
-                }
-            }
-            if ((fromAction.getFromItem().equals(toAction.getToItem()) && !InventoryUtils.canStack(fromAction.getFromItem(), toAction.getFromItem()))
-                    || fromAction.getToItem().getId() == 0) { //slot swap
-                plan.add(Click.LEFT, fromSlot);
-                plan.add(Click.LEFT, toSlot);
-                if (fromAction.getToItem().getId() != 0) {
-                    plan.add(Click.LEFT, fromSlot);
-                }
-            } else if (InventoryUtils.canStack(fromAction.getFromItem(), toAction.getToItem())) { //partial item move
-                if (translator.getSlotType(fromSlot) == SlotType.FURNACE_OUTPUT) {
-                    ClientWindowActionPacket shiftClickPacket = new ClientWindowActionPacket(inventory.getId(),
-                            inventory.getTransactionId().getAndIncrement(),
-                            fromSlot, InventoryUtils.REFRESH_ITEM, WindowAction.SHIFT_CLICK_ITEM,
-                            ShiftClickItemParam.LEFT_CLICK);
-                    session.sendDownstreamPacket(shiftClickPacket);
-                    translator.updateInventory(session, inventory);
-                    return;
-                } else if (translator.getSlotType(fromSlot) == SlotType.OUTPUT) {
-                    session.setCraftSlot(cursorSlot);
-                    plan.add(Click.LEFT, fromSlot);
-                    int difference = toAction.getToItem().getCount() - toAction.getFromItem().getCount();
-                    for (int i = 0; i < difference; i++) {
-                        plan.add(Click.RIGHT, toSlot);
-                    }
-                    //client will send additional packets later to finish transferring crafting output
-                    //translator will know how to handle this using the craftSlot variable
-                } else {
-                    plan.add(Click.LEFT, fromSlot);
-                    int difference = toAction.getToItem().getCount() - toAction.getFromItem().getCount();
-                    for (int i = 0; i < difference; i++) {
-                        plan.add(Click.RIGHT, toSlot);
-                    }
-                    plan.add(Click.LEFT, fromSlot);
-                }
-            }
-            if (cursorSlot != -1) {
-                plan.add(Click.LEFT, cursorSlot);
-            }
-            plan.execute(session, translator, inventory, refresh);
-            return;
-        }
-
-        translator.updateInventory(session, inventory);
-        InventoryUtils.updateCursor(session);
-    }
-
-    private static int findTempSlot(Inventory inventory, ItemStack item, List<Integer> slotBlacklist, boolean emptyOnly) {
-        /*try and find a slot that can temporarily store the given item
-        only look in the main inventory and hotbar
-        only slots that are empty or contain a different type of item are valid*/
-        int offset = inventory.getId() == 0 ? 1 : 0; //offhand is not a viable slot (some servers disable it)
-        List<ItemStack> itemBlacklist = new ArrayList<>(slotBlacklist.size() + 1);
-        itemBlacklist.add(item);
-        for (int slot : slotBlacklist) {
-            ItemStack blacklistItem = inventory.getItem(slot);
-            if (blacklistItem != null)
-                itemBlacklist.add(blacklistItem);
-        }
-        for (int i = inventory.getSize() - (36 + offset); i < inventory.getSize() - offset; i++) {
-            ItemStack testItem = inventory.getItem(i);
-            boolean acceptable = true;
-            if (testItem != null) {
-                if (emptyOnly) {
-                    continue;
-                }
-                for (ItemStack blacklistItem : itemBlacklist) {
-                    if (InventoryUtils.canStack(testItem, blacklistItem)) {
-                        acceptable = false;
-                        break;
-                    }
-                }
-            }
-            if (acceptable && !slotBlacklist.contains(i))
-                return i;
-        }
-        //could not find a viable temp slot
-        return -1;
-    }
-}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/click/Click.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/click/Click.java
new file mode 100644
index 000000000..d3666a9e9
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/click/Click.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.click;
+
+import com.github.steveice10.mc.protocol.data.game.window.*;
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public enum Click {
+    LEFT(WindowAction.CLICK_ITEM, ClickItemParam.LEFT_CLICK),
+    RIGHT(WindowAction.CLICK_ITEM, ClickItemParam.RIGHT_CLICK),
+    LEFT_SHIFT(WindowAction.SHIFT_CLICK_ITEM, ShiftClickItemParam.LEFT_CLICK),
+    DROP_ONE(WindowAction.DROP_ITEM, DropItemParam.DROP_FROM_SELECTED),
+    DROP_ALL(WindowAction.DROP_ITEM, DropItemParam.DROP_SELECTED_STACK),
+    LEFT_OUTSIDE(WindowAction.CLICK_ITEM, ClickItemParam.LEFT_CLICK),
+    RIGHT_OUTSIDE(WindowAction.CLICK_ITEM, ClickItemParam.RIGHT_CLICK);
+
+    public static final int OUTSIDE_SLOT = -999;
+
+    public final WindowAction windowAction;
+    public final WindowActionParam actionParam;
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/click/ClickPlan.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/click/ClickPlan.java
new file mode 100644
index 000000000..c750baf51
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/click/ClickPlan.java
@@ -0,0 +1,294 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.click;
+
+import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
+import com.github.steveice10.mc.protocol.data.game.window.WindowAction;
+import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientConfirmTransactionPacket;
+import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientWindowActionPacket;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
+import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import it.unimi.dsi.fastutil.ints.IntSet;
+import lombok.Value;
+import org.geysermc.connector.inventory.GeyserItemStack;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.SlotType;
+import org.geysermc.connector.network.translators.inventory.translators.CraftingInventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.translators.PlayerInventoryTranslator;
+import org.geysermc.connector.utils.InventoryUtils;
+
+import java.util.*;
+
+public class ClickPlan {
+    private final List<ClickAction> plan = new ArrayList<>();
+    private final Int2ObjectMap<GeyserItemStack> simulatedItems;
+    private GeyserItemStack simulatedCursor;
+    private boolean simulating;
+
+    private final GeyserSession session;
+    private final InventoryTranslator translator;
+    private final Inventory inventory;
+    private final int gridSize;
+
+    public ClickPlan(GeyserSession session, InventoryTranslator translator, Inventory inventory) {
+        this.session = session;
+        this.translator = translator;
+        this.inventory = inventory;
+
+        this.simulatedItems = new Int2ObjectOpenHashMap<>(inventory.getSize());
+        this.simulatedCursor = session.getPlayerInventory().getCursor().copy();
+        this.simulating = true;
+
+        if (translator instanceof PlayerInventoryTranslator) {
+            gridSize = 4;
+        } else if (translator instanceof CraftingInventoryTranslator) {
+            gridSize = 9;
+        } else {
+            gridSize = -1;
+        }
+    }
+
+    private void resetSimulation() {
+        this.simulatedItems.clear();
+        this.simulatedCursor = session.getPlayerInventory().getCursor().copy();
+    }
+
+    public void add(Click click, int slot) {
+        add(click, slot, false);
+    }
+
+    public void add(Click click, int slot, boolean force) {
+        if (!simulating)
+            throw new UnsupportedOperationException("ClickPlan already executed");
+
+        if (click == Click.LEFT_OUTSIDE || click == Click.RIGHT_OUTSIDE) {
+            slot = Click.OUTSIDE_SLOT;
+        }
+
+        ClickAction action = new ClickAction(click, slot, force);
+        plan.add(action);
+        simulateAction(action);
+    }
+
+    public void execute(boolean refresh) {
+        //update geyser inventory after simulation to avoid net id desync
+        resetSimulation();
+        ListIterator<ClickAction> planIter = plan.listIterator();
+        while (planIter.hasNext()) {
+            ClickAction action = planIter.next();
+
+            if (action.slot != Click.OUTSIDE_SLOT && translator.getSlotType(action.slot) != SlotType.NORMAL) {
+                refresh = true;
+            }
+
+            ItemStack clickedItemStack;
+            if (!planIter.hasNext() && refresh) {
+                clickedItemStack = InventoryUtils.REFRESH_ITEM;
+            } else if (action.click.windowAction == WindowAction.DROP_ITEM || action.slot == Click.OUTSIDE_SLOT) {
+                clickedItemStack = null;
+            } else {
+                clickedItemStack = getItem(action.slot).getItemStack();
+            }
+
+            short actionId = inventory.getNextTransactionId();
+            ClientWindowActionPacket clickPacket = new ClientWindowActionPacket(
+                    inventory.getId(),
+                    actionId,
+                    action.slot,
+                    clickedItemStack,
+                    action.click.windowAction,
+                    action.click.actionParam
+            );
+
+            simulateAction(action);
+
+            session.sendDownstreamPacket(clickPacket);
+            if (clickedItemStack == InventoryUtils.REFRESH_ITEM || action.force) {
+                session.sendDownstreamPacket(new ClientConfirmTransactionPacket(inventory.getId(), actionId, true));
+            }
+        }
+
+        session.getPlayerInventory().setCursor(simulatedCursor, session);
+        for (Int2ObjectMap.Entry<GeyserItemStack> simulatedSlot : simulatedItems.int2ObjectEntrySet()) {
+            inventory.setItem(simulatedSlot.getIntKey(), simulatedSlot.getValue(), session);
+        }
+        simulating = false;
+    }
+
+    public GeyserItemStack getItem(int slot) {
+        return getItem(slot, true);
+    }
+
+    public GeyserItemStack getItem(int slot, boolean generate) {
+        if (generate) {
+            return simulatedItems.computeIfAbsent(slot, k -> inventory.getItem(slot).copy());
+        } else {
+            return simulatedItems.getOrDefault(slot, inventory.getItem(slot));
+        }
+    }
+
+    public GeyserItemStack getCursor() {
+        return simulatedCursor;
+    }
+
+    private void setItem(int slot, GeyserItemStack item) {
+        if (simulating) {
+            simulatedItems.put(slot, item);
+        } else {
+            inventory.setItem(slot, item, session);
+        }
+    }
+
+    private void setCursor(GeyserItemStack item) {
+        if (simulating) {
+            simulatedCursor = item;
+        } else {
+            session.getPlayerInventory().setCursor(item, session);
+        }
+    }
+
+    private void simulateAction(ClickAction action) {
+        GeyserItemStack cursor = simulating ? getCursor() : session.getPlayerInventory().getCursor();
+        switch (action.click) {
+            case LEFT_OUTSIDE:
+                setCursor(GeyserItemStack.EMPTY);
+                return;
+            case RIGHT_OUTSIDE:
+                if (!cursor.isEmpty()) {
+                    cursor.sub(1);
+                }
+                return;
+        }
+
+        GeyserItemStack clicked = simulating ? getItem(action.slot) : inventory.getItem(action.slot);
+        if (translator.getSlotType(action.slot) == SlotType.OUTPUT) {
+            switch (action.click) {
+                case LEFT:
+                case RIGHT:
+                    if (cursor.isEmpty() && !clicked.isEmpty()) {
+                        setCursor(clicked.copy());
+                    } else if (InventoryUtils.canStack(cursor, clicked)) {
+                        cursor.add(clicked.getAmount());
+                    }
+                    reduceCraftingGrid(false);
+                    break;
+                case LEFT_SHIFT:
+                    reduceCraftingGrid(true);
+                    break;
+            }
+        } else {
+            switch (action.click) {
+                case LEFT:
+                    if (!InventoryUtils.canStack(cursor, clicked)) {
+                        setCursor(clicked);
+                        setItem(action.slot, cursor);
+                    } else {
+                        setCursor(GeyserItemStack.EMPTY);
+                        clicked.add(cursor.getAmount());
+                    }
+                    break;
+                case RIGHT:
+                    if (cursor.isEmpty() && !clicked.isEmpty()) {
+                        int half = clicked.getAmount() / 2; //smaller half
+                        setCursor(clicked.copy(clicked.getAmount() - half)); //larger half
+                        clicked.setAmount(half);
+                    } else if (!cursor.isEmpty() && clicked.isEmpty()) {
+                        cursor.sub(1);
+                        setItem(action.slot, cursor.copy(1));
+                    } else if (InventoryUtils.canStack(cursor, clicked)) {
+                        cursor.sub(1);
+                        clicked.add(1);
+                    }
+                    break;
+                case LEFT_SHIFT:
+                    //TODO
+                    break;
+                case DROP_ONE:
+                    if (!clicked.isEmpty()) {
+                        clicked.sub(1);
+                    }
+                    break;
+                case DROP_ALL:
+                    setItem(action.slot, GeyserItemStack.EMPTY);
+                    break;
+            }
+        }
+    }
+
+    //TODO
+    private void reduceCraftingGrid(boolean makeAll) {
+        if (gridSize == -1)
+            return;
+
+        int crafted;
+        if (!makeAll) {
+            crafted = 1;
+        } else {
+            crafted = 0;
+            for (int i = 0; i < gridSize; i++) {
+                GeyserItemStack item = getItem(i + 1);
+                if (!item.isEmpty()) {
+                    if (crafted == 0) {
+                        crafted = item.getAmount();
+                    }
+                    crafted = Math.min(crafted, item.getAmount());
+                }
+            }
+        }
+
+        for (int i = 0; i < gridSize; i++) {
+            GeyserItemStack item = getItem(i + 1);
+            if (!item.isEmpty())
+                item.sub(crafted);
+        }
+    }
+
+    /**
+     * @return a new set of all affected slots. This isn't a constant variable; it's newly generated each time it is run.
+     */
+    public IntSet getAffectedSlots() {
+        IntSet affectedSlots = new IntOpenHashSet();
+        for (ClickAction action : plan) {
+            if (translator.getSlotType(action.slot) == SlotType.NORMAL && action.slot != Click.OUTSIDE_SLOT) {
+                affectedSlots.add(action.slot);
+            }
+        }
+        return affectedSlots;
+    }
+
+    @Value
+    private static class ClickAction {
+        Click click;
+        /**
+         * Java slot
+         */
+        int slot;
+        boolean force;
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/holder/BlockInventoryHolder.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/holder/BlockInventoryHolder.java
index 49ab80fdd..e7bfd90f0 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/holder/BlockInventoryHolder.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/holder/BlockInventoryHolder.java
@@ -26,34 +26,89 @@
 package org.geysermc.connector.network.translators.inventory.holder;
 
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.Position;
+import com.google.common.collect.ImmutableSet;
 import com.nukkitx.math.vector.Vector3i;
 import com.nukkitx.nbt.NbtMap;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
 import com.nukkitx.protocol.bedrock.packet.BlockEntityDataPacket;
+import com.nukkitx.protocol.bedrock.packet.ContainerClosePacket;
 import com.nukkitx.protocol.bedrock.packet.ContainerOpenPacket;
 import com.nukkitx.protocol.bedrock.packet.UpdateBlockPacket;
-import lombok.AllArgsConstructor;
+import org.geysermc.connector.inventory.Container;
 import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
+import org.geysermc.connector.network.translators.world.block.BlockTranslator;
 
-@AllArgsConstructor
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Manages the fake block we implement for each inventory, should we need to.
+ * This class will attempt to use a real block first, if possible.
+ */
 public class BlockInventoryHolder extends InventoryHolder {
-    private final int javaBlockState;
+    /**
+     * The default Java block ID to translate as a fake block
+     */
+    private final int defaultJavaBlockState;
     private final ContainerType containerType;
+    private final Set<String> validBlocks;
+
+    public BlockInventoryHolder(String javaBlockIdentifier, ContainerType containerType, String... validBlocks) {
+        this.defaultJavaBlockState = BlockTranslator.getJavaBlockState(javaBlockIdentifier);
+        this.containerType = containerType;
+        if (validBlocks != null) {
+            Set<String> validBlocksTemp = new HashSet<>(validBlocks.length + 1);
+            Collections.addAll(validBlocksTemp, validBlocks);
+            validBlocksTemp.add(javaBlockIdentifier.split("\\[")[0]);
+            this.validBlocks = ImmutableSet.copyOf(validBlocksTemp);
+        } else {
+            this.validBlocks = Collections.singleton(javaBlockIdentifier.split("\\[")[0]);
+        }
+    }
 
     @Override
     public void prepareInventory(InventoryTranslator translator, GeyserSession session, Inventory inventory) {
+        // Check to see if there is an existing block we can use that the player just selected.
+        // First, verify that the player's position has not changed, so we don't try to select a block wildly out of range.
+        // (This could be a virtual inventory that the player is opening)
+        if (session.getLastInteractionPlayerPosition().equals(session.getPlayerEntity().getPosition())) {
+            // Then, check to see if the interacted block is valid for this inventory by ensuring the block state identifier is valid
+            int javaBlockId = session.getConnector().getWorldManager().getBlockAt(session, session.getLastInteractionBlockPosition());
+            String[] javaBlockString = BlockTranslator.getJavaIdBlockMap().inverse().getOrDefault(javaBlockId, "minecraft:air").split("\\[");
+            if (isValidBlock(javaBlockString)) {
+                // We can safely use this block
+                inventory.setHolderPosition(session.getLastInteractionBlockPosition());
+                ((Container) inventory).setUsingRealBlock(true, javaBlockString[0]);
+                setCustomName(session, session.getLastInteractionBlockPosition(), inventory, javaBlockId);
+                return;
+            }
+        }
+
+        // Otherwise, time to conjure up a fake block!
         Vector3i position = session.getPlayerEntity().getPosition().toInt();
         position = position.add(Vector3i.UP);
         UpdateBlockPacket blockPacket = new UpdateBlockPacket();
         blockPacket.setDataLayer(0);
         blockPacket.setBlockPosition(position);
-        blockPacket.setRuntimeId(session.getBlockTranslator().getBedrockBlockId(javaBlockState));
+        blockPacket.setRuntimeId(session.getBlockTranslator().getBedrockBlockId(defaultJavaBlockState));
         blockPacket.getFlags().addAll(UpdateBlockPacket.FLAG_ALL_PRIORITY);
         session.sendUpstreamPacket(blockPacket);
         inventory.setHolderPosition(position);
 
+        setCustomName(session, position, inventory, defaultJavaBlockState);
+    }
+
+    /**
+     * @return true if this Java block ID can be used for player inventory.
+     */
+    protected boolean isValidBlock(String[] javaBlockString) {
+        return this.validBlocks.contains(javaBlockString[0]);
+    }
+
+    protected void setCustomName(GeyserSession session, Vector3i position, Inventory inventory, int javaBlockState) {
         NbtMap tag = NbtMap.builder()
                 .putInt("x", position.getX())
                 .putInt("y", position.getY())
@@ -77,6 +132,16 @@ public class BlockInventoryHolder extends InventoryHolder {
 
     @Override
     public void closeInventory(InventoryTranslator translator, GeyserSession session, Inventory inventory) {
+        if (((Container) inventory).isUsingRealBlock()) {
+            // No need to reset a block since we didn't change any blocks
+            // But send a container close packet because we aren't destroying the original.
+            ContainerClosePacket packet = new ContainerClosePacket();
+            packet.setId((byte) inventory.getId());
+            packet.setUnknownBool0(true); //TODO needs to be changed in Protocol to "server-side" or something
+            session.sendUpstreamPacket(packet);
+            return;
+        }
+
         Vector3i holderPos = inventory.getHolderPosition();
         Position pos = new Position(holderPos.getX(), holderPos.getY(), holderPos.getZ());
         int realBlock = session.getConnector().getWorldManager().getBlockAt(session, pos.getX(), pos.getY(), pos.getZ());
@@ -84,6 +149,7 @@ public class BlockInventoryHolder extends InventoryHolder {
         blockPacket.setDataLayer(0);
         blockPacket.setBlockPosition(holderPos);
         blockPacket.setRuntimeId(session.getBlockTranslator().getBedrockBlockId(realBlock));
+        blockPacket.getFlags().addAll(UpdateBlockPacket.FLAG_ALL_PRIORITY);
         session.sendUpstreamPacket(blockPacket);
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/MerchantInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/AbstractBlockInventoryTranslator.java
similarity index 50%
rename from connector/src/main/java/org/geysermc/connector/network/translators/inventory/MerchantInventoryTranslator.java
rename to connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/AbstractBlockInventoryTranslator.java
index aa36a8a81..49caef13b 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/MerchantInventoryTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/AbstractBlockInventoryTranslator.java
@@ -23,84 +23,60 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.connector.network.translators.inventory;
+package org.geysermc.connector.network.translators.inventory.translators;
 
-import com.nukkitx.protocol.bedrock.data.inventory.ContainerId;
-import com.nukkitx.protocol.bedrock.data.inventory.InventoryActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
 import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
-import org.geysermc.connector.network.translators.inventory.updater.CursorInventoryUpdater;
+import org.geysermc.connector.network.translators.inventory.holder.BlockInventoryHolder;
+import org.geysermc.connector.network.translators.inventory.holder.InventoryHolder;
 import org.geysermc.connector.network.translators.inventory.updater.InventoryUpdater;
 
-import java.util.List;
-
-public class MerchantInventoryTranslator extends BaseInventoryTranslator {
-
+/**
+ * Provided as a base for any inventory that requires a block for opening it
+ */
+public abstract class AbstractBlockInventoryTranslator extends BaseInventoryTranslator {
+    private final InventoryHolder holder;
     private final InventoryUpdater updater;
 
-    public MerchantInventoryTranslator() {
-        super(3);
-        this.updater = new CursorInventoryUpdater();
+    /**
+     * @param size the amount of slots that the inventory adds alongside the base inventory slots
+     * @param javaBlockIdentifier a Java block identifier that is used as a temporary block
+     * @param containerType the container type of this inventory
+     * @param updater updater
+     * @param additionalValidBlocks any other block identifiers that can safely use this inventory without a fake block
+     */
+    public AbstractBlockInventoryTranslator(int size, String javaBlockIdentifier, ContainerType containerType, InventoryUpdater updater,
+                                            String... additionalValidBlocks) {
+        super(size);
+        this.holder = new BlockInventoryHolder(javaBlockIdentifier, containerType, additionalValidBlocks);
+        this.updater = updater;
     }
 
-    @Override
-    public int javaSlotToBedrock(int slot) {
-        switch (slot) {
-            case 0:
-                return 4;
-            case 1:
-                return 5;
-            case 2:
-                return 50;
-        }
-        return super.javaSlotToBedrock(slot);
-    }
-
-    @Override
-    public int bedrockSlotToJava(InventoryActionData action) {
-        switch (action.getSource().getContainerId()) {
-            case ContainerId.UI:
-                switch (action.getSlot()) {
-                    case 4:
-                        return 0;
-                    case 5:
-                        return 1;
-                    case 50:
-                        return 2;
-                }
-                break;
-            case -28: // Trading 1?
-                return 0;
-            case -29: // Trading 2?
-                return 1;
-            case -30: // Trading Output?
-                return 2;
-        }
-        return super.bedrockSlotToJava(action);
-    }
-
-    @Override
-    public SlotType getSlotType(int javaSlot) {
-        if (javaSlot == 2) {
-            return SlotType.OUTPUT;
-        }
-        return SlotType.NORMAL;
+    /**
+     * @param size the amount of slots that the inventory adds alongside the base inventory slots
+     * @param holder the custom block holder
+     * @param updater updater
+     */
+    public AbstractBlockInventoryTranslator(int size, InventoryHolder holder, InventoryUpdater updater) {
+        super(size);
+        this.holder = holder;
+        this.updater = updater;
     }
 
     @Override
     public void prepareInventory(GeyserSession session, Inventory inventory) {
-
+        holder.prepareInventory(this, session, inventory);
     }
 
     @Override
     public void openInventory(GeyserSession session, Inventory inventory) {
-
+        holder.openInventory(this, session, inventory);
     }
 
     @Override
     public void closeInventory(GeyserSession session, Inventory inventory) {
-        session.setLastInteractedVillagerEid(-1);
-        session.setVillagerTrades(null);
+        holder.closeInventory(this, session, inventory);
     }
 
     @Override
@@ -112,13 +88,4 @@ public class MerchantInventoryTranslator extends BaseInventoryTranslator {
     public void updateSlot(GeyserSession session, Inventory inventory, int slot) {
         updater.updateSlot(this, session, inventory, slot);
     }
-
-    @Override
-    public void translateActions(GeyserSession session, Inventory inventory, List<InventoryActionData> actions) {
-        if (actions.stream().anyMatch(a -> a.getSource().getContainerId() == -31)) {
-            return;
-        }
-
-        super.translateActions(session, inventory, actions);
-    }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/AnvilInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/AnvilInventoryTranslator.java
new file mode 100644
index 000000000..38a0935e6
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/AnvilInventoryTranslator.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators;
+
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientRenameItemPacket;
+import com.nukkitx.nbt.NbtMap;
+import com.nukkitx.protocol.bedrock.data.inventory.*;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.CraftResultsDeprecatedStackRequestActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.StackRequestActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.StackRequestActionType;
+import com.nukkitx.protocol.bedrock.packet.ItemStackResponsePacket;
+import org.geysermc.connector.inventory.AnvilContainer;
+import org.geysermc.connector.inventory.GeyserItemStack;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.PlayerInventory;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.updater.UIInventoryUpdater;
+import org.geysermc.connector.network.translators.item.ItemTranslator;
+
+public class AnvilInventoryTranslator extends AbstractBlockInventoryTranslator {
+    public AnvilInventoryTranslator() {
+        super(3, "minecraft:anvil[facing=north]", ContainerType.ANVIL, UIInventoryUpdater.INSTANCE,
+                "minecraft:chipped_anvil", "minecraft:damaged_anvil");
+    }
+
+    /* 1.16.100 support start */
+    @Override
+    @Deprecated
+    public boolean shouldHandleRequestFirst(StackRequestActionData action, Inventory inventory) {
+        return action.getType() == StackRequestActionType.CRAFT_NON_IMPLEMENTED_DEPRECATED;
+    }
+
+    @Override
+    @Deprecated
+    public ItemStackResponsePacket.Response translateSpecialRequest(GeyserSession session, Inventory inventory, ItemStackRequest request) {
+        if (!(request.getActions()[1] instanceof CraftResultsDeprecatedStackRequestActionData)) {
+            // Just silently log an error
+            session.getConnector().getLogger().debug("Something isn't quite right with taking an item out of an anvil.");
+            return translateRequest(session, inventory, request);
+        }
+        CraftResultsDeprecatedStackRequestActionData actionData = (CraftResultsDeprecatedStackRequestActionData) request.getActions()[1];
+        ItemData resultItem = actionData.getResultItems()[0];
+        if (resultItem.getTag() != null) {
+            NbtMap displayTag = resultItem.getTag().getCompound("display");
+            if (displayTag != null && displayTag.containsKey("Name")) {
+                ItemData sourceSlot = inventory.getItem(0).getItemData(session);
+
+                if (sourceSlot.getTag() != null) {
+                    NbtMap oldDisplayTag = sourceSlot.getTag().getCompound("display");
+                    if (oldDisplayTag != null && oldDisplayTag.containsKey("Name")) {
+                        if (!displayTag.getString("Name").equals(oldDisplayTag.getString("Name"))) {
+                            // Name has changed
+                            sendRenamePacket(session, inventory, resultItem, displayTag.getString("Name"));
+                        }
+                    } else {
+                        // No display tag on the old item
+                        sendRenamePacket(session, inventory, resultItem, displayTag.getString("Name"));
+                    }
+                } else {
+                    // New NBT tag
+                    sendRenamePacket(session, inventory, resultItem, displayTag.getString("Name"));
+                }
+            }
+        }
+        return translateRequest(session, inventory, request);
+    }
+
+    private void sendRenamePacket(GeyserSession session, Inventory inventory, ItemData outputItem, String name) {
+        session.sendDownstreamPacket(new ClientRenameItemPacket(name));
+        inventory.setItem(2, GeyserItemStack.from(ItemTranslator.translateToJava(outputItem)), session);
+    }
+
+    /* 1.16.100 support end */
+
+    @Override
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        switch (slotInfoData.getContainer()) {
+            case ANVIL_INPUT:
+                return 0;
+            case ANVIL_MATERIAL:
+                return 1;
+            case ANVIL_RESULT:
+            case CREATIVE_OUTPUT:
+                return 2;
+        }
+        return super.bedrockSlotToJava(slotInfoData);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        switch (slot) {
+            case 0:
+                return new BedrockContainerSlot(ContainerSlotType.ANVIL_INPUT, 1);
+            case 1:
+                return new BedrockContainerSlot(ContainerSlotType.ANVIL_MATERIAL, 2);
+            case 2:
+                return new BedrockContainerSlot(ContainerSlotType.ANVIL_RESULT, 50);
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
+
+    @Override
+    public int javaSlotToBedrock(int slot) {
+        switch (slot) {
+            case 0:
+                return 1;
+            case 1:
+                return 2;
+            case 2:
+                return 50;
+        }
+        return super.javaSlotToBedrock(slot);
+    }
+
+    @Override
+    public Inventory createInventory(String name, int windowId, WindowType windowType, PlayerInventory playerInventory) {
+        return new AnvilContainer(name, windowId, this.size, windowType, playerInventory);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/BaseInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/BaseInventoryTranslator.java
similarity index 53%
rename from connector/src/main/java/org/geysermc/connector/network/translators/inventory/BaseInventoryTranslator.java
rename to connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/BaseInventoryTranslator.java
index ca241e299..5b3be5b27 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/BaseInventoryTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/BaseInventoryTranslator.java
@@ -23,18 +23,21 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.connector.network.translators.inventory;
+package org.geysermc.connector.network.translators.inventory.translators;
 
-import com.nukkitx.protocol.bedrock.data.inventory.ContainerId;
-import com.nukkitx.protocol.bedrock.data.inventory.InventoryActionData;
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
+import org.geysermc.connector.inventory.Container;
 import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.PlayerInventory;
 import org.geysermc.connector.network.session.GeyserSession;
-import org.geysermc.connector.network.translators.inventory.action.InventoryActionDataTranslator;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.SlotType;
 
-import java.util.List;
-
-public abstract class BaseInventoryTranslator extends InventoryTranslator{
-    BaseInventoryTranslator(int size) {
+public abstract class BaseInventoryTranslator extends InventoryTranslator {
+    public BaseInventoryTranslator(int size) {
         super(size);
     }
 
@@ -44,15 +47,18 @@ public abstract class BaseInventoryTranslator extends InventoryTranslator{
     }
 
     @Override
-    public int bedrockSlotToJava(InventoryActionData action) {
-        int slotnum = action.getSlot();
-        if (action.getSource().getContainerId() == ContainerId.INVENTORY) {
-            //hotbar
-            if (slotnum >= 9) {
-                return slotnum + this.size - 9;
-            } else {
-                return slotnum + this.size + 27;
-            }
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        int slotnum = slotInfoData.getSlot();
+        switch (slotInfoData.getContainer()) {
+            case HOTBAR_AND_INVENTORY:
+            case HOTBAR:
+            case INVENTORY:
+                //hotbar
+                if (slotnum >= 9) {
+                    return slotnum + this.size - 9;
+                } else {
+                    return slotnum + this.size + 27;
+                }
         }
         return slotnum;
     }
@@ -70,13 +76,26 @@ public abstract class BaseInventoryTranslator extends InventoryTranslator{
         return slot;
     }
 
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        if (slot >= this.size) {
+            final int tmp = slot - this.size;
+            if (tmp < 27) {
+                return new BedrockContainerSlot(ContainerSlotType.INVENTORY, tmp + 9);
+            } else {
+                return new BedrockContainerSlot(ContainerSlotType.HOTBAR, tmp - 27);
+            }
+        }
+        throw new IllegalArgumentException("Unknown bedrock slot");
+    }
+
     @Override
     public SlotType getSlotType(int javaSlot) {
         return SlotType.NORMAL;
     }
 
     @Override
-    public void translateActions(GeyserSession session, Inventory inventory, List<InventoryActionData> actions) {
-        InventoryActionDataTranslator.translate(this, session, inventory, actions);
+    public Inventory createInventory(String name, int windowId, WindowType windowType, PlayerInventory playerInventory) {
+        return new Container(name, windowId, this.size, windowType, playerInventory);
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/BeaconInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/BeaconInventoryTranslator.java
new file mode 100644
index 000000000..46c09b66b
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/BeaconInventoryTranslator.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators;
+
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientSetBeaconEffectPacket;
+import com.nukkitx.math.vector.Vector3i;
+import com.nukkitx.nbt.NbtMap;
+import com.nukkitx.nbt.NbtMapBuilder;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
+import com.nukkitx.protocol.bedrock.data.inventory.ItemStackRequest;
+import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.BeaconPaymentStackRequestActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.StackRequestActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.StackRequestActionType;
+import com.nukkitx.protocol.bedrock.packet.BlockEntityDataPacket;
+import com.nukkitx.protocol.bedrock.packet.ItemStackResponsePacket;
+import org.geysermc.connector.inventory.BeaconContainer;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.PlayerInventory;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.updater.UIInventoryUpdater;
+
+import java.util.Collections;
+
+public class BeaconInventoryTranslator extends AbstractBlockInventoryTranslator {
+    public BeaconInventoryTranslator() {
+        super(1, "minecraft:beacon", ContainerType.BEACON, UIInventoryUpdater.INSTANCE);
+    }
+
+    @Override
+    public void updateProperty(GeyserSession session, Inventory inventory, int key, int value) {
+        //FIXME?: Beacon graphics look weird after inputting an item. This might be a Bedrock bug, since it resets to nothing
+        // on BDS
+        BeaconContainer beaconContainer = (BeaconContainer) inventory;
+        switch (key) {
+            case 0:
+                // Power - beacon doesn't use this, and uses the block position instead
+                break;
+            case 1:
+                beaconContainer.setPrimaryId(value == -1 ? 0 : value);
+                break;
+            case 2:
+                beaconContainer.setSecondaryId(value == -1 ? 0 : value);
+                break;
+        }
+
+        // Send a block entity data packet update to the fake beacon inventory
+        Vector3i position = inventory.getHolderPosition();
+        NbtMapBuilder builder = NbtMap.builder()
+                .putInt("x", position.getX())
+                .putInt("y", position.getY())
+                .putInt("z", position.getZ())
+                .putString("CustomName", inventory.getTitle())
+                .putString("id", "Beacon")
+                .putInt("primary", beaconContainer.getPrimaryId())
+                .putInt("secondary", beaconContainer.getSecondaryId());
+
+        BlockEntityDataPacket packet = new BlockEntityDataPacket();
+        packet.setBlockPosition(position);
+        packet.setData(builder.build());
+        session.sendUpstreamPacket(packet);
+    }
+
+    @Override
+    public boolean shouldHandleRequestFirst(StackRequestActionData action, Inventory inventory) {
+        return action.getType() == StackRequestActionType.BEACON_PAYMENT;
+    }
+
+    @Override
+    public ItemStackResponsePacket.Response translateSpecialRequest(GeyserSession session, Inventory inventory, ItemStackRequest request) {
+        // Input a beacon payment
+        BeaconPaymentStackRequestActionData beaconPayment = (BeaconPaymentStackRequestActionData) request.getActions()[0];
+        ClientSetBeaconEffectPacket packet = new ClientSetBeaconEffectPacket(beaconPayment.getPrimaryEffect(), beaconPayment.getSecondaryEffect());
+        session.sendDownstreamPacket(packet);
+        return acceptRequest(request, makeContainerEntries(session, inventory, Collections.emptySet()));
+    }
+
+    @Override
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        if (slotInfoData.getContainer() == ContainerSlotType.BEACON_PAYMENT) {
+            return 0;
+        }
+        return super.bedrockSlotToJava(slotInfoData);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        if (slot == 0) {
+            return new BedrockContainerSlot(ContainerSlotType.BEACON_PAYMENT, 27);
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
+
+    @Override
+    public int javaSlotToBedrock(int slot) {
+        if (slot == 0) {
+            return 27;
+        }
+        return super.javaSlotToBedrock(slot);
+    }
+
+    @Override
+    public Inventory createInventory(String name, int windowId, WindowType windowType, PlayerInventory playerInventory) {
+        return new BeaconContainer(name, windowId, this.size, windowType, playerInventory);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/BrewingInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/BrewingInventoryTranslator.java
similarity index 71%
rename from connector/src/main/java/org/geysermc/connector/network/translators/inventory/BrewingInventoryTranslator.java
rename to connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/BrewingInventoryTranslator.java
index 2242a979d..992a74511 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/BrewingInventoryTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/BrewingInventoryTranslator.java
@@ -23,18 +23,20 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.connector.network.translators.inventory;
+package org.geysermc.connector.network.translators.inventory.translators;
 
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
-import com.nukkitx.protocol.bedrock.data.inventory.InventoryActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
 import com.nukkitx.protocol.bedrock.packet.ContainerSetDataPacket;
 import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
 import org.geysermc.connector.network.translators.inventory.updater.ContainerInventoryUpdater;
 
-public class BrewingInventoryTranslator extends BlockInventoryTranslator {
+public class BrewingInventoryTranslator extends AbstractBlockInventoryTranslator {
     public BrewingInventoryTranslator() {
-        super(5, "minecraft:brewing_stand[has_bottle_0=false,has_bottle_1=false,has_bottle_2=false]", ContainerType.BREWING_STAND, new ContainerInventoryUpdater());
+        super(5, "minecraft:brewing_stand[has_bottle_0=false,has_bottle_1=false,has_bottle_2=false]", ContainerType.BREWING_STAND, ContainerInventoryUpdater.INSTANCE);
     }
 
     @Override
@@ -66,20 +68,16 @@ public class BrewingInventoryTranslator extends BlockInventoryTranslator {
     }
 
     @Override
-    public int bedrockSlotToJava(InventoryActionData action) {
-        final int slot = super.bedrockSlotToJava(action);
-        switch (slot) {
-            case 0:
-                return 3;
-            case 1:
-                return 0;
-            case 2:
-                return 1;
-            case 3:
-                return 2;
-            default:
-                return slot;
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        if (slotInfoData.getContainer() == ContainerSlotType.BREWING_INPUT) {
+            // Ingredient
+            return 3;
         }
+        if (slotInfoData.getContainer() == ContainerSlotType.BREWING_RESULT) {
+            // Potions
+            return slotInfoData.getSlot() - 1;
+        }
+        return super.bedrockSlotToJava(slotInfoData);
     }
 
     @Override
@@ -96,4 +94,19 @@ public class BrewingInventoryTranslator extends BlockInventoryTranslator {
         }
         return super.javaSlotToBedrock(slot);
     }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        switch (slot) {
+            case 0:
+            case 1:
+            case 2:
+                return new BedrockContainerSlot(ContainerSlotType.BREWING_RESULT, javaSlotToBedrock(slot));
+            case 3:
+                return new BedrockContainerSlot(ContainerSlotType.BREWING_INPUT, 0);
+            case 4:
+                return new BedrockContainerSlot(ContainerSlotType.BREWING_INPUT, 0);
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/CartographyInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/CartographyInventoryTranslator.java
new file mode 100644
index 000000000..319d9ec0a
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/CartographyInventoryTranslator.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators;
+
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
+import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
+import org.geysermc.connector.inventory.CartographyContainer;
+import org.geysermc.connector.inventory.GeyserItemStack;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.PlayerInventory;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.updater.UIInventoryUpdater;
+
+public class CartographyInventoryTranslator extends AbstractBlockInventoryTranslator {
+    public CartographyInventoryTranslator() {
+        super(3, "minecraft:cartography_table", ContainerType.CARTOGRAPHY, UIInventoryUpdater.INSTANCE);
+    }
+
+    @Override
+    public boolean shouldRejectItemPlace(GeyserSession session, Inventory inventory, ContainerSlotType bedrockSourceContainer,
+                                         int javaSourceSlot, ContainerSlotType bedrockDestinationContainer, int javaDestinationSlot) {
+        if (javaDestinationSlot == 0) {
+            // Bedrock Edition can use paper in slot 0
+            GeyserItemStack itemStack = javaSourceSlot == -1 ? session.getPlayerInventory().getCursor() : inventory.getItem(javaSourceSlot);
+            return itemStack.getItemEntry().getJavaIdentifier().equals("minecraft:paper");
+        } else if (javaDestinationSlot == 1) {
+            // Bedrock Edition can use a compass to create locator maps in the ADDITIONAL slot
+            GeyserItemStack itemStack = javaSourceSlot == -1 ? session.getPlayerInventory().getCursor() : inventory.getItem(javaSourceSlot);
+            return itemStack.getItemEntry().getJavaIdentifier().equals("minecraft:compass");
+        }
+        return false;
+    }
+
+    @Override
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        if (slotInfoData.getContainer() == ContainerSlotType.CARTOGRAPHY_INPUT) {
+            return 0;
+        }
+        if (slotInfoData.getContainer() == ContainerSlotType.CARTOGRAPHY_ADDITIONAL) {
+            return 1;
+        }
+        if (slotInfoData.getContainer() == ContainerSlotType.CARTOGRAPHY_RESULT || slotInfoData.getContainer() == ContainerSlotType.CREATIVE_OUTPUT) {
+            return 2;
+        }
+        return super.bedrockSlotToJava(slotInfoData);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        switch (slot) {
+            case 0:
+                return new BedrockContainerSlot(ContainerSlotType.CARTOGRAPHY_INPUT, 12);
+            case 1:
+                return new BedrockContainerSlot(ContainerSlotType.CARTOGRAPHY_ADDITIONAL, 13);
+            case 2:
+                return new BedrockContainerSlot(ContainerSlotType.CARTOGRAPHY_RESULT, 50);
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
+
+    @Override
+    public int javaSlotToBedrock(int slot) {
+        switch (slot) {
+            case 0:
+                return 12;
+            case 1:
+                return 13;
+            case 2:
+                return 50;
+        }
+        return super.javaSlotToBedrock(slot);
+    }
+
+    @Override
+    public Inventory createInventory(String name, int windowId, WindowType windowType, PlayerInventory playerInventory) {
+        return new CartographyContainer(name, windowId, this.size, windowType, playerInventory);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/CraftingInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/CraftingInventoryTranslator.java
similarity index 53%
rename from connector/src/main/java/org/geysermc/connector/network/translators/inventory/CraftingInventoryTranslator.java
rename to connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/CraftingInventoryTranslator.java
index 18cbbae75..81769c00a 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/CraftingInventoryTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/CraftingInventoryTranslator.java
@@ -23,47 +23,18 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.connector.network.translators.inventory;
+package org.geysermc.connector.network.translators.inventory.translators;
 
-import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
-import com.nukkitx.protocol.bedrock.data.inventory.ContainerId;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
-import com.nukkitx.protocol.bedrock.data.inventory.InventoryActionData;
-import com.nukkitx.protocol.bedrock.data.inventory.InventorySource;
-import org.geysermc.connector.inventory.Inventory;
-import org.geysermc.connector.network.session.GeyserSession;
-import org.geysermc.connector.network.translators.inventory.updater.CursorInventoryUpdater;
-import org.geysermc.connector.utils.InventoryUtils;
+import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.SlotType;
+import org.geysermc.connector.network.translators.inventory.updater.UIInventoryUpdater;
 
-import java.util.List;
-
-public class CraftingInventoryTranslator extends BlockInventoryTranslator {
+public class CraftingInventoryTranslator extends AbstractBlockInventoryTranslator {
     public CraftingInventoryTranslator() {
-        super(10, "minecraft:crafting_table", ContainerType.WORKBENCH, new CursorInventoryUpdater());
-    }
-
-    @Override
-    public int bedrockSlotToJava(InventoryActionData action) {
-        if (action.getSlot() == 50) {
-            // Slot 50 is used for crafting with a controller.
-            return 0;
-        }
-
-        if (action.getSource().getContainerId() == ContainerId.UI) {
-            int slotnum = action.getSlot();
-            if (slotnum >= 32 && 42 >= slotnum) {
-                return slotnum - 31;
-            }
-        }
-        return super.bedrockSlotToJava(action);
-    }
-
-    @Override
-    public int javaSlotToBedrock(int slot) {
-        if (slot < size) {
-            return slot == 0 ? 50 : slot + 31;
-        }
-        return super.javaSlotToBedrock(slot);
+        super(10, "minecraft:crafting_table", ContainerType.WORKBENCH, UIInventoryUpdater.INSTANCE);
     }
 
     @Override
@@ -74,16 +45,34 @@ public class CraftingInventoryTranslator extends BlockInventoryTranslator {
     }
 
     @Override
-    public void translateActions(GeyserSession session, Inventory inventory, List<InventoryActionData> actions) {
-        if (session.getGameMode() == GameMode.CREATIVE) {
-            for (InventoryActionData action : actions) {
-                if (action.getSource().getType() == InventorySource.Type.CREATIVE) {
-                    updateInventory(session, inventory);
-                    InventoryUtils.updateCursor(session);
-                    return;
-                }
-            }
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        if (slot >= 1 && slot <= 9) {
+            return new BedrockContainerSlot(ContainerSlotType.CRAFTING_INPUT, slot + 31);
         }
-        super.translateActions(session, inventory, actions);
+        if (slot == 0) {
+            return new BedrockContainerSlot(ContainerSlotType.CRAFTING_OUTPUT, 0);
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
+
+    @Override
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        if (slotInfoData.getContainer() == ContainerSlotType.CRAFTING_INPUT) {
+            // Java goes from 1 - 9, left to right then up to down
+            // Bedrock is the same, but it starts from 32.
+            return slotInfoData.getSlot() - 31;
+        }
+        if (slotInfoData.getContainer() == ContainerSlotType.CRAFTING_OUTPUT || slotInfoData.getContainer() == ContainerSlotType.CREATIVE_OUTPUT) {
+            return 0;
+        }
+        return super.bedrockSlotToJava(slotInfoData);
+    }
+
+    @Override
+    public int javaSlotToBedrock(int slot) {
+        if (slot < size) {
+            return slot == 0 ? 50 : slot + 31;
+        }
+        return super.javaSlotToBedrock(slot);
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/EnchantingInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/EnchantingInventoryTranslator.java
new file mode 100644
index 000000000..03f8bb104
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/EnchantingInventoryTranslator.java
@@ -0,0 +1,213 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators;
+
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientClickWindowButtonPacket;
+import com.nukkitx.protocol.bedrock.data.inventory.*;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.CraftRecipeStackRequestActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.StackRequestActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.StackRequestActionType;
+import com.nukkitx.protocol.bedrock.packet.ItemStackResponsePacket;
+import com.nukkitx.protocol.bedrock.packet.PlayerEnchantOptionsPacket;
+import org.geysermc.connector.inventory.EnchantingContainer;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.PlayerInventory;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.updater.UIInventoryUpdater;
+import org.geysermc.connector.network.translators.item.Enchantment;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+public class EnchantingInventoryTranslator extends AbstractBlockInventoryTranslator {
+    public EnchantingInventoryTranslator() {
+        super(2, "minecraft:enchanting_table", ContainerType.ENCHANTMENT, UIInventoryUpdater.INSTANCE);
+    }
+
+    @Override
+    public void updateProperty(GeyserSession session, Inventory inventory, int key, int value) {
+        int slotToUpdate;
+        EnchantingContainer enchantingInventory = (EnchantingContainer) inventory;
+        boolean shouldUpdate = false;
+        switch (key) {
+            case 0:
+            case 1:
+            case 2:
+                // Experience required
+                slotToUpdate = key;
+                enchantingInventory.getGeyserEnchantOptions()[slotToUpdate].setXpCost(value);
+                break;
+            case 4:
+            case 5:
+            case 6:
+                // Enchantment type
+                slotToUpdate = key - 4;
+                int index = value;
+                if (index != -1) {
+                    Enchantment enchantment = Enchantment.getByJavaIdentifier("minecraft:" + JavaEnchantment.values()[index].name().toLowerCase());
+                    if (enchantment != null) {
+                        // Convert the Java enchantment index to Bedrock's
+                        index = enchantment.ordinal();
+                    } else {
+                        index = -1;
+                    }
+                }
+                enchantingInventory.getGeyserEnchantOptions()[slotToUpdate].setJavaEnchantIndex(value);
+                enchantingInventory.getGeyserEnchantOptions()[slotToUpdate].setBedrockEnchantIndex(index);
+                break;
+            case 7:
+            case 8:
+            case 9:
+                // Enchantment level
+                slotToUpdate = key - 7;
+                enchantingInventory.getGeyserEnchantOptions()[slotToUpdate].setEnchantLevel(value);
+                shouldUpdate = true; // Java sends each property as its own packet, so let's only update after all properties have been sent
+                break;
+            default:
+                return;
+        }
+        if (shouldUpdate) {
+            enchantingInventory.getEnchantOptions()[slotToUpdate] = enchantingInventory.getGeyserEnchantOptions()[slotToUpdate].build(session);
+            PlayerEnchantOptionsPacket packet = new PlayerEnchantOptionsPacket();
+            packet.getOptions().addAll(Arrays.asList(enchantingInventory.getEnchantOptions()));
+            session.sendUpstreamPacket(packet);
+        }
+    }
+
+    @Override
+    public boolean shouldHandleRequestFirst(StackRequestActionData action, Inventory inventory) {
+        return action.getType() == StackRequestActionType.CRAFT_RECIPE;
+    }
+
+    @Override
+    public ItemStackResponsePacket.Response translateSpecialRequest(GeyserSession session, Inventory inventory, ItemStackRequest request) {
+        // Client has requested an item to be enchanted
+        CraftRecipeStackRequestActionData craftRecipeData = (CraftRecipeStackRequestActionData) request.getActions()[0];
+        EnchantingContainer enchantingInventory = (EnchantingContainer) inventory;
+        int javaSlot = -1;
+        for (int i = 0; i < enchantingInventory.getEnchantOptions().length; i++) {
+            EnchantOptionData enchantData = enchantingInventory.getEnchantOptions()[i];
+            if (enchantData != null) {
+                if (craftRecipeData.getRecipeNetworkId() == enchantData.getEnchantNetId()) {
+                    // Enchant net ID is how we differentiate between what item Bedrock wants
+                    javaSlot = enchantingInventory.getGeyserEnchantOptions()[i].getJavaIndex();
+                    break;
+                }
+            }
+        }
+        if (javaSlot == -1) {
+            // Slot should be determined as 0, 1, or 2
+            return rejectRequest(request);
+        }
+        ClientClickWindowButtonPacket packet = new ClientClickWindowButtonPacket(inventory.getId(), javaSlot);
+        session.sendDownstreamPacket(packet);
+        return acceptRequest(request, makeContainerEntries(session, inventory, Collections.emptySet()));
+    }
+
+    @Override
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        if (slotInfoData.getContainer() == ContainerSlotType.ENCHANTING_INPUT) {
+            return 0;
+        }
+        if (slotInfoData.getContainer() == ContainerSlotType.ENCHANTING_LAPIS) {
+            return 1;
+        }
+        return super.bedrockSlotToJava(slotInfoData);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        if (slot == 0) {
+            return new BedrockContainerSlot(ContainerSlotType.ENCHANTING_INPUT, 14);
+        }
+        if (slot == 1) {
+            return new BedrockContainerSlot(ContainerSlotType.ENCHANTING_LAPIS, 15);
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
+
+    @Override
+    public int javaSlotToBedrock(int slot) {
+        if (slot == 0) {
+            return 14;
+        }
+        if (slot == 1) {
+            return 15;
+        }
+        return super.javaSlotToBedrock(slot);
+    }
+
+    @Override
+    public Inventory createInventory(String name, int windowId, WindowType windowType, PlayerInventory playerInventory) {
+        return new EnchantingContainer(name, windowId, this.size, windowType, playerInventory);
+    }
+
+    /**
+     * Enchantments classified by their Java index
+     */
+    public enum JavaEnchantment {
+        PROTECTION,
+        FIRE_PROTECTION,
+        FEATHER_FALLING,
+        BLAST_PROTECTION,
+        PROJECTILE_PROTECTION,
+        RESPIRATION,
+        AQUA_AFFINITY,
+        THORNS,
+        DEPTH_STRIDER,
+        FROST_WALKER,
+        BINDING_CURSE,
+        SOUL_SPEED,
+        SHARPNESS,
+        SMITE,
+        BANE_OF_ARTHROPODS,
+        KNOCKBACK,
+        FIRE_ASPECT,
+        LOOTING,
+        SWEEPING,
+        EFFICIENCY,
+        SILK_TOUCH,
+        UNBREAKING,
+        FORTUNE,
+        POWER,
+        PUNCH,
+        FLAME,
+        INFINITY,
+        LUCK_OF_THE_SEA,
+        LURE,
+        LOYALTY,
+        IMPALING,
+        RIPTIDE,
+        CHANNELING,
+        MULTISHOT,
+        QUICK_CHARGE,
+        PIERCING,
+        MENDING,
+        VANISHING_CURSE
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/Generic3X3InventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/Generic3X3InventoryTranslator.java
new file mode 100644
index 000000000..ceac1b2c1
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/Generic3X3InventoryTranslator.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators;
+
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
+import com.nukkitx.protocol.bedrock.packet.ContainerOpenPacket;
+import org.geysermc.connector.inventory.Generic3X3Container;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.PlayerInventory;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.updater.ContainerInventoryUpdater;
+
+/**
+ * Droppers and dispensers
+ */
+public class Generic3X3InventoryTranslator extends AbstractBlockInventoryTranslator {
+    public Generic3X3InventoryTranslator() {
+        super(9, "minecraft:dispenser[facing=north,triggered=false]", ContainerType.DISPENSER, ContainerInventoryUpdater.INSTANCE,
+                "minecraft:dropper");
+    }
+
+    @Override
+    public Inventory createInventory(String name, int windowId, WindowType windowType, PlayerInventory playerInventory) {
+        return new Generic3X3Container(name, windowId, this.size, windowType, playerInventory);
+    }
+
+    @Override
+    public void openInventory(GeyserSession session, Inventory inventory) {
+        ContainerOpenPacket containerOpenPacket = new ContainerOpenPacket();
+        containerOpenPacket.setId((byte) inventory.getId());
+        // Required for opening the real block - otherwise, if the container type is incorrect, it refuses to open
+        containerOpenPacket.setType(((Generic3X3Container) inventory).isDropper() ? ContainerType.DROPPER : ContainerType.DISPENSER);
+        containerOpenPacket.setBlockPosition(inventory.getHolderPosition());
+        containerOpenPacket.setUniqueEntityId(inventory.getHolderId());
+        session.sendUpstreamPacket(containerOpenPacket);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int javaSlot) {
+        if (javaSlot < this.size) {
+            return new BedrockContainerSlot(ContainerSlotType.CONTAINER, javaSlot);
+        }
+        return super.javaSlotToBedrockContainer(javaSlot);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/GrindstoneInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/GrindstoneInventoryTranslator.java
similarity index 55%
rename from connector/src/main/java/org/geysermc/connector/network/translators/inventory/GrindstoneInventoryTranslator.java
rename to connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/GrindstoneInventoryTranslator.java
index 87448ff53..65364e147 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/GrindstoneInventoryTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/GrindstoneInventoryTranslator.java
@@ -23,34 +23,44 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.connector.network.translators.inventory;
+package org.geysermc.connector.network.translators.inventory.translators;
 
-import com.nukkitx.protocol.bedrock.data.inventory.ContainerId;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
-import com.nukkitx.protocol.bedrock.data.inventory.InventoryActionData;
-import org.geysermc.connector.network.translators.inventory.updater.CursorInventoryUpdater;
-
-public class GrindstoneInventoryTranslator extends BlockInventoryTranslator {
+import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.updater.UIInventoryUpdater;
 
+public class GrindstoneInventoryTranslator extends AbstractBlockInventoryTranslator {
     public GrindstoneInventoryTranslator() {
-        super(3, "minecraft:grindstone[face=floor,facing=north]", ContainerType.GRINDSTONE, new CursorInventoryUpdater());
+        super(3, "minecraft:grindstone[face=floor,facing=north]", ContainerType.GRINDSTONE, UIInventoryUpdater.INSTANCE);
     }
 
     @Override
-    public int bedrockSlotToJava(InventoryActionData action) {
-        final int slot = super.bedrockSlotToJava(action);
-        if (action.getSource().getContainerId() == ContainerId.UI) {
-            switch (slot) {
-                case 16:
-                    return 0;
-                case 17:
-                    return 1;
-                case 50:
-                    return 2;
-                default:
-                    return slot;
-            }
-        } return slot;
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        switch (slotInfoData.getContainer()) {
+            case GRINDSTONE_INPUT:
+                return 0;
+            case GRINDSTONE_ADDITIONAL:
+                return 1;
+            case GRINDSTONE_RESULT:
+            case CREATIVE_OUTPUT:
+                return 2;
+        }
+        return super.bedrockSlotToJava(slotInfoData);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        switch (slot) {
+            case 0:
+                return new BedrockContainerSlot(ContainerSlotType.GRINDSTONE_INPUT, 16);
+            case 1:
+                return new BedrockContainerSlot(ContainerSlotType.GRINDSTONE_ADDITIONAL, 17);
+            case 2:
+                return new BedrockContainerSlot(ContainerSlotType.GRINDSTONE_RESULT, 50);
+        }
+        return super.javaSlotToBedrockContainer(slot);
     }
 
     @Override
@@ -65,5 +75,4 @@ public class GrindstoneInventoryTranslator extends BlockInventoryTranslator {
         }
         return super.javaSlotToBedrock(slot);
     }
-
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/HopperInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/HopperInventoryTranslator.java
new file mode 100644
index 000000000..7f067d1c0
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/HopperInventoryTranslator.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators;
+
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.updater.ContainerInventoryUpdater;
+
+/**
+ * Implemented on top of any block that does not have special properties implemented
+ */
+public class HopperInventoryTranslator extends AbstractBlockInventoryTranslator {
+    public HopperInventoryTranslator() {
+        super(5, "minecraft:hopper[enabled=false,facing=down]", ContainerType.HOPPER, ContainerInventoryUpdater.INSTANCE);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int javaSlot) {
+        if (javaSlot < this.size) {
+            return new BedrockContainerSlot(ContainerSlotType.CONTAINER, javaSlot);
+        }
+        return super.javaSlotToBedrockContainer(javaSlot);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/LecternInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/LecternInventoryTranslator.java
new file mode 100644
index 000000000..dbbc418ba
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/LecternInventoryTranslator.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators;
+
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientClickWindowButtonPacket;
+import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientCloseWindowPacket;
+import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
+import com.github.steveice10.opennbt.tag.builtin.ListTag;
+import com.nukkitx.math.vector.Vector3i;
+import com.nukkitx.nbt.NbtMap;
+import com.nukkitx.nbt.NbtMapBuilder;
+import com.nukkitx.nbt.NbtType;
+import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
+import org.geysermc.connector.inventory.GeyserItemStack;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.LecternContainer;
+import org.geysermc.connector.inventory.PlayerInventory;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.updater.InventoryUpdater;
+import org.geysermc.connector.utils.BlockEntityUtils;
+import org.geysermc.connector.utils.InventoryUtils;
+
+import java.util.Collections;
+
+public class LecternInventoryTranslator extends BaseInventoryTranslator {
+    private final InventoryUpdater updater;
+
+    public LecternInventoryTranslator() {
+        super(1);
+        this.updater = new LecternInventoryUpdater();
+    }
+
+    @Override
+    public void prepareInventory(GeyserSession session, Inventory inventory) {
+
+    }
+
+    @Override
+    public void openInventory(GeyserSession session, Inventory inventory) {
+
+    }
+
+    @Override
+    public void closeInventory(GeyserSession session, Inventory inventory) {
+
+    }
+
+    @Override
+    public void updateProperty(GeyserSession session, Inventory inventory, int key, int value) {
+        if (key == 0) { // Lectern page update
+            LecternContainer lecternContainer = (LecternContainer) inventory;
+            lecternContainer.setCurrentBedrockPage(value / 2);
+            lecternContainer.setBlockEntityTag(lecternContainer.getBlockEntityTag().toBuilder().putInt("page", lecternContainer.getCurrentBedrockPage()).build());
+            BlockEntityUtils.updateBlockEntity(session, lecternContainer.getBlockEntityTag(), lecternContainer.getPosition());
+        }
+    }
+
+    @Override
+    public void updateInventory(GeyserSession session, Inventory inventory) {
+
+    }
+
+    @Override
+    public void updateSlot(GeyserSession session, Inventory inventory, int slot) {
+        this.updater.updateSlot(this, session, inventory, slot);
+        if (slot == 0) {
+            LecternContainer lecternContainer = (LecternContainer) inventory;
+            if (session.isDroppingLecternBook()) {
+                // We have to enter the inventory GUI to eject the book
+                ClientClickWindowButtonPacket packet = new ClientClickWindowButtonPacket(inventory.getId(), 3);
+                session.sendDownstreamPacket(packet);
+                session.setDroppingLecternBook(false);
+                InventoryUtils.closeInventory(session, inventory.getId(), false);
+            } else if (lecternContainer.getBlockEntityTag() == null) {
+                // If the method returns true, this is already handled for us
+                GeyserItemStack geyserItemStack = inventory.getItem(0);
+                CompoundTag tag = geyserItemStack.getNbt();
+                // Position has to be the last interacted position... right?
+                Vector3i position = session.getLastInteractionBlockPosition();
+                // shouldRefresh means that we should boot out the client on our side because their lectern GUI isn't updated yet
+                boolean shouldRefresh = !session.getConnector().getWorldManager().shouldExpectLecternHandled() && !session.getLecternCache().contains(position);
+
+                NbtMap blockEntityTag;
+                if (tag != null) {
+                    int pagesSize = ((ListTag) tag.get("pages")).size();
+                    ItemData itemData = geyserItemStack.getItemData(session);
+                    NbtMapBuilder lecternTag = getBaseLecternTag(position.getX(), position.getY(), position.getZ(), pagesSize);
+                    lecternTag.putCompound("book", NbtMap.builder()
+                            .putByte("Count", (byte) itemData.getCount())
+                            .putShort("Damage", (short) 0)
+                            .putString("Name", "minecraft:written_book")
+                            .putCompound("tag", itemData.getTag())
+                            .build());
+                    lecternTag.putInt("page", lecternContainer.getCurrentBedrockPage());
+                    blockEntityTag = lecternTag.build();
+                } else {
+                    // There is *a* book here, but... no NBT.
+                    NbtMapBuilder lecternTag = getBaseLecternTag(position.getX(), position.getY(), position.getZ(), 1);
+                    NbtMapBuilder bookTag = NbtMap.builder()
+                            .putByte("Count", (byte) 1)
+                            .putShort("Damage", (short) 0)
+                            .putString("Name", "minecraft:writable_book")
+                            .putCompound("tag", NbtMap.builder().putList("pages", NbtType.COMPOUND, Collections.singletonList(
+                                    NbtMap.builder()
+                                        .putString("photoname", "")
+                                        .putString("text", "")
+                                    .build()
+                            )).build());
+
+                    blockEntityTag = lecternTag.putCompound("book", bookTag.build()).build();
+                }
+                
+                // Even with serverside access to lecterns, we don't easily know which lectern this is, so we need to rebuild
+                // the block entity tag
+                lecternContainer.setBlockEntityTag(blockEntityTag);
+                lecternContainer.setPosition(position);
+                if (shouldRefresh) {
+                    // Update the lectern because it's not updated client-side
+                    BlockEntityUtils.updateBlockEntity(session, blockEntityTag, position);
+                    session.getLecternCache().add(position);
+                    // Close the window - we will reopen it once the client has this data synced
+                    ClientCloseWindowPacket closeWindowPacket = new ClientCloseWindowPacket(lecternContainer.getId());
+                    session.sendDownstreamPacket(closeWindowPacket);
+                    InventoryUtils.closeInventory(session, inventory.getId(), false);
+                }
+            }
+        }
+    }
+
+    @Override
+    public Inventory createInventory(String name, int windowId, WindowType windowType, PlayerInventory playerInventory) {
+        return new LecternContainer(name, windowId, this.size, windowType, playerInventory);
+    }
+
+    public static NbtMapBuilder getBaseLecternTag(int x, int y, int z, int totalPages) {
+        NbtMapBuilder builder = NbtMap.builder()
+                .putInt("x", x)
+                .putInt("y", y)
+                .putInt("z", z)
+                .putString("id", "Lectern");
+        if (totalPages != 0) {
+            builder.putByte("hasBook", (byte) 1);
+            builder.putInt("totalPages", totalPages);
+        } else {
+            // Not usually needed, but helps with kicking out Bedrock players from reading the UI
+            builder.putByte("hasBook", (byte) 0);
+        }
+        return builder;
+    }
+
+    private static class LecternInventoryUpdater extends InventoryUpdater {
+
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/LoomInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/LoomInventoryTranslator.java
new file mode 100644
index 000000000..38758c5f8
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/LoomInventoryTranslator.java
@@ -0,0 +1,222 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators;
+
+import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientClickWindowButtonPacket;
+import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
+import com.github.steveice10.opennbt.tag.builtin.ListTag;
+import com.nukkitx.nbt.NbtMap;
+import com.nukkitx.nbt.NbtType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
+import com.nukkitx.protocol.bedrock.data.inventory.ItemStackRequest;
+import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.CraftResultsDeprecatedStackRequestActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.StackRequestActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.StackRequestActionType;
+import com.nukkitx.protocol.bedrock.packet.ItemStackResponsePacket;
+import it.unimi.dsi.fastutil.objects.Object2IntMap;
+import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
+import org.geysermc.connector.inventory.GeyserItemStack;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.updater.UIInventoryUpdater;
+import org.geysermc.connector.network.translators.item.translators.BannerTranslator;
+
+import java.util.Collections;
+import java.util.List;
+
+public class LoomInventoryTranslator extends AbstractBlockInventoryTranslator {
+    /**
+     * A map of Bedrock patterns to Java index. Used to request for a specific banner pattern.
+     */
+    private static final Object2IntMap<String> PATTERN_TO_INDEX = new Object2IntOpenHashMap<>();
+
+    static {
+        // Added from left-to-right then up-to-down in the order Java presents it
+        int index = 1;
+        PATTERN_TO_INDEX.put("bl", index++);
+        PATTERN_TO_INDEX.put("br", index++);
+        PATTERN_TO_INDEX.put("tl", index++);
+        PATTERN_TO_INDEX.put("tr", index++);
+        PATTERN_TO_INDEX.put("bs", index++);
+        PATTERN_TO_INDEX.put("ts", index++);
+        PATTERN_TO_INDEX.put("ls", index++);
+        PATTERN_TO_INDEX.put("rs", index++);
+        PATTERN_TO_INDEX.put("cs", index++);
+        PATTERN_TO_INDEX.put("ms", index++);
+        PATTERN_TO_INDEX.put("drs", index++);
+        PATTERN_TO_INDEX.put("dls", index++);
+        PATTERN_TO_INDEX.put("ss", index++);
+        PATTERN_TO_INDEX.put("cr", index++);
+        PATTERN_TO_INDEX.put("sc", index++);
+        PATTERN_TO_INDEX.put("bt", index++);
+        PATTERN_TO_INDEX.put("tt", index++);
+        PATTERN_TO_INDEX.put("bts", index++);
+        PATTERN_TO_INDEX.put("tts", index++);
+        PATTERN_TO_INDEX.put("ld", index++);
+        PATTERN_TO_INDEX.put("rd", index++);
+        PATTERN_TO_INDEX.put("lud", index++);
+        PATTERN_TO_INDEX.put("rud", index++);
+        PATTERN_TO_INDEX.put("mc", index++);
+        PATTERN_TO_INDEX.put("mr", index++);
+        PATTERN_TO_INDEX.put("vh", index++);
+        PATTERN_TO_INDEX.put("hh", index++);
+        PATTERN_TO_INDEX.put("vhr", index++);
+        PATTERN_TO_INDEX.put("hhb", index++);
+        PATTERN_TO_INDEX.put("bo", index++);
+        index++; // Bordure indented, does not appear to exist in Bedrock?
+        PATTERN_TO_INDEX.put("gra", index++);
+        PATTERN_TO_INDEX.put("gru", index);
+        // Bricks do not appear to be a pattern on Bedrock, either
+    }
+
+    public LoomInventoryTranslator() {
+        super(4, "minecraft:loom[facing=north]", ContainerType.LOOM, UIInventoryUpdater.INSTANCE);
+    }
+
+    @Override
+    public boolean shouldRejectItemPlace(GeyserSession session, Inventory inventory, ContainerSlotType bedrockSourceContainer,
+                                         int javaSourceSlot, ContainerSlotType bedrockDestinationContainer, int javaDestinationSlot) {
+        if (javaDestinationSlot != 1) {
+            return false;
+        }
+        GeyserItemStack itemStack = javaSourceSlot == -1 ? session.getPlayerInventory().getCursor() : inventory.getItem(javaSourceSlot);
+        if (itemStack.isEmpty()) {
+            return false;
+        }
+
+        // Reject the item if Bedrock is attempting to put in a dye that is not a dye in Java Edition
+        return !itemStack.getItemEntry().getJavaIdentifier().endsWith("_dye");
+    }
+
+    @Override
+    public boolean shouldHandleRequestFirst(StackRequestActionData action, Inventory inventory) {
+        // If the LOOM_MATERIAL slot is not empty, we are crafting a pattern that does not come from an item
+        return action.getType() == StackRequestActionType.CRAFT_NON_IMPLEMENTED_DEPRECATED && inventory.getItem(2).isEmpty();
+    }
+
+    @Override
+    public ItemStackResponsePacket.Response translateSpecialRequest(GeyserSession session, Inventory inventory, ItemStackRequest request) {
+        // TODO: I anticipate this will be changed in the future to use something non-deprecated. Keep an eye out.
+        StackRequestActionData data = request.getActions()[1];
+        if (!(data instanceof CraftResultsDeprecatedStackRequestActionData)) {
+            return rejectRequest(request);
+        }
+        CraftResultsDeprecatedStackRequestActionData craftData = (CraftResultsDeprecatedStackRequestActionData) data;
+
+        // Get the patterns compound tag
+        List<NbtMap> newBlockEntityTag = craftData.getResultItems()[0].getTag().getList("Patterns", NbtType.COMPOUND);
+        // Get the pattern that the Bedrock client requests - the last pattern in the Patterns list
+        NbtMap pattern = newBlockEntityTag.get(newBlockEntityTag.size() - 1);
+        // Get the Java index of this pattern
+        int index = PATTERN_TO_INDEX.getOrDefault(pattern.getString("Pattern"), -1);
+        if (index == -1) {
+            return rejectRequest(request);
+        }
+        // Java's formula: 4 * row + col
+        // And the Java loom window has a fixed row/width of four
+        // So... Number / 4 = row (so we don't have to bother there), and number % 4 is our column, which leads us back to our index. :)
+        ClientClickWindowButtonPacket packet = new ClientClickWindowButtonPacket(inventory.getId(), index);
+        session.sendDownstreamPacket(packet);
+
+        GeyserItemStack inputCopy = inventory.getItem(0).copy();
+        inputCopy.setNetId(session.getNextItemNetId());
+        // Add the pattern manually, for better item synchronization
+        if (inputCopy.getNbt() == null) {
+            inputCopy.setNbt(new CompoundTag(""));
+        }
+        CompoundTag blockEntityTag = inputCopy.getNbt().get("BlockEntityTag");
+        CompoundTag javaBannerPattern = BannerTranslator.getJavaBannerPattern(pattern);
+
+        if (blockEntityTag != null) {
+            ListTag patternsList = blockEntityTag.get("Patterns");
+            if (patternsList != null) {
+                patternsList.add(javaBannerPattern);
+            } else {
+                patternsList = new ListTag("Patterns", Collections.singletonList(javaBannerPattern));
+                blockEntityTag.put(patternsList);
+            }
+        } else {
+            blockEntityTag = new CompoundTag("BlockEntityTag");
+            ListTag patternsList = new ListTag("Patterns", Collections.singletonList(javaBannerPattern));
+            blockEntityTag.put(patternsList);
+            inputCopy.getNbt().put(blockEntityTag);
+        }
+
+        // Set the new item as the output
+        inventory.setItem(3, inputCopy, session);
+
+        return translateRequest(session, inventory, request);
+    }
+
+    @Override
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        switch (slotInfoData.getContainer()) {
+            case LOOM_INPUT:
+                return 0;
+            case LOOM_DYE:
+                return 1;
+            case LOOM_MATERIAL:
+                return 2;
+            case LOOM_RESULT:
+            case CREATIVE_OUTPUT:
+                return 3;
+        }
+        return super.bedrockSlotToJava(slotInfoData);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        switch (slot) {
+            case 0:
+                return new BedrockContainerSlot(ContainerSlotType.LOOM_INPUT, 9);
+            case 1:
+                return new BedrockContainerSlot(ContainerSlotType.LOOM_DYE, 10);
+            case 2:
+                return new BedrockContainerSlot(ContainerSlotType.LOOM_MATERIAL, 11);
+            case 3:
+                return new BedrockContainerSlot(ContainerSlotType.LOOM_RESULT, 50);
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
+
+    @Override
+    public int javaSlotToBedrock(int slot) {
+        switch (slot) {
+            case 0:
+                return 9;
+            case 1:
+                return 10;
+            case 2:
+                return 11;
+            case 3:
+                return 50;
+        }
+        return super.javaSlotToBedrock(slot);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/MerchantInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/MerchantInventoryTranslator.java
new file mode 100644
index 000000000..736568868
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/MerchantInventoryTranslator.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators;
+
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import com.nukkitx.math.vector.Vector3f;
+import com.nukkitx.protocol.bedrock.data.entity.EntityData;
+import com.nukkitx.protocol.bedrock.data.entity.EntityDataMap;
+import com.nukkitx.protocol.bedrock.data.entity.EntityLinkData;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.ItemStackRequest;
+import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
+import com.nukkitx.protocol.bedrock.packet.ItemStackResponsePacket;
+import com.nukkitx.protocol.bedrock.packet.SetEntityLinkPacket;
+import org.geysermc.connector.entity.Entity;
+import org.geysermc.connector.entity.type.EntityType;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.MerchantContainer;
+import org.geysermc.connector.inventory.PlayerInventory;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.SlotType;
+import org.geysermc.connector.network.translators.inventory.updater.InventoryUpdater;
+import org.geysermc.connector.network.translators.inventory.updater.UIInventoryUpdater;
+
+public class MerchantInventoryTranslator extends BaseInventoryTranslator {
+    private final InventoryUpdater updater;
+
+    public MerchantInventoryTranslator() {
+        super(3);
+        this.updater = UIInventoryUpdater.INSTANCE;
+    }
+
+    @Override
+    public int javaSlotToBedrock(int slot) {
+        switch (slot) {
+            case 0:
+                return 4;
+            case 1:
+                return 5;
+            case 2:
+                return 50;
+        }
+        return super.javaSlotToBedrock(slot);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        switch (slot) {
+            case 0:
+                return new BedrockContainerSlot(ContainerSlotType.TRADE2_INGREDIENT1, 4);
+            case 1:
+                return new BedrockContainerSlot(ContainerSlotType.TRADE2_INGREDIENT2, 5);
+            case 2:
+                return new BedrockContainerSlot(ContainerSlotType.TRADE2_RESULT, 50);
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
+
+    @Override
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        switch (slotInfoData.getContainer()) {
+            case TRADE2_INGREDIENT1:
+                return 0;
+            case TRADE2_INGREDIENT2:
+                return 1;
+            case TRADE2_RESULT:
+            case CREATIVE_OUTPUT:
+                return 2;
+        }
+        return super.bedrockSlotToJava(slotInfoData);
+    }
+
+    @Override
+    public SlotType getSlotType(int javaSlot) {
+        if (javaSlot == 2) {
+            return SlotType.OUTPUT;
+        }
+        return SlotType.NORMAL;
+    }
+
+    @Override
+    public void prepareInventory(GeyserSession session, Inventory inventory) {
+        MerchantContainer merchantInventory = (MerchantContainer) inventory;
+        if (merchantInventory.getVillager() == null) {
+            long geyserId = session.getEntityCache().getNextEntityId().incrementAndGet();
+            Vector3f pos = session.getPlayerEntity().getPosition().sub(0, 3, 0);
+
+            EntityDataMap metadata = new EntityDataMap();
+            metadata.put(EntityData.SCALE, 0f);
+            metadata.put(EntityData.BOUNDING_BOX_WIDTH, 0f);
+            metadata.put(EntityData.BOUNDING_BOX_HEIGHT, 0f);
+
+            Entity villager = new Entity(0, geyserId, EntityType.VILLAGER, pos, Vector3f.ZERO, Vector3f.ZERO);
+            villager.setMetadata(metadata);
+            villager.spawnEntity(session);
+
+            SetEntityLinkPacket linkPacket = new SetEntityLinkPacket();
+            EntityLinkData.Type type = EntityLinkData.Type.PASSENGER;
+            linkPacket.setEntityLink(new EntityLinkData(session.getPlayerEntity().getGeyserId(), geyserId, type, true, false));
+            session.sendUpstreamPacket(linkPacket);
+
+            merchantInventory.setVillager(villager);
+        }
+    }
+
+    @Override
+    public void openInventory(GeyserSession session, Inventory inventory) {
+        //Handled in JavaTradeListTranslator
+        //TODO: send a blank inventory here in case the villager doesn't send a TradeList packet
+    }
+
+    @Override
+    public void closeInventory(GeyserSession session, Inventory inventory) {
+        MerchantContainer merchantInventory = (MerchantContainer) inventory;
+        if (merchantInventory.getVillager() != null) {
+            merchantInventory.getVillager().despawnEntity(session);
+        }
+    }
+
+    @Override
+    public ItemStackResponsePacket.Response translateAutoCraftingRequest(GeyserSession session, Inventory inventory, ItemStackRequest request) {
+        // We're not crafting here
+        // Called at least by consoles when pressing a trade option button
+        return translateRequest(session, inventory, request);
+    }
+
+    @Override
+    public void updateInventory(GeyserSession session, Inventory inventory) {
+        updater.updateInventory(this, session, inventory);
+    }
+
+    @Override
+    public void updateSlot(GeyserSession session, Inventory inventory, int slot) {
+        updater.updateSlot(this, session, inventory, slot);
+    }
+
+    @Override
+    public Inventory createInventory(String name, int windowId, WindowType windowType, PlayerInventory playerInventory) {
+        return new MerchantContainer(name, windowId, this.size, windowType, playerInventory);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/PlayerInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/PlayerInventoryTranslator.java
new file mode 100644
index 000000000..e3dbec507
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/PlayerInventoryTranslator.java
@@ -0,0 +1,490 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators;
+
+import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
+import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientCreativeInventoryActionPacket;
+import com.nukkitx.protocol.bedrock.data.inventory.*;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.*;
+import com.nukkitx.protocol.bedrock.packet.InventoryContentPacket;
+import com.nukkitx.protocol.bedrock.packet.InventorySlotPacket;
+import com.nukkitx.protocol.bedrock.packet.ItemStackResponsePacket;
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import it.unimi.dsi.fastutil.ints.IntSet;
+import org.geysermc.connector.inventory.GeyserItemStack;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.PlayerInventory;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.SlotType;
+import org.geysermc.connector.network.translators.item.ItemRegistry;
+import org.geysermc.connector.network.translators.item.ItemTranslator;
+import org.geysermc.connector.utils.InventoryUtils;
+import org.geysermc.connector.utils.LanguageUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+public class PlayerInventoryTranslator extends InventoryTranslator {
+    private static final ItemData UNUSUABLE_CRAFTING_SPACE_BLOCK = InventoryUtils.createUnusableSpaceBlock(LanguageUtils.getLocaleStringLog("geyser.inventory.unusable_item.creative"));
+
+    public PlayerInventoryTranslator() {
+        super(46);
+    }
+
+    @Override
+    public void updateInventory(GeyserSession session, Inventory inventory) {
+        updateCraftingGrid(session, inventory);
+
+        InventoryContentPacket inventoryContentPacket = new InventoryContentPacket();
+        inventoryContentPacket.setContainerId(ContainerId.INVENTORY);
+        ItemData[] contents = new ItemData[36];
+        // Inventory
+        for (int i = 9; i < 36; i++) {
+            contents[i] = inventory.getItem(i).getItemData(session);
+        }
+        // Hotbar
+        for (int i = 36; i < 45; i++) {
+            contents[i - 36] = inventory.getItem(i).getItemData(session);
+        }
+        inventoryContentPacket.setContents(Arrays.asList(contents));
+        session.sendUpstreamPacket(inventoryContentPacket);
+
+        // Armor
+        InventoryContentPacket armorContentPacket = new InventoryContentPacket();
+        armorContentPacket.setContainerId(ContainerId.ARMOR);
+        contents = new ItemData[4];
+        for (int i = 5; i < 9; i++) {
+            contents[i - 5] = inventory.getItem(i).getItemData(session);
+        }
+        armorContentPacket.setContents(Arrays.asList(contents));
+        session.sendUpstreamPacket(armorContentPacket);
+
+        // Offhand
+        InventoryContentPacket offhandPacket = new InventoryContentPacket();
+        offhandPacket.setContainerId(ContainerId.OFFHAND);
+        offhandPacket.setContents(Collections.singletonList(inventory.getItem(45).getItemData(session)));
+        session.sendUpstreamPacket(offhandPacket);
+    }
+
+    /**
+     * Update the crafting grid for the player to hide/show the barriers in the creative inventory
+     * @param session Session of the player
+     * @param inventory Inventory of the player
+     */
+    public static void updateCraftingGrid(GeyserSession session, Inventory inventory) {
+        // Crafting grid
+        for (int i = 1; i < 5; i++) {
+            InventorySlotPacket slotPacket = new InventorySlotPacket();
+            slotPacket.setContainerId(ContainerId.UI);
+            slotPacket.setSlot(i + 27);
+
+            if (session.getGameMode() == GameMode.CREATIVE) {
+                slotPacket.setItem(UNUSUABLE_CRAFTING_SPACE_BLOCK);
+            } else {
+                slotPacket.setItem(ItemTranslator.translateToBedrock(session, inventory.getItem(i).getItemStack()));
+            }
+
+            session.sendUpstreamPacket(slotPacket);
+        }
+    }
+
+    @Override
+    public void updateSlot(GeyserSession session, Inventory inventory, int slot) {
+        if (slot >= 1 && slot <= 44) {
+            InventorySlotPacket slotPacket = new InventorySlotPacket();
+            if (slot >= 9) {
+                slotPacket.setContainerId(ContainerId.INVENTORY);
+                if (slot >= 36) {
+                    slotPacket.setSlot(slot - 36);
+                } else {
+                    slotPacket.setSlot(slot);
+                }
+            } else if (slot >= 5) {
+                slotPacket.setContainerId(ContainerId.ARMOR);
+                slotPacket.setSlot(slot - 5);
+            } else {
+                slotPacket.setContainerId(ContainerId.UI);
+                slotPacket.setSlot(slot + 27);
+            }
+            slotPacket.setItem(inventory.getItem(slot).getItemData(session));
+            session.sendUpstreamPacket(slotPacket);
+        } else if (slot == 45) {
+            InventoryContentPacket offhandPacket = new InventoryContentPacket();
+            offhandPacket.setContainerId(ContainerId.OFFHAND);
+            offhandPacket.setContents(Collections.singletonList(inventory.getItem(slot).getItemData(session)));
+            session.sendUpstreamPacket(offhandPacket);
+        }
+    }
+
+    @Override
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        int slotnum = slotInfoData.getSlot();
+        switch (slotInfoData.getContainer()) {
+            case HOTBAR_AND_INVENTORY:
+            case HOTBAR:
+            case INVENTORY:
+                // Inventory
+                if (slotnum >= 9 && slotnum <= 35) {
+                    return slotnum;
+                }
+                // Hotbar
+                if (slotnum >= 0 && slotnum <= 8) {
+                    return slotnum + 36;
+                }
+                break;
+            case ARMOR:
+                if (slotnum >= 0 && slotnum <= 3) {
+                    return slotnum + 5;
+                }
+                break;
+            case OFFHAND:
+                return 45;
+            case CRAFTING_INPUT:
+                if (slotnum >= 28 && 31 >= slotnum) {
+                    return slotnum - 27;
+                }
+                break;
+            case CREATIVE_OUTPUT:
+                return 0;
+        }
+        return slotnum;
+    }
+
+    @Override
+    public int javaSlotToBedrock(int slot) {
+        return -1;
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        if (slot >= 36 && slot <= 44) {
+            return new BedrockContainerSlot(ContainerSlotType.HOTBAR, slot - 36);
+        } else if (slot >= 9 && slot <= 35) {
+            return new BedrockContainerSlot(ContainerSlotType.INVENTORY, slot);
+        } else if (slot >= 5 && slot <= 8) {
+            return new BedrockContainerSlot(ContainerSlotType.ARMOR, slot - 5);
+        } else if (slot == 45) {
+            return new BedrockContainerSlot(ContainerSlotType.OFFHAND, 1);
+        } else if (slot >= 1 && slot <= 4) {
+            return new BedrockContainerSlot(ContainerSlotType.CRAFTING_INPUT, slot + 27);
+        } else if (slot == 0) {
+            return new BedrockContainerSlot(ContainerSlotType.CRAFTING_OUTPUT, 0);
+        } else {
+            throw new IllegalArgumentException("Unknown bedrock slot");
+        }
+    }
+
+    @Override
+    public SlotType getSlotType(int javaSlot) {
+        if (javaSlot == 0)
+            return SlotType.OUTPUT;
+        return SlotType.NORMAL;
+    }
+
+    @Override
+    public ItemStackResponsePacket.Response translateRequest(GeyserSession session, Inventory inventory, ItemStackRequest request) {
+        if (session.getGameMode() != GameMode.CREATIVE) {
+            return super.translateRequest(session, inventory, request);
+        }
+
+        PlayerInventory playerInv = session.getPlayerInventory();
+        IntSet affectedSlots = new IntOpenHashSet();
+        for (StackRequestActionData action : request.getActions()) {
+            switch (action.getType()) {
+                case TAKE:
+                case PLACE: {
+                    TransferStackRequestActionData transferAction = (TransferStackRequestActionData) action;
+                    if (!(checkNetId(session, inventory, transferAction.getSource()) && checkNetId(session, inventory, transferAction.getDestination()))) {
+                        return rejectRequest(request);
+                    }
+                    if (isCraftingGrid(transferAction.getSource()) || isCraftingGrid(transferAction.getDestination())) {
+                        return rejectRequest(request, false);
+                    }
+
+                    int transferAmount = transferAction.getCount();
+                    if (isCursor(transferAction.getDestination())) {
+                        int sourceSlot = bedrockSlotToJava(transferAction.getSource());
+                        GeyserItemStack sourceItem = inventory.getItem(sourceSlot);
+                        if (playerInv.getCursor().isEmpty()) {
+                            playerInv.setCursor(sourceItem.copy(0), session);
+                        }
+
+                        playerInv.getCursor().add(transferAmount);
+                        sourceItem.sub(transferAmount);
+
+                        affectedSlots.add(sourceSlot);
+                    } else if (isCursor(transferAction.getSource())) {
+                        int destSlot = bedrockSlotToJava(transferAction.getDestination());
+                        GeyserItemStack sourceItem = playerInv.getCursor();
+                        if (inventory.getItem(destSlot).isEmpty()) {
+                            inventory.setItem(destSlot, sourceItem.copy(0), session);
+                        }
+
+                        inventory.getItem(destSlot).add(transferAmount);
+                        sourceItem.sub(transferAmount);
+
+                        affectedSlots.add(destSlot);
+                    } else {
+                        int sourceSlot = bedrockSlotToJava(transferAction.getSource());
+                        int destSlot = bedrockSlotToJava(transferAction.getDestination());
+                        GeyserItemStack sourceItem = inventory.getItem(sourceSlot);
+                        if (inventory.getItem(destSlot).isEmpty()) {
+                            inventory.setItem(destSlot, sourceItem.copy(0), session);
+                        }
+
+                        inventory.getItem(destSlot).add(transferAmount);
+                        sourceItem.sub(transferAmount);
+
+                        affectedSlots.add(sourceSlot);
+                        affectedSlots.add(destSlot);
+                    }
+                    break;
+                }
+                case SWAP: {
+                    SwapStackRequestActionData swapAction = (SwapStackRequestActionData) action;
+                    if (!(checkNetId(session, inventory, swapAction.getSource()) && checkNetId(session, inventory, swapAction.getDestination()))) {
+                        return rejectRequest(request);
+                    }
+                    if (isCraftingGrid(swapAction.getSource()) || isCraftingGrid(swapAction.getDestination())) {
+                        return rejectRequest(request, false);
+                    }
+
+                    if (isCursor(swapAction.getDestination())) {
+                        int sourceSlot = bedrockSlotToJava(swapAction.getSource());
+                        GeyserItemStack sourceItem = inventory.getItem(sourceSlot);
+                        GeyserItemStack destItem = playerInv.getCursor();
+
+                        playerInv.setCursor(sourceItem, session);
+                        inventory.setItem(sourceSlot, destItem, session);
+
+                        affectedSlots.add(sourceSlot);
+                    } else if (isCursor(swapAction.getSource())) {
+                        int destSlot = bedrockSlotToJava(swapAction.getDestination());
+                        GeyserItemStack sourceItem = playerInv.getCursor();
+                        GeyserItemStack destItem = inventory.getItem(destSlot);
+
+                        inventory.setItem(destSlot, sourceItem, session);
+                        playerInv.setCursor(destItem, session);
+
+                        affectedSlots.add(destSlot);
+                    } else {
+                        int sourceSlot = bedrockSlotToJava(swapAction.getSource());
+                        int destSlot = bedrockSlotToJava(swapAction.getDestination());
+                        GeyserItemStack sourceItem = inventory.getItem(sourceSlot);
+                        GeyserItemStack destItem = inventory.getItem(destSlot);
+
+                        inventory.setItem(destSlot, sourceItem, session);
+                        inventory.setItem(sourceSlot, destItem, session);
+
+                        affectedSlots.add(sourceSlot);
+                        affectedSlots.add(destSlot);
+                    }
+                    break;
+                }
+                case DROP: {
+                    DropStackRequestActionData dropAction = (DropStackRequestActionData) action;
+                    if (!checkNetId(session, inventory, dropAction.getSource())) {
+                        return rejectRequest(request);
+                    }
+                    if (isCraftingGrid(dropAction.getSource())) {
+                        return rejectRequest(request, false);
+                    }
+
+                    GeyserItemStack sourceItem;
+                    if (isCursor(dropAction.getSource())) {
+                        sourceItem = playerInv.getCursor();
+                    } else {
+                        int sourceSlot = bedrockSlotToJava(dropAction.getSource());
+                        sourceItem = inventory.getItem(sourceSlot);
+                        affectedSlots.add(sourceSlot);
+                    }
+
+                    if (sourceItem.isEmpty()) {
+                        return rejectRequest(request);
+                    }
+
+                    ClientCreativeInventoryActionPacket creativeDropPacket = new ClientCreativeInventoryActionPacket(-1, sourceItem.getItemStack(dropAction.getCount()));
+                    session.sendDownstreamPacket(creativeDropPacket);
+
+                    sourceItem.sub(dropAction.getCount());
+                    break;
+                }
+                case DESTROY: {
+                    // Only called when a creative client wants to destroy an item... I think - Camotoy
+                    DestroyStackRequestActionData destroyAction = (DestroyStackRequestActionData) action;
+                    if (!checkNetId(session, inventory, destroyAction.getSource())) {
+                        return rejectRequest(request);
+                    }
+                    if (isCraftingGrid(destroyAction.getSource())) {
+                        return rejectRequest(request, false);
+                    }
+
+                    if (!isCursor(destroyAction.getSource())) {
+                        // Item exists; let's remove it from the inventory
+                        int javaSlot = bedrockSlotToJava(destroyAction.getSource());
+                        GeyserItemStack existingItem = inventory.getItem(javaSlot);
+                        existingItem.sub(destroyAction.getCount());
+                        affectedSlots.add(javaSlot);
+                    } else {
+                        // Just sync up the item on our end, since the server doesn't care what's in our cursor
+                        playerInv.getCursor().sub(destroyAction.getCount());
+                    }
+                    break;
+                }
+                default:
+                    session.getConnector().getLogger().error("Unknown crafting state induced by " + session.getName());
+                    return rejectRequest(request);
+            }
+        }
+        for (int slot : affectedSlots) {
+            sendCreativeAction(session, inventory, slot);
+        }
+        return acceptRequest(request, makeContainerEntries(session, inventory, affectedSlots));
+    }
+
+    @Override
+    public ItemStackResponsePacket.Response translateCreativeRequest(GeyserSession session, Inventory inventory, ItemStackRequest request) {
+        ItemStack javaCreativeItem = null;
+        IntSet affectedSlots = new IntOpenHashSet();
+        CraftState craftState = CraftState.START;
+        for (StackRequestActionData action : request.getActions()) {
+            switch (action.getType()) {
+                case CRAFT_CREATIVE: {
+                    CraftCreativeStackRequestActionData creativeAction = (CraftCreativeStackRequestActionData) action;
+                    if (craftState != CraftState.START) {
+                        return rejectRequest(request);
+                    }
+                    craftState = CraftState.RECIPE_ID;
+
+                    int creativeId = creativeAction.getCreativeItemNetworkId() - 1;
+                    if (creativeId < 0 || creativeId >= ItemRegistry.CREATIVE_ITEMS.length) {
+                        return rejectRequest(request);
+                    }
+                    // Reference the creative items list we send to the client to know what it's asking of us
+                    ItemData creativeItem = ItemRegistry.CREATIVE_ITEMS[creativeId];
+                    javaCreativeItem = ItemTranslator.translateToJava(creativeItem);
+                    break;
+                }
+                case CRAFT_RESULTS_DEPRECATED: {
+                    CraftResultsDeprecatedStackRequestActionData deprecatedCraftAction = (CraftResultsDeprecatedStackRequestActionData) action;
+                    if (craftState != CraftState.RECIPE_ID) {
+                        return rejectRequest(request);
+                    }
+                    craftState = CraftState.DEPRECATED;
+                    break;
+                }
+                case DESTROY: {
+                    DestroyStackRequestActionData destroyAction = (DestroyStackRequestActionData) action;
+                    if (craftState != CraftState.DEPRECATED) {
+                        return rejectRequest(request);
+                    }
+
+                    int sourceSlot = bedrockSlotToJava(destroyAction.getSource());
+                    inventory.setItem(sourceSlot, GeyserItemStack.EMPTY, session); //assume all creative destroy requests will empty the slot
+                    affectedSlots.add(sourceSlot);
+                    break;
+                }
+                case TAKE:
+                case PLACE: {
+                    TransferStackRequestActionData transferAction = (TransferStackRequestActionData) action;
+                    if (!(craftState == CraftState.DEPRECATED || craftState == CraftState.TRANSFER)) {
+                        return rejectRequest(request);
+                    }
+                    craftState = CraftState.TRANSFER;
+
+                    if (transferAction.getSource().getContainer() != ContainerSlotType.CREATIVE_OUTPUT) {
+                        return rejectRequest(request);
+                    }
+
+                    if (isCursor(transferAction.getDestination())) {
+                        if (session.getPlayerInventory().getCursor().isEmpty()) {
+                            GeyserItemStack newItemStack = GeyserItemStack.from(javaCreativeItem);
+                            newItemStack.setAmount(transferAction.getCount());
+                            session.getPlayerInventory().setCursor(newItemStack, session);
+                        } else {
+                            session.getPlayerInventory().getCursor().add(transferAction.getCount());
+                        }
+                        //cursor is always included in response
+                    } else {
+                        int destSlot = bedrockSlotToJava(transferAction.getDestination());
+                        if (inventory.getItem(destSlot).isEmpty()) {
+                            GeyserItemStack newItemStack = GeyserItemStack.from(javaCreativeItem);
+                            newItemStack.setAmount(transferAction.getCount());
+                            inventory.setItem(destSlot, newItemStack, session);
+                        } else {
+                            inventory.getItem(destSlot).add(transferAction.getCount());
+                        }
+                        affectedSlots.add(destSlot);
+                    }
+                    break;
+                }
+                default:
+                    return rejectRequest(request);
+            }
+        }
+        for (int slot : affectedSlots) {
+            sendCreativeAction(session, inventory, slot);
+        }
+        return acceptRequest(request, makeContainerEntries(session, inventory, affectedSlots));
+    }
+
+    private static void sendCreativeAction(GeyserSession session, Inventory inventory, int slot) {
+        GeyserItemStack item = inventory.getItem(slot);
+        ItemStack itemStack = item.isEmpty() ? new ItemStack(-1, 0, null) : item.getItemStack();
+
+        ClientCreativeInventoryActionPacket creativePacket = new ClientCreativeInventoryActionPacket(slot, itemStack);
+        session.sendDownstreamPacket(creativePacket);
+    }
+
+    private static boolean isCraftingGrid(StackRequestSlotInfoData slotInfoData) {
+        return slotInfoData.getContainer() == ContainerSlotType.CRAFTING_INPUT;
+    }
+
+    @Override
+    public Inventory createInventory(String name, int windowId, WindowType windowType, PlayerInventory playerInventory) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void prepareInventory(GeyserSession session, Inventory inventory) {
+    }
+
+    @Override
+    public void openInventory(GeyserSession session, Inventory inventory) {
+    }
+
+    @Override
+    public void closeInventory(GeyserSession session, Inventory inventory) {
+    }
+
+    @Override
+    public void updateProperty(GeyserSession session, Inventory inventory, int key, int value) {
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/ShulkerInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/ShulkerInventoryTranslator.java
new file mode 100644
index 000000000..76d1cb1cf
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/ShulkerInventoryTranslator.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators;
+
+import com.nukkitx.math.vector.Vector3i;
+import com.nukkitx.nbt.NbtMap;
+import com.nukkitx.nbt.NbtMapBuilder;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
+import com.nukkitx.protocol.bedrock.packet.BlockEntityDataPacket;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.holder.BlockInventoryHolder;
+import org.geysermc.connector.network.translators.inventory.updater.ContainerInventoryUpdater;
+import org.geysermc.connector.network.translators.world.block.entity.BlockEntityTranslator;
+
+public class ShulkerInventoryTranslator extends AbstractBlockInventoryTranslator {
+    public ShulkerInventoryTranslator() {
+        super(27, new BlockInventoryHolder("minecraft:shulker_box[facing=north]", ContainerType.CONTAINER) {
+            private final BlockEntityTranslator shulkerBoxTranslator = BlockEntityTranslator.BLOCK_ENTITY_TRANSLATORS.get("ShulkerBox");
+
+            @Override
+            protected boolean isValidBlock(String[] javaBlockString) {
+                return javaBlockString[0].contains("shulker_box");
+            }
+
+            @Override
+            protected void setCustomName(GeyserSession session, Vector3i position, Inventory inventory, int javaBlockState) {
+                NbtMapBuilder tag = NbtMap.builder()
+                        .putInt("x", position.getX())
+                        .putInt("y", position.getY())
+                        .putInt("z", position.getZ())
+                        .putString("CustomName", inventory.getTitle());
+                // Don't reset facing property
+                shulkerBoxTranslator.translateTag(tag, null, javaBlockState);
+
+                BlockEntityDataPacket dataPacket = new BlockEntityDataPacket();
+                dataPacket.setData(tag.build());
+                dataPacket.setBlockPosition(position);
+                session.sendUpstreamPacket(dataPacket);
+            }
+        }, ContainerInventoryUpdater.INSTANCE);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int javaSlot) {
+        if (javaSlot < this.size) {
+            return new BedrockContainerSlot(ContainerSlotType.SHULKER, javaSlot);
+        }
+        return super.javaSlotToBedrockContainer(javaSlot);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/SmithingInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/SmithingInventoryTranslator.java
similarity index 55%
rename from connector/src/main/java/org/geysermc/connector/network/translators/inventory/SmithingInventoryTranslator.java
rename to connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/SmithingInventoryTranslator.java
index 19c2522ea..dbe04f68b 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/SmithingInventoryTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/SmithingInventoryTranslator.java
@@ -23,34 +23,44 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.connector.network.translators.inventory;
+package org.geysermc.connector.network.translators.inventory.translators;
 
-import com.nukkitx.protocol.bedrock.data.inventory.ContainerId;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
-import com.nukkitx.protocol.bedrock.data.inventory.InventoryActionData;
-import org.geysermc.connector.network.translators.inventory.updater.CursorInventoryUpdater;
-
-public class SmithingInventoryTranslator extends BlockInventoryTranslator {
+import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.updater.UIInventoryUpdater;
 
+public class SmithingInventoryTranslator extends AbstractBlockInventoryTranslator {
     public SmithingInventoryTranslator() {
-        super(3, "minecraft:smithing_table", ContainerType.SMITHING_TABLE, new CursorInventoryUpdater());
+        super(3, "minecraft:smithing_table", ContainerType.SMITHING_TABLE, UIInventoryUpdater.INSTANCE);
     }
 
     @Override
-    public int bedrockSlotToJava(InventoryActionData action) {
-        final int slot = super.bedrockSlotToJava(action);
-        if (action.getSource().getContainerId() == ContainerId.UI) {
-            switch (slot) {
-                case 51:
-                    return 0;
-                case 52:
-                    return 1;
-                case 50:
-                    return 2;
-                default:
-                    return slot;
-            }
-        } return slot;
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        switch (slotInfoData.getContainer()) {
+            case SMITHING_TABLE_INPUT:
+                return 0;
+            case SMITHING_TABLE_MATERIAL:
+                return 1;
+            case SMITHING_TABLE_RESULT:
+            case CREATIVE_OUTPUT:
+                return 2;
+        }
+        return super.bedrockSlotToJava(slotInfoData);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        switch (slot) {
+            case 0:
+                return new BedrockContainerSlot(ContainerSlotType.SMITHING_TABLE_INPUT, 51);
+            case 1:
+                return new BedrockContainerSlot(ContainerSlotType.SMITHING_TABLE_MATERIAL, 52);
+            case 2:
+                return new BedrockContainerSlot(ContainerSlotType.SMITHING_TABLE_RESULT, 50);
+        }
+        return super.javaSlotToBedrockContainer(slot);
     }
 
     @Override
@@ -65,5 +75,4 @@ public class SmithingInventoryTranslator extends BlockInventoryTranslator {
         }
         return super.javaSlotToBedrock(slot);
     }
-
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/StonecutterInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/StonecutterInventoryTranslator.java
new file mode 100644
index 000000000..2acce3a9b
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/StonecutterInventoryTranslator.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators;
+
+import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
+import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientClickWindowButtonPacket;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
+import com.nukkitx.protocol.bedrock.data.inventory.ItemStackRequest;
+import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.CraftResultsDeprecatedStackRequestActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.StackRequestActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.StackRequestActionType;
+import com.nukkitx.protocol.bedrock.packet.ItemStackResponsePacket;
+import it.unimi.dsi.fastutil.ints.IntList;
+import org.geysermc.connector.inventory.GeyserItemStack;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.PlayerInventory;
+import org.geysermc.connector.inventory.StonecutterContainer;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.SlotType;
+import org.geysermc.connector.network.translators.inventory.updater.UIInventoryUpdater;
+import org.geysermc.connector.network.translators.item.ItemTranslator;
+
+public class StonecutterInventoryTranslator extends AbstractBlockInventoryTranslator {
+    public StonecutterInventoryTranslator() {
+        super(2, "minecraft:stonecutter[facing=north]", ContainerType.STONECUTTER, UIInventoryUpdater.INSTANCE);
+    }
+
+    @Override
+    public boolean shouldHandleRequestFirst(StackRequestActionData action, Inventory inventory) {
+        return action.getType() == StackRequestActionType.CRAFT_NON_IMPLEMENTED_DEPRECATED;
+    }
+
+    @Override
+    public ItemStackResponsePacket.Response translateSpecialRequest(GeyserSession session, Inventory inventory, ItemStackRequest request) {
+        // TODO: Also surely to change in the future
+        StackRequestActionData data = request.getActions()[1];
+        if (!(data instanceof CraftResultsDeprecatedStackRequestActionData)) {
+            return rejectRequest(request);
+        }
+        CraftResultsDeprecatedStackRequestActionData craftData = (CraftResultsDeprecatedStackRequestActionData) data;
+
+        StonecutterContainer container = (StonecutterContainer) inventory;
+        // Get the ID of the item we are cutting
+        int id = inventory.getItem(0).getJavaId();
+        // Look up all possible options of cutting from this ID
+        IntList results = session.getStonecutterRecipes().get(id);
+        if (results == null) {
+            return rejectRequest(request);
+        }
+
+        ItemStack javaOutput = ItemTranslator.translateToJava(craftData.getResultItems()[0]);
+        int button = results.indexOf(javaOutput.getId());
+        // If we've already pressed the button with this item, no need to press it again!
+        if (container.getStonecutterButton() != button) {
+            // Getting the index of the item in the Java stonecutter list
+            ClientClickWindowButtonPacket packet = new ClientClickWindowButtonPacket(inventory.getId(), button);
+            session.sendDownstreamPacket(packet);
+            container.setStonecutterButton(button);
+            if (inventory.getItem(1).getJavaId() != javaOutput.getId()) {
+                // We don't know there is an output here, so we tell ourselves that there is
+                inventory.setItem(1, GeyserItemStack.from(javaOutput), session);
+            }
+        }
+
+        return translateRequest(session, inventory, request);
+    }
+
+    @Override
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        switch (slotInfoData.getContainer()) {
+            case STONECUTTER_INPUT:
+                return 0;
+            case STONECUTTER_RESULT:
+            case CREATIVE_OUTPUT:
+                return 1;
+        }
+        return super.bedrockSlotToJava(slotInfoData);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        if (slot == 0) {
+            return new BedrockContainerSlot(ContainerSlotType.STONECUTTER_INPUT, 3);
+        }
+        if (slot == 1) {
+            return new BedrockContainerSlot(ContainerSlotType.STONECUTTER_RESULT, 50);
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
+
+    @Override
+    public int javaSlotToBedrock(int slot) {
+        if (slot == 0) {
+            return 3;
+        }
+        if (slot == 1) {
+            return 50;
+        }
+        return super.javaSlotToBedrock(slot);
+    }
+
+    @Override
+    public SlotType getSlotType(int javaSlot) {
+        if (javaSlot == 1) {
+            return SlotType.OUTPUT;
+        }
+        return super.getSlotType(javaSlot);
+    }
+
+    @Override
+    public Inventory createInventory(String name, int windowId, WindowType windowType, PlayerInventory playerInventory) {
+        return new StonecutterContainer(name, windowId, this.size, windowType, playerInventory);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/ChestInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/chest/ChestInventoryTranslator.java
similarity index 64%
rename from connector/src/main/java/org/geysermc/connector/network/translators/inventory/ChestInventoryTranslator.java
rename to connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/chest/ChestInventoryTranslator.java
index 3bc587b1a..d54419b82 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/ChestInventoryTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/chest/ChestInventoryTranslator.java
@@ -23,16 +23,15 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.connector.network.translators.inventory;
+package org.geysermc.connector.network.translators.inventory.translators.chest;
 
-import com.nukkitx.protocol.bedrock.data.inventory.InventoryActionData;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
 import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.translators.BaseInventoryTranslator;
 import org.geysermc.connector.network.translators.inventory.updater.ChestInventoryUpdater;
 import org.geysermc.connector.network.translators.inventory.updater.InventoryUpdater;
-import org.geysermc.connector.utils.InventoryUtils;
-
-import java.util.List;
 
 public abstract class ChestInventoryTranslator extends BaseInventoryTranslator {
     private final InventoryUpdater updater;
@@ -42,6 +41,16 @@ public abstract class ChestInventoryTranslator extends BaseInventoryTranslator {
         this.updater = new ChestInventoryUpdater(paddedSize);
     }
 
+    @Override
+    public boolean shouldRejectItemPlace(GeyserSession session, Inventory inventory, ContainerSlotType bedrockSourceContainer,
+                                         int javaSourceSlot, ContainerSlotType bedrockDestinationContainer, int javaDestinationSlot) {
+        // Reject any item placements that occur in the unusable inventory space
+        if (bedrockSourceContainer == ContainerSlotType.CONTAINER && javaSourceSlot >= this.size) {
+            return true;
+        }
+        return bedrockDestinationContainer == ContainerSlotType.CONTAINER && javaDestinationSlot >= this.size;
+    }
+
     @Override
     public void updateInventory(GeyserSession session, Inventory inventory) {
         updater.updateInventory(this, session, inventory);
@@ -53,17 +62,10 @@ public abstract class ChestInventoryTranslator extends BaseInventoryTranslator {
     }
 
     @Override
-    public void translateActions(GeyserSession session, Inventory inventory, List<InventoryActionData> actions) {
-        for (InventoryActionData action : actions) {
-            if (action.getSource().getContainerId() == inventory.getId()) {
-                if (action.getSlot() >= size) {
-                    updateInventory(session, inventory);
-                    InventoryUtils.updateCursor(session);
-                    return;
-                }
-            }
+    public BedrockContainerSlot javaSlotToBedrockContainer(int javaSlot) {
+        if (javaSlot < this.size) {
+            return new BedrockContainerSlot(ContainerSlotType.CONTAINER, javaSlot);
         }
-
-        super.translateActions(session, inventory, actions);
+        return super.javaSlotToBedrockContainer(javaSlot);
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/DoubleChestInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/chest/DoubleChestInventoryTranslator.java
similarity index 62%
rename from connector/src/main/java/org/geysermc/connector/network/translators/inventory/DoubleChestInventoryTranslator.java
rename to connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/chest/DoubleChestInventoryTranslator.java
index 90cecd037..78ac0b609 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/DoubleChestInventoryTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/chest/DoubleChestInventoryTranslator.java
@@ -23,32 +23,66 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.connector.network.translators.inventory;
+package org.geysermc.connector.network.translators.inventory.translators.chest;
 
-import com.github.steveice10.mc.protocol.data.game.entity.metadata.Position;
 import com.nukkitx.math.vector.Vector3i;
 import com.nukkitx.nbt.NbtMap;
+import com.nukkitx.nbt.NbtMapBuilder;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
 import com.nukkitx.protocol.bedrock.packet.BlockEntityDataPacket;
+import com.nukkitx.protocol.bedrock.packet.ContainerClosePacket;
 import com.nukkitx.protocol.bedrock.packet.ContainerOpenPacket;
 import com.nukkitx.protocol.bedrock.packet.UpdateBlockPacket;
+import org.geysermc.connector.inventory.Container;
 import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.world.block.BlockStateValues;
 import org.geysermc.connector.network.translators.world.block.BlockTranslator;
+import org.geysermc.connector.network.translators.world.block.DoubleChestValue;
+import org.geysermc.connector.network.translators.world.block.entity.DoubleChestBlockEntityTranslator;
 
 public class DoubleChestInventoryTranslator extends ChestInventoryTranslator {
-    private final int javaBlockState;
+    private final int defaultJavaBlockState;
 
     public DoubleChestInventoryTranslator(int size) {
         super(size, 54);
-        this.javaBlockState = BlockTranslator.getJavaBlockState("minecraft:chest[facing=north,type=single,waterlogged=false]");
+        this.defaultJavaBlockState = BlockTranslator.getJavaBlockState("minecraft:chest[facing=north,type=single,waterlogged=false]");
     }
 
     @Override
     public void prepareInventory(GeyserSession session, Inventory inventory) {
+        // See BlockInventoryHolder - same concept there except we're also dealing with a specific block state
+        if (session.getLastInteractionPlayerPosition().equals(session.getPlayerEntity().getPosition())) {
+            int javaBlockId = session.getConnector().getWorldManager().getBlockAt(session, session.getLastInteractionBlockPosition());
+            String[] javaBlockString = BlockTranslator.getJavaIdBlockMap().inverse().getOrDefault(javaBlockId, "minecraft:air").split("\\[");
+            if (javaBlockString.length > 1 && (javaBlockString[0].equals("minecraft:chest") || javaBlockString[0].equals("minecraft:trapped_chest"))
+                    && !javaBlockString[1].contains("type=single")) {
+                inventory.setHolderPosition(session.getLastInteractionBlockPosition());
+                ((Container) inventory).setUsingRealBlock(true, javaBlockString[0]);
+
+                NbtMapBuilder tag = NbtMap.builder()
+                        .putString("id", "Chest")
+                        .putInt("x", session.getLastInteractionBlockPosition().getX())
+                        .putInt("y", session.getLastInteractionBlockPosition().getY())
+                        .putInt("z", session.getLastInteractionBlockPosition().getZ())
+                        .putString("CustomName", inventory.getTitle())
+                        .putString("id", "Chest");
+
+                DoubleChestValue chestValue = BlockStateValues.getDoubleChestValues().get(javaBlockId);
+                DoubleChestBlockEntityTranslator.translateChestValue(tag, chestValue,
+                        session.getLastInteractionBlockPosition().getX(), session.getLastInteractionBlockPosition().getZ());
+
+                BlockEntityDataPacket dataPacket = new BlockEntityDataPacket();
+                dataPacket.setData(tag.build());
+                dataPacket.setBlockPosition(session.getLastInteractionBlockPosition());
+                session.sendUpstreamPacket(dataPacket);
+                return;
+            }
+        }
+
         Vector3i position = session.getPlayerEntity().getPosition().toInt().add(Vector3i.UP);
         Vector3i pairPosition = position.add(Vector3i.UNIT_X);
-        int bedrockBlockId = session.getBlockTranslator().getBedrockBlockId(javaBlockState);
+        int bedrockBlockId = session.getBlockTranslator().getBedrockBlockId(defaultJavaBlockState);
 
         UpdateBlockPacket blockPacket = new UpdateBlockPacket();
         blockPacket.setDataLayer(0);
@@ -105,9 +139,18 @@ public class DoubleChestInventoryTranslator extends ChestInventoryTranslator {
 
     @Override
     public void closeInventory(GeyserSession session, Inventory inventory) {
+        if (((Container) inventory).isUsingRealBlock()) {
+            // No need to reset a block since we didn't change any blocks
+            // But send a container close packet because we aren't destroying the original.
+            ContainerClosePacket packet = new ContainerClosePacket();
+            packet.setId((byte) inventory.getId());
+            packet.setUnknownBool0(true); //TODO needs to be changed in Protocol to "server-side" or something
+            session.sendUpstreamPacket(packet);
+            return;
+        }
+
         Vector3i holderPos = inventory.getHolderPosition();
-        Position pos = new Position(holderPos.getX(), holderPos.getY(), holderPos.getZ());
-        int realBlock = session.getConnector().getWorldManager().getBlockAt(session, pos.getX(), pos.getY(), pos.getZ());
+        int realBlock = session.getConnector().getWorldManager().getBlockAt(session, holderPos);
         UpdateBlockPacket blockPacket = new UpdateBlockPacket();
         blockPacket.setDataLayer(0);
         blockPacket.setBlockPosition(holderPos);
@@ -115,8 +158,7 @@ public class DoubleChestInventoryTranslator extends ChestInventoryTranslator {
         session.sendUpstreamPacket(blockPacket);
 
         holderPos = holderPos.add(Vector3i.UNIT_X);
-        pos = new Position(holderPos.getX(), holderPos.getY(), holderPos.getZ());
-        realBlock = session.getConnector().getWorldManager().getBlockAt(session, pos.getX(), pos.getY(), pos.getZ());
+        realBlock = session.getConnector().getWorldManager().getBlockAt(session, holderPos);
         blockPacket = new UpdateBlockPacket();
         blockPacket.setDataLayer(0);
         blockPacket.setBlockPosition(holderPos);
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/SingleChestInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/chest/SingleChestInventoryTranslator.java
similarity index 73%
rename from connector/src/main/java/org/geysermc/connector/network/translators/inventory/SingleChestInventoryTranslator.java
rename to connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/chest/SingleChestInventoryTranslator.java
index 5e6cd637b..42b23d5b4 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/SingleChestInventoryTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/chest/SingleChestInventoryTranslator.java
@@ -23,22 +23,32 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.connector.network.translators.inventory;
+package org.geysermc.connector.network.translators.inventory.translators.chest;
 
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
 import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.inventory.holder.BlockInventoryHolder;
 import org.geysermc.connector.network.translators.inventory.holder.InventoryHolder;
-import org.geysermc.connector.network.translators.world.block.BlockTranslator;
 
 public class SingleChestInventoryTranslator extends ChestInventoryTranslator {
     private final InventoryHolder holder;
 
     public SingleChestInventoryTranslator(int size) {
         super(size, 27);
-        int javaBlockState = BlockTranslator.getJavaBlockState("minecraft:chest[facing=north,type=single,waterlogged=false]");
-        this.holder = new BlockInventoryHolder(javaBlockState, ContainerType.CONTAINER);
+        this.holder = new BlockInventoryHolder("minecraft:chest[facing=north,type=single,waterlogged=false]", ContainerType.CONTAINER,
+                "minecraft:ender_chest", "minecraft:trapped_chest") {
+            @Override
+            protected boolean isValidBlock(String[] javaBlockString) {
+                if (javaBlockString[0].equals("minecraft:ender_chest")) {
+                    // Can't have double ender chests
+                    return true;
+                }
+
+                // Add provision to ensure this isn't a double chest
+                return super.isValidBlock(javaBlockString) && (javaBlockString.length > 1 && javaBlockString[1].contains("type=single"));
+            }
+        };
     }
 
     @Override
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/FurnaceInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/furnace/AbstractFurnaceInventoryTranslator.java
similarity index 69%
rename from connector/src/main/java/org/geysermc/connector/network/translators/inventory/FurnaceInventoryTranslator.java
rename to connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/furnace/AbstractFurnaceInventoryTranslator.java
index c7bc6acf2..dc9b00fd7 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/FurnaceInventoryTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/furnace/AbstractFurnaceInventoryTranslator.java
@@ -23,18 +23,21 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.connector.network.translators.inventory;
+package org.geysermc.connector.network.translators.inventory.translators.furnace;
 
-import com.github.steveice10.mc.protocol.data.game.window.WindowType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
 import com.nukkitx.protocol.bedrock.packet.ContainerSetDataPacket;
 import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+import org.geysermc.connector.network.translators.inventory.SlotType;
+import org.geysermc.connector.network.translators.inventory.translators.AbstractBlockInventoryTranslator;
 import org.geysermc.connector.network.translators.inventory.updater.ContainerInventoryUpdater;
 
-public class FurnaceInventoryTranslator extends BlockInventoryTranslator {
-    public FurnaceInventoryTranslator() {
-        super(3, "minecraft:furnace[facing=north,lit=false]", ContainerType.FURNACE, new ContainerInventoryUpdater());
+public abstract class AbstractFurnaceInventoryTranslator extends AbstractBlockInventoryTranslator {
+    AbstractFurnaceInventoryTranslator(String javaBlockIdentifier, ContainerType containerType) {
+        super(3, javaBlockIdentifier, containerType, ContainerInventoryUpdater.INSTANCE);
     }
 
     @Override
@@ -50,9 +53,6 @@ public class FurnaceInventoryTranslator extends BlockInventoryTranslator {
                 break;
             case 2:
                 dataPacket.setProperty(ContainerSetDataPacket.FURNACE_TICK_COUNT);
-                if (inventory.getWindowType() == WindowType.BLAST_FURNACE || inventory.getWindowType() == WindowType.SMOKER) {
-                    value *= 2;
-                }
                 break;
             default:
                 return;
@@ -67,4 +67,15 @@ public class FurnaceInventoryTranslator extends BlockInventoryTranslator {
             return SlotType.FURNACE_OUTPUT;
         return SlotType.NORMAL;
     }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        if (slot == 1) {
+            return new BedrockContainerSlot(ContainerSlotType.FURNACE_FUEL, javaSlotToBedrock(slot));
+        }
+        if (slot == 2) {
+            return new BedrockContainerSlot(ContainerSlotType.FURNACE_OUTPUT, javaSlotToBedrock(slot));
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/furnace/BlastFurnaceInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/furnace/BlastFurnaceInventoryTranslator.java
new file mode 100644
index 000000000..ed9a8a79c
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/furnace/BlastFurnaceInventoryTranslator.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators.furnace;
+
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+
+public class BlastFurnaceInventoryTranslator extends AbstractFurnaceInventoryTranslator {
+    public BlastFurnaceInventoryTranslator() {
+        super("minecraft:blast_furnace[facing=north,lit=false]", ContainerType.BLAST_FURNACE);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        if (slot == 0) {
+            return new BedrockContainerSlot(ContainerSlotType.BLAST_FURNACE_INGREDIENT, javaSlotToBedrock(slot));
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/furnace/FurnaceInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/furnace/FurnaceInventoryTranslator.java
new file mode 100644
index 000000000..b41c9b03b
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/furnace/FurnaceInventoryTranslator.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators.furnace;
+
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+
+public class FurnaceInventoryTranslator extends AbstractFurnaceInventoryTranslator {
+    public FurnaceInventoryTranslator() {
+        super("minecraft:furnace[facing=north,lit=false]", ContainerType.FURNACE);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        if (slot == 0) {
+            return new BedrockContainerSlot(ContainerSlotType.FURNACE_INGREDIENT, javaSlotToBedrock(slot));
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/furnace/SmokerInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/furnace/SmokerInventoryTranslator.java
new file mode 100644
index 000000000..2b9a78c7d
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/furnace/SmokerInventoryTranslator.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators.furnace;
+
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+
+public class SmokerInventoryTranslator extends AbstractFurnaceInventoryTranslator {
+    public SmokerInventoryTranslator() {
+        super("minecraft:smoker[facing=north,lit=false]", ContainerType.SMOKER);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        if (slot == 0) {
+            return new BedrockContainerSlot(ContainerSlotType.SMOKER_INGREDIENT, javaSlotToBedrock(slot));
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/BlockInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/AbstractHorseInventoryTranslator.java
similarity index 67%
rename from connector/src/main/java/org/geysermc/connector/network/translators/inventory/BlockInventoryTranslator.java
rename to connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/AbstractHorseInventoryTranslator.java
index a24f75be3..6c6c9a0c2 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/BlockInventoryTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/AbstractHorseInventoryTranslator.java
@@ -23,40 +23,35 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.connector.network.translators.inventory;
+package org.geysermc.connector.network.translators.inventory.translators.horse;
 
-import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
 import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
-import org.geysermc.connector.network.translators.world.block.BlockTranslator;
-import org.geysermc.connector.network.translators.inventory.holder.BlockInventoryHolder;
-import org.geysermc.connector.network.translators.inventory.holder.InventoryHolder;
+import org.geysermc.connector.network.translators.inventory.translators.BaseInventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.updater.HorseInventoryUpdater;
 import org.geysermc.connector.network.translators.inventory.updater.InventoryUpdater;
 
-public class BlockInventoryTranslator extends BaseInventoryTranslator {
-    private final InventoryHolder holder;
+public abstract class AbstractHorseInventoryTranslator extends BaseInventoryTranslator {
     private final InventoryUpdater updater;
 
-    public BlockInventoryTranslator(int size, String javaBlockIdentifier, ContainerType containerType, InventoryUpdater updater) {
+    public AbstractHorseInventoryTranslator(int size) {
         super(size);
-        int javaBlockState = BlockTranslator.getJavaBlockState(javaBlockIdentifier);
-        this.holder = new BlockInventoryHolder(javaBlockState, containerType);
-        this.updater = updater;
+        this.updater = HorseInventoryUpdater.INSTANCE;
     }
 
     @Override
     public void prepareInventory(GeyserSession session, Inventory inventory) {
-        holder.prepareInventory(this, session, inventory);
+
     }
 
     @Override
     public void openInventory(GeyserSession session, Inventory inventory) {
-        holder.openInventory(this, session, inventory);
+
     }
 
     @Override
     public void closeInventory(GeyserSession session, Inventory inventory) {
-        holder.closeInventory(this, session, inventory);
+
     }
 
     @Override
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/ChestedHorseInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/ChestedHorseInventoryTranslator.java
new file mode 100644
index 000000000..77a1976be
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/ChestedHorseInventoryTranslator.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators.horse;
+
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerId;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
+import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
+import com.nukkitx.protocol.bedrock.packet.InventoryContentPacket;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+
+import java.util.Arrays;
+
+public abstract class ChestedHorseInventoryTranslator extends AbstractHorseInventoryTranslator {
+    private final int chestSize;
+    private final int equipSlot;
+
+    /**
+     * @param size the total Java size of the inventory
+     * @param equipSlot the Java equipment slot. Java always has two slots - one for armor and one for saddle. Chested horses
+     *                  on Bedrock only acknowledge one slot.
+     */
+    public ChestedHorseInventoryTranslator(int size, int equipSlot) {
+        super(size);
+        this.chestSize = size - 2;
+        this.equipSlot = equipSlot;
+    }
+
+    @Override
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        if (slotInfoData.getContainer() == ContainerSlotType.HORSE_EQUIP) {
+            return this.equipSlot;
+        }
+        if (slotInfoData.getContainer() == ContainerSlotType.CONTAINER) {
+            return slotInfoData.getSlot() + 1;
+        }
+        return super.bedrockSlotToJava(slotInfoData);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        if (slot == this.equipSlot) {
+            return new BedrockContainerSlot(ContainerSlotType.HORSE_EQUIP, 0);
+        }
+        if (slot <= this.size - 1) { // Accommodate for the lack of one slot (saddle or armor)
+            return new BedrockContainerSlot(ContainerSlotType.CONTAINER, slot - 1);
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
+
+    @Override
+    public int javaSlotToBedrock(int slot) {
+        if (slot == 0 && this.equipSlot == 0) {
+            return 0;
+        }
+        if (slot <= this.size - 1) {
+            return slot - 1;
+        }
+        return super.javaSlotToBedrock(slot);
+    }
+
+    @Override
+    public void updateInventory(GeyserSession session, Inventory inventory) {
+        ItemData[] bedrockItems = new ItemData[36];
+        for (int i = 0; i < 36; i++) {
+            final int offset = i < 9 ? 27 : -9;
+            bedrockItems[i] = inventory.getItem(this.size + i + offset).getItemData(session);
+        }
+        InventoryContentPacket contentPacket = new InventoryContentPacket();
+        contentPacket.setContainerId(ContainerId.INVENTORY);
+        contentPacket.setContents(Arrays.asList(bedrockItems));
+        session.sendUpstreamPacket(contentPacket);
+
+        ItemData[] horseItems = new ItemData[chestSize + 1];
+        // Manually specify the first slot - Java always has two slots (armor and saddle) and one is invisible.
+        // Bedrock doesn't have this invisible slot.
+        horseItems[0] = inventory.getItem(this.equipSlot).getItemData(session);
+        for (int i = 1; i < horseItems.length; i++) {
+            horseItems[i] = inventory.getItem(i + 1).getItemData(session);
+        }
+
+        InventoryContentPacket horseContentsPacket = new InventoryContentPacket();
+        horseContentsPacket.setContainerId(inventory.getId());
+        horseContentsPacket.setContents(Arrays.asList(horseItems));
+        session.sendUpstreamPacket(horseContentsPacket);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/DonkeyInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/DonkeyInventoryTranslator.java
new file mode 100644
index 000000000..bf13bd6da
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/DonkeyInventoryTranslator.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators.horse;
+
+public class DonkeyInventoryTranslator extends ChestedHorseInventoryTranslator {
+    public DonkeyInventoryTranslator(int size) {
+        super(size, 0);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/HorseInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/HorseInventoryTranslator.java
new file mode 100644
index 000000000..09a8f5de3
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/HorseInventoryTranslator.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators.horse;
+
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
+import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
+import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot;
+
+public class HorseInventoryTranslator extends AbstractHorseInventoryTranslator {
+    public HorseInventoryTranslator(int size) {
+        super(size);
+    }
+
+    @Override
+    public int bedrockSlotToJava(StackRequestSlotInfoData slotInfoData) {
+        if (slotInfoData.getContainer() == ContainerSlotType.HORSE_EQUIP) {
+            return slotInfoData.getSlot();
+        }
+        return super.bedrockSlotToJava(slotInfoData);
+    }
+
+    @Override
+    public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
+        if (slot == 0 || slot == 1) {
+            return new BedrockContainerSlot(ContainerSlotType.HORSE_EQUIP, slot);
+        }
+        return super.javaSlotToBedrockContainer(slot);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/LlamaInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/LlamaInventoryTranslator.java
new file mode 100644
index 000000000..cea605f83
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/horse/LlamaInventoryTranslator.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.translators.horse;
+
+public class LlamaInventoryTranslator extends ChestedHorseInventoryTranslator {
+    public LlamaInventoryTranslator(int size) {
+        super(size, 1);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/ChestInventoryUpdater.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/ChestInventoryUpdater.java
index 73c1f2ebc..f5029f749 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/ChestInventoryUpdater.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/ChestInventoryUpdater.java
@@ -32,7 +32,6 @@ import lombok.AllArgsConstructor;
 import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
-import org.geysermc.connector.network.translators.item.ItemTranslator;
 import org.geysermc.connector.utils.InventoryUtils;
 import org.geysermc.connector.utils.LanguageUtils;
 
@@ -52,7 +51,7 @@ public class ChestInventoryUpdater extends InventoryUpdater {
         List<ItemData> bedrockItems = new ArrayList<>(paddedSize);
         for (int i = 0; i < paddedSize; i++) {
             if (i < translator.size) {
-                bedrockItems.add(ItemTranslator.translateToBedrock(session, inventory.getItem(i)));
+                bedrockItems.add(inventory.getItem(i).getItemData(session));
             } else {
                 bedrockItems.add(UNUSUABLE_SPACE_BLOCK);
             }
@@ -72,7 +71,7 @@ public class ChestInventoryUpdater extends InventoryUpdater {
         InventorySlotPacket slotPacket = new InventorySlotPacket();
         slotPacket.setContainerId(inventory.getId());
         slotPacket.setSlot(translator.javaSlotToBedrock(javaSlot));
-        slotPacket.setItem(ItemTranslator.translateToBedrock(session, inventory.getItem(javaSlot)));
+        slotPacket.setItem(inventory.getItem(javaSlot).getItemData(session));
         session.sendUpstreamPacket(slotPacket);
         return true;
     }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/ContainerInventoryUpdater.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/ContainerInventoryUpdater.java
index d7bdbde45..f77e687a0 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/ContainerInventoryUpdater.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/ContainerInventoryUpdater.java
@@ -31,18 +31,19 @@ import com.nukkitx.protocol.bedrock.packet.InventorySlotPacket;
 import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
-import org.geysermc.connector.network.translators.item.ItemTranslator;
 
 import java.util.Arrays;
 
 public class ContainerInventoryUpdater extends InventoryUpdater {
+    public static final ContainerInventoryUpdater INSTANCE = new ContainerInventoryUpdater();
+
     @Override
     public void updateInventory(InventoryTranslator translator, GeyserSession session, Inventory inventory) {
         super.updateInventory(translator, session, inventory);
 
         ItemData[] bedrockItems = new ItemData[translator.size];
         for (int i = 0; i < bedrockItems.length; i++) {
-            bedrockItems[translator.javaSlotToBedrock(i)] = ItemTranslator.translateToBedrock(session, inventory.getItem(i));
+            bedrockItems[translator.javaSlotToBedrock(i)] = inventory.getItem(i).getItemData(session);
         }
 
         InventoryContentPacket contentPacket = new InventoryContentPacket();
@@ -59,7 +60,7 @@ public class ContainerInventoryUpdater extends InventoryUpdater {
         InventorySlotPacket slotPacket = new InventorySlotPacket();
         slotPacket.setContainerId(inventory.getId());
         slotPacket.setSlot(translator.javaSlotToBedrock(javaSlot));
-        slotPacket.setItem(ItemTranslator.translateToBedrock(session, inventory.getItem(javaSlot)));
+        slotPacket.setItem(inventory.getItem(javaSlot).getItemData(session));
         session.sendUpstreamPacket(slotPacket);
         return true;
     }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/HorseInventoryUpdater.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/HorseInventoryUpdater.java
new file mode 100644
index 000000000..d238b4148
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/HorseInventoryUpdater.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.inventory.updater;
+
+import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
+import com.nukkitx.protocol.bedrock.packet.InventoryContentPacket;
+import com.nukkitx.protocol.bedrock.packet.InventorySlotPacket;
+import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
+
+import java.util.Arrays;
+
+public class HorseInventoryUpdater extends InventoryUpdater {
+    public static final HorseInventoryUpdater INSTANCE = new HorseInventoryUpdater();
+
+    @Override
+    public void updateInventory(InventoryTranslator translator, GeyserSession session, Inventory inventory) {
+        super.updateInventory(translator, session, inventory);
+
+        ItemData[] bedrockItems = new ItemData[translator.size];
+        for (int i = 0; i < bedrockItems.length; i++) {
+            bedrockItems[translator.javaSlotToBedrock(i)] = inventory.getItem(i).getItemData(session);
+        }
+
+        InventoryContentPacket contentPacket = new InventoryContentPacket();
+        contentPacket.setContainerId(inventory.getId());
+        contentPacket.setContents(Arrays.asList(bedrockItems));
+        session.sendUpstreamPacket(contentPacket);
+    }
+
+    @Override
+    public boolean updateSlot(InventoryTranslator translator, GeyserSession session, Inventory inventory, int javaSlot) {
+        if (super.updateSlot(translator, session, inventory, javaSlot))
+            return true;
+
+        InventorySlotPacket slotPacket = new InventorySlotPacket();
+        slotPacket.setContainerId(4); // Horse GUI?
+        slotPacket.setSlot(translator.javaSlotToBedrock(javaSlot));
+        slotPacket.setItem(inventory.getItem(javaSlot).getItemData(session));
+        session.sendUpstreamPacket(slotPacket);
+        return true;
+    }
+
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/InventoryUpdater.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/InventoryUpdater.java
index 020f74671..d7c137177 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/InventoryUpdater.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/InventoryUpdater.java
@@ -32,7 +32,6 @@ import com.nukkitx.protocol.bedrock.packet.InventorySlotPacket;
 import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
-import org.geysermc.connector.network.translators.item.ItemTranslator;
 
 import java.util.Arrays;
 
@@ -41,7 +40,7 @@ public abstract class InventoryUpdater {
         ItemData[] bedrockItems = new ItemData[36];
         for (int i = 0; i < 36; i++) {
             final int offset = i < 9 ? 27 : -9;
-            bedrockItems[i] = ItemTranslator.translateToBedrock(session, inventory.getItem(translator.size + i + offset));
+            bedrockItems[i] = inventory.getItem(translator.size + i + offset).getItemData(session);
         }
         InventoryContentPacket contentPacket = new InventoryContentPacket();
         contentPacket.setContainerId(ContainerId.INVENTORY);
@@ -54,7 +53,7 @@ public abstract class InventoryUpdater {
             InventorySlotPacket slotPacket = new InventorySlotPacket();
             slotPacket.setContainerId(ContainerId.INVENTORY);
             slotPacket.setSlot(translator.javaSlotToBedrock(javaSlot));
-            slotPacket.setItem(ItemTranslator.translateToBedrock(session, inventory.getItem(javaSlot)));
+            slotPacket.setItem(inventory.getItem(javaSlot).getItemData(session));
             session.sendUpstreamPacket(slotPacket);
             return true;
         }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/CursorInventoryUpdater.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/UIInventoryUpdater.java
similarity index 87%
rename from connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/CursorInventoryUpdater.java
rename to connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/UIInventoryUpdater.java
index 89abdd847..5bb8ad5d2 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/CursorInventoryUpdater.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/UIInventoryUpdater.java
@@ -30,11 +30,10 @@ import com.nukkitx.protocol.bedrock.packet.InventorySlotPacket;
 import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
-import org.geysermc.connector.network.translators.item.ItemTranslator;
 
-public class CursorInventoryUpdater extends InventoryUpdater {
+public class UIInventoryUpdater extends InventoryUpdater {
+    public static final UIInventoryUpdater INSTANCE = new UIInventoryUpdater();
 
-    //TODO: Consider renaming this? Since the Protocol enum updated
     @Override
     public void updateInventory(InventoryTranslator translator, GeyserSession session, Inventory inventory) {
         super.updateInventory(translator, session, inventory);
@@ -46,7 +45,7 @@ public class CursorInventoryUpdater extends InventoryUpdater {
             InventorySlotPacket slotPacket = new InventorySlotPacket();
             slotPacket.setContainerId(ContainerId.UI);
             slotPacket.setSlot(bedrockSlot);
-            slotPacket.setItem(ItemTranslator.translateToBedrock(session, inventory.getItem(i)));
+            slotPacket.setItem(inventory.getItem(i).getItemData(session));
             session.sendUpstreamPacket(slotPacket);
         }
     }
@@ -59,7 +58,7 @@ public class CursorInventoryUpdater extends InventoryUpdater {
         InventorySlotPacket slotPacket = new InventorySlotPacket();
         slotPacket.setContainerId(ContainerId.UI);
         slotPacket.setSlot(translator.javaSlotToBedrock(javaSlot));
-        slotPacket.setItem(ItemTranslator.translateToBedrock(session, inventory.getItem(javaSlot)));
+        slotPacket.setItem(inventory.getItem(javaSlot).getItemData(session));
         session.sendUpstreamPacket(slotPacket);
         return true;
     }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemEntry.java b/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemEntry.java
index f61c3d709..278d708f9 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemEntry.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemEntry.java
@@ -34,7 +34,7 @@ import lombok.ToString;
 @ToString
 public class ItemEntry {
 
-    public static ItemEntry AIR = new ItemEntry("minecraft:air", "minecraft:air", 0, 0, 0, false);
+    public static ItemEntry AIR = new ItemEntry("minecraft:air", "minecraft:air", 0, 0, 0, false, 64);
 
     private final String javaIdentifier;
     private final String bedrockIdentifier;
@@ -43,6 +43,7 @@ public class ItemEntry {
     private final int bedrockData;
 
     private final boolean block;
+    private final int stackSize;
 
     @Override
     public boolean equals(Object obj) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemRegistry.java b/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemRegistry.java
index 9d1921731..c865a162a 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemRegistry.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemRegistry.java
@@ -56,7 +56,7 @@ public class ItemRegistry {
      * A list of all identifiers that only exist on Java. Used to prevent creative items from becoming these unintentionally.
      */
     private static final List<String> JAVA_ONLY_ITEMS = Arrays.asList("minecraft:spectral_arrow", "minecraft:debug_stick",
-            "minecraft:knowledge_book");
+            "minecraft:knowledge_book", "minecraft:tipped_arrow", "minecraft:furnace_minecart");
 
     public static final ItemData[] CREATIVE_ITEMS;
 
@@ -158,6 +158,8 @@ public class ItemRegistry {
             if (bedrockIdentifier == null) {
                 throw new RuntimeException("Missing Bedrock ID in mappings!: " + bedrockId);
             }
+            JsonNode stackSizeNode = entry.getValue().get("stack_size");
+            int stackSize = stackSizeNode == null ? 64 : stackSizeNode.intValue();
             if (entry.getValue().has("tool_type")) {
                 if (entry.getValue().has("tool_tier")) {
                     ITEM_ENTRIES.put(itemIndex, new ToolItemEntry(
@@ -165,19 +167,22 @@ public class ItemRegistry {
                             entry.getValue().get("bedrock_data").intValue(),
                             entry.getValue().get("tool_type").textValue(),
                             entry.getValue().get("tool_tier").textValue(),
-                            entry.getValue().get("is_block") != null && entry.getValue().get("is_block").booleanValue()));
+                            entry.getValue().get("is_block").booleanValue(),
+                            stackSize));
                 } else {
                     ITEM_ENTRIES.put(itemIndex, new ToolItemEntry(
                             entry.getKey(), bedrockIdentifier, itemIndex, bedrockId,
                             entry.getValue().get("bedrock_data").intValue(),
                             entry.getValue().get("tool_type").textValue(),
-                            "", entry.getValue().get("is_block").booleanValue()));
+                            "", entry.getValue().get("is_block").booleanValue(),
+                            stackSize));
                 }
             } else {
                 ITEM_ENTRIES.put(itemIndex, new ItemEntry(
                         entry.getKey(), bedrockIdentifier, itemIndex, bedrockId,
                         entry.getValue().get("bedrock_data").intValue(),
-                        entry.getValue().get("is_block") != null && entry.getValue().get("is_block").booleanValue()));
+                        entry.getValue().get("is_block").booleanValue(),
+                        stackSize));
             }
             switch (entry.getKey()) {
                 case "minecraft:barrier":
@@ -225,7 +230,7 @@ public class ItemRegistry {
 
         // Add the loadstone compass since it doesn't exist on java but we need it for item conversion
         ITEM_ENTRIES.put(itemIndex, new ItemEntry("minecraft:lodestone_compass", "minecraft:lodestone_compass", itemIndex,
-                lodestoneCompassId, 0, false));
+                lodestoneCompassId, 0, false, 1));
 
         /* Load creative items */
         stream = FileUtils.getResource("bedrock/creative_items.json");
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemTranslator.java
index 2a72bc71b..f1ae98faf 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemTranslator.java
@@ -124,8 +124,12 @@ public abstract class ItemTranslator {
         }
 
         ItemEntry bedrockItem = ItemRegistry.getItem(stack);
+        if (bedrockItem == null) {
+            session.getConnector().getLogger().debug("No matching ItemEntry for " + stack);
+            return ItemData.AIR;
+        }
 
-        com.github.steveice10.opennbt.tag.builtin.CompoundTag nbt = stack.getNbt() != null ? stack.getNbt().clone() : null;
+        CompoundTag nbt = stack.getNbt() != null ? stack.getNbt().clone() : null;
 
         // This is a fallback for maps with no nbt
         if (nbt == null && bedrockItem.getJavaIdentifier().equals("minecraft:filled_map")) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/item/RecipeRegistry.java b/connector/src/main/java/org/geysermc/connector/network/translators/item/RecipeRegistry.java
index 7e307281e..110014bf5 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/item/RecipeRegistry.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/item/RecipeRegistry.java
@@ -26,13 +26,25 @@
 package org.geysermc.connector.network.translators.item;
 
 import com.fasterxml.jackson.databind.JsonNode;
+import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
+import com.github.steveice10.mc.protocol.data.game.recipe.Ingredient;
+import com.github.steveice10.mc.protocol.data.game.recipe.Recipe;
+import com.github.steveice10.mc.protocol.data.game.recipe.RecipeType;
+import com.github.steveice10.mc.protocol.data.game.recipe.data.ShapedRecipeData;
+import com.github.steveice10.mc.protocol.data.game.recipe.data.ShapelessRecipeData;
+import com.nukkitx.nbt.NbtMap;
+import com.nukkitx.nbt.NbtUtils;
 import com.nukkitx.protocol.bedrock.data.inventory.CraftingData;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
+import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
 import it.unimi.dsi.fastutil.objects.ObjectArrayList;
 import org.geysermc.connector.GeyserConnector;
 import org.geysermc.connector.utils.FileUtils;
 import org.geysermc.connector.utils.LanguageUtils;
 
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
 import java.io.InputStream;
 import java.util.*;
 
@@ -47,6 +59,12 @@ public class RecipeRegistry {
      */
     public static int LAST_RECIPE_NET_ID = 0;
 
+    /**
+     * A list of all the following crafting recipes, but in a format understood by Java servers.
+     * Used for console autocrafting.
+     */
+    public static final Int2ObjectMap<Recipe> ALL_CRAFTING_RECIPES = new Int2ObjectOpenHashMap<>();
+
     /**
      * A list of all possible leather armor dyeing recipes.
      * Created manually.
@@ -78,6 +96,12 @@ public class RecipeRegistry {
      */
     public static final List<CraftingData> TIPPED_ARROW_RECIPES = new ObjectArrayList<>();
 
+    /**
+     * Recipe data that, when sent to the client, enables cartography features.
+     * This does not have a Java equivalent.
+     */
+    public static final List<CraftingData> CARTOGRAPHY_RECIPE_DATA = new ObjectArrayList<>();
+
     /**
      * Recipe data that, when sent to the client, enables book cloning
      */
@@ -106,6 +130,11 @@ public class RecipeRegistry {
         MAP_EXTENDING_RECIPE_DATA = CraftingData.fromMulti(UUID.fromString("d392b075-4ba1-40ae-8789-af868d56f6ce"), LAST_RECIPE_NET_ID++);
         MAP_CLONING_RECIPE_DATA = CraftingData.fromMulti(UUID.fromString("85939755-ba10-4d9d-a4cc-efb7a8e943c4"), LAST_RECIPE_NET_ID++);
         BANNER_DUPLICATING_RECIPE_DATA = CraftingData.fromMulti(UUID.fromString("b5c5d105-75a2-4076-af2b-923ea2bf4bf0"), LAST_RECIPE_NET_ID++);
+
+        CARTOGRAPHY_RECIPE_DATA.add(CraftingData.fromMulti(UUID.fromString("8b36268c-1829-483c-a0f1-993b7156a8f2"), LAST_RECIPE_NET_ID++)); // Map extending
+        CARTOGRAPHY_RECIPE_DATA.add(CraftingData.fromMulti(UUID.fromString("442d85ed-8272-4543-a6f1-418f90ded05d"), LAST_RECIPE_NET_ID++)); // Map cloning
+        CARTOGRAPHY_RECIPE_DATA.add(CraftingData.fromMulti(UUID.fromString("98c84b38-1085-46bd-b1ce-dd38c159e6cc"), LAST_RECIPE_NET_ID++)); // Map upgrading
+        CARTOGRAPHY_RECIPE_DATA.add(CraftingData.fromMulti(UUID.fromString("602234e4-cac1-4353-8bb7-b1ebff70024b"), LAST_RECIPE_NET_ID++)); // Map locking
         // https://github.com/pmmp/PocketMine-MP/blob/stable/src/pocketmine/inventory/MultiRecipe.php
 
         // Get all recipes that are not directly sent from a Java server
@@ -118,7 +147,7 @@ public class RecipeRegistry {
             throw new AssertionError(LanguageUtils.getLocaleStringLog("geyser.toolbox.fail.runtime_java"), e);
         }
 
-        for (JsonNode entry: items.get("leather_armor")) {
+        for (JsonNode entry : items.get("leather_armor")) {
             // This won't be perfect, as we can't possibly send every leather input for every kind of color
             // But it does display the correct output from a base leather armor, and besides visuals everything works fine
             LEATHER_DYEING_RECIPES.add(getCraftingDataFromJsonNode(entry));
@@ -146,9 +175,13 @@ public class RecipeRegistry {
      * @return the {@link CraftingData} to send to the Bedrock client.
      */
     private static CraftingData getCraftingDataFromJsonNode(JsonNode node) {
-        ItemData output = ItemRegistry.getBedrockItemFromJson(node.get("output").get(0));
+        int netId = LAST_RECIPE_NET_ID++;
+        int type = node.get("bedrockRecipeType").asInt();
+        JsonNode outputNode = node.get("output");
+        ItemEntry outputEntry = ItemRegistry.getItemEntry(outputNode.get("identifier").asText());
+        ItemData output = getBedrockItemFromIdentifierJson(outputEntry, outputNode);
         UUID uuid = UUID.randomUUID();
-        if (node.get("type").asInt() == 1) {
+        if (type == 1) {
             // Shaped recipe
             List<String> shape = new ArrayList<>();
             // Get the shape of the recipe
@@ -158,10 +191,12 @@ public class RecipeRegistry {
 
             // In recipes.json each recipe is mapped by a letter
             Map<String, ItemData> letterToRecipe = new HashMap<>();
-            Iterator<Map.Entry<String, JsonNode>> iterator = node.get("input").fields();
+            Iterator<Map.Entry<String, JsonNode>> iterator = node.get("inputs").fields();
             while (iterator.hasNext()) {
                 Map.Entry<String, JsonNode> entry = iterator.next();
-                letterToRecipe.put(entry.getKey(), ItemRegistry.getBedrockItemFromJson(entry.getValue()));
+                JsonNode inputNode = entry.getValue();
+                ItemEntry inputEntry = ItemRegistry.getItemEntry(inputNode.get("identifier").asText());
+                letterToRecipe.put(entry.getKey(), getBedrockItemFromIdentifierJson(inputEntry, inputNode));
             }
 
             List<ItemData> inputs = new ArrayList<>(shape.size() * shape.get(0).length());
@@ -175,20 +210,69 @@ public class RecipeRegistry {
                 }
             }
 
+            /* Convert into a Java recipe class for autocrafting */
+            List<Ingredient> ingredients = new ArrayList<>();
+            for (ItemData input : inputs) {
+                ingredients.add(new Ingredient(new ItemStack[]{ItemTranslator.translateToJava(input)}));
+            }
+            ShapedRecipeData data = new ShapedRecipeData(shape.get(0).length(), shape.size(), "crafting_table",
+                    ingredients.toArray(new Ingredient[0]), ItemTranslator.translateToJava(output));
+            Recipe recipe = new Recipe(RecipeType.CRAFTING_SHAPED, "", data);
+            ALL_CRAFTING_RECIPES.put(netId, recipe);
+            /* Convert end */
+
             return CraftingData.fromShaped(uuid.toString(), shape.get(0).length(), shape.size(),
-                    inputs, Collections.singletonList(output), uuid, "crafting_table", 0, LAST_RECIPE_NET_ID++);
+                    inputs, Collections.singletonList(output), uuid, "crafting_table", 0, netId);
         }
         List<ItemData> inputs = new ObjectArrayList<>();
-        for (JsonNode entry : node.get("input")) {
-            inputs.add(ItemRegistry.getBedrockItemFromJson(entry));
+        for (JsonNode entry : node.get("inputs")) {
+            ItemEntry inputEntry = ItemRegistry.getItemEntry(entry.get("identifier").asText());
+            inputs.add(getBedrockItemFromIdentifierJson(inputEntry, entry));
         }
-        if (node.get("type").asInt() == 5) {
+
+        /* Convert into a Java Recipe class for autocrafting */
+        List<Ingredient> ingredients = new ArrayList<>();
+        for (ItemData input : inputs) {
+            ingredients.add(new Ingredient(new ItemStack[]{ItemTranslator.translateToJava(input)}));
+        }
+        ShapelessRecipeData data = new ShapelessRecipeData("crafting_table",
+                ingredients.toArray(new Ingredient[0]), ItemTranslator.translateToJava(output));
+        Recipe recipe = new Recipe(RecipeType.CRAFTING_SHAPELESS, "", data);
+        ALL_CRAFTING_RECIPES.put(netId, recipe);
+        /* Convert end */
+
+        if (type == 5) {
             // Shulker box
             return CraftingData.fromShulkerBox(uuid.toString(),
-                    inputs, Collections.singletonList(output), uuid, "crafting_table", 0, LAST_RECIPE_NET_ID++);
+                    inputs, Collections.singletonList(output), uuid, "crafting_table", 0, netId);
         }
         return CraftingData.fromShapeless(uuid.toString(),
-                inputs, Collections.singletonList(output), uuid, "crafting_table", 0, LAST_RECIPE_NET_ID++);
+                inputs, Collections.singletonList(output), uuid, "crafting_table", 0, netId);
+    }
+
+    private static ItemData getBedrockItemFromIdentifierJson(ItemEntry itemEntry, JsonNode itemNode) {
+        int count = 1;
+        short damage = 0;
+        NbtMap tag = null;
+        JsonNode damageNode = itemNode.get("bedrockDamage");
+        if (damageNode != null) {
+            damage = damageNode.numberValue().shortValue();
+        }
+        JsonNode countNode = itemNode.get("count");
+        if (countNode != null) {
+            count = countNode.asInt();
+        }
+        JsonNode nbtNode = itemNode.get("bedrockNbt");
+        if (nbtNode != null) {
+            byte[] bytes = Base64.getDecoder().decode(nbtNode.asText());
+            ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
+            try {
+                tag = (NbtMap) NbtUtils.createReaderLE(bais).readTag();
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
+        }
+        return ItemData.of(itemEntry.getBedrockId(), damage, count, tag);
     }
 
     public static void init() {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/item/ToolItemEntry.java b/connector/src/main/java/org/geysermc/connector/network/translators/item/ToolItemEntry.java
index 5352938c0..ba1753a35 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/item/ToolItemEntry.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/item/ToolItemEntry.java
@@ -32,8 +32,8 @@ public class ToolItemEntry extends ItemEntry {
     private final String toolType;
     private final String toolTier;
 
-    public ToolItemEntry(String javaIdentifier, String bedrockIdentifier, int javaId, int bedrockId, int bedrockData, String toolType, String toolTier, boolean isBlock) {
-        super(javaIdentifier, bedrockIdentifier, javaId, bedrockId, bedrockData, isBlock);
+    public ToolItemEntry(String javaIdentifier, String bedrockIdentifier, int javaId, int bedrockId, int bedrockData, String toolType, String toolTier, boolean isBlock, int stackSize) {
+        super(javaIdentifier, bedrockIdentifier, javaId, bedrockId, bedrockData, isBlock, stackSize);
         this.toolType = toolType;
         this.toolTier = toolTier;
     }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/item/translators/BannerTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/item/translators/BannerTranslator.java
index 25bfe3d2e..a96c47f6a 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/item/translators/BannerTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/item/translators/BannerTranslator.java
@@ -195,13 +195,14 @@ public class BannerTranslator extends ItemTranslator {
             blockEntityTag.put(OMINOUS_BANNER_PATTERN);
 
             itemStack.getNbt().put(blockEntityTag);
-        } else if (nbtTag.containsKey("Patterns", NbtType.COMPOUND)) {
+        } else if (nbtTag.containsKey("Patterns", NbtType.LIST)) {
             List<NbtMap> patterns = nbtTag.getList("Patterns", NbtType.COMPOUND);
 
             CompoundTag blockEntityTag = new CompoundTag("BlockEntityTag");
             blockEntityTag.put(convertBannerPattern(patterns));
 
             itemStack.getNbt().put(blockEntityTag);
+            itemStack.getNbt().remove("Patterns"); // Remove the old Bedrock patterns list
         }
 
         return itemStack;
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaDeclareRecipesTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaDeclareRecipesTranslator.java
index 33ebc7ea9..bf78d52c6 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaDeclareRecipesTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaDeclareRecipesTranslator.java
@@ -25,17 +25,18 @@
 
 package org.geysermc.connector.network.translators.java;
 
+import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
 import com.github.steveice10.mc.protocol.data.game.recipe.Ingredient;
 import com.github.steveice10.mc.protocol.data.game.recipe.Recipe;
 import com.github.steveice10.mc.protocol.data.game.recipe.data.ShapedRecipeData;
 import com.github.steveice10.mc.protocol.data.game.recipe.data.ShapelessRecipeData;
+import com.github.steveice10.mc.protocol.data.game.recipe.data.StoneCuttingRecipeData;
 import com.github.steveice10.mc.protocol.packet.ingame.server.ServerDeclareRecipesPacket;
 import com.nukkitx.nbt.NbtMap;
 import com.nukkitx.protocol.bedrock.data.inventory.CraftingData;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
 import com.nukkitx.protocol.bedrock.packet.CraftingDataPacket;
-import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
-import it.unimi.dsi.fastutil.ints.IntSet;
+import it.unimi.dsi.fastutil.ints.*;
 import lombok.AllArgsConstructor;
 import lombok.EqualsAndHashCode;
 import org.geysermc.connector.network.session.GeyserSession;
@@ -46,13 +47,20 @@ import org.geysermc.connector.network.translators.item.*;
 import java.util.*;
 import java.util.stream.Collectors;
 
+/**
+ * Used to send all valid recipes from Java to Bedrock.
+ *
+ * Bedrock REQUIRES a CraftingDataPacket to be sent in order to craft anything.
+ */
 @Translator(packet = ServerDeclareRecipesPacket.class)
 public class JavaDeclareRecipesTranslator extends PacketTranslator<ServerDeclareRecipesPacket> {
 
     @Override
     public void translate(ServerDeclareRecipesPacket packet, GeyserSession session) {
         // Get the last known network ID (first used for the pregenerated recipes) and increment from there.
-        int networkId = RecipeRegistry.LAST_RECIPE_NET_ID;
+        int netId = RecipeRegistry.LAST_RECIPE_NET_ID + 1;
+        Int2ObjectMap<Recipe> recipeMap = new Int2ObjectOpenHashMap<>(RecipeRegistry.ALL_CRAFTING_RECIPES);
+        Int2ObjectMap<List<StoneCuttingRecipeData>> unsortedStonecutterData = new Int2ObjectOpenHashMap<>();
         CraftingDataPacket craftingDataPacket = new CraftingDataPacket();
         craftingDataPacket.setCleanRecipes(true);
         for (Recipe recipe : packet.getRecipes()) {
@@ -60,25 +68,29 @@ public class JavaDeclareRecipesTranslator extends PacketTranslator<ServerDeclare
                 case CRAFTING_SHAPELESS: {
                     ShapelessRecipeData shapelessRecipeData = (ShapelessRecipeData) recipe.getData();
                     ItemData output = ItemTranslator.translateToBedrock(session, shapelessRecipeData.getResult());
-                    output = ItemData.of(output.getId(), output.getDamage(), output.getCount()); //strip NBT
+                    // Strip NBT - tools won't appear in the recipe book otherwise
+                    output = ItemData.of(output.getId(), output.getDamage(), output.getCount());
                     ItemData[][] inputCombinations = combinations(session, shapelessRecipeData.getIngredients());
                     for (ItemData[] inputs : inputCombinations) {
                         UUID uuid = UUID.randomUUID();
                         craftingDataPacket.getCraftingData().add(CraftingData.fromShapeless(uuid.toString(),
-                                Arrays.asList(inputs), Collections.singletonList(output), uuid, "crafting_table", 0, networkId++));
+                                Arrays.asList(inputs), Collections.singletonList(output), uuid, "crafting_table", 0, netId));
+                        recipeMap.put(netId++, recipe);
                     }
                     break;
                 }
                 case CRAFTING_SHAPED: {
                     ShapedRecipeData shapedRecipeData = (ShapedRecipeData) recipe.getData();
                     ItemData output = ItemTranslator.translateToBedrock(session, shapedRecipeData.getResult());
-                    output = ItemData.of(output.getId(), output.getDamage(), output.getCount()); //strip NBT
+                    // See above
+                    output = ItemData.of(output.getId(), output.getDamage(), output.getCount());
                     ItemData[][] inputCombinations = combinations(session, shapedRecipeData.getIngredients());
                     for (ItemData[] inputs : inputCombinations) {
                         UUID uuid = UUID.randomUUID();
                         craftingDataPacket.getCraftingData().add(CraftingData.fromShaped(uuid.toString(),
                                 shapedRecipeData.getWidth(), shapedRecipeData.getHeight(), Arrays.asList(inputs),
-                                Collections.singletonList(output), uuid, "crafting_table", 0, networkId++));
+                                        Collections.singletonList(output), uuid, "crafting_table", 0, netId));
+                        recipeMap.put(netId++, recipe);
                     }
                     break;
                 }
@@ -131,13 +143,68 @@ public class JavaDeclareRecipesTranslator extends PacketTranslator<ServerDeclare
                     craftingDataPacket.getCraftingData().addAll(RecipeRegistry.LEATHER_DYEING_RECIPES);
                     break;
                 }
+                case STONECUTTING: {
+                    StoneCuttingRecipeData stoneCuttingData = (StoneCuttingRecipeData) recipe.getData();
+                    ItemStack ingredient = stoneCuttingData.getIngredient().getOptions()[0];
+                    List<StoneCuttingRecipeData> data = unsortedStonecutterData.get(ingredient.getId());
+                    if (data == null) {
+                        data = new ArrayList<>();
+                        unsortedStonecutterData.put(ingredient.getId(), data);
+                    }
+                    data.add(stoneCuttingData);
+                    // Save for processing after all recipes have been received
+                }
             }
         }
+        // Add all cartography table recipe UUIDs, so we can use the cartography table
+        craftingDataPacket.getCraftingData().addAll(RecipeRegistry.CARTOGRAPHY_RECIPE_DATA);
+
         craftingDataPacket.getPotionMixData().addAll(PotionMixRegistry.POTION_MIXES);
+
+        Int2ObjectMap<IntList> stonecutterRecipeMap = new Int2ObjectOpenHashMap<>();
+        for (Int2ObjectMap.Entry<List<StoneCuttingRecipeData>> data : unsortedStonecutterData.int2ObjectEntrySet()) {
+            // Sort the list by each output item's Java identifier - this is how it's sorted on Java, and therefore
+            // We can get the correct order for button pressing
+            data.getValue().sort(Comparator.comparing((stoneCuttingRecipeData ->
+                    ItemRegistry.getItem(stoneCuttingRecipeData.getResult()).getJavaIdentifier())));
+
+            // Now that it's sorted, let's translate these recipes
+            for (StoneCuttingRecipeData stoneCuttingData : data.getValue()) {
+                // As of 1.16.4, all stonecutter recipes have one ingredient option
+                ItemStack ingredient = stoneCuttingData.getIngredient().getOptions()[0];
+                ItemData input = ItemTranslator.translateToBedrock(session, ingredient);
+                ItemData output = ItemTranslator.translateToBedrock(session, stoneCuttingData.getResult());
+                UUID uuid = UUID.randomUUID();
+
+                // We need to register stonecutting recipes so they show up on Bedrock
+                craftingDataPacket.getCraftingData().add(CraftingData.fromShapeless(uuid.toString(),
+                        Collections.singletonList(input), Collections.singletonList(output), uuid, "stonecutter", 0, netId++));
+
+                // Save the recipe list for reference when crafting
+                IntList outputs = stonecutterRecipeMap.get(ingredient.getId());
+                if (outputs == null) {
+                    outputs = new IntArrayList();
+                    // Add the ingredient as the key and all possible values as the value
+                    stonecutterRecipeMap.put(ingredient.getId(), outputs);
+                }
+                outputs.add(stoneCuttingData.getResult().getId());
+            }
+        }
+
         session.sendUpstreamPacket(craftingDataPacket);
+        session.setCraftingRecipes(recipeMap);
+        session.getUnlockedRecipes().clear();
+        session.setStonecutterRecipes(stonecutterRecipeMap);
+        session.getLastRecipeNetId().set(netId);
     }
 
     //TODO: rewrite
+    /**
+     * The Java server sends an array of items for each ingredient you can use per slot in the crafting grid.
+     * Bedrock recipes take only one ingredient per crafting grid slot.
+     *
+     * @return the Java ingredient list as an array that Bedrock can understand
+     */
     private ItemData[][] combinations(GeyserSession session, Ingredient[] ingredients) {
         Map<Set<ItemData>, IntSet> squashedOptions = new HashMap<>();
         for (int i = 0; i < ingredients.length; i++) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaRespawnTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaRespawnTranslator.java
index 6c8faeb52..7c8cd0583 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaRespawnTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaRespawnTranslator.java
@@ -35,6 +35,7 @@ import org.geysermc.connector.entity.attribute.AttributeType;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
+import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
 import org.geysermc.connector.utils.DimensionUtils;
 
 @Translator(packet = ServerRespawnPacket.class)
@@ -48,7 +49,11 @@ public class JavaRespawnTranslator extends PacketTranslator<ServerRespawnPacket>
         // Max health must be divisible by two in bedrock
         entity.getAttributes().put(AttributeType.HEALTH, AttributeType.HEALTH.getAttribute(maxHealth, (maxHealth % 2 == 1 ? maxHealth + 1 : maxHealth)));
 
-        session.getInventoryCache().setOpenInventory(null);
+        session.addInventoryTask(() -> {
+            session.setInventoryTranslator(InventoryTranslator.PLAYER_INVENTORY_TRANSLATOR);
+            session.setOpenInventory(null);
+            session.setClosingInventory(false);
+        });
 
         SetPlayerGameTypePacket playerGameTypePacket = new SetPlayerGameTypePacket();
         playerGameTypePacket.setGamemode(packet.getGamemode().ordinal());
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaUnlockRecipesTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaUnlockRecipesTranslator.java
new file mode 100644
index 000000000..0a0ba4d2d
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaUnlockRecipesTranslator.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.java;
+
+import com.github.steveice10.mc.protocol.data.game.UnlockRecipesAction;
+import com.github.steveice10.mc.protocol.packet.ingame.server.ServerUnlockRecipesPacket;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.PacketTranslator;
+import org.geysermc.connector.network.translators.Translator;
+
+import java.util.Arrays;
+
+/**
+ * Used to list recipes that we can definitely use the recipe book for (and therefore save on packet usage)
+ */
+@Translator(packet = ServerUnlockRecipesPacket.class)
+public class JavaUnlockRecipesTranslator extends PacketTranslator<ServerUnlockRecipesPacket> {
+
+    @Override
+    public void translate(ServerUnlockRecipesPacket packet, GeyserSession session) {
+        if (packet.getAction() == UnlockRecipesAction.REMOVE) {
+            session.getUnlockedRecipes().removeAll(Arrays.asList(packet.getRecipes()));
+        } else {
+            session.getUnlockedRecipes().addAll(Arrays.asList(packet.getRecipes()));
+        }
+    }
+}
+
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/entity/player/JavaPlayerActionAckTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/entity/player/JavaPlayerActionAckTranslator.java
index 837adb126..ace9cb934 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/entity/player/JavaPlayerActionAckTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/entity/player/JavaPlayerActionAckTranslator.java
@@ -33,6 +33,7 @@ import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.LevelEventType;
 import com.nukkitx.protocol.bedrock.packet.LevelEventPacket;
+import org.geysermc.connector.inventory.GeyserItemStack;
 import org.geysermc.connector.inventory.PlayerInventory;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
@@ -71,12 +72,12 @@ public class JavaPlayerActionAckTranslator extends PacketTranslator<ServerPlayer
                             packet.getPosition().getY(),
                             packet.getPosition().getZ()
                     ));
-                    PlayerInventory inventory = session.getInventory();
-                    ItemStack item = inventory.getItemInHand();
+                    PlayerInventory inventory = session.getPlayerInventory();
+                    GeyserItemStack item = inventory.getItemInHand();
                     ItemEntry itemEntry = null;
                     CompoundTag nbtData = new CompoundTag("");
                     if (item != null) {
-                        itemEntry = ItemRegistry.getItem(item);
+                        itemEntry = item.getItemEntry();
                         nbtData = item.getNbt();
                     }
                     double breakTime = Math.ceil(BlockUtils.getBreakTime(blockHardness, packet.getNewState(), itemEntry, nbtData, session) * 20);
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/entity/player/JavaPlayerChangeHeldItemTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/entity/player/JavaPlayerChangeHeldItemTranslator.java
index b84547c5b..68df63e2b 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/entity/player/JavaPlayerChangeHeldItemTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/entity/player/JavaPlayerChangeHeldItemTranslator.java
@@ -36,12 +36,14 @@ public class JavaPlayerChangeHeldItemTranslator extends PacketTranslator<ServerP
 
     @Override
     public void translate(ServerPlayerChangeHeldItemPacket packet, GeyserSession session) {
-        PlayerHotbarPacket hotbarPacket = new PlayerHotbarPacket();
-        hotbarPacket.setContainerId(0);
-        hotbarPacket.setSelectedHotbarSlot(packet.getSlot());
-        hotbarPacket.setSelectHotbarSlot(true);
-        session.sendUpstreamPacket(hotbarPacket);
+        session.addInventoryTask(() -> {
+            PlayerHotbarPacket hotbarPacket = new PlayerHotbarPacket();
+            hotbarPacket.setContainerId(0);
+            hotbarPacket.setSelectedHotbarSlot(packet.getSlot());
+            hotbarPacket.setSelectHotbarSlot(true);
+            session.sendUpstreamPacket(hotbarPacket);
 
-        session.getInventory().setHeldItemSlot(packet.getSlot());
+            session.getPlayerInventory().setHeldItemSlot(packet.getSlot());
+        });
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaCloseWindowTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaCloseWindowTranslator.java
index 770e73cc7..9efdee7fc 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaCloseWindowTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaCloseWindowTranslator.java
@@ -36,7 +36,9 @@ public class JavaCloseWindowTranslator extends PacketTranslator<ServerCloseWindo
 
     @Override
     public void translate(ServerCloseWindowPacket packet, GeyserSession session) {
-        InventoryUtils.closeWindow(session, packet.getWindowId());
-        InventoryUtils.closeInventory(session, packet.getWindowId());
+        session.addInventoryTask(() ->
+                // Sometimes the server can request a window close of ID 0... when the window isn't even open
+                // Don't confirm in this instance
+                InventoryUtils.closeInventory(session, packet.getWindowId(), (session.getOpenInventory() != null && session.getOpenInventory().getId() == packet.getWindowId())));
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaConfirmTransactionTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaConfirmTransactionTranslator.java
index cc0d153b4..3b55733bf 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaConfirmTransactionTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaConfirmTransactionTranslator.java
@@ -27,6 +27,7 @@ package org.geysermc.connector.network.translators.java.window;
 
 import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientConfirmTransactionPacket;
 import com.github.steveice10.mc.protocol.packet.ingame.server.window.ServerConfirmTransactionPacket;
+import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
@@ -36,9 +37,11 @@ public class JavaConfirmTransactionTranslator extends PacketTranslator<ServerCon
 
     @Override
     public void translate(ServerConfirmTransactionPacket packet, GeyserSession session) {
-        if (!packet.isAccepted()) {
-            ClientConfirmTransactionPacket confirmPacket = new ClientConfirmTransactionPacket(packet.getWindowId(), packet.getActionId(), true);
-            session.sendDownstreamPacket(confirmPacket);
-        }
+        session.addInventoryTask(() -> {
+            if (!packet.isAccepted()) {
+                ClientConfirmTransactionPacket confirmPacket = new ClientConfirmTransactionPacket(packet.getWindowId(), packet.getActionId(), true);
+                session.sendDownstreamPacket(confirmPacket);
+            }
+        });
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaOpenHorseWindowTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaOpenHorseWindowTranslator.java
new file mode 100644
index 000000000..5016b6150
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaOpenHorseWindowTranslator.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.java.window;
+
+import com.github.steveice10.mc.protocol.packet.ingame.server.window.ServerOpenHorseWindowPacket;
+import com.nukkitx.nbt.NbtMap;
+import com.nukkitx.nbt.NbtMapBuilder;
+import com.nukkitx.nbt.NbtType;
+import com.nukkitx.protocol.bedrock.data.entity.EntityData;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
+import com.nukkitx.protocol.bedrock.packet.UpdateEquipPacket;
+import org.geysermc.connector.entity.Entity;
+import org.geysermc.connector.entity.living.animal.horse.ChestedHorseEntity;
+import org.geysermc.connector.entity.living.animal.horse.LlamaEntity;
+import org.geysermc.connector.inventory.Container;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.PacketTranslator;
+import org.geysermc.connector.network.translators.Translator;
+import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.translators.horse.DonkeyInventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.translators.horse.HorseInventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.translators.horse.LlamaInventoryTranslator;
+import org.geysermc.connector.utils.InventoryUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+@Translator(packet = ServerOpenHorseWindowPacket.class)
+public class JavaOpenHorseWindowTranslator extends PacketTranslator<ServerOpenHorseWindowPacket> {
+
+    private static final NbtMap ARMOR_SLOT;
+    private static final NbtMap CARPET_SLOT;
+    private static final NbtMap SADDLE_SLOT;
+
+    static {
+        // Build the NBT mappings that Bedrock wants to lay out the GUI
+        String[] acceptedHorseArmorIdentifiers = new String[] {"minecraft:horsearmorleather", "minecraft:horsearmoriron",
+                "minecraft:horsearmorgold", "minecraft:horsearmordiamond"};
+        NbtMapBuilder armorBuilder = NbtMap.builder();
+        List<NbtMap> acceptedArmors = new ArrayList<>(4);
+        for (String identifier : acceptedHorseArmorIdentifiers) {
+            NbtMapBuilder acceptedItemBuilder = NbtMap.builder()
+                    .putShort("Aux", Short.MAX_VALUE)
+                    .putString("Name", identifier);
+            acceptedArmors.add(NbtMap.builder().putCompound("slotItem", acceptedItemBuilder.build()).build());
+        }
+        armorBuilder.putList("acceptedItems", NbtType.COMPOUND, acceptedArmors);
+        NbtMapBuilder armorItem = NbtMap.builder()
+                .putShort("Aux", Short.MAX_VALUE)
+                .putString("Name", "minecraft:horsearmoriron");
+        armorBuilder.putCompound("item", armorItem.build());
+        armorBuilder.putInt("slotNumber", 1);
+        ARMOR_SLOT = armorBuilder.build();
+
+        NbtMapBuilder carpetBuilder = NbtMap.builder();
+        NbtMapBuilder carpetItem = NbtMap.builder()
+                .putShort("Aux", Short.MAX_VALUE)
+                .putString("Name", "minecraft:carpet");
+        List<NbtMap> acceptedCarpet = Collections.singletonList(NbtMap.builder().putCompound("slotItem", carpetItem.build()).build());
+        carpetBuilder.putList("acceptedItems", NbtType.COMPOUND, acceptedCarpet);
+        carpetBuilder.putCompound("item", carpetItem.build());
+        carpetBuilder.putInt("slotNumber", 1);
+        CARPET_SLOT = carpetBuilder.build();
+
+        NbtMapBuilder saddleBuilder = NbtMap.builder();
+        NbtMapBuilder acceptedSaddle = NbtMap.builder()
+                .putShort("Aux", Short.MAX_VALUE)
+                .putString("Name", "minecraft:saddle");
+        List<NbtMap> acceptedItem = Collections.singletonList(NbtMap.builder().putCompound("slotItem", acceptedSaddle.build()).build());
+        saddleBuilder.putList("acceptedItems", NbtType.COMPOUND, acceptedItem);
+        saddleBuilder.putCompound("item", acceptedSaddle.build());
+        saddleBuilder.putInt("slotNumber", 0);
+        SADDLE_SLOT = saddleBuilder.build();
+    }
+
+    @Override
+    public void translate(ServerOpenHorseWindowPacket packet, GeyserSession session) {
+        Entity entity = session.getEntityCache().getEntityByJavaId(packet.getEntityId());
+        if (entity == null) {
+            return;
+        }
+
+        UpdateEquipPacket updateEquipPacket = new UpdateEquipPacket();
+        updateEquipPacket.setWindowId((short) packet.getWindowId());
+        updateEquipPacket.setWindowType((short) ContainerType.HORSE.getId());
+        updateEquipPacket.setUniqueEntityId(entity.getGeyserId());
+
+        NbtMapBuilder builder = NbtMap.builder();
+        List<NbtMap> slots = new ArrayList<>();
+
+        InventoryTranslator inventoryTranslator;
+        if (entity instanceof LlamaEntity) {
+            inventoryTranslator = new LlamaInventoryTranslator(packet.getNumberOfSlots());
+            slots.add(CARPET_SLOT);
+        } else if (entity instanceof ChestedHorseEntity) {
+            inventoryTranslator = new DonkeyInventoryTranslator(packet.getNumberOfSlots());
+            slots.add(SADDLE_SLOT);
+        } else {
+            inventoryTranslator = new HorseInventoryTranslator(packet.getNumberOfSlots());
+            slots.add(SADDLE_SLOT);
+            slots.add(ARMOR_SLOT);
+        }
+
+        // Build the NbtMap that sets the icons for Bedrock (e.g. sets the saddle outline on the saddle slot)
+        builder.putList("slots", NbtType.COMPOUND, slots);
+
+        updateEquipPacket.setTag(builder.build());
+        session.sendUpstreamPacket(updateEquipPacket);
+
+        session.setInventoryTranslator(inventoryTranslator);
+        InventoryUtils.openInventory(session, new Container(entity.getMetadata().getString(EntityData.NAMETAG), packet.getWindowId(), packet.getNumberOfSlots(), null, session.getPlayerInventory()));
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaOpenWindowTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaOpenWindowTranslator.java
index 4c50b3131..79abcc957 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaOpenWindowTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaOpenWindowTranslator.java
@@ -41,35 +41,38 @@ public class JavaOpenWindowTranslator extends PacketTranslator<ServerOpenWindowP
 
     @Override
     public void translate(ServerOpenWindowPacket packet, GeyserSession session) {
-        if (packet.getWindowId() == 0) {
-            return;
-        }
-        InventoryTranslator newTranslator = InventoryTranslator.INVENTORY_TRANSLATORS.get(packet.getType());
-        Inventory openInventory = session.getInventoryCache().getOpenInventory();
-        if (newTranslator == null) {
+        session.addInventoryTask(() -> {
+            if (packet.getWindowId() == 0) {
+                return;
+            }
+
+            InventoryTranslator newTranslator = InventoryTranslator.INVENTORY_TRANSLATORS.get(packet.getType());
+            Inventory openInventory = session.getOpenInventory();
+            //No translator exists for this window type. Close all windows and return.
+            if (newTranslator == null) {
+                if (openInventory != null) {
+                    InventoryUtils.closeInventory(session, openInventory.getId(), true);
+                }
+                ClientCloseWindowPacket closeWindowPacket = new ClientCloseWindowPacket(packet.getWindowId());
+                session.sendDownstreamPacket(closeWindowPacket);
+                return;
+            }
+
+            String name = MessageTranslator.convertMessageLenient(packet.getName(), session.getLocale());
+            name = LocaleUtils.getLocaleString(name, session.getLocale());
+
+            Inventory newInventory = newTranslator.createInventory(name, packet.getWindowId(), packet.getType(), session.getPlayerInventory());
             if (openInventory != null) {
-                InventoryUtils.closeWindow(session, openInventory.getId());
-                InventoryUtils.closeInventory(session, openInventory.getId());
+                // If the window type is the same, don't close.
+                // In rare cases, inventories can do funny things where it keeps the same window type up but change the contents.
+                if (openInventory.getWindowType() != packet.getType()) {
+                    // Sometimes the server can double-open an inventory with the same ID - don't confirm in that instance.
+                    InventoryUtils.closeInventory(session, openInventory.getId(), openInventory.getId() != packet.getWindowId());
+                }
             }
-            ClientCloseWindowPacket closeWindowPacket = new ClientCloseWindowPacket(packet.getWindowId());
-            session.sendDownstreamPacket(closeWindowPacket);
-            return;
-        }
 
-        String name = MessageTranslator.convertMessageLenient(packet.getName(), session.getLocale());
-
-        name = LocaleUtils.getLocaleString(name, session.getLocale());
-
-        Inventory newInventory = new Inventory(name, packet.getWindowId(), packet.getType(), newTranslator.size + 36);
-        session.getInventoryCache().cacheInventory(newInventory);
-        if (openInventory != null) {
-            InventoryTranslator openTranslator = InventoryTranslator.INVENTORY_TRANSLATORS.get(openInventory.getWindowType());
-            if (!openTranslator.getClass().equals(newTranslator.getClass())) {
-                InventoryUtils.closeWindow(session, openInventory.getId());
-                InventoryUtils.closeInventory(session, openInventory.getId());
-            }
-        }
-
-        InventoryUtils.openInventory(session, newInventory);
+            session.setInventoryTranslator(newTranslator);
+            InventoryUtils.openInventory(session, newInventory);
+        });
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaSetSlotTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaSetSlotTranslator.java
index 8caa25183..b5978ba76 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaSetSlotTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaSetSlotTranslator.java
@@ -25,36 +25,254 @@
 
 package org.geysermc.connector.network.translators.java.window;
 
+import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
+import com.github.steveice10.mc.protocol.data.game.recipe.Ingredient;
+import com.github.steveice10.mc.protocol.data.game.recipe.Recipe;
+import com.github.steveice10.mc.protocol.data.game.recipe.RecipeType;
+import com.github.steveice10.mc.protocol.data.game.recipe.data.ShapedRecipeData;
+import com.github.steveice10.mc.protocol.data.game.recipe.data.ShapelessRecipeData;
 import com.github.steveice10.mc.protocol.packet.ingame.server.window.ServerSetSlotPacket;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerId;
+import com.nukkitx.protocol.bedrock.data.inventory.CraftingData;
+import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
+import com.nukkitx.protocol.bedrock.packet.CraftingDataPacket;
+import com.nukkitx.protocol.bedrock.packet.InventorySlotPacket;
+import org.geysermc.connector.inventory.GeyserItemStack;
 import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
 import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.translators.CraftingInventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.translators.PlayerInventoryTranslator;
+import org.geysermc.connector.network.translators.item.ItemTranslator;
 import org.geysermc.connector.utils.InventoryUtils;
 
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
 @Translator(packet = ServerSetSlotPacket.class)
 public class JavaSetSlotTranslator extends PacketTranslator<ServerSetSlotPacket> {
 
     @Override
     public void translate(ServerSetSlotPacket packet, GeyserSession session) {
-        if (packet.getWindowId() == 255 && packet.getSlot() == -1) { //cursor
-            if (session.getCraftSlot() != 0)
+        session.addInventoryTask(() -> {
+            if (packet.getWindowId() == 255) { //cursor
+                GeyserItemStack newItem = GeyserItemStack.from(packet.getItem());
+                session.getPlayerInventory().setCursor(newItem, session);
+                InventoryUtils.updateCursor(session);
+                return;
+            }
+
+            //TODO: support window id -2, should update player inventory
+            Inventory inventory = InventoryUtils.getInventory(session, packet.getWindowId());
+            if (inventory == null)
                 return;
 
-            session.getInventory().setCursor(packet.getItem());
-            InventoryUtils.updateCursor(session);
-            return;
-        }
+            InventoryTranslator translator = session.getInventoryTranslator();
+            if (translator != null) {
+                if (session.getCraftingGridFuture() != null) {
+                    session.getCraftingGridFuture().cancel(false);
+                }
+                session.setCraftingGridFuture(session.getConnector().getGeneralThreadPool().schedule(() -> session.addInventoryTask(() -> updateCraftingGrid(session, packet, inventory, translator)), 150, TimeUnit.MILLISECONDS));
 
-        Inventory inventory = session.getInventoryCache().getInventories().get(packet.getWindowId());
-        if (inventory == null || (packet.getWindowId() != 0 && inventory.getWindowType() == null))
-            return;
+                GeyserItemStack newItem = GeyserItemStack.from(packet.getItem());
+                if (packet.getWindowId() == 0 && !(translator instanceof PlayerInventoryTranslator)) {
+                    // In rare cases, the window ID can still be 0 but Java treats it as valid
+                    session.getPlayerInventory().setItem(packet.getSlot(), newItem, session);
+                    InventoryTranslator.PLAYER_INVENTORY_TRANSLATOR.updateSlot(session, session.getPlayerInventory(), packet.getSlot());
+                } else {
+                    inventory.setItem(packet.getSlot(), newItem, session);
+                    translator.updateSlot(session, inventory, packet.getSlot());
+                }
+            }
+        });
+    }
 
-        InventoryTranslator translator = InventoryTranslator.INVENTORY_TRANSLATORS.get(inventory.getWindowType());
-        if (translator != null) {
-            inventory.setItem(packet.getSlot(), packet.getItem());
-            translator.updateSlot(session, inventory, packet.getSlot());
+    private static void updateCraftingGrid(GeyserSession session, ServerSetSlotPacket packet, Inventory inventory, InventoryTranslator translator) {
+        if (packet.getSlot() == 0) {
+            int gridSize;
+            if (translator instanceof PlayerInventoryTranslator) {
+                gridSize = 4;
+            } else if (translator instanceof CraftingInventoryTranslator) {
+                gridSize = 9;
+            } else {
+                return;
+            }
+
+            if (packet.getItem() == null || packet.getItem().getId() == 0) {
+                return;
+            }
+
+            int offset = gridSize == 4 ? 28 : 32;
+            int gridDimensions = gridSize == 4 ? 2 : 3;
+            int firstRow = -1, height = -1;
+            int firstCol = -1, width = -1;
+            for (int row = 0; row < gridDimensions; row++) {
+                for (int col = 0; col < gridDimensions; col++) {
+                    if (!inventory.getItem(col + (row * gridDimensions) + 1).isEmpty()) {
+                        if (firstRow == -1) {
+                            firstRow = row;
+                            firstCol = col;
+                        } else {
+                            firstCol = Math.min(firstCol, col);
+                        }
+                        height = Math.max(height, row);
+                        width = Math.max(width, col);
+                    }
+                }
+            }
+
+            //empty grid
+            if (firstRow == -1) {
+                return;
+            }
+
+            height += -firstRow + 1;
+            width += -firstCol + 1;
+
+            //TODO
+            recipes:
+            for (Recipe recipe : session.getCraftingRecipes().values()) {
+                if (recipe.getType() == RecipeType.CRAFTING_SHAPED) {
+                    ShapedRecipeData data = (ShapedRecipeData) recipe.getData();
+                    if (!data.getResult().equals(packet.getItem())) {
+                        continue;
+                    }
+                    if (data.getWidth() != width || data.getHeight() != height || width * height != data.getIngredients().length) {
+                        continue;
+                    }
+
+                    Ingredient[] ingredients = data.getIngredients();
+                    if (!testShapedRecipe(ingredients, inventory, gridDimensions, firstRow, height, firstCol, width)) {
+                        Ingredient[] mirroredIngredients = new Ingredient[data.getIngredients().length];
+                        for (int row = 0; row < height; row++) {
+                            for (int col = 0; col < width; col++) {
+                                mirroredIngredients[col + (row * width)] = ingredients[(width - 1 - col) + (row * width)];
+                            }
+                        }
+
+                        if (Arrays.equals(ingredients, mirroredIngredients) ||
+                                !testShapedRecipe(mirroredIngredients, inventory, gridDimensions, firstRow, height, firstCol, width)) {
+                            continue;
+                        }
+                    }
+                    // Recipe is had, don't sent packet
+                    return;
+                } else if (recipe.getType() == RecipeType.CRAFTING_SHAPELESS) {
+                    ShapelessRecipeData data = (ShapelessRecipeData) recipe.getData();
+                    if (!data.getResult().equals(packet.getItem())) {
+                        continue;
+                    }
+                    for (int i = 0; i < data.getIngredients().length; i++) {
+                        Ingredient ingredient = data.getIngredients()[i];
+                        for (ItemStack itemStack : ingredient.getOptions()) {
+                            boolean inventoryHasItem = false;
+                            for (int j = 0; j < inventory.getSize(); j++) {
+                                GeyserItemStack geyserItemStack = inventory.getItem(j);
+                                if (geyserItemStack.isEmpty()) {
+                                    inventoryHasItem = itemStack == null || itemStack.getId() == 0;
+                                    if (inventoryHasItem) {
+                                        break;
+                                    }
+                                } else if (itemStack.equals(geyserItemStack.getItemStack(1))) {
+                                    inventoryHasItem = true;
+                                    break;
+                                }
+                            }
+                            if (!inventoryHasItem) {
+                                continue recipes;
+                            }
+                        }
+                    }
+                    // Recipe is had, don't sent packet
+                    return;
+                }
+            }
+
+            UUID uuid = UUID.randomUUID();
+            int newRecipeId = session.getLastRecipeNetId().incrementAndGet();
+
+            ItemData[] ingredients = new ItemData[height * width];
+            //construct ingredient list and clear slots on client
+            Ingredient[] javaIngredients = new Ingredient[height * width];
+            int index = 0;
+            for (int row = firstRow; row < height + firstRow; row++) {
+                for (int col = firstCol; col < width + firstCol; col++) {
+                    GeyserItemStack geyserItemStack = inventory.getItem(col + (row * gridDimensions) + 1);
+                    ingredients[index] = geyserItemStack.getItemData(session);
+                    ItemStack[] itemStacks = new ItemStack[] {geyserItemStack.isEmpty() ? null : geyserItemStack.getItemStack(1)};
+                    javaIngredients[index] = new Ingredient(itemStacks);
+
+                    InventorySlotPacket slotPacket = new InventorySlotPacket();
+                    slotPacket.setContainerId(ContainerId.UI);
+                    slotPacket.setSlot(col + (row * gridDimensions) + offset);
+                    slotPacket.setItem(ItemData.AIR);
+                    session.sendUpstreamPacket(slotPacket);
+                    index++;
+                }
+            }
+
+            ShapedRecipeData data = new ShapedRecipeData(width, height, "", javaIngredients, packet.getItem());
+            // Cache this recipe so we know the client has received it
+            session.getCraftingRecipes().put(newRecipeId, new Recipe(RecipeType.CRAFTING_SHAPED, uuid.toString(), data));
+
+            CraftingDataPacket craftPacket = new CraftingDataPacket();
+            craftPacket.getCraftingData().add(CraftingData.fromShaped(
+                    uuid.toString(),
+                    width,
+                    height,
+                    Arrays.asList(ingredients),
+                    Collections.singletonList(ItemTranslator.translateToBedrock(session, packet.getItem())),
+                    uuid,
+                    "crafting_table",
+                    0,
+                    newRecipeId
+            ));
+            craftPacket.setCleanRecipes(false);
+            session.sendUpstreamPacket(craftPacket);
+
+            index = 0;
+            for (int row = firstRow; row < height + firstRow; row++) {
+                for (int col = firstCol; col < width + firstCol; col++) {
+                    InventorySlotPacket slotPacket = new InventorySlotPacket();
+                    slotPacket.setContainerId(ContainerId.UI);
+                    slotPacket.setSlot(col + (row * gridDimensions) + offset);
+                    slotPacket.setItem(ingredients[index]);
+                    session.sendUpstreamPacket(slotPacket);
+                    index++;
+                }
+            }
         }
     }
+
+    private static boolean testShapedRecipe(Ingredient[] ingredients, Inventory inventory, int gridDimensions, int firstRow, int height, int firstCol, int width) {
+        int ingredientIndex = 0;
+        for (int row = firstRow; row < height + firstRow; row++) {
+            for (int col = firstCol; col < width + firstCol; col++) {
+                GeyserItemStack geyserItemStack = inventory.getItem(col + (row * gridDimensions) + 1);
+                Ingredient ingredient = ingredients[ingredientIndex++];
+                if (ingredient.getOptions().length == 0) {
+                    if (!geyserItemStack.isEmpty()) {
+                        return false;
+                    }
+                } else {
+                    boolean inventoryHasItem = false;
+                    for (ItemStack item : ingredient.getOptions()) {
+                        if (Objects.equals(geyserItemStack.getItemStack(1), item)) {
+                            inventoryHasItem = true;
+                            break;
+                        }
+                    }
+                    if (!inventoryHasItem) {
+                        return false;
+                    }
+                }
+            }
+        }
+        return true;
+    }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaWindowItemsTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaWindowItemsTranslator.java
index a50518e86..7d29f54b3 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaWindowItemsTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaWindowItemsTranslator.java
@@ -26,32 +26,33 @@
 package org.geysermc.connector.network.translators.java.window;
 
 import com.github.steveice10.mc.protocol.packet.ingame.server.window.ServerWindowItemsPacket;
+import org.geysermc.connector.inventory.GeyserItemStack;
 import org.geysermc.connector.inventory.Inventory;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
 import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
-
-import java.util.Arrays;
+import org.geysermc.connector.utils.InventoryUtils;
 
 @Translator(packet = ServerWindowItemsPacket.class)
 public class JavaWindowItemsTranslator extends PacketTranslator<ServerWindowItemsPacket> {
 
     @Override
     public void translate(ServerWindowItemsPacket packet, GeyserSession session) {
-        Inventory inventory = session.getInventoryCache().getInventories().get(packet.getWindowId());
-        if (inventory == null || (packet.getWindowId() != 0 && inventory.getWindowType() == null))
-            return;
+        session.addInventoryTask(() -> {
+            Inventory inventory = InventoryUtils.getInventory(session, packet.getWindowId());
+            if (inventory == null)
+                return;
 
-        if (packet.getItems().length < inventory.getSize()) {
-            inventory.setItems(Arrays.copyOf(packet.getItems(), inventory.getSize()));
-        } else {
-            inventory.setItems(packet.getItems());
-        }
+            for (int i = 0; i < packet.getItems().length; i++) {
+                GeyserItemStack newItem = GeyserItemStack.from(packet.getItems()[i]);
+                inventory.setItem(i, newItem, session);
+            }
 
-        InventoryTranslator translator = InventoryTranslator.INVENTORY_TRANSLATORS.get(inventory.getWindowType());
-        if (translator != null) {
-            translator.updateInventory(session, inventory);
-        }
+            InventoryTranslator translator = session.getInventoryTranslator();
+            if (translator != null) {
+                translator.updateInventory(session, inventory);
+            }
+        });
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaWindowPropertyTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaWindowPropertyTranslator.java
index d325f36d6..c31a39029 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaWindowPropertyTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaWindowPropertyTranslator.java
@@ -31,19 +31,22 @@ import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
 import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
+import org.geysermc.connector.utils.InventoryUtils;
 
 @Translator(packet = ServerWindowPropertyPacket.class)
 public class JavaWindowPropertyTranslator extends PacketTranslator<ServerWindowPropertyPacket> {
 
     @Override
     public void translate(ServerWindowPropertyPacket packet, GeyserSession session) {
-        Inventory inventory = session.getInventoryCache().getInventories().get(packet.getWindowId());
-        if (inventory == null || (packet.getWindowId() != 0 && inventory.getWindowType() == null))
-            return;
+        session.addInventoryTask(() -> {
+            Inventory inventory = InventoryUtils.getInventory(session, packet.getWindowId());
+            if (inventory == null)
+                return;
 
-        InventoryTranslator translator = InventoryTranslator.INVENTORY_TRANSLATORS.get(inventory.getWindowType());
-        if (translator != null) {
-            translator.updateProperty(session, inventory, packet.getRawProperty(), packet.getValue());
-        }
+            InventoryTranslator translator = session.getInventoryTranslator();
+            if (translator != null) {
+                translator.updateProperty(session, inventory, packet.getRawProperty(), packet.getValue());
+            }
+        });
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaBlockChangeTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaBlockChangeTranslator.java
index 371446b7e..e362e335f 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaBlockChangeTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaBlockChangeTranslator.java
@@ -93,7 +93,7 @@ public class JavaBlockChangeTranslator extends PacketTranslator<ServerBlockChang
     }
 
     private void checkInteract(GeyserSession session, ServerBlockChangePacket packet) {
-        Vector3i lastInteractPos = session.getLastInteractionPosition();
+        Vector3i lastInteractPos = session.getLastInteractionBlockPosition();
         if (lastInteractPos == null || !session.isInteracting()) {
             return;
         }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaNotifyClientTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaNotifyClientTranslator.java
index e8b244b35..8a0ddb3b5 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaNotifyClientTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaNotifyClientTranslator.java
@@ -42,7 +42,7 @@ import org.geysermc.connector.entity.player.PlayerEntity;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
-import org.geysermc.connector.network.translators.inventory.PlayerInventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.translators.PlayerInventoryTranslator;
 import org.geysermc.connector.utils.LocaleUtils;
 
 @Translator(packet = ServerNotifyClientPacket.class)
@@ -111,7 +111,7 @@ public class JavaNotifyClientTranslator extends PacketTranslator<ServerNotifyCli
                 session.sendAdventureSettings();
 
                 // Update the crafting grid to add/remove barriers for creative inventory
-                PlayerInventoryTranslator.updateCraftingGrid(session, session.getInventory());
+                PlayerInventoryTranslator.updateCraftingGrid(session, session.getPlayerInventory());
                 break;
             case ENTER_CREDITS:
                 switch ((EnterCreditsValue) packet.getValue()) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaTradeListTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaTradeListTranslator.java
index 228a25341..df8339079 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaTradeListTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaTradeListTranslator.java
@@ -31,13 +31,13 @@ import com.github.steveice10.mc.protocol.packet.ingame.server.window.ServerTrade
 import com.nukkitx.nbt.NbtMap;
 import com.nukkitx.nbt.NbtMapBuilder;
 import com.nukkitx.nbt.NbtType;
-import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
+import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
 import com.nukkitx.protocol.bedrock.packet.UpdateTradePacket;
 import org.geysermc.connector.entity.Entity;
-import org.geysermc.connector.entity.type.EntityType;
 import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.MerchantContainer;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
@@ -53,8 +53,14 @@ public class JavaTradeListTranslator extends PacketTranslator<ServerTradeListPac
 
     @Override
     public void translate(ServerTradeListPacket packet, GeyserSession session) {
-        Entity villager = session.getPlayerEntity();
-        session.setVillagerTrades(packet.getTrades());
+        Inventory openInventory = session.getOpenInventory();
+        if (!(openInventory instanceof MerchantContainer && openInventory.getId() == packet.getWindowId())) {
+            return;
+        }
+
+        MerchantContainer merchantInventory = (MerchantContainer) openInventory;
+        merchantInventory.setVillagerTrades(packet.getTrades());
+        Entity villager = merchantInventory.getVillager();
         villager.getMetadata().put(EntityData.TRADE_TIER, packet.getVillagerLevel() - 1);
         villager.getMetadata().put(EntityData.MAX_TRADE_TIER, 4);
         villager.getMetadata().put(EntityData.TRADE_XP, packet.getExperience());
@@ -64,30 +70,21 @@ public class JavaTradeListTranslator extends PacketTranslator<ServerTradeListPac
         updateTradePacket.setTradeTier(packet.getVillagerLevel() - 1);
         updateTradePacket.setContainerId((short) packet.getWindowId());
         updateTradePacket.setContainerType(ContainerType.TRADE);
-        Inventory openInv = session.getInventoryCache().getOpenInventory();
-        String displayName;
-        if (openInv != null && openInv.getId() == packet.getWindowId()) {
-            displayName = openInv.getTitle();
-        } else {
-            Entity realVillager = session.getEntityCache().getEntityByGeyserId(session.getLastInteractedVillagerEid());
-            if (realVillager != null && realVillager.getMetadata().containsKey(EntityData.NAMETAG) && realVillager.getMetadata().getString(EntityData.NAMETAG) != null) {
-                displayName = realVillager.getMetadata().getString(EntityData.NAMETAG);
-            } else {
-                displayName = realVillager != null &&
-                        realVillager.getEntityType() == EntityType.WANDERING_TRADER ? "Wandering Trader" : "Villager";
-            }
-        }
-        updateTradePacket.setDisplayName(displayName);
+        updateTradePacket.setDisplayName(openInventory.getTitle());
         updateTradePacket.setSize(0);
         updateTradePacket.setNewTradingUi(true);
         updateTradePacket.setUsingEconomyTrade(true);
         updateTradePacket.setPlayerUniqueEntityId(session.getPlayerEntity().getGeyserId());
-        updateTradePacket.setTraderUniqueEntityId(session.getPlayerEntity().getGeyserId());
+        updateTradePacket.setTraderUniqueEntityId(villager.getGeyserId());
+
         NbtMapBuilder builder = NbtMap.builder();
-        List<NbtMap> tags = new ArrayList<>();
-        for (VillagerTrade trade : packet.getTrades()) {
+        boolean addExtraTrade = packet.isRegularVillager() && packet.getVillagerLevel() < 5;
+        List<NbtMap> tags = new ArrayList<>(addExtraTrade ? packet.getTrades().length + 1 : packet.getTrades().length);
+        for (int i = 0; i < packet.getTrades().length; i++) {
+            VillagerTrade trade = packet.getTrades()[i];
             NbtMapBuilder recipe = NbtMap.builder();
-            recipe.putInt("maxUses", trade.getMaxUses());
+            recipe.putInt("netId", i + 1);
+            recipe.putInt("maxUses", trade.isTradeDisabled() ? 0 : trade.getMaxUses());
             recipe.putInt("traderExp", trade.getXp());
             recipe.putFloat("priceMultiplierA", trade.getPriceMultiplier());
             recipe.put("sell", getItemTag(session, trade.getOutput(), 0));
@@ -106,7 +103,7 @@ public class JavaTradeListTranslator extends PacketTranslator<ServerTradeListPac
         }
 
         //Hidden trade to fix visual experience bug
-        if (packet.isRegularVillager() && packet.getVillagerLevel() < 5) {
+        if (addExtraTrade) {
             tags.add(NbtMap.builder()
                     .putInt("maxUses", 0)
                     .putInt("traderExp", 0)
@@ -122,13 +119,15 @@ public class JavaTradeListTranslator extends PacketTranslator<ServerTradeListPac
         }
 
         builder.putList("Recipes", NbtType.COMPOUND, tags);
-        List<NbtMap> expTags = new ArrayList<>();
+
+        List<NbtMap> expTags = new ArrayList<>(5);
         expTags.add(NbtMap.builder().putInt("0", 0).build());
         expTags.add(NbtMap.builder().putInt("1", 10).build());
         expTags.add(NbtMap.builder().putInt("2", 70).build());
         expTags.add(NbtMap.builder().putInt("3", 150).build());
         expTags.add(NbtMap.builder().putInt("4", 250).build());
         builder.putList("TierExpRequirements", NbtType.COMPOUND, expTags);
+
         updateTradePacket.setOffers(builder.build());
         session.sendUpstreamPacket(updateTradePacket);
     }
@@ -136,6 +135,7 @@ public class JavaTradeListTranslator extends PacketTranslator<ServerTradeListPac
     private NbtMap getItemTag(GeyserSession session, ItemStack stack, int specialPrice) {
         ItemData itemData = ItemTranslator.translateToBedrock(session, stack);
         ItemEntry itemEntry = ItemRegistry.getItem(stack);
+
         NbtMapBuilder builder = NbtMap.builder();
         builder.putByte("Count", (byte) (Math.max(itemData.getCount() + specialPrice, 1)));
         builder.putShort("Damage", itemData.getDamage());
@@ -144,6 +144,14 @@ public class JavaTradeListTranslator extends PacketTranslator<ServerTradeListPac
             NbtMap tag = itemData.getTag().toBuilder().build();
             builder.put("tag", tag);
         }
+
+        NbtMap blockTag = session.getBlockTranslator().getBedrockBlockNbt(itemEntry.getJavaIdentifier());
+        if (blockTag != null) {
+            // This fixes certain blocks being unable to stack after grabbing one
+            builder.putCompound("Block", blockTag);
+            builder.putShort("Damage", (short) 0);
+        }
+
         return builder.build();
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaUnloadChunkTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaUnloadChunkTranslator.java
index 4e0f03bd1..6d3efc1cb 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaUnloadChunkTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/world/JavaUnloadChunkTranslator.java
@@ -49,5 +49,14 @@ public class JavaUnloadChunkTranslator extends PacketTranslator<ServerUnloadChun
                 iterator.remove();
             }
         }
+
+        // Do the same thing with lecterns
+        iterator = session.getLecternCache().iterator();
+        while (iterator.hasNext()) {
+            Vector3i position = iterator.next();
+            if ((position.getX() >> 4) == packet.getX() && (position.getZ() >> 4) == packet.getZ()) {
+                iterator.remove();
+            }
+        }
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/sound/BlockSoundInteractionHandler.java b/connector/src/main/java/org/geysermc/connector/network/translators/sound/BlockSoundInteractionHandler.java
index 5ef004499..2172fd9e6 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/sound/BlockSoundInteractionHandler.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/sound/BlockSoundInteractionHandler.java
@@ -27,6 +27,7 @@ package org.geysermc.connector.network.translators.sound;
 
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
 import com.nukkitx.math.vector.Vector3f;
+import org.geysermc.connector.inventory.GeyserItemStack;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.item.ItemRegistry;
 
@@ -60,12 +61,12 @@ public interface BlockSoundInteractionHandler extends SoundInteractionHandler<St
                 }
                 if (!contains) continue;
             }
-            ItemStack itemInHand = session.getInventory().getItemInHand();
+            GeyserItemStack itemInHand = session.getPlayerInventory().getItemInHand();
             if (interactionEntry.getKey().items().length != 0) {
-                if (itemInHand == null || itemInHand.getId() == 0) {
+                if (itemInHand.isEmpty()) {
                     continue;
                 }
-                String handIdentifier = ItemRegistry.getItem(session.getInventory().getItemInHand()).getJavaIdentifier();
+                String handIdentifier = itemInHand.getItemEntry().getJavaIdentifier();
                 boolean contains = false;
                 for (String itemIdentifier : interactionEntry.getKey().items()) {
                     if (handIdentifier.contains(itemIdentifier)) {
@@ -76,7 +77,7 @@ public interface BlockSoundInteractionHandler extends SoundInteractionHandler<St
                 if (!contains) continue;
             }
             if (session.isSneaking() && !interactionEntry.getKey().ignoreSneakingWhileHolding()) {
-                if (session.getInventory().getItemInHand() != null && session.getInventory().getItemInHand().getId() != 0) {
+                if (!itemInHand.isEmpty()) {
                     continue;
                 }
             }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/sound/EntitySoundInteractionHandler.java b/connector/src/main/java/org/geysermc/connector/network/translators/sound/EntitySoundInteractionHandler.java
index 484936e52..49d4f0777 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/sound/EntitySoundInteractionHandler.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/sound/EntitySoundInteractionHandler.java
@@ -28,6 +28,7 @@ package org.geysermc.connector.network.translators.sound;
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
 import com.nukkitx.math.vector.Vector3f;
 import org.geysermc.connector.entity.Entity;
+import org.geysermc.connector.inventory.GeyserItemStack;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.item.ItemRegistry;
 
@@ -61,12 +62,12 @@ public interface EntitySoundInteractionHandler extends SoundInteractionHandler<E
                 }
                 if (!contains) continue;
             }
-            ItemStack itemInHand = session.getInventory().getItemInHand();
+            GeyserItemStack itemInHand = session.getPlayerInventory().getItemInHand();
             if (interactionEntry.getKey().items().length != 0) {
-                if (itemInHand == null || itemInHand.getId() == 0) {
+                if (itemInHand.isEmpty()) {
                     continue;
                 }
-                String handIdentifier = ItemRegistry.getItem(session.getInventory().getItemInHand()).getJavaIdentifier();
+                String handIdentifier = itemInHand.getItemEntry().getJavaIdentifier();
                 boolean contains = false;
                 for (String itemIdentifier : interactionEntry.getKey().items()) {
                     if (handIdentifier.contains(itemIdentifier)) {
@@ -77,7 +78,7 @@ public interface EntitySoundInteractionHandler extends SoundInteractionHandler<E
                 if (!contains) continue;
             }
             if (session.isSneaking() && !interactionEntry.getKey().ignoreSneakingWhileHolding()) {
-                if (session.getInventory().getItemInHand() != null && session.getInventory().getItemInHand().getId() != 0) {
+                if (!itemInHand.isEmpty()) {
                     continue;
                 }
             }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/sound/block/BucketSoundInteractionHandler.java b/connector/src/main/java/org/geysermc/connector/network/translators/sound/block/BucketSoundInteractionHandler.java
index bad9b41d8..afb9ccc6a 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/sound/block/BucketSoundInteractionHandler.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/sound/block/BucketSoundInteractionHandler.java
@@ -39,7 +39,7 @@ public class BucketSoundInteractionHandler implements BlockSoundInteractionHandl
     @Override
     public void handleInteraction(GeyserSession session, Vector3f position, String identifier) {
         if (session.getBucketScheduledFuture() == null) return; // No bucket was really interacted with
-        String handItemIdentifier = ItemRegistry.getItem(session.getInventory().getItemInHand()).getJavaIdentifier();
+        String handItemIdentifier = session.getPlayerInventory().getItemInHand().getItemEntry().getJavaIdentifier();
         LevelSoundEventPacket soundEventPacket = new LevelSoundEventPacket();
         soundEventPacket.setPosition(position);
         soundEventPacket.setIdentifier(":");
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/sound/entity/MilkCowSoundInteractionHandler.java b/connector/src/main/java/org/geysermc/connector/network/translators/sound/entity/MilkCowSoundInteractionHandler.java
index e2dcf29ae..ffd458010 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/sound/entity/MilkCowSoundInteractionHandler.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/sound/entity/MilkCowSoundInteractionHandler.java
@@ -39,7 +39,7 @@ public class MilkCowSoundInteractionHandler implements EntitySoundInteractionHan
 
     @Override
     public void handleInteraction(GeyserSession session, Vector3f position, Entity value) {
-        if (!ItemRegistry.getItem(session.getInventory().getItemInHand()).getJavaIdentifier().equals("minecraft:bucket")) {
+        if (!session.getPlayerInventory().getItemInHand().getItemEntry().getJavaIdentifier().equals("minecraft:bucket")) {
             return;
         }
         LevelSoundEventPacket levelSoundEventPacket = new LevelSoundEventPacket();
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/GeyserWorldManager.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/GeyserWorldManager.java
index 5507b7784..6d2d8720d 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/GeyserWorldManager.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/GeyserWorldManager.java
@@ -30,10 +30,14 @@ import com.github.steveice10.mc.protocol.data.game.chunk.Column;
 import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
 import com.github.steveice10.mc.protocol.data.game.setting.Difficulty;
 import com.github.steveice10.mc.protocol.packet.ingame.client.ClientChatPacket;
+import com.nukkitx.nbt.NbtMap;
+import com.nukkitx.nbt.NbtMapBuilder;
 import it.unimi.dsi.fastutil.objects.Object2ObjectMap;
 import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.session.cache.ChunkCache;
+import org.geysermc.connector.network.translators.inventory.translators.LecternInventoryTranslator;
+import org.geysermc.connector.network.translators.world.block.BlockTranslator;
 import org.geysermc.connector.utils.GameRule;
 
 public class GeyserWorldManager extends WorldManager {
@@ -46,7 +50,7 @@ public class GeyserWorldManager extends WorldManager {
         if (chunkCache != null) { // Chunk cache can be null if the session is closed asynchronously
             return chunkCache.getBlockAt(x, y, z);
         }
-        return 0;
+        return BlockTranslator.JAVA_AIR_ID;
     }
 
     @Override
@@ -88,6 +92,29 @@ public class GeyserWorldManager extends WorldManager {
         return new int[1024];
     }
 
+    @Override
+    public NbtMap getLecternDataAt(GeyserSession session, int x, int y, int z, boolean isChunkLoad) {
+        // Without direct server access, we can't get lectern information on-the-fly.
+        // I should have set this up so it's only called when there is a book in the block state. - Camotoy
+        NbtMapBuilder lecternTag = LecternInventoryTranslator.getBaseLecternTag(x, y, z, 1);
+        lecternTag.putCompound("book", NbtMap.builder()
+                .putByte("Count", (byte) 1)
+                .putShort("Damage", (short) 0)
+                .putString("Name", "minecraft:written_book")
+                .putCompound("tag", NbtMap.builder()
+                        .putString("photoname", "")
+                        .putString("text", "")
+                        .build())
+                .build());
+        lecternTag.putInt("page", -1); // I'm surprisingly glad this exists - it forces Bedrock to stop reading immediately. Usually.
+        return lecternTag.build();
+    }
+
+    @Override
+    public boolean shouldExpectLecternHandled() {
+        return false;
+    }
+
     @Override
     public void setGameRule(GeyserSession session, String name, Object value) {
         session.sendDownstreamPacket(new ClientChatPacket("/gamerule " + name + " " + value));
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/WorldManager.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/WorldManager.java
index d8b484f20..6795ae4bf 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/WorldManager.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/WorldManager.java
@@ -30,6 +30,7 @@ import com.github.steveice10.mc.protocol.data.game.entity.metadata.Position;
 import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
 import com.github.steveice10.mc.protocol.data.game.setting.Difficulty;
 import com.nukkitx.math.vector.Vector3i;
+import com.nukkitx.nbt.NbtMap;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.utils.GameRule;
 
@@ -106,6 +107,32 @@ public abstract class WorldManager {
      */
     public abstract int[] getBiomeDataAt(GeyserSession session, int x, int z);
 
+    /**
+     * Sigh. <br>
+     *
+     * So, on Java Edition, the lectern is an inventory. Java opens it and gets the contents of the book there.
+     * On Bedrock, the lectern contents are part of the block entity tag. Therefore, Bedrock expects to have the contents
+     * of the lectern ready and present in the world. If the contents are not there, it takes at least two clicks for the
+     * lectern to update the tag and then present itself. <br>
+     *
+     * We solve this problem by querying all loaded lecterns, where possible, and sending their information in a block entity
+     * tag.
+     *
+     * @param session the session of the player
+     * @param x the x coordinate of the lectern
+     * @param y the y coordinate of the lectern
+     * @param z the z coordinate of the lectern
+     * @param isChunkLoad if this is called during a chunk load or not. Changes behavior in certain instances.
+     * @return the Bedrock lectern block entity tag. This may not be the exact block entity tag - for example, Spigot's
+     * block handled must be done on the server thread, so we send the tag manually there.
+     */
+    public abstract NbtMap getLecternDataAt(GeyserSession session, int x, int y, int z, boolean isChunkLoad);
+
+    /**
+     * @return whether we should expect lectern data to update, or if we have to fall back on a workaround.
+     */
+    public abstract boolean shouldExpectLecternHandled();
+
     /**
      * Updates a gamerule value on the Java server
      *
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/BlockStateValues.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/BlockStateValues.java
index e354a4095..ebc90b722 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/BlockStateValues.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/BlockStateValues.java
@@ -39,6 +39,7 @@ public class BlockStateValues {
     private static final Int2ByteMap COMMAND_BLOCK_VALUES = new Int2ByteOpenHashMap();
     private static final Int2ObjectMap<DoubleChestValue> DOUBLE_CHEST_VALUES = new Int2ObjectOpenHashMap<>();
     private static final Int2ObjectMap<String> FLOWER_POT_VALUES = new Int2ObjectOpenHashMap<>();
+    private static final Int2BooleanMap LECTERN_BOOK_STATES = new Int2BooleanOpenHashMap();
     private static final Int2IntMap NOTEBLOCK_PITCHES = new Int2IntOpenHashMap();
     private static final Int2BooleanMap IS_STICKY_PISTON = new Int2BooleanOpenHashMap();
     private static final Int2BooleanMap PISTON_VALUES = new Int2BooleanOpenHashMap();
@@ -87,6 +88,11 @@ public class BlockStateValues {
             return;
         }
 
+        if (javaId.startsWith("minecraft:lectern")) {
+            LECTERN_BOOK_STATES.put(javaBlockState, javaId.contains("has_book=true"));
+            return;
+        }
+
         JsonNode notePitch = blockData.get("note_pitch");
         if (notePitch != null) {
             NOTEBLOCK_PITCHES.put(javaBlockState, blockData.get("note_pitch").intValue());
@@ -193,6 +199,10 @@ public class BlockStateValues {
         return FLOWER_POT_VALUES;
     }
 
+    public static Int2BooleanMap getLecternBookStates() {
+        return LECTERN_BOOK_STATES;
+    }
+
     /**
      * The note that noteblocks output when hit is part of the block state in Java but sent as a BlockEventPacket in Bedrock.
      * This gives an integer pitch that Bedrock can use.
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/BlockTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/BlockTranslator.java
index 64bbae210..ec1c79950 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/BlockTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/BlockTranslator.java
@@ -106,6 +106,14 @@ public abstract class BlockTranslator {
 
     public static final int JAVA_RUNTIME_SPAWNER_ID;
 
+    /**
+     * Contains a map of Java blocks to their respective Bedrock block tag, if the Java identifier is different from Bedrock.
+     * Required to fix villager trades with these blocks.
+     */
+    private final Map<String, NbtMap> javaIdentifierToBedrockTag;
+
+    private static final int BLOCK_STATE_VERSION = 17825808;
+
     /**
      * Stores the raw blocks JSON until it is no longer needed.
      */
@@ -163,14 +171,13 @@ public abstract class BlockTranslator {
             BlockStateValues.storeBlockStateValues(entry.getKey(), javaRuntimeId, entry.getValue());
 
             String cleanJavaIdentifier = entry.getKey().split("\\[")[0];
+            String bedrockIdentifier = entry.getValue().get("bedrock_identifier").asText();
 
             if (!JAVA_ID_TO_JAVA_IDENTIFIER_MAP.containsValue(cleanJavaIdentifier)) {
                 uniqueJavaId++;
                 JAVA_ID_TO_JAVA_IDENTIFIER_MAP.put(uniqueJavaId, cleanJavaIdentifier);
             }
 
-            String bedrockIdentifier = entry.getValue().get("bedrock_identifier").asText();
-
             // Keeping this here since this is currently unchanged between versions
             if (!cleanJavaIdentifier.equals(bedrockIdentifier)) {
                 JAVA_TO_BEDROCK_IDENTIFIERS.put(cleanJavaIdentifier, bedrockIdentifier);
@@ -231,6 +238,8 @@ public abstract class BlockTranslator {
             throw new AssertionError("Unable to get blocks from runtime block states", e);
         }
 
+        javaIdentifierToBedrockTag = new Object2ObjectOpenHashMap<>();
+
         // New since 1.16.100 - find the block runtime ID by the order given to us in the block palette,
         // as we no longer send a block palette
         Object2IntMap<NbtMap> blockStateOrderedMap = new Object2IntOpenHashMap<>(blocksTag.size());
@@ -285,7 +294,11 @@ public abstract class BlockTranslator {
 
             // Get the tag needed for non-empty flower pots
             if (entry.getValue().get("pottable") != null) {
-                flowerPotBlocks.put(cleanJavaIdentifier, buildBedrockState(entry.getValue()));
+                flowerPotBlocks.put(cleanJavaIdentifier, blockTag);
+            }
+
+            if (!cleanJavaIdentifier.equals(entry.getValue().get("bedrock_identifier").asText())) {
+                javaIdentifierToBedrockTag.put(cleanJavaIdentifier, blockTag);
             }
 
             javaToBedrockBlockMap.put(javaRuntimeId, bedrockRuntimeId);
@@ -447,4 +460,13 @@ public abstract class BlockTranslator {
     public static String[] getAllBlockIdentifiers() {
         return JAVA_ID_TO_JAVA_IDENTIFIER_MAP.values().toArray(new String[0]);
     }
+
+    /**
+     * @param cleanJavaIdentifier the clean Java identifier of the block to look up
+     *
+     * @return the block tag of the block name mapped from Java to Bedrock.
+     */
+    public NbtMap getBedrockBlockNbt(String cleanJavaIdentifier) {
+        return javaIdentifierToBedrockTag.get(cleanJavaIdentifier);
+    }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BannerBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BannerBlockEntityTranslator.java
index 07760c468..12c0c9b64 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BannerBlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BannerBlockEntityTranslator.java
@@ -31,7 +31,7 @@ import com.nukkitx.nbt.NbtMapBuilder;
 import org.geysermc.connector.network.translators.item.translators.BannerTranslator;
 import org.geysermc.connector.network.translators.world.block.BlockStateValues;
 
-@BlockEntity(name = "Banner", regex = "banner")
+@BlockEntity(name = "Banner")
 public class BannerBlockEntityTranslator extends BlockEntityTranslator implements RequiresBlockState {
     @Override
     public boolean isBlock(int blockState) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BeaconBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BeaconBlockEntityTranslator.java
new file mode 100644
index 000000000..147651a18
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BeaconBlockEntityTranslator.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.connector.network.translators.world.block.entity;
+
+import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
+import com.nukkitx.nbt.NbtMapBuilder;
+
+@BlockEntity(name = "Beacon")
+public class BeaconBlockEntityTranslator extends BlockEntityTranslator {
+    @Override
+    public void translateTag(NbtMapBuilder builder, CompoundTag tag, int blockState) {
+        int primary = getOrDefault(tag.get("Primary"), 0);
+        // The effects here generally map one-to-one Java <-> Bedrock. Only the newer ones get more complicated
+        builder.putInt("primary", primary == -1 ? 0 : primary);
+        int secondary = getOrDefault(tag.get("Secondary"), 0);
+        builder.putInt("secondary", secondary == -1 ? 0 : secondary);
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BedBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BedBlockEntityTranslator.java
index 7d9dee98e..28cb52f64 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BedBlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BedBlockEntityTranslator.java
@@ -29,7 +29,7 @@ import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.nukkitx.nbt.NbtMapBuilder;
 import org.geysermc.connector.network.translators.world.block.BlockStateValues;
 
-@BlockEntity(name = "Bed", regex = "bed")
+@BlockEntity(name = "Bed")
 public class BedBlockEntityTranslator extends BlockEntityTranslator implements RequiresBlockState {
     @Override
     public boolean isBlock(int blockState) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BlockEntity.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BlockEntity.java
index acd0c87fe..a2b48b1cc 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BlockEntity.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BlockEntity.java
@@ -36,10 +36,4 @@ public @interface BlockEntity {
      * @return the name of the block entity
      */
     String name();
-
-    /**
-     * The search term used in BlockTranslator
-     * @return the search term used in BlockTranslator
-     */
-    String regex();
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BlockEntityTranslator.java
index 7f2e16efa..f109ed6b4 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/BlockEntityTranslator.java
@@ -109,7 +109,7 @@ public abstract class BlockEntityTranslator {
         int y = ((IntTag) tag.getValue().get("y")).getValue();
         int z = ((IntTag) tag.getValue().get("z")).getValue();
 
-        NbtMapBuilder tagBuilder = getConstantBedrockTag(BlockEntityUtils.getBedrockBlockEntityId(id), x, y, z).toBuilder();
+        NbtMapBuilder tagBuilder = getConstantBedrockTag(BlockEntityUtils.getBedrockBlockEntityId(id), x, y, z);
         translateTag(tagBuilder, tag, blockState);
         return tagBuilder.build();
     }
@@ -123,13 +123,12 @@ public abstract class BlockEntityTranslator {
         return tag;
     }
 
-    protected NbtMap getConstantBedrockTag(String bedrockId, int x, int y, int z) {
+    protected NbtMapBuilder getConstantBedrockTag(String bedrockId, int x, int y, int z) {
         return NbtMap.builder()
                 .putInt("x", x)
                 .putInt("y", y)
                 .putInt("z", z)
-                .putString("id", bedrockId)
-                .build();
+                .putString("id", bedrockId);
     }
 
     @SuppressWarnings("unchecked")
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/CampfireBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/CampfireBlockEntityTranslator.java
index 7ae4f315d..40f305ad6 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/CampfireBlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/CampfireBlockEntityTranslator.java
@@ -32,7 +32,7 @@ import com.nukkitx.nbt.NbtMapBuilder;
 import org.geysermc.connector.network.translators.item.ItemEntry;
 import org.geysermc.connector.network.translators.item.ItemRegistry;
 
-@BlockEntity(name = "Campfire", regex = "campfire")
+@BlockEntity(name = "Campfire")
 public class CampfireBlockEntityTranslator extends BlockEntityTranslator {
     @Override
     public void translateTag(NbtMapBuilder builder, CompoundTag tag, int blockState) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/CommandBlockBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/CommandBlockBlockEntityTranslator.java
index fe988854b..a4bb3e691 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/CommandBlockBlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/CommandBlockBlockEntityTranslator.java
@@ -30,7 +30,7 @@ import com.nukkitx.nbt.NbtMapBuilder;
 import org.geysermc.connector.network.translators.world.block.BlockStateValues;
 import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
-@BlockEntity(name = "CommandBlock", regex = "command_block")
+@BlockEntity(name = "CommandBlock")
 public class CommandBlockBlockEntityTranslator extends BlockEntityTranslator implements RequiresBlockState {
     @Override
     public void translateTag(NbtMapBuilder builder, CompoundTag tag, int blockState) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/DoubleChestBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/DoubleChestBlockEntityTranslator.java
index 991fb2665..9775017fb 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/DoubleChestBlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/DoubleChestBlockEntityTranslator.java
@@ -36,7 +36,7 @@ import org.geysermc.connector.utils.BlockEntityUtils;
 /**
  * Chests have more block entity properties in Bedrock, which is solved by implementing the BedrockOnlyBlockEntity
  */
-@BlockEntity(name = "Chest", regex = "chest")
+@BlockEntity(name = "Chest")
 public class DoubleChestBlockEntityTranslator extends BlockEntityTranslator implements BedrockOnlyBlockEntity, RequiresBlockState {
     @Override
     public boolean isBlock(int blockState) {
@@ -46,7 +46,7 @@ public class DoubleChestBlockEntityTranslator extends BlockEntityTranslator impl
     @Override
     public void updateBlock(GeyserSession session, int blockState, Vector3i position) {
         CompoundTag javaTag = getConstantJavaTag("chest", position.getX(), position.getY(), position.getZ());
-        NbtMapBuilder tagBuilder = getConstantBedrockTag(BlockEntityUtils.getBedrockBlockEntityId("chest"), position.getX(), position.getY(), position.getZ()).toBuilder();
+        NbtMapBuilder tagBuilder = getConstantBedrockTag(BlockEntityUtils.getBedrockBlockEntityId("chest"), position.getX(), position.getY(), position.getZ());
         translateTag(tagBuilder, javaTag, blockState);
         BlockEntityUtils.updateBlockEntity(session, tagBuilder.build(), position);
     }
@@ -57,29 +57,41 @@ public class DoubleChestBlockEntityTranslator extends BlockEntityTranslator impl
         if (chestValues != null) {
             int x = (int) tag.getValue().get("x").getValue();
             int z = (int) tag.getValue().get("z").getValue();
-            // Calculate the position of the other chest based on the Java block state
-            if (chestValues.isFacingEast) {
-                if (chestValues.isDirectionPositive) {
-                    // East
-                    z = z + (chestValues.isLeft ? 1 : -1);
-                } else {
-                    // West
-                    z = z + (chestValues.isLeft ? -1 : 1);
-                }
+            translateChestValue(builder, chestValues, x, z);
+        }
+    }
+
+    /**
+     * Add Bedrock block entity tags to a NbtMap based on Java properties
+     *
+     * @param builder the NbtMapBuilder to apply properties to
+     * @param chestValues the position properties of this double chest
+     * @param x the x position of this chest pair
+     * @param z the z position of this chest pair
+     */
+    public static void translateChestValue(NbtMapBuilder builder, DoubleChestValue chestValues, int x, int z) {
+        // Calculate the position of the other chest based on the Java block state
+        if (chestValues.isFacingEast) {
+            if (chestValues.isDirectionPositive) {
+                // East
+                z = z + (chestValues.isLeft ? 1 : -1);
             } else {
-                if (chestValues.isDirectionPositive) {
-                    // South
-                    x = x + (chestValues.isLeft ? -1 : 1);
-                } else {
-                    // North
-                    x = x + (chestValues.isLeft ? 1 : -1);
-                }
+                // West
+                z = z + (chestValues.isLeft ? -1 : 1);
             }
-            builder.put("pairx", x);
-            builder.put("pairz", z);
-            if (!chestValues.isLeft) {
-                builder.put("pairlead", (byte) 1);
+        } else {
+            if (chestValues.isDirectionPositive) {
+                // South
+                x = x + (chestValues.isLeft ? -1 : 1);
+            } else {
+                // North
+                x = x + (chestValues.isLeft ? 1 : -1);
             }
         }
+        builder.put("pairx", x);
+        builder.put("pairz", z);
+        if (!chestValues.isLeft) {
+            builder.put("pairlead", (byte) 1);
+        }
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/EmptyBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/EmptyBlockEntityTranslator.java
index 12afd530e..eb68337d7 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/EmptyBlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/EmptyBlockEntityTranslator.java
@@ -28,7 +28,7 @@ package org.geysermc.connector.network.translators.world.block.entity;
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.nukkitx.nbt.NbtMapBuilder;
 
-@BlockEntity(name = "Empty", regex = "")
+@BlockEntity(name = "Empty")
 public class EmptyBlockEntityTranslator extends BlockEntityTranslator {
     @Override
     public void translateTag(NbtMapBuilder builder, CompoundTag tag, int blockState) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/EndGatewayBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/EndGatewayBlockEntityTranslator.java
index 761d1c3ab..d580e3d21 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/EndGatewayBlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/EndGatewayBlockEntityTranslator.java
@@ -37,7 +37,7 @@ import it.unimi.dsi.fastutil.ints.IntList;
 
 import java.util.LinkedHashMap;
 
-@BlockEntity(name = "EndGateway", regex = "end_gateway")
+@BlockEntity(name = "EndGateway")
 public class EndGatewayBlockEntityTranslator extends BlockEntityTranslator {
     @Override
     public void translateTag(NbtMapBuilder builder, CompoundTag tag, int blockState) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/JigsawBlockBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/JigsawBlockBlockEntityTranslator.java
index 10582c7b6..18c28a115 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/JigsawBlockBlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/JigsawBlockBlockEntityTranslator.java
@@ -29,7 +29,7 @@ import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.github.steveice10.opennbt.tag.builtin.StringTag;
 import com.nukkitx.nbt.NbtMapBuilder;
 
-@BlockEntity(name = "JigsawBlock", regex = "jigsaw")
+@BlockEntity(name = "JigsawBlock")
 public class JigsawBlockBlockEntityTranslator extends BlockEntityTranslator {
     @Override
     public void translateTag(NbtMapBuilder builder, CompoundTag tag, int blockState) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/ShulkerBoxBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/ShulkerBoxBlockEntityTranslator.java
index 70fde3e47..04d58fcce 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/ShulkerBoxBlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/ShulkerBoxBlockEntityTranslator.java
@@ -29,10 +29,16 @@ import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.nukkitx.nbt.NbtMapBuilder;
 import org.geysermc.connector.network.translators.world.block.BlockStateValues;
 
-@BlockEntity(name = "ShulkerBox", regex = "shulker_box")
+import javax.annotation.Nullable;
+
+@BlockEntity(name = "ShulkerBox")
 public class ShulkerBoxBlockEntityTranslator extends BlockEntityTranslator {
+    /**
+     * Also used in {@link org.geysermc.connector.network.translators.inventory.translators.ShulkerInventoryTranslator}
+     * where {@code tag} is passed as null.
+     */
     @Override
-    public void translateTag(NbtMapBuilder builder, CompoundTag tag, int blockState) {
+    public void translateTag(NbtMapBuilder builder, @Nullable CompoundTag tag, int blockState) {
         byte direction = BlockStateValues.getShulkerBoxDirection(blockState);
         // Just in case...
         if (direction == -1) {
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SignBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SignBlockEntityTranslator.java
index 2a3950b34..a92fac599 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SignBlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SignBlockEntityTranslator.java
@@ -30,7 +30,7 @@ import com.nukkitx.nbt.NbtMapBuilder;
 import org.geysermc.connector.network.translators.chat.MessageTranslator;
 import org.geysermc.connector.utils.SignUtils;
 
-@BlockEntity(name = "Sign", regex = "sign")
+@BlockEntity(name = "Sign")
 public class SignBlockEntityTranslator extends BlockEntityTranslator {
     /**
      * Maps a color stored in a sign's Color tag to a Bedrock Edition formatting code.
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SkullBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SkullBlockEntityTranslator.java
index c41d45150..b3e25b212 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SkullBlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SkullBlockEntityTranslator.java
@@ -46,7 +46,7 @@ import java.util.UUID;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.TimeUnit;
 
-@BlockEntity(name = "Skull", regex = "skull")
+@BlockEntity(name = "Skull")
 public class SkullBlockEntityTranslator extends BlockEntityTranslator implements RequiresBlockState {
     public static boolean ALLOW_CUSTOM_SKULLS;
 
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SpawnerBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SpawnerBlockEntityTranslator.java
index 5a6f974be..a9a37b60a 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SpawnerBlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SpawnerBlockEntityTranslator.java
@@ -30,7 +30,7 @@ import com.github.steveice10.opennbt.tag.builtin.Tag;
 import com.nukkitx.nbt.NbtMapBuilder;
 import org.geysermc.connector.entity.type.EntityType;
 
-@BlockEntity(name = "MobSpawner", regex = "mob_spawner")
+@BlockEntity(name = "MobSpawner")
 public class SpawnerBlockEntityTranslator extends BlockEntityTranslator {
     @Override
     public void translateTag(NbtMapBuilder builder, CompoundTag tag, int blockState) {
diff --git a/connector/src/main/java/org/geysermc/connector/utils/BlockUtils.java b/connector/src/main/java/org/geysermc/connector/utils/BlockUtils.java
index 07fe9744d..a75c20872 100644
--- a/connector/src/main/java/org/geysermc/connector/utils/BlockUtils.java
+++ b/connector/src/main/java/org/geysermc/connector/utils/BlockUtils.java
@@ -135,7 +135,7 @@ public class BlockUtils {
                 && session.getBlockTranslator().getBedrockBlockId(session.getConnector().getWorldManager().getBlockAt(session, session.getPlayerEntity().getPosition().toInt())) == session.getBlockTranslator().getBedrockWaterId();
 
         boolean insideOfWaterWithoutAquaAffinity = isInWater &&
-                ItemUtils.getEnchantmentLevel(Optional.ofNullable(session.getInventory().getItem(5)).map(ItemStack::getNbt).orElse(null), "minecraft:aqua_affinity") < 1;
+                ItemUtils.getEnchantmentLevel(session.getPlayerInventory().getItem(5).getNbt(), "minecraft:aqua_affinity") < 1;
 
         boolean outOfWaterButNotOnGround = (!isInWater) && (!session.getPlayerEntity().isOnGround());
         boolean insideWaterNotOnGround = isInWater && !session.getPlayerEntity().isOnGround();
diff --git a/connector/src/main/java/org/geysermc/connector/utils/ChunkUtils.java b/connector/src/main/java/org/geysermc/connector/utils/ChunkUtils.java
index 65c15eb21..b3a31e1ab 100644
--- a/connector/src/main/java/org/geysermc/connector/utils/ChunkUtils.java
+++ b/connector/src/main/java/org/geysermc/connector/utils/ChunkUtils.java
@@ -51,6 +51,7 @@ import org.geysermc.connector.entity.Entity;
 import org.geysermc.connector.entity.ItemFrameEntity;
 import org.geysermc.connector.entity.player.SkullPlayerEntity;
 import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.network.translators.inventory.translators.LecternInventoryTranslator;
 import org.geysermc.connector.network.translators.world.block.BlockStateValues;
 import org.geysermc.connector.network.translators.world.block.BlockTranslator;
 import org.geysermc.connector.network.translators.world.block.entity.BedrockOnlyBlockEntity;
@@ -258,7 +259,6 @@ public class ChunkUtils {
             }
 
             String id = BlockEntityUtils.getBedrockBlockEntityId(tagName);
-            BlockEntityTranslator blockEntityTranslator = BlockEntityUtils.getBlockEntityTranslator(id);
             Position pos = new Position((int) tag.get("x").getValue(), (int) tag.get("y").getValue(), (int) tag.get("z").getValue());
 
             // Get Java blockstate ID from block entity position
@@ -268,6 +268,14 @@ public class ChunkUtils {
                 blockState = section.get(pos.getX() & 0xF, pos.getY() & 0xF, pos.getZ() & 0xF);
             }
 
+            if (tagName.equals("minecraft:lectern") && BlockStateValues.getLecternBookStates().get(blockState)) {
+                // If getLecternBookStates is false, let's just treat it like a normal block entity
+                bedrockBlockEntities[i] = session.getConnector().getWorldManager().getLecternDataAt(session, pos.getX(), pos.getY(), pos.getZ(), true);
+                i++;
+                continue;
+            }
+
+            BlockEntityTranslator blockEntityTranslator = BlockEntityUtils.getBlockEntityTranslator(id);
             bedrockBlockEntities[i] = blockEntityTranslator.getBlockEntityTag(tagName, tag, blockState);
 
             // Check for custom skulls
@@ -364,6 +372,28 @@ public class ChunkUtils {
         }
         session.sendUpstreamPacket(waterPacket);
 
+        if (BlockStateValues.getLecternBookStates().containsKey(blockState)) {
+            boolean lecternCachedHasBook = session.getLecternCache().contains(position);
+            boolean newLecternHasBook = BlockStateValues.getLecternBookStates().get(blockState);
+            if (!session.getConnector().getWorldManager().shouldExpectLecternHandled() && lecternCachedHasBook != newLecternHasBook) {
+                // Refresh the block entirely - it either has a book or no longer has a book
+                NbtMap newLecternTag;
+                if (newLecternHasBook) {
+                    newLecternTag = session.getConnector().getWorldManager().getLecternDataAt(session, position.getX(), position.getY(), position.getZ(), false);
+                } else {
+                    session.getLecternCache().remove(position);
+                    newLecternTag = LecternInventoryTranslator.getBaseLecternTag(position.getX(), position.getY(), position.getZ(), 0).build();
+                }
+                BlockEntityUtils.updateBlockEntity(session, newLecternTag, position);
+            } else {
+                // As of right now, no tag can be added asynchronously
+                session.getConnector().getWorldManager().getLecternDataAt(session, position.getX(), position.getY(), position.getZ(), false);
+            }
+        } else {
+            // Lectern has been destroyed, if it existed
+            session.getLecternCache().remove(position);
+        }
+
         // Since Java stores bed colors/skull information as part of the namespaced ID and Bedrock stores it as a tag
         // This is the only place I could find that interacts with the Java block state and block updates
         // Iterates through all block entity translators and determines if the block state needs to be saved
diff --git a/connector/src/main/java/org/geysermc/connector/utils/DimensionUtils.java b/connector/src/main/java/org/geysermc/connector/utils/DimensionUtils.java
index f193a61db..e002162c3 100644
--- a/connector/src/main/java/org/geysermc/connector/utils/DimensionUtils.java
+++ b/connector/src/main/java/org/geysermc/connector/utils/DimensionUtils.java
@@ -60,6 +60,7 @@ public class DimensionUtils {
 
         session.getEntityCache().removeAllEntities();
         session.getItemFrameCache().clear();
+        session.getLecternCache().clear();
         session.getSkullCache().clear();
 
         Vector3i pos = Vector3i.from(0, Short.MAX_VALUE, 0);
diff --git a/connector/src/main/java/org/geysermc/connector/utils/InventoryUtils.java b/connector/src/main/java/org/geysermc/connector/utils/InventoryUtils.java
index a4d722261..ccaa20e82 100644
--- a/connector/src/main/java/org/geysermc/connector/utils/InventoryUtils.java
+++ b/connector/src/main/java/org/geysermc/connector/utils/InventoryUtils.java
@@ -39,94 +39,103 @@ import com.nukkitx.protocol.bedrock.packet.InventorySlotPacket;
 import com.nukkitx.protocol.bedrock.packet.PlayerHotbarPacket;
 import org.geysermc.connector.GeyserConnector;
 import org.geysermc.connector.common.ChatColor;
+import org.geysermc.connector.inventory.Container;
+import org.geysermc.connector.inventory.GeyserItemStack;
 import org.geysermc.connector.inventory.Inventory;
+import org.geysermc.connector.inventory.PlayerInventory;
 import org.geysermc.connector.network.session.GeyserSession;
-import org.geysermc.connector.network.translators.inventory.DoubleChestInventoryTranslator;
 import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.translators.LecternInventoryTranslator;
+import org.geysermc.connector.network.translators.inventory.translators.chest.DoubleChestInventoryTranslator;
 import org.geysermc.connector.network.translators.item.ItemEntry;
 import org.geysermc.connector.network.translators.item.ItemRegistry;
-import org.geysermc.connector.network.translators.item.ItemTranslator;
-import org.geysermc.connector.network.translators.world.block.BlockTranslator;
 
 import java.util.Collections;
 import java.util.Objects;
 import java.util.concurrent.TimeUnit;
 
 public class InventoryUtils {
-    public static final ItemStack REFRESH_ITEM = new ItemStack(1, 127, new CompoundTag("")); //TODO: stop using this
+    public static final ItemStack REFRESH_ITEM = new ItemStack(1, 127, new CompoundTag(""));
 
     public static void openInventory(GeyserSession session, Inventory inventory) {
-        InventoryTranslator translator = InventoryTranslator.INVENTORY_TRANSLATORS.get(inventory.getWindowType());
+        session.setOpenInventory(inventory);
+        if (session.isClosingInventory()) {
+            //Wait for close confirmation from client before opening the new inventory.
+            //Handled in BedrockContainerCloseTranslator
+            inventory.setPending(true);
+            return;
+        }
+        displayInventory(session, inventory);
+    }
+
+    public static void displayInventory(GeyserSession session, Inventory inventory) {
+        InventoryTranslator translator = session.getInventoryTranslator();
         if (translator != null) {
-            session.getInventoryCache().setOpenInventory(inventory);
             translator.prepareInventory(session, inventory);
-            //Ensure at least half a second passes between closing and opening a new window
-            //The client will not open the new window if it is still closing the old one
-            long delay = 700 - (System.currentTimeMillis() - session.getLastWindowCloseTime());
-            //TODO: find better way to handle double chest delay
-            if (translator instanceof DoubleChestInventoryTranslator) {
-                delay = Math.max(delay, 200);
-            }
-            if (delay > 0) {
-                GeyserConnector.getInstance().getGeneralThreadPool().schedule(() -> {
-                    translator.openInventory(session, inventory);
-                    translator.updateInventory(session, inventory);
-                }, delay, TimeUnit.MILLISECONDS);
+            if (translator instanceof DoubleChestInventoryTranslator && !((Container) inventory).isUsingRealBlock()) {
+                GeyserConnector.getInstance().getGeneralThreadPool().schedule(() ->
+                    session.addInventoryTask(() -> {
+                        Inventory openInv = session.getOpenInventory();
+                        if (openInv != null && openInv.getId() == inventory.getId()) {
+                            translator.openInventory(session, inventory);
+                            translator.updateInventory(session, inventory);
+                        } else if (openInv != null && openInv.isPending()) {
+                            // Presumably, this inventory is no longer relevant, and the client doesn't care about it
+                            displayInventory(session, openInv);
+                        }
+                }), 200, TimeUnit.MILLISECONDS);
             } else {
                 translator.openInventory(session, inventory);
                 translator.updateInventory(session, inventory);
             }
-        }
-    }
-
-    public static void closeInventory(GeyserSession session, int windowId) {
-        if (windowId != 0) {
-            Inventory inventory = session.getInventoryCache().getInventories().get(windowId);
-            Inventory openInventory = session.getInventoryCache().getOpenInventory();
-            session.getInventoryCache().uncacheInventory(windowId);
-            if (inventory != null && openInventory != null && inventory.getId() == openInventory.getId()) {
-                InventoryTranslator translator = InventoryTranslator.INVENTORY_TRANSLATORS.get(inventory.getWindowType());
-                translator.closeInventory(session, inventory);
-                session.getInventoryCache().setOpenInventory(null);
-            } else {
-                return;
-            }
         } else {
-            Inventory inventory = session.getInventory();
-            inventory.setOpen(false);
-            InventoryTranslator translator = InventoryTranslator.INVENTORY_TRANSLATORS.get(inventory.getWindowType());
-            translator.updateInventory(session, inventory);
+            // Precaution - as of 1.16 every inventory should be translated so this shouldn't happen
+            session.setOpenInventory(null);
         }
-
-        session.setCraftSlot(0);
-        session.getInventory().setCursor(null);
-        updateCursor(session);
     }
 
-    public static void closeWindow(GeyserSession session, int windowId) {
-        //TODO: Investigate client crash when force closing window and opening a new one
-        //Instead, the window will eventually close by removing the fake blocks
-        session.setLastWindowCloseTime(System.currentTimeMillis());
+    public static void closeInventory(GeyserSession session, int windowId, boolean confirm) {
+        session.getPlayerInventory().setCursor(GeyserItemStack.EMPTY, session);
+        updateCursor(session);
 
-        /*
-        //Spamming close window packets can bug the client
-        if (System.currentTimeMillis() - session.getLastWindowCloseTime() > 500) {
-            ContainerClosePacket closePacket = new ContainerClosePacket();
-            closePacket.setId((byte) windowId);
-            session.sendUpstreamPacket(closePacket);
-            session.setLastWindowCloseTime(System.currentTimeMillis());
+        Inventory inventory = getInventory(session, windowId);
+        if (inventory != null) {
+            InventoryTranslator translator = session.getInventoryTranslator();
+            translator.closeInventory(session, inventory);
+            if (confirm && !inventory.isPending() && !(translator instanceof LecternInventoryTranslator)) {
+                session.setClosingInventory(true);
+            }
+        }
+        session.setInventoryTranslator(InventoryTranslator.PLAYER_INVENTORY_TRANSLATOR);
+        session.setOpenInventory(null);
+    }
+
+    public static Inventory getInventory(GeyserSession session, int windowId) {
+        if (windowId == 0) {
+            return session.getPlayerInventory();
+        } else {
+            Inventory openInventory = session.getOpenInventory();
+            if (openInventory != null && windowId == openInventory.getId()) {
+                return openInventory;
+            }
+            return null;
         }
-        */
     }
 
     public static void updateCursor(GeyserSession session) {
         InventorySlotPacket cursorPacket = new InventorySlotPacket();
         cursorPacket.setContainerId(ContainerId.UI);
         cursorPacket.setSlot(0);
-        cursorPacket.setItem(ItemTranslator.translateToBedrock(session, session.getInventory().getCursor()));
+        cursorPacket.setItem(session.getPlayerInventory().getCursor().getItemData(session));
         session.sendUpstreamPacket(cursorPacket);
     }
 
+    public static boolean canStack(GeyserItemStack item1, GeyserItemStack item2) {
+        if (item1.isEmpty() || item2.isEmpty())
+            return false;
+        return item1.getJavaId() == item2.getJavaId() && Objects.equals(item1.getNbt(), item2.getNbt());
+    }
+
     public static boolean canStack(ItemStack item1, ItemStack item2) {
         if (item1 == null || item2 == null)
             return false;
@@ -171,10 +180,7 @@ public class InventoryUtils {
      */
     public static void findOrCreateItem(GeyserSession session, String itemName) {
         // Get the inventory to choose a slot to pick
-        Inventory inventory = session.getInventoryCache().getOpenInventory();
-        if (inventory == null) {
-            inventory = session.getInventory();
-        }
+        PlayerInventory inventory = session.getPlayerInventory();
 
         if (itemName.equals("minecraft:air")) {
             return;
@@ -182,12 +188,12 @@ public class InventoryUtils {
 
         // Check hotbar for item
         for (int i = 36; i < 45; i++) {
-            if (inventory.getItem(i) == null) {
+            GeyserItemStack geyserItem = inventory.getItem(i);
+            if (geyserItem.isEmpty()) {
                 continue;
             }
-            ItemEntry item = ItemRegistry.getItem(inventory.getItem(i));
             // If this isn't the item we're looking for
-            if (!item.getJavaIdentifier().equals(itemName)) {
+            if (!geyserItem.getItemEntry().getJavaIdentifier().equals(itemName)) {
                 continue;
             }
 
@@ -198,12 +204,12 @@ public class InventoryUtils {
 
         // Check inventory for item
         for (int i = 9; i < 36; i++) {
-            if (inventory.getItem(i) == null) {
+            GeyserItemStack geyserItem = inventory.getItem(i);
+            if (geyserItem.isEmpty()) {
                 continue;
             }
-            ItemEntry item = ItemRegistry.getItem(inventory.getItem(i));
             // If this isn't the item we're looking for
-            if (!item.getJavaIdentifier().equals(itemName)) {
+            if (!geyserItem.getItemEntry().getJavaIdentifier().equals(itemName)) {
                 continue;
             }
 
@@ -214,10 +220,10 @@ public class InventoryUtils {
 
         // If we still have not found the item, and we're in creative, ask for the item from the server.
         if (session.getGameMode() == GameMode.CREATIVE) {
-            int slot = session.getInventory().getHeldItemSlot() + 36;
-            if (session.getInventory().getItemInHand() != null) { // Otherwise we should just use the current slot
+            int slot = inventory.getHeldItemSlot() + 36;
+            if (!inventory.getItemInHand().isEmpty()) { // Otherwise we should just use the current slot
                 for (int i = 36; i < 45; i++) {
-                    if (inventory.getItem(i) == null) {
+                    if (inventory.getItem(i).isEmpty()) {
                         slot = i;
                         break;
                     }
@@ -228,7 +234,7 @@ public class InventoryUtils {
             if (entry != null) {
                 ClientCreativeInventoryActionPacket actionPacket = new ClientCreativeInventoryActionPacket(slot,
                         new ItemStack(entry.getJavaId()));
-                if ((slot - 36) != session.getInventory().getHeldItemSlot()) {
+                if ((slot - 36) != inventory.getHeldItemSlot()) {
                     setHotbarItem(session, slot);
                 }
                 session.sendDownstreamPacket(actionPacket);
diff --git a/connector/src/main/resources/mappings b/connector/src/main/resources/mappings
index ef994d8fe..216e90086 160000
--- a/connector/src/main/resources/mappings
+++ b/connector/src/main/resources/mappings
@@ -1 +1 @@
-Subproject commit ef994d8fea421524cbc11f565a3f5ec59fc05741
+Subproject commit 216e9008678a761b3885808f4f3d43000404381b