package com.replaymod.replay; import com.github.steveice10.packetlib.io.NetOutput; import com.github.steveice10.packetlib.tcp.io.ByteBufNetOutput; import com.google.common.base.Preconditions; import com.google.common.io.Files; import com.replaymod.core.ReplayMod; import com.replaymod.core.mixin.MinecraftAccessor; import com.replaymod.core.mixin.TimerAccessor; import com.replaymod.core.utils.Restrictions; import com.replaymod.replay.camera.CameraEntity; import com.replaymod.replaystudio.io.ReplayInputStream; import com.replaymod.replaystudio.lib.viaversion.api.protocol.packet.State; import com.replaymod.replaystudio.protocol.PacketType; import com.replaymod.replaystudio.protocol.PacketTypeRegistry; import com.replaymod.replaystudio.replay.ReplayFile; import de.johni0702.minecraft.gui.utils.EventRegistrations; import de.johni0702.minecraft.gui.versions.callbacks.PreTickCallback; import io.netty.buffer.Unpooled; import io.netty.channel.Channel; import io.netty.channel.ChannelHandler.Sharable; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import net.minecraft.client.MinecraftClient; import net.minecraft.client.network.OtherClientPlayerEntity; import net.minecraft.client.gui.screen.DownloadingTerrainScreen; import net.minecraft.client.gui.screen.NoticeScreen; import net.minecraft.client.world.ClientWorld; import net.minecraft.entity.Entity; import net.minecraft.entity.player.PlayerEntity; import net.minecraft.network.Packet; import net.minecraft.network.packet.s2c.play.GameMessageS2CPacket; import net.minecraft.network.packet.s2c.play.CustomPayloadS2CPacket; import net.minecraft.network.packet.s2c.play.DisconnectS2CPacket; import net.minecraft.network.packet.s2c.play.EntitiesDestroyS2CPacket; import net.minecraft.network.packet.s2c.play.EntitySpawnS2CPacket; import net.minecraft.network.packet.s2c.play.ExperienceBarUpdateS2CPacket; import net.minecraft.network.packet.s2c.play.ExperienceOrbSpawnS2CPacket; import net.minecraft.network.packet.s2c.play.GameJoinS2CPacket; import net.minecraft.network.packet.s2c.play.GameStateChangeS2CPacket; import net.minecraft.network.packet.s2c.play.CloseScreenS2CPacket; import net.minecraft.network.packet.s2c.play.OpenHorseScreenS2CPacket; import net.minecraft.network.packet.s2c.play.ScreenHandlerSlotUpdateS2CPacket; import net.minecraft.network.packet.s2c.play.ScreenHandlerPropertyUpdateS2CPacket; import net.minecraft.network.packet.s2c.play.HealthUpdateS2CPacket; import net.minecraft.network.packet.s2c.login.LoginSuccessS2CPacket; import net.minecraft.network.packet.s2c.play.ParticleS2CPacket; import net.minecraft.network.packet.s2c.play.PlayerAbilitiesS2CPacket; import net.minecraft.network.packet.s2c.play.PlayerPositionLookS2CPacket; import net.minecraft.network.packet.s2c.play.PlayerRespawnS2CPacket; import net.minecraft.network.packet.s2c.play.SignEditorOpenS2CPacket; import net.minecraft.network.packet.s2c.play.StatisticsS2CPacket; import net.minecraft.text.Text; import net.minecraft.util.math.MathHelper; import net.minecraft.util.math.Vec3d; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; //#if MC>=12002 //$$ import net.minecraft.network.packet.s2c.config.ReadyS2CPacket; //$$ import net.minecraft.network.packet.s2c.play.CommonPlayerSpawnInfo; //$$ import net.minecraft.network.packet.s2c.play.EnterReconfigurationS2CPacket; //#else import net.minecraft.network.packet.s2c.play.PlayerSpawnS2CPacket; //#endif //#if MC>=11904 //$$ import net.minecraft.network.packet.s2c.play.PositionFlag; //#endif //#if MC>=11903 //$$ import net.minecraft.network.packet.s2c.play.ProfilelessChatMessageS2CPacket; //#endif //#if MC==11901 || MC==11902 //$$ import net.minecraft.network.packet.s2c.play.MessageHeaderS2CPacket; //#endif //#if MC>=11900 //$$ import net.minecraft.network.packet.s2c.play.ChatMessageS2CPacket; //#else import net.minecraft.network.packet.s2c.play.MobSpawnS2CPacket; import net.minecraft.network.packet.s2c.play.PaintingSpawnS2CPacket; //#endif //#if MC>=11600 //#else //$$ import net.minecraft.network.packet.s2c.play.EntitySpawnGlobalS2CPacket; //#endif //#if MC>=11400 import com.replaymod.core.versions.MCVer; import net.minecraft.network.packet.s2c.play.ChunkDataS2CPacket; import net.minecraft.network.packet.s2c.play.PlayerActionResponseS2CPacket; import net.minecraft.network.packet.s2c.play.OpenScreenS2CPacket; import net.minecraft.network.packet.s2c.play.OpenWrittenBookS2CPacket; import net.minecraft.entity.EntityType; import net.minecraft.text.TranslatableText; import net.minecraft.world.chunk.ChunkManager; import net.minecraft.world.chunk.WorldChunk; import net.minecraft.world.chunk.light.LightingProvider; //#else //$$ import net.minecraft.client.resources.I18n; //$$ import net.minecraft.world.EnumDifficulty; //$$ import net.minecraft.world.World; //$$ import net.minecraft.world.WorldType; //$$ import net.minecraft.world.chunk.Chunk; //$$ import net.minecraft.world.chunk.IChunkProvider; //$$ import java.util.Iterator; //#endif //#if MC>=11400 import net.minecraft.util.Identifier; //#if MC<11400 //$$ import net.minecraft.world.dimension.DimensionType; //#endif //#endif //#if MC>=11200 import net.minecraft.network.packet.s2c.play.AdvancementUpdateS2CPacket; import net.minecraft.network.packet.s2c.play.SelectAdvancementTabS2CPacket; import net.minecraft.network.packet.s2c.play.SynchronizeRecipesS2CPacket; //#endif //#if MC>=11002 import net.minecraft.world.GameMode; //#else //$$ import net.minecraft.world.WorldSettings.GameType; //#endif //#if MC>=10904 import net.minecraft.network.packet.s2c.play.UnloadChunkS2CPacket; //#else //$$ import net.minecraft.network.play.server.S21PacketChunkData; //#endif //#if MC>=10800 import net.minecraft.network.packet.s2c.play.ResourcePackSendS2CPacket; import net.minecraft.network.packet.s2c.play.SetCameraEntityS2CPacket; import net.minecraft.network.packet.s2c.play.TitleS2CPacket; //#else //$$ import org.apache.commons.io.Charsets; //#endif import java.io.*; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import static com.replaymod.core.utils.Utils.DEFAULT_MS_PER_TICK; import static com.replaymod.core.versions.MCVer.*; import static com.replaymod.replaystudio.util.Utils.readInt; /** * Sends replay packets to netty channels. * Even though {@link Sharable}, this should never be added to multiple pipes at once, it may however be re-added when * the replay restart from the beginning. */ @Sharable public class FullReplaySender extends ChannelInboundHandlerAdapter implements ReplaySender { /** * These packets are ignored completely during replay. */ private static final List BAD_PACKETS = Arrays.asList( //#if MC>=11404 PlayerActionResponseS2CPacket.class, //#endif //#if MC>=11400 OpenWrittenBookS2CPacket.class, OpenScreenS2CPacket.class, //#endif //#if MC>=11200 SynchronizeRecipesS2CPacket.class, AdvancementUpdateS2CPacket.class, SelectAdvancementTabS2CPacket.class, //#endif //#if MC>=10800 SetCameraEntityS2CPacket.class, TitleS2CPacket.class, //#endif HealthUpdateS2CPacket.class, OpenHorseScreenS2CPacket.class, CloseScreenS2CPacket.class, ScreenHandlerSlotUpdateS2CPacket.class, ScreenHandlerPropertyUpdateS2CPacket.class, SignEditorOpenS2CPacket.class, StatisticsS2CPacket.class, ExperienceBarUpdateS2CPacket.class, PlayerAbilitiesS2CPacket.class ); private static int TP_DISTANCE_LIMIT = 128; /** * The replay handler responsible for the current replay. */ private final ReplayHandler replayHandler; /** * Whether to work in async mode. * * When in async mode, a separate thread send packets and waits according to their delays. * This is default in normal playback mode. * * When in sync mode, no packets will be sent until {@link #sendPacketsTill(int)} is called. * This is used during path playback and video rendering. */ protected boolean asyncMode; /** * Timestamp of the last packet sent in milliseconds since the start. */ protected int lastTimeStamp; /** * @see #currentTimeStamp() */ protected int currentTimeStamp; /** * The replay file. */ protected ReplayFile replayFile; /** * The channel used to send packets to minecraft. */ protected Channel channel; /** * The replay input stream from which new packets are read. * When accessing this stream make sure to synchronize on {@code this} as it's used from multiple threads. */ protected ReplayInputStream replayIn; /** * The next packet that should be sent. * This is required as some actions such as jumping to a specified timestamp have to peek at the next packet. */ protected PacketData nextPacket; /** * Which protocol (state) we're currently in. */ private PacketTypeRegistry registry = getPacketTypeRegistry(State.LOGIN); /** * Whether we need to restart the current replay. E.g. when jumping backwards in time */ protected boolean startFromBeginning = true; /** * Whether to terminate the replay. This only has an effect on the async mode and is {@code true} during sync mode. */ protected boolean terminate; /** * The speed of the replay. 1 is normal, 2 is twice as fast, 0.5 is half speed and 0 is frozen */ protected double replaySpeed = 1f; /** * Whether the world has been loaded and the dirt-screen should go away. */ protected boolean hasWorldLoaded; /** * Whether we are currently in the middle of a bundle packet. */ protected boolean inBundle; /** * The minecraft instance. */ protected MinecraftClient mc = getMinecraft(); /** * The total length of this replay in milliseconds. */ protected final int replayLength; /** * Our actual entity id that the server gave to us. */ protected int actualID = -1; /** * Whether to allow (process) the next player movement packet. * * Must only be accessed from the main thread. */ protected boolean allowMovement; /** * Directory to which resource packs are extracted. */ private final File tempResourcePackFolder = Files.createTempDir(); private final EventHandler events = new EventHandler(); /** * Create a new replay sender. * @param file The replay file */ public FullReplaySender(ReplayHandler replayHandler, ReplayFile file) throws IOException { this.replayHandler = replayHandler; this.replayFile = file; this.replayLength = file.getMetaData().getDuration(); events.register(); } public void setChannel(Channel channel) { this.channel = channel; } /** * Set whether this replay sender operates in async mode. * When in async mode, it will send packets timed from a separate thread. * When not in async mode, it will send packets when {@link #sendPacketsTill(int)} is called. * @param asyncMode {@code true} to enable async mode */ @Override public void setAsyncMode(boolean asyncMode) { if (this.asyncMode == asyncMode) return; this.asyncMode = asyncMode; if (asyncMode) { this.terminate = false; new Thread(asyncSender, "replaymod-async-sender").start(); } else { this.terminate = true; } } @Override public boolean isAsyncMode() { return asyncMode; } /** * Set whether this replay sender to operate in sync mode. * When in sync mode, it will send packets when {@link #sendPacketsTill(int)} is called. * This call will block until the async worker thread has stopped. */ @Override public void setSyncModeAndWait() { if (!this.asyncMode) return; this.asyncMode = false; this.terminate = true; synchronized (this) { // This will wait for the worker thread to leave the synchronized code part } } /** * Return a fake system tile in milliseconds value that respects slowdown/speedup/pause and works in both, * sync and async mode. * Note: For sync mode this returns the last value passed to {@link #sendPacketsTill(int)}. * @return The timestamp in milliseconds since the start of the replay */ @Override public int currentTimeStamp() { if (asyncMode && !paused()) { return (int) ((System.currentTimeMillis() - realTimeStart) * realTimeStartSpeed); } else { return lastTimeStamp; } } /** * Terminate this replay sender. */ public void terminateReplay() { if (terminate) { return; } terminate = true; syncSender.shutdown(); events.unregister(); try { channel.pipeline().fireChannelInactive(); channel.pipeline().close(); FileUtils.deleteDirectory(tempResourcePackFolder); } catch(Exception e) { e.printStackTrace(); } } private class EventHandler extends EventRegistrations { { on(PreTickCallback.EVENT, this::onWorldTick); } private void onWorldTick() { // Spawning a player into an empty chunk (which we might do with the recording player) // prevents it from being moved by teleport packets (it essentially gets stuck) because // Entity#addedToChunk is not set and it is therefore not updated every tick. // To counteract this, we need to manually update it's position if it hasn't been added // to any chunk yet. // The `updateNeeded` flag appears to have been removed in 1.17, so this should no longer be an issue. //#if MC<11700 if (mc.world != null) { for (PlayerEntity playerEntity : mc.world.getPlayers()) { if (!playerEntity.updateNeeded && playerEntity instanceof OtherClientPlayerEntity) { playerEntity.tickMovement(); } } } //#endif } } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { // When in async mode and the replay sender shut down, then don't send packets if(terminate && asyncMode) { return; } if (msg instanceof Packet) { try { Packet p = (Packet) msg; if (p != null) { p = processPacket(p); if (p != null) { super.channelRead(ctx, p); } maybeRemoveDeadEntities(p); //#if MC>=11400 if (p instanceof ChunkDataS2CPacket) { Runnable doLightUpdates = () -> { ClientWorld world = mc.world; if (world != null) { //#if MC>=11800 //$$ while (!world.hasNoChunkUpdaters()) { //$$ world.runQueuedChunkUpdates(); //$$ } //#endif LightingProvider provider = world.getChunkManager().getLightingProvider(); while (provider.hasUpdates()) { //#if MC>=12000 //$$ provider.doLightUpdates(); //#else provider.doLightUpdates(Integer.MAX_VALUE, true, true); //#endif } } }; if (mc.isOnThread()) { doLightUpdates.run(); } else { mc.send(doLightUpdates); } } //#endif } } catch (Exception e) { // We'd rather not have a failure parsing one packet screw up the whole replay process e.printStackTrace(); } } } // If we do not give minecraft time to tick, there will be dead entity artifacts left in the world // Therefore we have to remove all loaded, dead entities manually if we are in sync mode. // We do this after every SpawnX packet and after the destroy entities packet. private void maybeRemoveDeadEntities(Packet packet) { if (asyncMode) { return; // MC should have enough time to tick } boolean relevantPacket = packet instanceof EntitySpawnS2CPacket //#if MC<12002 || packet instanceof PlayerSpawnS2CPacket //#endif //#if MC<11900 || packet instanceof MobSpawnS2CPacket || packet instanceof PaintingSpawnS2CPacket //#endif //#if MC<11600 //$$ || packet instanceof EntitySpawnGlobalS2CPacket //#endif || packet instanceof ExperienceOrbSpawnS2CPacket || packet instanceof EntitiesDestroyS2CPacket; if (!relevantPacket) { return; // don't want to do it too often, only when there's likely to be a dead entity } mc.send(() -> { ClientWorld world = mc.world; if (world != null) { removeDeadEntities(world); } }); } private void removeDeadEntities(ClientWorld world) { //#if MC>=11700 //$$ // From the looks of it, this has now been resolved (thanks to EntityChangeListener) //#elseif MC>=11400 // Note: Not sure if it's still required but there's this really handy method anyway world.finishRemovingEntities(); //#else //$$ Iterator iter = world.loadedEntityList.iterator(); //$$ while (iter.hasNext()) { //$$ Entity entity = iter.next(); //$$ if (entity.isDead) { //$$ int chunkX = entity.chunkCoordX; //$$ int chunkY = entity.chunkCoordZ; //$$ //#if MC>=11400 //$$ if (entity.addedToChunk && world.getChunkProvider().provideChunk(chunkX, chunkY, false, false) != null) { //#else //#if MC>=10904 //$$ if (entity.addedToChunk && world.getChunkProvider().getLoadedChunk(chunkX, chunkY) != null) { //#else //$$ if (entity.addedToChunk && world.getChunkProvider().chunkExists(chunkX, chunkY)) { //#endif //#endif //$$ world.getChunkFromChunkCoords(chunkX, chunkY).removeEntity(entity); //$$ } //$$ //$$ iter.remove(); //$$ world.onEntityRemoved(entity); //$$ } //$$ //$$ } //#endif } /** * Process a packet and return the result. * @param p The packet to process * @return The processed packet or {@code null} if no packet shall be sent */ protected Packet processPacket(Packet p) throws Exception { if (p instanceof LoginSuccessS2CPacket) { registry = registry.withLoginSuccess(); return p; } //#if MC>=12002 //$$ if (p instanceof ReadyS2CPacket) { //$$ registry = registry.withState(State.PLAY); //$$ return p; //$$ } //$$ if (p instanceof EnterReconfigurationS2CPacket) { //$$ registry = registry.withState(State.CONFIGURATION); //$$ hasWorldLoaded = false; //$$ return p; //$$ } //#endif if (p instanceof CustomPayloadS2CPacket) { CustomPayloadS2CPacket packet = (CustomPayloadS2CPacket) p; if (Restrictions.PLUGIN_CHANNEL.equals(packet.getChannel())) { final String unknown = replayHandler.getRestrictions().handle(packet); if (unknown == null) { return null; } else { // Failed to parse options, make sure that under no circumstances further packets are parsed terminateReplay(); // Then end replay and show error GUI ReplayMod.instance.runLater(() -> { try { replayHandler.endReplay(); } catch (IOException e) { e.printStackTrace(); } mc.openScreen(new NoticeScreen( //#if MC>=11400 () -> mc.openScreen(null), new TranslatableText("replaymod.error.unknownrestriction1"), new TranslatableText("replaymod.error.unknownrestriction2", unknown) //#else //$$ I18n.format("replaymod.error.unknownrestriction1"), //$$ I18n.format("replaymod.error.unknownrestriction2", unknown) //#endif )); }); } } } if (p instanceof DisconnectS2CPacket) { Text reason = ((DisconnectS2CPacket) p).getReason(); String message = reason.getString(); if ("Please update to view this replay.".equals(message)) { // This version of the mod supports replay restrictions so we are allowed // to remove this packet. return null; } } if(BAD_PACKETS.contains(p.getClass())) return null; if (p instanceof CustomPayloadS2CPacket) { CustomPayloadS2CPacket packet = (CustomPayloadS2CPacket) p; //#if MC>=11400 Identifier channelName = packet.getChannel(); //#else //$$ String channelName = packet.getChannelName(); //#endif String channelNameStr = channelName.toString(); if (channelNameStr.startsWith("fabric-screen-handler-api-v")) { return null; // we do not want to show modded screens which got opened for the recording player } // On 1.14+ there's a dedicated OpenWrittenBookS2CPacket now //#if MC<11400 //#if MC>=11400 //$$ if (SPacketCustomPayload.BOOK_OPEN.equals(channelName)) { //#else //$$ if ("MC|BOpen".equals(channelName)) { //#endif //$$ return null; //$$ } //#endif //#if MC>=10800 } if(p instanceof ResourcePackSendS2CPacket) { ResourcePackSendS2CPacket packet = (ResourcePackSendS2CPacket) p; //#if MC>=12003 //$$ String url = packet.url(); //#else String url = packet.getURL(); //#endif if (url.startsWith("replay://")) { //#else //$$ String url; //$$ if ("MC|RPack".equals(channelName) && //$$ (url = new String(packet.func_149168_d(), Charsets.UTF_8)).startsWith("replay://")) { //#endif int id = Integer.parseInt(url.substring("replay://".length())); Map index = replayFile.getResourcePackIndex(); if (index != null) { String hash = index.get(id); if (hash != null) { File file = new File(tempResourcePackFolder, hash + ".zip"); if (!file.exists()) { IOUtils.copy(replayFile.getResourcePack(hash).get(), new FileOutputStream(file)); } //#if MC>=12003 //$$ schedulePacketHandler(() -> mc.getServerResourcePackProvider().addResourcePack(packet.id(), file.toPath())); //#else setServerResourcePack(file); //#endif } } return null; } } if(p instanceof GameJoinS2CPacket) { GameJoinS2CPacket packet = (GameJoinS2CPacket) p; int entId = packet.getEntityId(); schedulePacketHandler(() -> allowMovement = true); actualID = entId; entId = -1789435; // Camera entity id should be negative which is an invalid id and can't be used by servers //#if MC>=11400 p = new GameJoinS2CPacket( entId, //#if MC>=12002 //$$ packet.hardcore(), //$$ packet.dimensionIds(), //$$ packet.maxPlayers(), //$$ packet.viewDistance(), //$$ packet.simulationDistance(), //$$ packet.reducedDebugInfo(), //$$ packet.showDeathScreen(), //$$ packet.doLimitedCrafting(), //$$ withSpectatorMode(packet.commonPlayerSpawnInfo()) //#if MC>=12006 //$$ , packet.enforcesSecureChat() //#endif //#else //#if MC>=11800 //$$ packet.hardcore(), //#endif GameMode.SPECTATOR, //#if MC>=11600 GameMode.SPECTATOR, //#endif //#if MC<11800 //#if MC>=11500 packet.getSha256Seed(), //#endif false, //#endif //#if MC>=11600 //#if MC>=11603 packet.getDimensionIds(), //#if MC>=11800 //$$ packet.registryManager(), //#else (net.minecraft.util.registry.DynamicRegistryManager.Impl) packet.getRegistryManager(), //#endif packet.getDimensionType(), //#else //$$ packet.method_29443(), //$$ (net.minecraft.util.registry.RegistryTracker.Modifiable) packet.getDimension(), //$$ packet.method_29444(), //#endif packet.getDimensionId(), //#else //$$ packet.getDimension(), //#endif //#if MC>=11800 //$$ packet.sha256Seed(), //#endif 0, // max players (has no getter -> never actually used) //#if MC<11600 //$$ packet.getGeneratorType(), //#endif packet.getViewDistance(), //#if MC>=11800 //$$ packet.simulationDistance(), //#endif packet.hasReducedDebugInfo() //#if MC>=11500 , packet.showsDeathScreen() //#endif //#if MC>=11600 , packet.isDebugWorld() , packet.isFlatWorld() //#endif //#if MC>=11900 //$$ , java.util.Optional.empty() //#endif //#if MC>=12000 //$$ , packet.portalCooldown() //#endif //#endif ); //#else //#if MC>=10800 //#if MC>=11400 //$$ DimensionType dimension = packet.func_212642_e(); //#else //$$ int dimension = packet.getDimension(); //#endif //$$ EnumDifficulty difficulty = packet.getDifficulty(); //#if MC>=11400 //$$ int maxPlayers = 0; // literally never used by vanilla (i.e. no accessor) //#else //$$ int maxPlayers = packet.getMaxPlayers(); //#endif //$$ WorldType worldType = packet.getWorldType(); //$$ //#if MC>=10904 //$$ p = new SPacketJoinGame(entId, GameType.SPECTATOR, false, dimension, //$$ difficulty, maxPlayers, worldType, false); //#else //$$ p = new S01PacketJoinGame(entId, GameType.SPECTATOR, false, dimension, //$$ difficulty, maxPlayers, worldType, false); //#endif //#else //$$ int dimension = packet.func_149194_f(); //$$ EnumDifficulty difficulty = packet.func_149192_g(); //$$ int maxPlayers = packet.func_149193_h(); //$$ WorldType worldType = packet.func_149196_i(); //$$ //$$ p = new S01PacketJoinGame(entId, GameType.ADVENTURE, false, dimension, //$$ difficulty, maxPlayers, worldType); //#endif //#endif } if(p instanceof PlayerRespawnS2CPacket) { PlayerRespawnS2CPacket respawn = (PlayerRespawnS2CPacket) p; //#if MC>=11400 p = new PlayerRespawnS2CPacket( //#if MC>=12002 //$$ withSpectatorMode(respawn.commonPlayerSpawnInfo()), //$$ (byte) 0 //#else //#if MC>=11600 respawn.method_29445(), //#endif respawn.getDimension(), //#if MC>=11500 respawn.getSha256Seed(), //#endif //#if MC>=11600 GameMode.SPECTATOR, GameMode.SPECTATOR, respawn.isDebugWorld(), respawn.isFlatWorld(), //#if MC>=11903 //$$ (byte) 0 //#else false //#endif //#else //$$ respawn.getGeneratorType(), //$$ GameMode.SPECTATOR //#endif //#if MC>=11900 //$$ , java.util.Optional.empty() //#endif //#if MC>=12000 //$$ , respawn.getPortalCooldown() //#endif //#endif ); //#else //#if MC>=10809 //$$ p = new SPacketRespawn(respawn.getDimensionID(), //$$ respawn.getDifficulty(), respawn.getWorldType(), GameType.SPECTATOR); //#else //$$ p = new S07PacketRespawn(respawn.func_149082_c(), //$$ respawn.func_149081_d(), respawn.func_149080_f(), //#if MC>=10800 //$$ GameType.SPECTATOR); //#else //$$ GameType.ADVENTURE); //#endif //#endif //#endif schedulePacketHandler(() -> allowMovement = true); } if(p instanceof PlayerPositionLookS2CPacket) { final PlayerPositionLookS2CPacket ppl = (PlayerPositionLookS2CPacket) p; if(!hasWorldLoaded) hasWorldLoaded = true; ReplayMod.instance.runLater(() -> { if (mc.currentScreen instanceof DownloadingTerrainScreen) { // Close the world loading screen manually in case we swallow the packet mc.openScreen(null); } }); if(replayHandler.shouldSuppressCameraMovements()) return null; //#if MC>=10800 //#if MC>=11904 //$$ for (PositionFlag relative : ppl.getFlags()) { //$$ if (relative == PositionFlag.X || relative == PositionFlag.Y || relative == PositionFlag.Z) { //#elseif MC>=11400 for (PlayerPositionLookS2CPacket.Flag relative : ppl.getFlags()) { if (relative == PlayerPositionLookS2CPacket.Flag.X || relative == PlayerPositionLookS2CPacket.Flag.Y || relative == PlayerPositionLookS2CPacket.Flag.Z) { //#else //#if MC>=10904 //$$ for (SPacketPlayerPosLook.EnumFlags relative : ppl.getFlags()) { //#else //$$ for (Object relative : ppl.func_179834_f()) { //#endif //$$ if (relative == SPacketPlayerPosLook.EnumFlags.X //$$ || relative == SPacketPlayerPosLook.EnumFlags.Y //$$ || relative == SPacketPlayerPosLook.EnumFlags.Z) { //#endif return null; // At least one of the coordinates is relative, so we don't care } } //#endif schedulePacketHandler(new Runnable() { @Override @SuppressWarnings("unchecked") public void run() { // FIXME: world shouldn't ever be null at this point, now that we use the packet queue // probably fine to remove on the next non-patch version (don't want to break stuff now) if (mc.world == null || !mc.isOnThread()) { ReplayMod.instance.runLater(this); return; } CameraEntity cent = replayHandler.getCameraEntity(); if (!allowMovement && !((Math.abs(cent.getX() - ppl.getX()) > TP_DISTANCE_LIMIT) || (Math.abs(cent.getZ() - ppl.getZ()) > TP_DISTANCE_LIMIT))) { return; } else { allowMovement = false; } cent.setCameraPosition(ppl.getX(), ppl.getY(), ppl.getZ()); cent.setCameraRotation(ppl.getYaw(), ppl.getPitch(), cent.roll); } }); return null; } if(p instanceof GameStateChangeS2CPacket) { GameStateChangeS2CPacket pg = (GameStateChangeS2CPacket)p; // only allow the following packets: // 1 - End raining // 2 - Begin raining // // The following values are to control sky color (e.g. if thunderstorm) // 7 - Fade value // 8 - Fade time if (!Arrays.asList( //#if MC>=11600 GameStateChangeS2CPacket.RAIN_STARTED, GameStateChangeS2CPacket.RAIN_STOPPED, GameStateChangeS2CPacket.RAIN_GRADIENT_CHANGED, GameStateChangeS2CPacket.THUNDER_GRADIENT_CHANGED //#else //$$ 1, //$$ 2, //$$ 7, //$$ 8 //#endif ).contains(pg.getReason())) { return null; } } //#if MC>=11903 //$$ if (p instanceof GameMessageS2CPacket || p instanceof ChatMessageS2CPacket || p instanceof ProfilelessChatMessageS2CPacket) { //#elseif MC==11901 || MC==11902 //$$ if (p instanceof GameMessageS2CPacket || p instanceof ChatMessageS2CPacket || p instanceof MessageHeaderS2CPacket) { //#elseif MC>=11900 //$$ if (p instanceof GameMessageS2CPacket || p instanceof ChatMessageS2CPacket) { //#else if (p instanceof GameMessageS2CPacket) { //#endif if (!ReplayModReplay.instance.getCore().getSettingsRegistry().get(Setting.SHOW_CHAT)) { return null; } } if (asyncMode) { return processPacketAsync(p); } else { Packet fp = p; mc.send(() -> processPacketSync(fp)); return p; } } //#if MC>=12002 //$$ private CommonPlayerSpawnInfo withSpectatorMode(CommonPlayerSpawnInfo org) { //$$ return new CommonPlayerSpawnInfo( //$$ org.dimensionType(), //$$ org.dimension(), //$$ org.seed(), //$$ GameMode.SPECTATOR, //$$ GameMode.SPECTATOR, //$$ org.isDebug(), //$$ org.isFlat(), //$$ org.lastDeathLocation(), //$$ org.portalCooldown() //$$ ); //$$ } //#endif /** * Returns the speed of the replay. 1 being normal speed, 0.5 half and 2 twice as fast. * If 0 is returned, the replay is paused. * @return speed multiplier */ @Override public double getReplaySpeed() { if(!paused()) return replaySpeed; else return 0; } /** * Set the speed of the replay. 1 being normal speed, 0.5 half and 2 twice as fast. * The speed may not be set to 0 nor to negative values. * @param d Speed multiplier */ @Override public void setReplaySpeed(final double d) { if (d != 0) { this.replaySpeed = d; this.realTimeStartSpeed = d; this.realTimeStart = System.currentTimeMillis() - (long) (lastTimeStamp / d); } TimerAccessor timer = (TimerAccessor) ((MinecraftAccessor) mc).getTimer(); //#if MC>=11200 timer.setTickLength(DEFAULT_MS_PER_TICK / (float) d); //#else //$$ timer.setTimerSpeed((float) d); //#endif } ///////////////////////////////////////////////////////// // Asynchronous packet processing // ///////////////////////////////////////////////////////// /** * Timestamp in milliseconds of when we started (or would have started when taking pauses and speed into account) * the playback of the replay. * Updated only when replay speed changes or on pause/unpause but definitely not on every packet to prevent gradual * drifting. */ private long realTimeStart; /** * The replay speed used for {@link #realTimeStart}. * If the target speed differs from this one, the timestamp is recalculated. */ private double realTimeStartSpeed; /** * There is no waiting performed until a packet with at least this timestamp is reached (but not yet sent). * If this is -1, then timing is normal. */ private long desiredTimeStamp = -1; /** * Runnable which performs timed dispatching of packets from the input stream. */ private Runnable asyncSender = new Runnable() { public void run() { try { REPLAY_LOOP: while (!terminate) { synchronized (FullReplaySender.this) { if (replayIn == null) { replayIn = replayFile.getPacketData(getPacketTypeRegistry(State.LOGIN)); } // Packet loop while (true) { try { // When playback is paused and the world has loaded (we don't want any dirt-screens) we sleep while (paused() && hasWorldLoaded && !inBundle) { // Unless we are going to terminate, restart or jump if (terminate || startFromBeginning || desiredTimeStamp != -1) { break; } Thread.sleep(10); } if (terminate && !inBundle) { break REPLAY_LOOP; } if (startFromBeginning) { // In case we need to restart from the beginning // break out of the loop sending all packets which will // cause the replay to be restarted by the outer loop break; } // Read the next packet if we don't already have one if (nextPacket == null) { nextPacket = new PacketData(replayIn); } int nextTimeStamp = nextPacket.timestamp; // If we aren't jumping and the world has already been loaded (no dirt-screens) then wait // the required amount to get proper packet timing if (!isHurrying() && hasWorldLoaded && !inBundle) { // Timestamp of when the next packet should be sent long expectedTime = realTimeStart + (long) (nextTimeStamp / replaySpeed); long now = System.currentTimeMillis(); // If the packet should not yet be sent, wait a bit if (expectedTime > now) { Thread.sleep(expectedTime - now); } } // Process packet if (nextPacket.type == PacketType.Bundle) inBundle = !inBundle; channel.pipeline().fireChannelRead(Unpooled.wrappedBuffer(nextPacket.bytes)); nextPacket = null; lastTimeStamp = nextTimeStamp; // MC as of 1.20.2 relies on autoRead, so it can update the connection state on the main // thread before the next packet is read. As such, we need to stall if that was just // enabled. // Might be safe to do the same on older versions too, but I'd rather not poke the // monster that is Forge networking. //#if MC>=12002 //$$ while (!channel.config().isAutoRead()) { //$$ Thread.sleep(0, 100_000); //$$ } //#endif // In case we finished jumping // We need to check that we aren't planing to restart so we don't accidentally run this // code before we actually restarted if (isHurrying() && lastTimeStamp > desiredTimeStamp && !startFromBeginning) { desiredTimeStamp = -1; replayHandler.moveCameraToTargetPosition(); // Pause after jumping (this will also reset realTimeStart accordingly) setReplaySpeed(0); } } catch (EOFException eof) { // Reached end of file // Pause the replay which will cause it to freeze before getting restarted setReplaySpeed(0); // Then wait until the user tells us to continue while (paused() && hasWorldLoaded && desiredTimeStamp == -1 && !terminate) { Thread.sleep(10); } if (terminate) { break REPLAY_LOOP; } break; } catch (IOException e) { e.printStackTrace(); } } // Restart the replay. hasWorldLoaded = false; inBundle = false; lastTimeStamp = 0; registry = getPacketTypeRegistry(State.LOGIN); startFromBeginning = false; nextPacket = null; realTimeStart = System.currentTimeMillis(); if (replayIn != null) { replayIn.close(); replayIn = null; } ReplayMod.instance.runSync(replayHandler::restartedReplay); } } } catch (Exception e) { e.printStackTrace(); } } }; /** * Return whether this replay sender is currently rushing. When rushing, all packets are sent without waiting until * a specified timestamp is passed. * @return {@code true} if currently rushing, {@code false} otherwise */ public boolean isHurrying() { return desiredTimeStamp != -1; } /** * Cancels the hurrying. */ public void stopHurrying() { desiredTimeStamp = -1; } /** * Return the timestamp to which this replay sender is currently rushing. All packets with an lower or equal * timestamp will be sent out without any sleeping. * @return The timestamp in milliseconds since the start of the replay */ public long getDesiredTimestamp() { return desiredTimeStamp; } /** * Jumps to the specified timestamp when in async mode by rushing all packets until one with a timestamp greater * than the specified timestamp is found. * If the timestamp has already passed, this causes the replay to restart and then rush all packets. * @param millis Timestamp in milliseconds since the start of the replay */ @Override public void jumpToTime(int millis) { Preconditions.checkState(asyncMode, "Can only jump in async mode. Use sendPacketsTill(int) instead."); if(millis < lastTimeStamp && !isHurrying()) { startFromBeginning = true; } desiredTimeStamp = millis; } protected Packet processPacketAsync(Packet p) { //If hurrying, ignore some packets, except for short durations if(desiredTimeStamp - lastTimeStamp > 1000) { if(p instanceof ParticleS2CPacket) return null; if(p instanceof EntitySpawnS2CPacket) { EntitySpawnS2CPacket pso = (EntitySpawnS2CPacket)p; //#if MC>=11400 if (pso.getEntityTypeId() == EntityType.FIREWORK_ROCKET) return null; //#else //$$ int type = pso.getType(); //$$ if(type == 76) { // Firework rocket //$$ return null; //$$ } //#endif } } return p; } ///////////////////////////////////////////////////////// // Synchronous packet processing // ///////////////////////////////////////////////////////// // Even in sync mode, we send from another thread because mods may rely on that private final ExecutorService syncSender = Executors.newSingleThreadExecutor(runnable -> new Thread(runnable, "replaymod-sync-sender")); /** * Sends all packets until the specified timestamp is reached (inclusive). * If the timestamp is smaller than the last packet sent, the replay is restarted from the beginning. * @param timestamp The timestamp in milliseconds since the beginning of this replay */ @Override public void sendPacketsTill(int timestamp) { Preconditions.checkState(!asyncMode, "This method cannot be used in async mode. Use jumpToTime(int) instead."); // Submit our target to the sender thread and track its progress AtomicBoolean doneSending = new AtomicBoolean(); syncSender.submit(() -> { try { doSendPacketsTill(timestamp); } finally { doneSending.set(true); } }); // Drain the task queue while we are sending (in case a mod blocks the io thread waiting for the main thread) while (!doneSending.get()) { executeTaskQueue(); // Wait until the sender thread has made progress try { //noinspection BusyWait Thread.sleep(0, 100_000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } // Everything has been sent, drain the queue one last time executeTaskQueue(); } private void doSendPacketsTill(int timestamp) { try { synchronized (this) { if (timestamp == lastTimeStamp) { // Do nothing if we're already there return; } if (timestamp < lastTimeStamp) { // Restart the replay if we need to go backwards in time hasWorldLoaded = false; inBundle = false; lastTimeStamp = 0; if (replayIn != null) { replayIn.close(); replayIn = null; } registry = getPacketTypeRegistry(State.LOGIN); startFromBeginning = false; nextPacket = null; ReplayMod.instance.runSync(replayHandler::restartedReplay); } if (replayIn == null) { replayIn = replayFile.getPacketData(getPacketTypeRegistry(State.LOGIN)); } while (true) { // Send packets try { PacketData pd; if (nextPacket != null) { // If there is still a packet left from before, use it first pd = nextPacket; nextPacket = null; } else { // Otherwise read one from the input stream pd = new PacketData(replayIn); } int nextTimeStamp = pd.timestamp; if (nextTimeStamp > timestamp && !inBundle) { // We are done sending all packets nextPacket = pd; break; } // Process packet if (pd.type == PacketType.Bundle) inBundle = !inBundle; channel.pipeline().fireChannelRead(Unpooled.wrappedBuffer(pd.bytes)); // MC as of 1.20.2 relies on autoRead, so it can update the connection state on the main // thread before the next packet is read. As such, we need to stall if that was just // enabled. // Might be safe to do the same on older versions too, but I'd rather not poke the // monster that is Forge networking. //#if MC>=12002 //$$ while (!channel.config().isAutoRead()) { //$$ Thread.sleep(0, 100_000); //$$ } //#endif } catch (EOFException eof) { // Shit! We hit the end before finishing our job! What shall we do now? // well, let's just pretend we're done... replayIn = null; break; } catch (IOException e) { e.printStackTrace(); } } // This might be required if we change to async mode anytime soon realTimeStart = System.currentTimeMillis() - (long) (timestamp / replaySpeed); lastTimeStamp = timestamp; } } catch (Exception e) { e.printStackTrace(); } } private void executeTaskQueue() { //#if MC>=11400 ((MCVer.MinecraftMethodAccessor) mc).replayModExecuteTaskQueue(); //#else //$$ java.util.Queue> scheduledTasks = ((MinecraftAccessor) mc).getScheduledTasks(); //$$ //$$ // Live-lock detection: if we already hold the lock, then the sender thread will never be able to queue its //$$ // tasks //$$ if (Thread.holdsLock(scheduledTasks)) { //$$ throw new IllegalStateException("Task queue already locked. " + //$$ "You may want to use `Scheduler.runLaterWithoutLock` to run while the lock is not taken."); //$$ } //$$ //$$ //noinspection SynchronizationOnLocalVariableOrMethodParameter //$$ synchronized (scheduledTasks) { //$$ while (!scheduledTasks.isEmpty()) { //$$ scheduledTasks.poll().run(); //$$ } //$$ } //#endif ReplayMod.instance.runTasks(); } /** * Runs the given runnable on the main thread as if it was a packet handler. * Note that the packet handler queue has different behavior than the standard ReplayMod queue. */ private void schedulePacketHandler(Runnable runnable) { if (mc.isOnThread()) { runnable.run(); } else { //#if MC>=11400 mc.execute(runnable); //#else //$$ mc.addScheduledTask(runnable); //#endif } } protected void processPacketSync(Packet p) { //#if MC>=10904 if (p instanceof UnloadChunkS2CPacket) { UnloadChunkS2CPacket packet = (UnloadChunkS2CPacket) p; int x = packet.getX(); int z = packet.getZ(); //#else //$$ if (p instanceof S21PacketChunkData && ((S21PacketChunkData) p).getExtractedSize() == 0) { //$$ S21PacketChunkData packet = (S21PacketChunkData) p; //$$ int x = packet.getChunkX(); //$$ int z = packet.getChunkZ(); //#endif // If the chunk is getting unloaded, we will have to forcefully update the position of all entities // within. Otherwise, if there wasn't a game tick recently, there may be entities that have moved // out of the chunk by now but are still registered in it. If we do not update those, they will get // unloaded even though they shouldn't. // Note: This is only half of the truth. Entities may be removed by chunk-unloading, see else-case below. // To make things worse, it seems like players were never supposed to be unloaded this way because // they will remain glitched in the World#playerEntities list. // 1.14+: The update issue remains but only for non-players and the unloading list bug appears to have been // fixed (chunk unloading no longer removes the entities). // Get the chunk that will be unloaded //#if MC>=11400 ClientWorld world = mc.world; ChunkManager chunkProvider = world.getChunkManager(); WorldChunk chunk = chunkProvider.getWorldChunk(x, z); if (chunk != null) { //#else //$$ World world = mc.world; //$$ IChunkProvider chunkProvider = world.getChunkProvider(); //$$ Chunk chunk = chunkProvider.provideChunk(x, z); //$$ if (!chunk.isEmpty()) { //#endif List entitiesInChunk = new ArrayList<>(); // Gather all entities in that chunk //#if MC>=11700 //$$ for (Entity entity : mc.world.getEntities()) { //$$ if (entity.getChunkPos().equals(chunk.getPos())) { //$$ entitiesInChunk.add(entity); //$$ } //$$ } //#else for (Collection entityList : chunk.getEntitySectionArray()) { entitiesInChunk.addAll(entityList); } //#endif for (Entity entity : entitiesInChunk) { // Skip interpolation of position updates coming from server // (See: newX in EntityLivingBase or otherPlayerMPX in EntityOtherPlayerMP) forcePositionForVehicleAndSelf(entity); // Check whether the entity has left the chunk //#if MC>=11700 //$$ // This is now handled automatically in Entity.setPos (called from tick()) //#elseif MC>=11404 int chunkX = MathHelper.floor(entity.getX() / 16); int chunkY = MathHelper.floor(entity.getY() / 16); int chunkZ = MathHelper.floor(entity.getZ() / 16); if (entity.chunkX != chunkX || entity.chunkY != chunkY || entity.chunkZ != chunkZ) { if (entity.updateNeeded) { // Entity has left the chunk chunk.remove(entity, entity.chunkY); } WorldChunk newChunk = chunkProvider.getWorldChunk(chunkX, chunkZ); if (newChunk != null) { newChunk.addEntity(entity); } else { // Entity has left all loaded chunks entity.updateNeeded = false; } } //#else //$$ int chunkX = MathHelper.floor(entity.posX / 16); //$$ int chunkZ = MathHelper.floor(entity.posZ / 16); //$$ if (entity.chunkCoordX != chunkX || entity.chunkCoordZ != chunkZ) { //$$ // Entity has left the chunk //$$ chunk.removeEntityAtIndex(entity, entity.chunkCoordY); //#if MC>=10904 //$$ Chunk newChunk = chunkProvider.getLoadedChunk(chunkX, chunkZ); //#else //$$ Chunk newChunk = chunkProvider.chunkExists(chunkX, chunkZ) //$$ ? chunkProvider.provideChunk(chunkX, chunkZ) : null; //#endif //$$ if (newChunk != null) { //$$ newChunk.addEntity(entity); //$$ } else { //$$ // Entity has left all loaded chunks //$$ entity.addedToChunk = false; //$$ } //$$ } else { //$$ // When entities remain in a chunk that's to be unloaded, they'll only be added to a unload //$$ // queue and remain loaded as before until the next tick (which during jumping is way off). //$$ // So, if they are re-spawned with the same entity id, MC actually cleans up the old entity and //$$ // then adds the new one but leaves the unload queue as is. //$$ // Finally, on the next tick the legitimate entity will be unloaded because it's part of the //$$ // unload queue (entities .equals based purely on their id). However, the old entity object //$$ // is used to determine the chunk the entity is removed from and in this case that'll allow the //$$ // legitimate entity to remain registered in a loaded chunk, causing them to still be rendered. //$$ // //$$ // The usual removal-due-to-chunk-unload process will, without touching the entityList, call //$$ // onEntityRemoved. In that method WorldClient checks to see whether the entity is still in the //$$ // entityList (which it is) and then adds it to the entitySpawnQueue. //$$ // As the final result the entity will remain loaded. //$$ // To get the same result without ticking, we just remove the entity from the to-be-unloaded //$$ // chunk but keep it loaded otherwise. They won't be rendered because they're not part of any //$$ // chunk and will be removed properly if the server decides to re-spawn the entity. //$$ chunk.removeEntityAtIndex(entity, entity.chunkCoordY); //$$ entity.addedToChunk = false; //$$ } //#endif } } } } private void forcePositionForVehicleAndSelf(Entity entity) { Entity vehicle = entity.getVehicle(); if (vehicle != null) { forcePositionForVehicleAndSelf(vehicle); } // Skip interpolation of position updates coming from server // (See: newX in EntityLivingBase or otherPlayerMPX in EntityOtherPlayerMP) int ticks = 0; Vec3d prevPos; do { prevPos = entity.getPos(); if (vehicle != null) { entity.tickRiding(); } else { entity.tick(); } } while (prevPos.squaredDistanceTo(entity.getPos()) > 0.0001 && ticks++ < 100); } private static final class PacketData { private static final com.github.steveice10.netty.buffer.ByteBuf byteBuf = com.github.steveice10.netty.buffer.Unpooled.buffer(); private static final NetOutput netOutput = new ByteBufNetOutput(byteBuf); private final int timestamp; private final byte[] bytes; private final PacketType type; PacketData(ReplayInputStream in) throws IOException { if (ReplayMod.isMinimalMode()) { // Minimal mode, we can only read our exact protocol version and cannot use ReplayStudio timestamp = readInt(in); int length = readInt(in); if (timestamp == -1 || length == -1) { throw new EOFException(); } bytes = new byte[length]; IOUtils.readFully(in, bytes); type = PacketType.UnknownLogin; } else { com.replaymod.replaystudio.PacketData data = in.readPacket(); if (data == null) { throw new EOFException(); } timestamp = (int) data.getTime(); com.replaymod.replaystudio.protocol.Packet packet = data.getPacket(); type = packet.getType(); // Workaround for ReplayMod 2.7.16-17 saving the LoginSuccess packet with an incorrect packet id // A fake one will have been sythesized by ReplayStudo, so we can simply drop the broken one. if (packet.getId() == -1) { bytes = new byte[0]; return; } // We need to re-encode ReplayStudio packets, so we can later decode them as NMS packets // The main reason we aren't reading them as NMS packets is that we want ReplayStudio to be able // to apply ViaVersion (and potentially other magic) to it. synchronized (byteBuf) { byteBuf.markReaderIndex(); // Mark the current reader and writer index (should be at start) byteBuf.markWriterIndex(); netOutput.writeVarInt(packet.getId()); int idSize = byteBuf.readableBytes(); int contentSize = packet.getBuf().readableBytes(); bytes = new byte[idSize + contentSize]; // Create bytes array of sufficient size byteBuf.readBytes(bytes, 0, idSize); packet.getBuf().readBytes(bytes, idSize, contentSize); byteBuf.resetReaderIndex(); // Reset reader & writer index for next use byteBuf.resetWriterIndex(); } packet.getBuf().release(); } } } }