diff --git a/prebuilt/legacylink-0.1.1.jar b/prebuilt/legacylink-0.1.1.jar index eef0fb2..c82e5ed 100644 Binary files a/prebuilt/legacylink-0.1.1.jar and b/prebuilt/legacylink-0.1.1.jar differ diff --git a/src/main/java/dev/ohno/legacylink/LegacyLinkConstants.java b/src/main/java/dev/ohno/legacylink/LegacyLinkConstants.java index 2cbf547..01b1eeb 100644 --- a/src/main/java/dev/ohno/legacylink/LegacyLinkConstants.java +++ b/src/main/java/dev/ohno/legacylink/LegacyLinkConstants.java @@ -88,6 +88,16 @@ public final class LegacyLinkConstants { "minecraft:air_drag_modifier", "minecraft:friction_modifier" ); + /** + * 26.2-only particles not present on 26.1 clients. + * These must be filtered from registry sync and remapped in outbound particle packets. + */ + public static final Set LEGACY_UNSUPPORTED_PARTICLE_IDS = Set.of( + "minecraft:noxious_gas", + "minecraft:noxious_gas_cloud", + "minecraft:sulfur_bubbles", + "minecraft:sulfur_cube_goo" + ); /** Built-in items omitted from the 26.1 wire-id sequence (26.2-only); keep {@link #SULFUR_ITEM_IDS} authoritative. */ public static boolean is26_2OnlyItemId(String registryId) { diff --git a/src/main/java/dev/ohno/legacylink/handler/LegacyPacketHandler.java b/src/main/java/dev/ohno/legacylink/handler/LegacyPacketHandler.java index f18062b..82b9726 100644 --- a/src/main/java/dev/ohno/legacylink/handler/LegacyPacketHandler.java +++ b/src/main/java/dev/ohno/legacylink/handler/LegacyPacketHandler.java @@ -31,6 +31,12 @@ import it.unimi.dsi.fastutil.ints.IntArrayList; import it.unimi.dsi.fastutil.ints.IntList; import net.minecraft.core.Holder; +import net.minecraft.core.particles.BlockParticleOption; +import net.minecraft.core.particles.ItemParticleOption; +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.core.particles.ParticleType; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.core.particles.SimpleParticleType; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.Registries; import net.minecraft.network.Connection; @@ -46,6 +52,7 @@ import net.minecraft.network.protocol.game.ClientboundBlockUpdatePacket; import net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket; import net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket; +import net.minecraft.network.protocol.game.ClientboundLevelParticlesPacket; import net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket; import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket; import net.minecraft.network.protocol.game.ClientboundSectionBlocksUpdatePacket; @@ -76,6 +83,8 @@ import net.minecraft.world.item.crafting.StonecutterRecipe; import net.minecraft.world.item.crafting.Ingredient; import net.minecraft.world.item.crafting.display.SlotDisplay; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; import java.util.ArrayList; import java.util.HashMap; @@ -95,6 +104,12 @@ public class LegacyPacketHandler extends ChannelDuplexHandler { private static final String HANDLER_NAME = "legacylink"; private static final EntityType LEGACY_SLIME_TYPE = resolveEntityType("minecraft:slime"); + private static final Map LEGACY_PARTICLE_FALLBACKS = Map.of( + "minecraft:noxious_gas", ParticleTypes.SMOKE, + "minecraft:noxious_gas_cloud", ParticleTypes.CLOUD, + "minecraft:sulfur_bubbles", ParticleTypes.BUBBLE, + "minecraft:sulfur_cube_goo", ParticleTypes.ITEM_SLIME + ); private static final Constructor UPDATE_ATTRIBUTES_REBUILD_CTOR; private static final Field TAGS_NETWORK_PAYLOAD_TAGS_FIELD; @@ -103,6 +118,7 @@ public class LegacyPacketHandler extends ChannelDuplexHandler { private static final Constructor RECIPE_PROP_SET_CTOR; static { + validateParticleFallbackCoverage(); try { Constructor ctor = ClientboundUpdateAttributesPacket.class.getDeclaredConstructor(int.class, List.class); @@ -126,6 +142,23 @@ public class LegacyPacketHandler extends ChannelDuplexHandler { throw new ExceptionInInitializerError(e); } } + + private static void validateParticleFallbackCoverage() { + Set unsupported = LegacyLinkConstants.LEGACY_UNSUPPORTED_PARTICLE_IDS; + Set fallbackKeys = LEGACY_PARTICLE_FALLBACKS.keySet(); + + Set missing = new HashSet<>(unsupported); + missing.removeAll(fallbackKeys); + + Set unexpected = new HashSet<>(fallbackKeys); + unexpected.removeAll(unsupported); + + if (!missing.isEmpty() || !unexpected.isEmpty()) { + throw new IllegalStateException( + "[LegacyLink] Particle fallback coverage mismatch: missing=" + missing + ", unexpected=" + unexpected + ); + } + } private final Set remappedLegacyEntityIds = new HashSet<>(); /** * {@link ClientboundSetEntityDataPacket} is often handled on the Netty thread, where {@link net.minecraft.server.level.ServerLevel#getEntity(int)} @@ -370,6 +403,9 @@ private Object routePlayPacket(Object msg) { if (msg instanceof ClientboundUpdateTagsPacket tagsPacket) { return remapUpdateTags(tagsPacket); } + if (msg instanceof ClientboundLevelParticlesPacket levelParticles) { + return remapLevelParticles(levelParticles); + } return msg; } @@ -532,6 +568,7 @@ private ClientboundRegistryDataPacket filterRegistryData(ClientboundRegistryData boolean changed = false; boolean attributeRegistry = Registries.ATTRIBUTE.equals(registryKey); boolean entityTypeRegistry = Registries.ENTITY_TYPE.equals(registryKey); + boolean particleRegistry = Registries.PARTICLE_TYPE.equals(registryKey); int legacyAttributeStrips = 0; List removedEntryIds = new ArrayList<>(); @@ -542,6 +579,8 @@ private ClientboundRegistryDataPacket filterRegistryData(ClientboundRegistryData boolean strip26_2OnlyAttribute = attributeRegistry && LegacyLinkConstants.LEGACY_UNSUPPORTED_ATTRIBUTE_IDS.contains(entryId); + boolean strip26_2OnlyParticle = + particleRegistry && LegacyLinkConstants.LEGACY_UNSUPPORTED_PARTICLE_IDS.contains(entryId); /* * {@code minecraft:sulfur_cube} is 26.2-only; drop it from entity_type sync so 26.1 never decodes it. * That renumbers following vanilla types on the wire — {@link dev.ohno.legacylink.mixin.ClientboundAddEntityPacketMixin} @@ -551,6 +590,7 @@ private ClientboundRegistryDataPacket filterRegistryData(ClientboundRegistryData boolean stripSulfurCubeEntityType = entityTypeRegistry && LegacyLinkConstants.SULFUR_CUBE_ENTITY_ID.contentEquals(entryId); boolean stripSulfurOrModEntry = !entityTypeRegistry + && !particleRegistry && (LegacyLinkConstants.SULFUR_BLOCK_IDS.contains(entryId) || LegacyLinkConstants.SULFUR_ITEM_IDS.contains(entryId) || entryId.equals(LegacyLinkConstants.SULFUR_CAVES_BIOME_ID) @@ -558,6 +598,7 @@ private ClientboundRegistryDataPacket filterRegistryData(ClientboundRegistryData || entryId.contains("sulfur")); if (stripSulfurCubeEntityType || stripSulfurOrModEntry + || strip26_2OnlyParticle || strip26_2OnlyAttribute) { iterator.remove(); changed = true; @@ -596,6 +637,77 @@ private ClientboundRegistryDataPacket filterRegistryData(ClientboundRegistryData return packet; } + private ClientboundLevelParticlesPacket remapLevelParticles(ClientboundLevelParticlesPacket packet) { + ParticleOptions source = packet.getParticle(); + ParticleOptions remappedParticle = remapParticleOptionsForLegacy(source); + Identifier particleId = BuiltInRegistries.PARTICLE_TYPE.getKey(remappedParticle.getType()); + if (particleId == null) { + return remappedParticle == source ? packet : copyParticlePacket(packet, remappedParticle); + } + String key = particleId.toString(); + if (!LegacyLinkConstants.LEGACY_UNSUPPORTED_PARTICLE_IDS.contains(key)) { + return remappedParticle == source ? packet : copyParticlePacket(packet, remappedParticle); + } + SimpleParticleType fallback = LEGACY_PARTICLE_FALLBACKS.get(key); + if (fallback == null) { + throw new IllegalStateException("[LegacyLink] Missing particle fallback for unsupported id: " + key); + } + LegacyLinkMod.LOGGER.debug( + "[LegacyLink] Remapped unsupported 26.2 particle {} -> {} for 26.1 client", + key, + BuiltInRegistries.PARTICLE_TYPE.getKey(fallback) + ); + return copyParticlePacket(packet, fallback); + } + + private ParticleOptions remapParticleOptionsForLegacy(ParticleOptions source) { + if (source instanceof BlockParticleOption blockOpt) { + try { + int currentStateId = Block.BLOCK_STATE_REGISTRY.getId(blockOpt.getState()); + int remappedStateId = RegistryRemapper.remapBlockState(currentStateId); + if (remappedStateId != currentStateId) { + BlockState remappedState = Block.BLOCK_STATE_REGISTRY.byId(remappedStateId); + if (remappedState != null) { + @SuppressWarnings("unchecked") + ParticleType typed = (ParticleType) blockOpt.getType(); + return new BlockParticleOption(typed, remappedState); + } + } + } catch (RuntimeException e) { + LegacyLinkMod.LOGGER.warn( + "[LegacyLink] Failed to remap block particle option; using original payload for legacy compatibility safety", + e + ); + return source; + } + } + if (source instanceof ItemParticleOption itemOpt) { + ItemStackTemplate remapped = ItemRewriter.remapTemplate(itemOpt.getItem()); + if (remapped != itemOpt.getItem()) { + @SuppressWarnings("unchecked") + ParticleType typed = (ParticleType) itemOpt.getType(); + return new ItemParticleOption(typed, remapped); + } + } + return source; + } + + private ClientboundLevelParticlesPacket copyParticlePacket(ClientboundLevelParticlesPacket packet, ParticleOptions particle) { + return new ClientboundLevelParticlesPacket( + particle, + packet.isOverrideLimiter(), + packet.alwaysShow(), + packet.getX(), + packet.getY(), + packet.getZ(), + packet.getXDist(), + packet.getYDist(), + packet.getZDist(), + packet.getMaxSpeed(), + packet.getCount() + ); + } + public ClientboundSetEntityDataPacket remapEntityData(ClientboundSetEntityDataPacket packet) { int entityId = packet.id(); List> packedItems = packet.packedItems();