diff --git a/fabric-networking-api-v1/src/main/java/net/fabricmc/fabric/impl/networking/RecipeBookAddPacketSplitter.java b/fabric-networking-api-v1/src/main/java/net/fabricmc/fabric/impl/networking/RecipeBookAddPacketSplitter.java new file mode 100644 index 0000000000..91436b3eef --- /dev/null +++ b/fabric-networking-api-v1/src/main/java/net/fabricmc/fabric/impl/networking/RecipeBookAddPacketSplitter.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2016, 2017, 2018, 2019 FabricMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.fabricmc.fabric.impl.networking; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.handler.PacketInflater; +import net.minecraft.network.packet.s2c.play.RecipeBookAddS2CPacket; +import net.minecraft.server.network.ServerPlayNetworkHandler; + +import net.fabricmc.fabric.api.networking.v1.PacketByteBufs; + +public final class RecipeBookAddPacketSplitter { + // -1 byte due the the "replace" boolean + private static final int MAXIMUM_PACKET_SIZE = PacketInflater.MAXIMUM_PACKET_SIZE - 1; + + private RecipeBookAddPacketSplitter() { + } + + public static void split(RecipeBookAddS2CPacket packet, ServerPlayNetworkHandler networkHandler, Consumer packetSender) { + RegistryByteBuf byteBuf = new RegistryByteBuf(PacketByteBufs.create(), networkHandler.getPlayer().getRegistryManager()); + List collectedEntries = new ArrayList<>(); + // Ensure that only the first packet sets the replace flag + boolean shouldReplace = packet.replace(); + + for (RecipeBookAddS2CPacket.Entry entry : packet.entries()) { + int beforeSize = byteBuf.readableBytes(); + RecipeBookAddS2CPacket.Entry.PACKET_CODEC.encode(byteBuf, entry); + int entrySize = byteBuf.readableBytes() - beforeSize; + + if (entrySize > MAXIMUM_PACKET_SIZE) { + // Individual entry is larger than the max size, cannot be sent. + throw new IllegalStateException("Entry is larger than the max size: " + entry); + } + + if (byteBuf.readableBytes() >= MAXIMUM_PACKET_SIZE) { + // Packet would be larger than the max size, send what fits, the current entry will be sent in the next packet. + packetSender.accept(new RecipeBookAddS2CPacket(collectedEntries, shouldReplace)); + + collectedEntries = new ArrayList<>(); + byteBuf = new RegistryByteBuf(PacketByteBufs.create(), networkHandler.getPlayer().getRegistryManager()); + shouldReplace = false; + + // Must re-encode the current entry so it gets counted. + RecipeBookAddS2CPacket.Entry.PACKET_CODEC.encode(byteBuf, entry); + } + + collectedEntries.add(entry); + } + + packetSender.accept(new RecipeBookAddS2CPacket(collectedEntries, shouldReplace)); + } +} diff --git a/fabric-networking-api-v1/src/main/java/net/fabricmc/fabric/mixin/networking/ServerRecipeBookMixin.java b/fabric-networking-api-v1/src/main/java/net/fabricmc/fabric/mixin/networking/ServerRecipeBookMixin.java new file mode 100644 index 0000000000..d853c6d470 --- /dev/null +++ b/fabric-networking-api-v1/src/main/java/net/fabricmc/fabric/mixin/networking/ServerRecipeBookMixin.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2016, 2017, 2018, 2019 FabricMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.fabricmc.fabric.mixin.networking; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; + +import net.minecraft.network.packet.Packet; +import net.minecraft.network.packet.s2c.play.RecipeBookAddS2CPacket; +import net.minecraft.server.network.ServerPlayNetworkHandler; +import net.minecraft.server.network.ServerRecipeBook; + +import net.fabricmc.fabric.impl.networking.RecipeBookAddPacketSplitter; + +@Mixin(ServerRecipeBook.class) +public abstract class ServerRecipeBookMixin { + @Unique + private static final boolean DISABLE_SPLIT = System.getProperty("fabric-networking-api-v1.disableRecipeBookPacketSplitter") != null; + + @WrapOperation(method = {"unlockRecipes", "sendInitRecipesPacket"}, at = @At(value = "INVOKE", target = "Lnet/minecraft/server/network/ServerPlayNetworkHandler;sendPacket(Lnet/minecraft/network/packet/Packet;)V")) + private void splitRecipeBookAddPackets(ServerPlayNetworkHandler instance, Packet packet, Operation original) { + if (!DISABLE_SPLIT && packet instanceof RecipeBookAddS2CPacket recipeBookAddPacket) { + RecipeBookAddPacketSplitter.split( + recipeBookAddPacket, + instance, + newPacket -> original.call(instance, newPacket) + ); + + return; + } + + original.call(instance, packet); + } +} diff --git a/fabric-networking-api-v1/src/main/resources/fabric-networking-api-v1.mixins.json b/fabric-networking-api-v1/src/main/resources/fabric-networking-api-v1.mixins.json index 5332bd004e..ad24f42983 100644 --- a/fabric-networking-api-v1/src/main/resources/fabric-networking-api-v1.mixins.json +++ b/fabric-networking-api-v1/src/main/resources/fabric-networking-api-v1.mixins.json @@ -19,6 +19,7 @@ "ServerConfigurationNetworkHandlerMixin", "ServerLoginNetworkHandlerMixin", "ServerPlayNetworkHandlerMixin", + "ServerRecipeBookMixin", "accessor.EntityTrackerAccessor", "accessor.ServerCommonNetworkHandlerAccessor", "accessor.ServerLoginNetworkHandlerAccessor", diff --git a/fabric-networking-api-v1/src/test/java/net/fabricmc/fabric/test/networking/unit/RecipeBookAddPacketSplitterTests.java b/fabric-networking-api-v1/src/test/java/net/fabricmc/fabric/test/networking/unit/RecipeBookAddPacketSplitterTests.java new file mode 100644 index 0000000000..3179b0c4d6 --- /dev/null +++ b/fabric-networking-api-v1/src/test/java/net/fabricmc/fabric/test/networking/unit/RecipeBookAddPacketSplitterTests.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2016, 2017, 2018, 2019 FabricMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.fabricmc.fabric.test.networking.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import net.minecraft.Bootstrap; +import net.minecraft.SharedConstants; +import net.minecraft.component.DataComponentTypes; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.nbt.NbtOps; +import net.minecraft.network.packet.s2c.play.RecipeBookAddS2CPacket; +import net.minecraft.recipe.NetworkRecipeId; +import net.minecraft.recipe.RecipeDisplayEntry; +import net.minecraft.recipe.book.RecipeBookCategories; +import net.minecraft.recipe.display.FurnaceRecipeDisplay; +import net.minecraft.recipe.display.SlotDisplay; +import net.minecraft.registry.DynamicRegistryManager; +import net.minecraft.registry.Registry; +import net.minecraft.registry.RegistryOps; +import net.minecraft.server.network.ServerPlayNetworkHandler; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.collection.IndexedIterable; + +import net.fabricmc.fabric.impl.networking.RecipeBookAddPacketSplitter; + +public class RecipeBookAddPacketSplitterTests { + private static final Text LONG_TEXT = Text.of("x".repeat(10000)); + + @BeforeAll + static void beforeAll() { + SharedConstants.createGameVersion(); + Bootstrap.initialize(); + } + + @Test + void sendEmpty() { + RecipeBookAddS2CPacket packet = new RecipeBookAddS2CPacket(List.of(), true); + List splitPackets = getSplitPackets(packet); + + assertEquals(1, splitPackets.size()); + assertTrue(splitPackets.getFirst().replace()); + assertEquals(0, splitPackets.getFirst().entries().size()); + } + + @Test + void sendSingleEntry() { + RecipeBookAddS2CPacket packet = new RecipeBookAddS2CPacket(entries(1), true); + List splitPackets = getSplitPackets(packet); + + assertEquals(1, splitPackets.size()); + assertTrue(splitPackets.getFirst().replace()); + assertEquals(1, splitPackets.getFirst().entries().size()); + } + + @Test + void sendFewEntries() { + RecipeBookAddS2CPacket packet = new RecipeBookAddS2CPacket(entries(5), true); + List splitPackets = getSplitPackets(packet); + + assertEquals(1, splitPackets.size()); + assertTrue(splitPackets.getFirst().replace()); + assertEquals(5, splitPackets.getFirst().entries().size()); + } + + @Test + void sendManyEntriesReplace() { + RecipeBookAddS2CPacket packet = new RecipeBookAddS2CPacket(entries(1000), true); + List splitPackets = getSplitPackets(packet); + + assertEquals(5, splitPackets.size()); + assertTrue(splitPackets.getFirst().replace()); + assertFalse(splitPackets.get(1).replace()); + assertEquals(1000, splitPackets.stream().mapToLong(p -> p.entries().size()).sum()); + } + + @Test + void sendManyEntriesDontReplace() { + RecipeBookAddS2CPacket packet = new RecipeBookAddS2CPacket(entries(1000), false); + List splitPackets = getSplitPackets(packet); + + assertEquals(5, splitPackets.size()); + assertFalse(splitPackets.getFirst().replace()); + assertFalse(splitPackets.getLast().replace()); + assertEquals(1000, splitPackets.stream().mapToLong(p -> p.entries().size()).sum()); + } + + private static List getSplitPackets(RecipeBookAddS2CPacket packet) { + ServerPlayNetworkHandler networkHandler = getMockNetworkHandler(); + + List sentPackets = new ArrayList<>(); + RecipeBookAddPacketSplitter.split(packet, networkHandler, sentPackets::add); + return sentPackets; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static ServerPlayNetworkHandler getMockNetworkHandler() { + IndexedIterable indexedEntries = mock(IndexedIterable.class); + when(indexedEntries.getRawIdOrThrow(any())).thenReturn(0); + + Registry registry = mock(Registry.class); + when(registry.getIndexedEntries()).thenReturn(indexedEntries); + + DynamicRegistryManager drm = mock(DynamicRegistryManager.class); + when(drm.getOps(any())).thenReturn((RegistryOps) (Object) RegistryOps.of(NbtOps.INSTANCE, drm)); + when(drm.getOrThrow(any())).thenReturn((Registry) registry); + + ServerPlayerEntity player = mock(ServerPlayerEntity.class); + when(player.getRegistryManager()).thenReturn(drm); + + ServerPlayNetworkHandler handler = mock(ServerPlayNetworkHandler.class); + when(handler.getPlayer()).thenReturn(player); + + return handler; + } + + private static List entries(int size) { + List entries = new ArrayList<>(); + + for (int i = 0; i < size; i++) { + entries.add(entry(i)); + } + + return entries; + } + + private static RecipeBookAddS2CPacket.Entry entry(int id) { + ItemStack largeStack = new ItemStack(Items.DIAMOND); + largeStack.set(DataComponentTypes.CUSTOM_NAME, LONG_TEXT); + SlotDisplay.StackSlotDisplay slotDisplay = new SlotDisplay.StackSlotDisplay(largeStack); + + final var display = new FurnaceRecipeDisplay( + slotDisplay, + slotDisplay, + slotDisplay, + slotDisplay, + 100, + 5f + ); + return new RecipeBookAddS2CPacket.Entry( + new RecipeDisplayEntry( + new NetworkRecipeId(id), + display, + OptionalInt.empty(), + RecipeBookCategories.FURNACE_MISC, + Optional.empty() + ), + true, + true + ); + } +}