diff --git a/src/main/java/com/replaymod/replay/FullReplaySender.java b/src/main/java/com/replaymod/replay/FullReplaySender.java new file mode 100755 index 00000000..c91316fc --- /dev/null +++ b/src/main/java/com/replaymod/replay/FullReplaySender.java @@ -0,0 +1,1143 @@ +package com.replaymod.replay; + +import com.google.common.base.Preconditions; +import com.google.common.io.Files; +import com.replaymod.core.ReplayMod; +import com.replaymod.core.utils.Restrictions; +import com.replaymod.replay.camera.CameraEntity; +import com.replaymod.replaystudio.io.ReplayInputStream; +import com.replaymod.replaystudio.io.ReplayOutputStream; +import com.replaymod.replaystudio.replay.ReplayFile; +import com.replaymod.replaystudio.studio.ReplayStudio; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufOutputStream; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelHandler.Sharable; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; +import net.minecraft.client.Minecraft; +import net.minecraft.client.entity.EntityOtherPlayerMP; +import net.minecraft.client.gui.GuiDownloadTerrain; +import net.minecraft.client.gui.GuiErrorScreen; +import net.minecraft.client.resources.I18n; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.network.*; +import net.minecraft.network.play.server.*; +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 net.minecraftforge.common.MinecraftForge; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; + +//#if MC>=11200 +import com.replaymod.core.utils.WrappedTimer; +//#endif +//#if MC>=11002 +import net.minecraft.world.GameType; +//#else +//$$ import net.minecraft.world.WorldSettings.GameType; +//#endif +//#if MC>=10904 +import net.minecraft.util.text.ITextComponent; +//#else +//$$ import net.minecraft.util.IChatComponent; +//#endif + +//#if MC>=10800 +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; +//#else +//$$ import cpw.mods.fml.common.eventhandler.SubscribeEvent; +//$$ import cpw.mods.fml.common.gameevent.TickEvent; +//$$ 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 static com.replaymod.core.versions.MCVer.*; + +/** + * 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 ChannelDuplexHandler implements ReplaySender { + /** + * These packets are ignored completely during replay. + */ + private static final List BAD_PACKETS = Arrays.asList( + //#if MC>=11200 + SPacketRecipeBook.class, + SPacketAdvancementInfo.class, + SPacketSelectAdvancementsTab.class, + //#endif + //#if MC>=10904 + // TODO Update possibly more? + SPacketUpdateHealth.class, + SPacketOpenWindow.class, + SPacketCloseWindow.class, + SPacketSetSlot.class, + SPacketWindowItems.class, + SPacketSignEditorOpen.class, + SPacketStatistics.class, + SPacketSetExperience.class, + SPacketCamera.class, + SPacketPlayerAbilities.class, + SPacketTitle.class + //#else + //#if MC>=10800 + //$$ S43PacketCamera.class, + //$$ S45PacketTitle.class, + //#endif + //$$ S06PacketUpdateHealth.class, + //$$ S2DPacketOpenWindow.class, + //$$ S2EPacketCloseWindow.class, + //$$ S2FPacketSetSlot.class, + //$$ S30PacketWindowItems.class, + //$$ S36PacketSignEditorOpen.class, + //$$ S37PacketStatistics.class, + //$$ S1FPacketSetExperience.class, + //$$ S39PacketPlayerAbilities.class + //#endif + ); + + 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 handler context used to send packets to minecraft. + */ + protected ChannelHandlerContext ctx; + + /** + * 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; + + /** + * 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; + + /** + * The minecraft instance. + */ + protected Minecraft mc = Minecraft.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. + */ + protected boolean allowMovement; + + /** + * Directory to which resource packs are extracted. + */ + private final File tempResourcePackFolder = Files.createTempDir(); + + /** + * Create a new replay sender. + * @param file The replay file + * @param asyncMode {@code true} for async mode, {@code false} otherwise + * @see #asyncMode + */ + public FullReplaySender(ReplayHandler replayHandler, ReplayFile file, boolean asyncMode) throws IOException { + this.replayHandler = replayHandler; + this.replayFile = file; + this.asyncMode = asyncMode; + this.replayLength = file.getMetaData().getDuration(); + + MinecraftForge.EVENT_BUS.register(this); + + if (asyncMode) { + new Thread(asyncSender, "replaymod-async-sender").start(); + } + } + + /** + * 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 {@link Minecraft#getSystemTime()} 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) { + int timePassed = (int) (System.currentTimeMillis() - lastPacketSent); + return lastTimeStamp + (int) (timePassed * getReplaySpeed()); + } else { + return lastTimeStamp; + } + } + + /** + * Terminate this replay sender. + */ + public void terminateReplay() { + terminate = true; + MinecraftForge.EVENT_BUS.unregister(this); + try { + channelInactive(ctx); + ctx.channel().pipeline().close(); + FileUtils.deleteDirectory(tempResourcePackFolder); + } catch(Exception e) { + e.printStackTrace(); + } + } + + @SubscribeEvent + public void onWorldTick(TickEvent.ClientTickEvent event) { + // Unfortunately the WorldTickEvent doesn't seem to be emitted on the CLIENT side + if (event.phase != TickEvent.Phase.START) return; + + // 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. + if (world(mc) != null) { + for (EntityPlayer playerEntity : playerEntities(world(mc))) { + if (!playerEntity.addedToChunk && playerEntity instanceof EntityOtherPlayerMP) { + playerEntity.onLivingUpdate(); + } + } + } + } + + @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; + } + + // When a packet is sent directly, perform no filtering + if(msg instanceof Packet) { + super.channelRead(ctx, msg); + } + + if (msg instanceof byte[]) { + try { + Packet p = deserializePacket((byte[]) msg); + + if (p != null) { + p = processPacket(p); + if (p != null) { + super.channelRead(ctx, p); + } + + // 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. + if (!asyncMode && world(mc) != null) { + //#if MC>=10904 + if (p instanceof SPacketSpawnPlayer + || p instanceof SPacketSpawnObject + || p instanceof SPacketSpawnMob + || p instanceof SPacketSpawnGlobalEntity + || p instanceof SPacketSpawnPainting + || p instanceof SPacketSpawnExperienceOrb + || p instanceof SPacketDestroyEntities) { + //#else + //$$ if (p instanceof S0CPacketSpawnPlayer + //$$ || p instanceof S0EPacketSpawnObject + //$$ || p instanceof S0FPacketSpawnMob + //$$ || p instanceof S2CPacketSpawnGlobalEntity + //$$ || p instanceof S10PacketSpawnPainting + //$$ || p instanceof S11PacketSpawnExperienceOrb + //$$ || p instanceof S13PacketDestroyEntities) { + //#endif + World world = world(mc); + for (int i = 0; i < world.loadedEntityList.size(); ++i) { + Entity entity = loadedEntityList(world).get(i); + if (entity.isDead) { + int chunkX = entity.chunkCoordX; + int chunkY = entity.chunkCoordZ; + + //#if MC>=10904 + if (entity.addedToChunk && world.getChunkProvider().getLoadedChunk(chunkX, chunkY) != null) { + //#else + //$$ if (entity.addedToChunk && world.getChunkProvider().chunkExists(chunkX, chunkY)) { + //#endif + world.getChunkFromChunkCoords(chunkX, chunkY).removeEntity(entity); + } + + world.loadedEntityList.remove(i--); + world.onEntityRemoved(entity); + } + + } + } + } + } + } catch (Exception e) { + // We'd rather not have a failure parsing one packet screw up the whole replay process + e.printStackTrace(); + } + } + + } + + private Packet deserializePacket(byte[] bytes) throws IOException, IllegalAccessException, InstantiationException { + ByteBuf bb = Unpooled.wrappedBuffer(bytes); + PacketBuffer pb = new PacketBuffer(bb); + + int i = readVarInt(pb); + + //#if MC>=10800 + Packet p = EnumConnectionState.PLAY.getPacket(EnumPacketDirection.CLIENTBOUND, i); + //#else + //$$ Packet p = Packet.generatePacket(EnumConnectionState.PLAY.func_150755_b(), i); + //#endif + p.readPacketData(pb); + + return p; + } + + /** + * 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 MC>=10904 + if (p instanceof SPacketCustomPayload) { + SPacketCustomPayload packet = (SPacketCustomPayload) p; + //#else + //$$ if (p instanceof S3FPacketCustomPayload) { + //$$ S3FPacketCustomPayload packet = (S3FPacketCustomPayload) p; + //#endif + //#if MC>=10800 + String channelName = packet.getChannelName(); + //#else + //$$ String channelName = packet.func_149169_c(); + //#endif + if (Restrictions.PLUGIN_CHANNEL.equals(channelName)) { + 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 + mc.addScheduledTask(new Runnable() { + @Override + public void run() { + try { + replayHandler.endReplay(); + } catch (IOException e) { + e.printStackTrace(); + } + mc.displayGuiScreen(new GuiErrorScreen( + I18n.format("replaymod.error.unknownrestriction1"), + I18n.format("replaymod.error.unknownrestriction2", unknown) + )); + } + }); + } + } + } + //#if MC>=10904 + if (p instanceof SPacketDisconnect) { + ITextComponent reason = ((SPacketDisconnect) p).getReason(); + //#else + //$$ if (p instanceof S40PacketDisconnect) { + //#if MC>=10809 + //$$ IChatComponent reason = ((S40PacketDisconnect) p).getReason(); + //#else + //$$ IChatComponent reason = ((S40PacketDisconnect) p).func_149165_c(); + //#endif + //#endif + if ("Please update to view this replay.".equals(reason.getUnformattedText())) { + // 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 MC>=10904 + if (p instanceof SPacketCustomPayload) { + SPacketCustomPayload packet = (SPacketCustomPayload) p; + //#else + //$$ if (p instanceof S3FPacketCustomPayload) { + //$$ S3FPacketCustomPayload packet = (S3FPacketCustomPayload) p; + //#endif + //#if MC>=10800 + String channelName = packet.getChannelName(); + //#else + //$$ String channelName = packet.func_149169_c(); + //#endif + if ("MC|BOpen".equals(channelName)) { + return null; + } + //#if MC>=10800 + } + + //#if MC>=10904 + if(p instanceof SPacketResourcePackSend) { + SPacketResourcePackSend packet = (SPacketResourcePackSend) p; + String url = packet.getURL(); + //#else + //$$ if(p instanceof S48PacketResourcePackSend) { + //$$ S48PacketResourcePackSend packet = (S48PacketResourcePackSend) p; + //#if MC>=10809 + //$$ String url = packet.getURL(); + //#else + //$$ String url = packet.func_179783_a(); + //#endif + //#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)); + } + setServerResourcePack(mc.getResourcePackRepository(), file); + } + } + return null; + } + } + + //#if MC>=10904 + if(p instanceof SPacketJoinGame) { + SPacketJoinGame packet = (SPacketJoinGame) p; + int entId = packet.getPlayerId(); + //#else + //$$ if(p instanceof S01PacketJoinGame) { + //$$ S01PacketJoinGame packet = (S01PacketJoinGame) p; + //#if MC>=10800 + //$$ int entId = packet.getEntityId(); + //#else + //$$ int entId = packet.func_149197_c(); + //#endif + //#endif + 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>=10800 + int dimension = packet.getDimension(); + EnumDifficulty difficulty = packet.getDifficulty(); + int maxPlayers = packet.getMaxPlayers(); + 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 + } + + //#if MC>=10904 + if(p instanceof SPacketRespawn) { + SPacketRespawn respawn = (SPacketRespawn) p; + p = new SPacketRespawn(respawn.getDimensionID(), + respawn.getDifficulty(), respawn.getWorldType(), GameType.SPECTATOR); + //#else + //$$ if(p instanceof S07PacketRespawn) { + //$$ S07PacketRespawn respawn = (S07PacketRespawn) p; + //#if MC>=10809 + //$$ p = new S07PacketRespawn(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 + + allowMovement = true; + } + + //#if MC>=10904 + if(p instanceof SPacketPlayerPosLook) { + final SPacketPlayerPosLook ppl = (SPacketPlayerPosLook) p; + //#else + //$$ if(p instanceof S08PacketPlayerPosLook) { + //$$ final S08PacketPlayerPosLook ppl = (S08PacketPlayerPosLook) p; + //#endif + if(!hasWorldLoaded) hasWorldLoaded = true; + + if (mc.currentScreen instanceof GuiDownloadTerrain) { + // Close the world loading screen manually in case we swallow the packet + mc.displayGuiScreen(null); + } + + if(replayHandler.shouldSuppressCameraMovements()) return null; + + CameraEntity cent = replayHandler.getCameraEntity(); + + //#if MC>=10800 + //#if MC>=10904 + for (SPacketPlayerPosLook.EnumFlags relative : ppl.getFlags()) { + if (relative == SPacketPlayerPosLook.EnumFlags.X + || relative == SPacketPlayerPosLook.EnumFlags.Y + || relative == SPacketPlayerPosLook.EnumFlags.Z) { + //#else + //$$ for (Object relative : ppl.func_179834_f()) { + //$$ if (relative == S08PacketPlayerPosLook.EnumFlags.X + //$$ || relative == S08PacketPlayerPosLook.EnumFlags.Y + //$$ || relative == S08PacketPlayerPosLook.EnumFlags.Z) { + //#endif + return null; // At least one of the coordinates is relative, so we don't care + } + } + //#endif + + if(cent != null) { + //#if MC>=10809 + if(!allowMovement && !((Math.abs(cent.posX - ppl.getX()) > TP_DISTANCE_LIMIT) || + (Math.abs(cent.posZ - ppl.getZ()) > TP_DISTANCE_LIMIT))) { + //#else + //$$ if(!allowMovement && !((Math.abs(cent.posX - ppl.func_148932_c()) > TP_DISTANCE_LIMIT) || + //$$ (Math.abs(cent.posZ - ppl.func_148933_e()) > TP_DISTANCE_LIMIT))) { + //#endif + return null; + } else { + allowMovement = false; + } + } + + new Runnable() { + @Override + @SuppressWarnings("unchecked") + public void run() { + if (world(mc) == null || !mc.isCallingFromMinecraftThread()) { + ReplayMod.instance.runLater(this); + return; + } + + CameraEntity cent = replayHandler.getCameraEntity(); + //#if MC>=10809 + cent.setCameraPosition(ppl.getX(), ppl.getY(), ppl.getZ()); + //#else + //$$ cent.setCameraPosition(ppl.func_148932_c(), ppl.func_148928_d(), ppl.func_148933_e()); + //#endif + } + }.run(); + } + + //#if MC>=10904 + if(p instanceof SPacketChangeGameState) { + SPacketChangeGameState pg = (SPacketChangeGameState)p; + int reason = pg.getGameState(); + //#else + //$$ if(p instanceof S2BPacketChangeGameState) { + //$$ S2BPacketChangeGameState pg = (S2BPacketChangeGameState)p; + //#if MC>=10809 + //$$ int reason = pg.getGameState(); + //#else + //$$ int reason = pg.func_149138_c(); + //#endif + //#endif + + // 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(!(reason == 1 || reason == 2 || reason == 7 || reason == 8)) { + return null; + } + } + + //#if MC>=10904 + if (p instanceof SPacketChat) { + //#else + //$$ if (p instanceof S02PacketChat) { + //#endif + if (!ReplayModReplay.instance.getCore().getSettingsRegistry().get(Setting.SHOW_CHAT)) { + return null; + } + } + + return asyncMode ? processPacketAsync(p) : processPacketSync(p); + } + + @Override + @SuppressWarnings("unchecked") + public void channelActive(ChannelHandlerContext ctx) throws Exception { + this.ctx = ctx; + //#if MC>=10904 + ctx.channel().attr(NetworkManager.PROTOCOL_ATTRIBUTE_KEY).set(EnumConnectionState.PLAY); + //#else + //#if MC>=10800 + //$$ ctx.attr(NetworkManager.attrKeyConnectionState).set(EnumConnectionState.PLAY); + //#endif + //#endif + super.channelActive(ctx); + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + // The embedded channel's event loop will consider every thread to be in it and as such provides no + // guarantees that only one thread is using the pipeline at any one time. + // For reading the replay sender (either sync or async) is the only thread ever writing. + // For writing it may very well happen that multiple threads want to use the pipline at the same time. + // It's unclear whether the EmbeddedChannel is supposed to be thread-safe (the behavior of the event loop + // does suggest that). However it seems like it either isn't (likely) or there is a race condition. + // See: https://www.replaymod.com/forum/thread/1752#post8045 (https://paste.replaymod.com/lotacatuwo) + // To work around this issue, we just outright drop all write/flush requests (they aren't needed anyway). + // This still leaves channel handlers upstream with the threading issue but they all seem to cope well with it. + promise.setSuccess(); + } + + @Override + public void flush(ChannelHandlerContext ctx) throws Exception { + // See write method above + } + + /** + * 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; + //#if MC>=11200 + mc.timer.tickLength = WrappedTimer.DEFAULT_MS_PER_TICK / (float) d; + //#else + //$$ mc.timer.timerSpeed = (float) d; + //#endif + } + + ///////////////////////////////////////////////////////// + // Asynchronous packet processing // + ///////////////////////////////////////////////////////// + + /** + * The real time at which the last packet was sent in milliseconds. + */ + private long lastPacketSent; + + /** + * 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 { + while (ctx == null && !terminate) { + Thread.sleep(10); + } + REPLAY_LOOP: + while (!terminate) { + synchronized (FullReplaySender.this) { + if (replayIn == null) { + replayIn = replayFile.getPacketData(); + } + // 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) { + // Unless we are going to terminate, restart or jump + if (terminate || startFromBeginning || desiredTimeStamp != -1) { + break; + } + Thread.sleep(10); + } + + if (terminate) { + 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) { + // How much time should have passed + int timeWait = (int) Math.round((nextTimeStamp - lastTimeStamp) / replaySpeed); + // How much time did pass + long timeDiff = System.currentTimeMillis() - lastPacketSent; + // How much time we need to wait to make up for the difference + long timeToSleep = Math.max(0, timeWait - timeDiff); + + Thread.sleep(timeToSleep); + lastPacketSent = System.currentTimeMillis(); + } + + // Process packet + channelRead(ctx, nextPacket.bytes); + nextPacket = null; + + lastTimeStamp = nextTimeStamp; + + // 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 + 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); + } + break; + } catch (IOException e) { + e.printStackTrace(); + } + } + + // Restart the replay. + hasWorldLoaded = false; + lastTimeStamp = 0; + startFromBeginning = false; + nextPacket = null; + lastPacketSent = System.currentTimeMillis(); + replayHandler.restartedReplay(); + if (replayIn != null) { + replayIn.close(); + replayIn = null; + } + } + } + } 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 MC>=10904 + if(p instanceof SPacketParticles) return null; + + if(p instanceof SPacketSpawnObject) { + SPacketSpawnObject pso = (SPacketSpawnObject)p; + int type = pso.getType(); + //#else + //$$ if(p instanceof S2APacketParticles) return null; + //$$ + //$$ if(p instanceof S0EPacketSpawnObject) { + //$$ S0EPacketSpawnObject pso = (S0EPacketSpawnObject)p; + //#if MC>=10809 + //$$ int type = pso.getType(); + //#else + //$$ int type = pso.func_148993_l(); + //#endif + //#endif + if(type == 76) { // Firework rocket + return null; + } + } + } + return p; + } + + ///////////////////////////////////////////////////////// + // Synchronous packet processing // + ///////////////////////////////////////////////////////// + + /** + * 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."); + try { + while (ctx == null && !terminate) { // Make sure channel is ready + Thread.sleep(10); + } + + 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; + lastTimeStamp = 0; + if (replayIn != null) { + replayIn.close(); + replayIn = null; + } + startFromBeginning = false; + nextPacket = null; + replayHandler.restartedReplay(); + } + + if (replayIn == null) { + replayIn = replayFile.getPacketData(); + } + + 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) { + // We are done sending all packets + nextPacket = pd; + break; + } + + // Process packet + channelRead(ctx, pd.bytes); + } 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 + lastPacketSent = System.currentTimeMillis(); + lastTimeStamp = timestamp; + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + protected Packet processPacketSync(Packet p) { + //#if MC>=10904 + if (p instanceof SPacketUnloadChunk) { + SPacketUnloadChunk packet = (SPacketUnloadChunk) p; + int x = packet.getX(); + int z = packet.getZ(); + //#else + //#if MC>=10809 + //$$ if (p instanceof S21PacketChunkData && ((S21PacketChunkData) p).getExtractedSize() == 0) { + //$$ S21PacketChunkData packet = (S21PacketChunkData) p; + //$$ int x = packet.getChunkX(); + //$$ int z = packet.getChunkZ(); + //#else + //$$ if (p instanceof S21PacketChunkData && ((S21PacketChunkData) p).func_149276_g() == 0) { + //$$ S21PacketChunkData packet = (S21PacketChunkData) p; + //$$ int x = packet.func_149273_e(); + //$$ int z = packet.func_149271_f(); + //#endif + //#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. + World world = world(mc); + IChunkProvider chunkProvider = world.getChunkProvider(); + // Get the chunk that will be unloaded + Chunk chunk = chunkProvider.provideChunk(x, z); + if (!chunk.isEmpty()) { + List entitiesInChunk = new ArrayList<>(); + // Gather all entities in that chunk + for (Collection entityList : getEntityLists(chunk)) { + entitiesInChunk.addAll(entityList); + } + for (Entity entity : entitiesInChunk) { + // Skip interpolation of position updates coming from server + // (See: newX in EntityLivingBase or otherPlayerMPX in EntityOtherPlayerMP) + // Needs to be called at least 4 times thanks to + // EntityOtherPlayerMP#otherPlayerMPPosRotationIncrements (max vanilla value is 3) + for (int i = 0; i < 4; i++) { + entity.onUpdate(); + } + + // Check whether the entity has left the chunk + int chunkX = floor(entity.posX / 16); + int chunkZ = 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; + } + } + } + } + return p; // During synchronous playback everything is sent normally + } + + private static final class PacketData { + private static final ByteBuf byteBuf = Unpooled.buffer(); + private static final ByteBufOutputStream byteBufOut = new ByteBufOutputStream(byteBuf); + private static final ReplayOutputStream encoder = new ReplayOutputStream(new ReplayStudio(), byteBufOut); + private final int timestamp; + private final byte[] bytes; + + public PacketData(ReplayInputStream in) throws IOException { + com.replaymod.replaystudio.PacketData data = in.readPacket(); + timestamp = (int) data.getTime(); + // We need to re-encode MCProtocolLib 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 (encoder) { + byteBuf.markReaderIndex(); // Mark the current reader and writer index (should be at start) + byteBuf.markWriterIndex(); + + encoder.write(data); // Re-encode packet, data will end up in byteBuf + encoder.flush(); + + byteBuf.skipBytes(8); // Skip packet length & timestamp + bytes = new byte[byteBuf.readableBytes()]; // Create bytes array of sufficient size + byteBuf.readBytes(bytes); // Read all data into bytes + + byteBuf.resetReaderIndex(); // Reset reader & writer index for next use + byteBuf.resetWriterIndex(); + } + } + } +} diff --git a/src/main/java/com/replaymod/replay/QuickReplaySender.java b/src/main/java/com/replaymod/replay/QuickReplaySender.java new file mode 100644 index 00000000..fb299866 --- /dev/null +++ b/src/main/java/com/replaymod/replay/QuickReplaySender.java @@ -0,0 +1,520 @@ +package com.replaymod.replay; + +import com.github.steveice10.mc.protocol.data.game.PlayerListEntry; +import com.github.steveice10.mc.protocol.data.game.PlayerListEntryAction; +import com.github.steveice10.mc.protocol.data.game.chunk.BlockStorage; +import com.github.steveice10.mc.protocol.data.game.chunk.Column; +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.github.steveice10.mc.protocol.data.game.world.WorldType; +import com.github.steveice10.mc.protocol.data.game.world.block.BlockChangeRecord; +import com.github.steveice10.mc.protocol.data.game.world.block.BlockState; +import com.github.steveice10.mc.protocol.packet.ingame.server.ServerPlayerListEntryPacket; +import com.github.steveice10.mc.protocol.packet.ingame.server.ServerRespawnPacket; +import com.github.steveice10.mc.protocol.packet.ingame.server.entity.ServerEntityDestroyPacket; +import com.github.steveice10.mc.protocol.packet.ingame.server.entity.ServerEntityTeleportPacket; +import com.github.steveice10.mc.protocol.packet.ingame.server.entity.player.ServerPlayerPositionRotationPacket; +import com.github.steveice10.mc.protocol.packet.ingame.server.entity.spawn.ServerSpawnMobPacket; +import com.github.steveice10.mc.protocol.packet.ingame.server.entity.spawn.ServerSpawnObjectPacket; +import com.github.steveice10.mc.protocol.packet.ingame.server.entity.spawn.ServerSpawnPaintingPacket; +import com.github.steveice10.mc.protocol.packet.ingame.server.entity.spawn.ServerSpawnPlayerPacket; +import com.github.steveice10.mc.protocol.packet.ingame.server.world.ServerBlockChangePacket; +import com.github.steveice10.mc.protocol.packet.ingame.server.world.ServerChunkDataPacket; +import com.github.steveice10.mc.protocol.packet.ingame.server.world.ServerMultiBlockChangePacket; +import com.github.steveice10.mc.protocol.packet.ingame.server.world.ServerUnloadChunkPacket; +import com.google.common.base.Throwables; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Multimaps; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import com.replaymod.core.utils.WrappedTimer; +import com.replaymod.replaystudio.PacketData; +import com.replaymod.replaystudio.io.ReplayInputStream; +import com.replaymod.replaystudio.io.ReplayOutputStream; +import com.replaymod.replaystudio.replay.ReplayFile; +import com.replaymod.replaystudio.studio.ReplayStudio; +import com.replaymod.replaystudio.util.Location; +import com.replaymod.replaystudio.util.PacketUtils; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufOutputStream; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelHandlerAdapter; +import io.netty.channel.ChannelHandlerContext; +import net.minecraft.client.Minecraft; +import net.minecraft.network.EnumConnectionState; +import net.minecraft.network.EnumPacketDirection; +import net.minecraft.network.Packet; +import net.minecraft.network.PacketBuffer; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.TreeMap; +import java.util.UUID; +import java.util.function.Consumer; + +import static com.replaymod.core.versions.MCVer.FML_BUS; +import static com.replaymod.replay.ReplayModReplay.LOGGER; + +/** + * Sends only chunk updates and entity position updates but tries to do so as quickly as possible. + * To do so, it performs an initial analysis of the replay, scanning all of its packets and storing entity positions + * and chunk states while doing so. + * This allows it to later jump to any time by doing a diff from the current time (including backwards jumping). + */ +@ChannelHandler.Sharable +public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySender { + private final Minecraft mc = Minecraft.getMinecraft(); + + private final ReplayModReplay mod; + private final ReplayFile replayFile; + private ChannelHandlerContext ctx; + + private int currentTimeStamp; + private double replaySpeed = 1; + + /** + * Whether async mode is enabled. + * Async mode is emulated by registering an event handler on client tick. + */ + private boolean asyncMode; + private long lastAsyncUpdateTime; + + private ListenableFuture initPromise; + + private TreeMap> thingSpawnsT = new TreeMap<>(); + private ListMultimap thingSpawns = Multimaps.newListMultimap(thingSpawnsT, ArrayList::new); + private TreeMap> thingDespawnsT = new TreeMap<>(); + private ListMultimap thingDespawns = Multimaps.newListMultimap(thingDespawnsT, ArrayList::new); + private List activeThings = new LinkedList<>(); + + public QuickReplaySender(ReplayModReplay mod, ReplayFile replayFile) { + this.mod = mod; + this.replayFile = replayFile; + } + + public void register() { + FML_BUS.register(this); + } + + public void unregister() { + FML_BUS.unregister(this); + } + + @Override + public void handlerAdded(ChannelHandlerContext ctx) { + this.ctx = ctx; + } + + public ListenableFuture initialize(Consumer progress) { + if (initPromise != null) { + return initPromise; + } + SettableFuture promise = SettableFuture.create(); + initPromise = promise; + new Thread(() -> { + try { + long start = System.currentTimeMillis(); + analyseReplay(progress); + LOGGER.info("Initialized quick replay sender in " + (System.currentTimeMillis() - start) + "ms"); + } catch (Throwable e) { + LOGGER.error("Initializing quick replay sender:", e); + mod.getCore().runLater(() -> { + mod.getCore().printWarningToChat("Error initializing quick replay sender: %s", e.getLocalizedMessage()); + promise.setException(e); + }); + return; + } + mod.getCore().runLater(() -> promise.set(null)); + }).start(); + return promise; + } + + public void ensureInitialized(Runnable body) { + if (initPromise == null) { + // TODO progress popup + initialize(progress -> {}); + } + Futures.addCallback(initPromise, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + body.run(); + } + + @Override + public void onFailure(Throwable t) { + // Error already printed by initialize method + } + }); + } + + public void restart() { + activeThings.clear(); + currentTimeStamp = 0; + ctx.fireChannelRead(toMC(new ServerRespawnPacket(0, Difficulty.NORMAL, GameMode.SPECTATOR, WorldType.DEFAULT))); + ctx.fireChannelRead(toMC(new ServerPlayerPositionRotationPacket(0, 0, 0, 0, 0, 0))); + } + + @Override + public int currentTimeStamp() { + return currentTimeStamp; + } + + @Override + public void setReplaySpeed(double factor) { + if (factor != 0) { + if (paused() && asyncMode) { + lastAsyncUpdateTime = System.currentTimeMillis(); // TODO test this + } + this.replaySpeed = factor; + } + //#if MC>=11200 + mc.timer.tickLength = WrappedTimer.DEFAULT_MS_PER_TICK / (float) factor; + //#else + //$$ mc.timer.timerSpeed = (float) factor; + //#endif + } + + @Override + public double getReplaySpeed() { + return replaySpeed; + } + + @Override + public boolean isAsyncMode() { + return asyncMode; + } + + @Override + public void setAsyncMode(boolean async) { + if (this.asyncMode == async) return; + ensureInitialized(() -> { + this.asyncMode = async; + if (async) { + lastAsyncUpdateTime = System.currentTimeMillis(); + } + }); + } + + @Override + public void setSyncModeAndWait() { + setAsyncMode(false); + // No waiting required, we emulated async mode via tick events + } + + @Override + public void jumpToTime(int value) { + sendPacketsTill(value); + } + + @SubscribeEvent + public void onTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.START) return; + if (!asyncMode) return; + + long now = System.currentTimeMillis(); + long realTimePassed = now - lastAsyncUpdateTime; + lastAsyncUpdateTime = now; + int replayTimePassed = (int) (realTimePassed * replaySpeed); + sendPacketsTill(currentTimeStamp + replayTimePassed); + } + + private void analyseReplay(Consumer progress) { + ReplayStudio studio = new ReplayStudio(); + PacketUtils.registerAllMovementRelated(studio); + studio.setParsing(ServerSpawnMobPacket.class, true); + studio.setParsing(ServerSpawnObjectPacket.class, true); + studio.setParsing(ServerSpawnPaintingPacket.class, true); + studio.setParsing(ServerSpawnPlayerPacket.class, true); + studio.setParsing(ServerEntityDestroyPacket.class, true); + studio.setParsing(ServerChunkDataPacket.class, true); + studio.setParsing(ServerUnloadChunkPacket.class, true); + studio.setParsing(ServerBlockChangePacket.class, true); + studio.setParsing(ServerMultiBlockChangePacket.class, true); + studio.setParsing(ServerPlayerListEntryPacket.class, true); + + Map playerListEntries = new HashMap<>(); + Map activeEntities = new HashMap<>(); + Map activeChunks = new HashMap<>(); + + try (ReplayInputStream in = replayFile.getPacketData(studio)) { + double duration = replayFile.getMetaData().getDuration(); + PacketData packetData; + while ((packetData = in.readPacket()) != null) { + com.github.steveice10.packetlib.packet.Packet packet = packetData.getPacket(); + int time = (int) packetData.getTime(); + progress.accept(time / duration); + Integer entityId = PacketUtils.getEntityId(packet); + if (packet instanceof ServerSpawnMobPacket + || packet instanceof ServerSpawnObjectPacket + || packet instanceof ServerSpawnPaintingPacket) { + Entity entity = new Entity(entityId, Collections.singletonList(toMC(packet))); + entity.spawnTime = time; + thingSpawns.put(time, entity); + Entity prev = activeEntities.put(entityId, entity); + if (prev != null) { + prev.despawnTime = time; + thingDespawns.put(time, prev); + } + } else if (packet instanceof ServerSpawnPlayerPacket) { + ServerPlayerListEntryPacket listEntryPacket = new ServerPlayerListEntryPacket( + PlayerListEntryAction.ADD_PLAYER, + new PlayerListEntry[]{ + playerListEntries.get(((ServerSpawnPlayerPacket) packet).getUUID()) + } + ); + Entity entity = new Entity(entityId, Arrays.asList(toMC(listEntryPacket), toMC(packet))); + entity.spawnTime = time; + thingSpawns.put(time, entity); + Entity prev = activeEntities.put(entityId, entity); + if (prev != null) { + prev.despawnTime = time; + thingDespawns.put(time, prev); + } + } else if (packet instanceof ServerEntityDestroyPacket) { + for (int id : ((ServerEntityDestroyPacket) packet).getEntityIds()) { + Entity entity = activeEntities.remove(id); + if (entity != null) { + entity.despawnTime = time; + thingDespawns.put(time, entity); + } + } + } else if (packet instanceof ServerChunkDataPacket) { + Column column = ((ServerChunkDataPacket) packet).getColumn(); + Chunk chunk = new Chunk(column); + chunk.spawnTime = time; + thingSpawns.put(time, chunk); + Chunk prev = activeChunks.put(coordToLong(column.getX(), column.getZ()), chunk); + if (prev != null) { + prev.currentBlockState = null; // free memory because we no longer need it + prev.despawnTime = time; + thingDespawns.put(time, prev); + } + } else if (packet instanceof ServerUnloadChunkPacket) { + ServerUnloadChunkPacket p = (ServerUnloadChunkPacket) packet; + Chunk prev = activeChunks.remove(coordToLong(p.getX(), p.getZ())); + if (prev != null) { + prev.currentBlockState = null; // free memory because we no longer need it + prev.despawnTime = time; + thingDespawns.put(time, prev); + } + } else if (packet instanceof ServerBlockChangePacket || packet instanceof ServerMultiBlockChangePacket) { + for (BlockChangeRecord record : + packet instanceof ServerBlockChangePacket + ? new BlockChangeRecord[]{ ((ServerBlockChangePacket) packet).getRecord() } + : ((ServerMultiBlockChangePacket) packet).getRecords()) { + Position pos = record.getPosition(); + Chunk chunk = activeChunks.get(coordToLong(pos.getX() / 16, pos.getZ() / 16)); + if (chunk != null) { + BlockStorage blockStorage = chunk.currentBlockState[pos.getY() / 16]; + int x = Math.floorMod(pos.getX(), 16), y = Math.floorMod(pos.getY(), 16), z = Math.floorMod(pos.getZ(), 16); + BlockState prevState = blockStorage.get(x, y, z); + BlockState newState = record.getBlock(); + blockStorage.set(x, y, z, newState); + chunk.blocks.put(time, new BlockChange(pos, prevState, newState)); + } + } + } else if (packet instanceof ServerPlayerListEntryPacket) { + ServerPlayerListEntryPacket p = (ServerPlayerListEntryPacket) packet; + if (p.getAction() == PlayerListEntryAction.ADD_PLAYER) { + for (PlayerListEntry entry : p.getEntries()) { + playerListEntries.put(entry.getProfile().getId(), entry); + } + } + } else if (packet instanceof ServerRespawnPacket) { + // FIXME + } + if (entityId != null) { + Entity entity = activeEntities.get(entityId); + if (entity != null) { + Location current = entity.locations.isEmpty() ? null : entity.locations.lastEntry().getValue(); + Location updated = PacketUtils.updateLocation(current, packet); + if (updated != null) { + entity.locations.put(time, updated); + } + } + } + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Override + public void sendPacketsTill(int replayTime) { + ensureInitialized(() -> { + if (replayTime > currentTimeStamp) { + activeThings.removeIf(thing -> { + if (thing.despawnTime < replayTime) { + thing.despawnPackets.forEach(ctx::fireChannelRead); + return true; + } else { + return false; + } + }); + thingSpawnsT.subMap(currentTimeStamp, false, replayTime, true).values() + .forEach(things -> things.forEach(thing -> { + if (thing.despawnTime > replayTime) { + thing.spawnPackets.forEach(ctx::fireChannelRead); + activeThings.add(thing); + } + })); + activeThings.forEach(thing -> thing.play(currentTimeStamp, replayTime, ctx::fireChannelRead)); + } else { + activeThings.removeIf(thing -> { + if (thing.spawnTime > replayTime) { + thing.despawnPackets.forEach(ctx::fireChannelRead); + return true; + } else { + return false; + } + }); + thingDespawnsT.subMap(replayTime, false, currentTimeStamp, true).values() + .forEach(things -> things.forEach(thing -> { + if (thing.spawnTime <= replayTime) { + thing.spawnPackets.forEach(ctx::fireChannelRead); + activeThings.add(thing); + } + })); + activeThings.forEach(thing -> thing.rewind(currentTimeStamp, replayTime, ctx::fireChannelRead)); + } + currentTimeStamp = replayTime; + }); + } + + private static final ByteBuf byteBuf = Unpooled.buffer(); + private static final ByteBufOutputStream byteBufOut = new ByteBufOutputStream(byteBuf); + private static final PacketBuffer packetBuf = new PacketBuffer(byteBuf); + private static final ReplayOutputStream encoder = new ReplayOutputStream(new ReplayStudio(), byteBufOut); + + private static Packet toMC(com.github.steveice10.packetlib.packet.Packet packet) { + // We need to re-encode MCProtocolLib packets, so we can then 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 (encoder) { + int readerIndex = byteBuf.readerIndex(); // Mark the current reader and writer index (should be at start) + int writerIndex = byteBuf.writerIndex(); + try { + encoder.write(0, packet); // Re-encode packet, data will end up in byteBuf + encoder.flush(); + + byteBuf.skipBytes(8); // Skip packet length & timestamp + + int packetId = packetBuf.readVarInt(); + Packet mcPacket = EnumConnectionState.PLAY.getPacket(EnumPacketDirection.CLIENTBOUND, packetId); + mcPacket.readPacketData(packetBuf); + return mcPacket; + } catch (Exception e) { + Throwables.throwIfUnchecked(e); + throw new RuntimeException(e); + } finally { + byteBuf.readerIndex(readerIndex); // Reset reader & writer index for next use + byteBuf.writerIndex(writerIndex); + } + } + } + + private static long coordToLong(int x, int z) { + return (long)x << 32 | (long)z & 0xFFFFFFFFL; + } + + private static abstract class TrackedThing { + List> spawnPackets; + List> despawnPackets; + int spawnTime; + int despawnTime = Integer.MAX_VALUE; + + public abstract void play(int currentTimeStamp, int replayTime, Consumer> send); + public abstract void rewind(int currentTimeStamp, int replayTime, Consumer> send); + } + + private static class Entity extends TrackedThing { + private int id; + private NavigableMap locations = new TreeMap<>(); + + private Entity(int entityId, List> spawnPackets) { + this.id = entityId; + this.spawnPackets = spawnPackets; + this.despawnPackets = Collections.singletonList(toMC(new ServerEntityDestroyPacket(entityId))); + } + + @Override + public void play(int currentTimeStamp, int replayTime, Consumer> send) { + Map.Entry lastUpdate = locations.floorEntry(replayTime); + if (lastUpdate != null && lastUpdate.getKey() > currentTimeStamp) { + Location l = lastUpdate.getValue(); + send.accept(toMC(new ServerEntityTeleportPacket(id, l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch(), false))); + } + } + + @Override + public void rewind(int currentTimeStamp, int replayTime, Consumer> send) { + Map.Entry lastUpdate = locations.floorEntry(replayTime); + if (lastUpdate != null && !lastUpdate.getKey().equals(locations.floorKey(currentTimeStamp))) { + Location l = lastUpdate.getValue(); + send.accept(toMC(new ServerEntityTeleportPacket(id, l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch(), false))); + } + } + } + + private static class Chunk extends TrackedThing { + private TreeMap> blocksT = new TreeMap<>(); + private ListMultimap blocks = Multimaps.newListMultimap(blocksT, LinkedList::new); // LinkedList to allow .descendingIterator + private BlockStorage[] currentBlockState = new BlockStorage[16]; + + private Chunk(Column column) { + this.spawnPackets = Collections.singletonList(toMC(new ServerChunkDataPacket(column))); + this.despawnPackets = Collections.singletonList(toMC(new ServerUnloadChunkPacket(column.getX(), column.getZ()))); + com.github.steveice10.mc.protocol.data.game.chunk.Chunk[] chunks = column.getChunks(); + for (int i = 0; i < currentBlockState.length; i++) { + currentBlockState[i] = chunks[i] == null ? new BlockStorage() : chunks[i].getBlocks(); + } + } + + @Override + public void play(int currentTimeStamp, int replayTime, Consumer> send) { + blocksT.subMap(currentTimeStamp, false, replayTime, true).values() + .forEach(updates -> updates.forEach(update -> { + send.accept(toMC(new ServerBlockChangePacket(new BlockChangeRecord(update.pos, update.to)))); + })); + } + + @Override + public void rewind(int currentTimeStamp, int replayTime, Consumer> send) { + if (currentTimeStamp >= despawnTime) { + play(spawnTime, replayTime, send); + return; + } + blocksT.subMap(replayTime, false, currentTimeStamp, true).descendingMap().values() + .forEach(updates -> + ((LinkedList) updates).descendingIterator().forEachRemaining(update -> + send.accept(toMC(new ServerBlockChangePacket(new BlockChangeRecord(update.pos, update.from)))))); + } + } + + private static class BlockChange { + private Position pos; + private BlockState from; + private BlockState to; + + private BlockChange(Position pos, BlockState from, BlockState to) { + this.pos = pos; + this.from = from; + this.to = to; + } + } +} diff --git a/src/main/java/com/replaymod/replay/ReplayHandler.java b/src/main/java/com/replaymod/replay/ReplayHandler.java index 6052a8c7..33ba056b 100755 --- a/src/main/java/com/replaymod/replay/ReplayHandler.java +++ b/src/main/java/com/replaymod/replay/ReplayHandler.java @@ -30,7 +30,6 @@ import java.util.*; import com.mojang.authlib.GameProfile; import net.minecraft.client.network.NetHandlerPlayClient; import net.minecraft.network.EnumPacketDirection; -import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.fml.client.FMLClientHandler; import net.minecraftforge.fml.common.network.handshake.NetworkDispatcher; @@ -67,7 +66,9 @@ public class ReplayHandler { /** * Decodes and sends packets into channel. */ - private final ReplaySender replaySender; + private final FullReplaySender fullReplaySender; + private final QuickReplaySender quickReplaySender; + private boolean quickMode = false; /** * Currently active replay restrictions. @@ -85,6 +86,8 @@ public class ReplayHandler { private EmbeddedChannel channel; + private int replayDuration; + /** * The position at which the camera should be located after the next jump. */ @@ -96,11 +99,14 @@ public class ReplayHandler { Preconditions.checkState(mc.isCallingFromMinecraftThread(), "Must be called from Minecraft thread."); this.replayFile = replayFile; + replayDuration = replayFile.getMetaData().getDuration(); + FML_BUS.post(new ReplayOpenEvent.Pre(this)); markers = new ArrayList<>(replayFile.getMarkers().or(Collections.emptySet())); - replaySender = new ReplaySender(this, replayFile, false); + fullReplaySender = new FullReplaySender(this, replayFile, false); + quickReplaySender = new QuickReplaySender(ReplayModReplay.instance, replayFile); setup(); @@ -109,7 +115,7 @@ public class ReplayHandler { FML_BUS.post(new ReplayOpenEvent.Post(this)); - replaySender.setAsyncMode(asyncMode); + fullReplaySender.setAsyncMode(asyncMode); } void restartedReplay() { @@ -131,7 +137,8 @@ public class ReplayHandler { FML_BUS.post(new ReplayCloseEvent.Pre(this)); - replaySender.terminateReplay(); + fullReplaySender.terminateReplay(); + quickReplaySender.unregister(); replayFile.save(); replayFile.close(); @@ -185,7 +192,8 @@ public class ReplayHandler { NetworkDispatcher networkDispatcher = new NetworkDispatcher(networkManager); channel.attr(NetworkDispatcher.FML_DISPATCHER).set(networkDispatcher); - channel.pipeline().addFirst("ReplayModReplay_replaySender", replaySender); + channel.pipeline().addFirst("ReplayModReplay_replaySender", fullReplaySender); + channel.pipeline().addFirst("ReplayModReplay_quickReplaySender", quickReplaySender); channel.pipeline().addLast("packet_handler", networkManager); channel.pipeline().fireChannelActive(); networkDispatcher.clientToServerHandshake(); @@ -194,7 +202,7 @@ public class ReplayHandler { //$$ NetworkDispatcher networkDispatcher = new NetworkDispatcher(networkManager); //$$ channel.attr(NetworkDispatcher.FML_DISPATCHER).set(networkDispatcher); //$$ - //$$ channel.pipeline().addFirst("ReplayModReplay_replaySender", replaySender); + //$$ channel.pipeline().addFirst("ReplayModReplay_replaySender", fullReplaySender); //$$ channel.pipeline().addAfter("ReplayModReplay_replaySender", "fml:packet_handler", networkDispatcher); //$$ channel.pipeline().fireChannelActive(); //#endif @@ -225,7 +233,7 @@ public class ReplayHandler { //$$ ChannelOutboundHandlerAdapter dummyHandler = new ChannelOutboundHandlerAdapter(); //$$ channel = new EmbeddedChannel(dummyHandler); //$$ channel.pipeline().remove(dummyHandler); - //$$ channel.pipeline().addFirst("ReplayModReplay_replaySender", replaySender); + //$$ channel.pipeline().addFirst("ReplayModReplay_replaySender", fullReplaySender); //$$ channel.pipeline().addAfter("ReplayModReplay_replaySender", "packet_handler", networkManager); //$$ channel.pipeline().fireChannelActive(); //$$ @@ -247,13 +255,40 @@ public class ReplayHandler { } public ReplaySender getReplaySender() { - return replaySender; + return quickMode ? quickReplaySender : fullReplaySender; } public GuiReplayOverlay getOverlay() { return overlay; } + public void setQuickMode(boolean quickMode) { + if (quickMode == this.quickMode) return; + if (quickMode && !fullReplaySender.isAsyncMode()) return; // Cannot activate quick mode when already in sync mode + this.quickMode = quickMode; + if (quickMode) { + fullReplaySender.setSyncModeAndWait(); + quickReplaySender.register(); + quickReplaySender.restart(); + quickReplaySender.sendPacketsTill(fullReplaySender.currentTimeStamp()); + quickReplaySender.setAsyncMode(true); + } else { + quickReplaySender.setSyncModeAndWait(); + quickReplaySender.unregister(); + fullReplaySender.sendPacketsTill(0); + fullReplaySender.sendPacketsTill(quickReplaySender.currentTimeStamp()); + fullReplaySender.setAsyncMode(true); + } + } + + public boolean isQuickMode() { + return quickMode; + } + + public int getReplayDuration() { + return replayDuration; + } + /** * Return whether camera movement by user inputs and/or server packets should be suppressed. * @return {@code true} if these kinds of movement should be suppressed @@ -363,6 +398,12 @@ public class ReplayHandler { } public void doJump(int targetTime, boolean retainCameraPosition) { + if (getReplaySender() == quickReplaySender) { + quickReplaySender.sendPacketsTill(targetTime); + return; + } + FullReplaySender replaySender = fullReplaySender; + if (replaySender.isHurrying()) { return; // When hurrying, no Timeline jumping etc. is possible } diff --git a/src/main/java/com/replaymod/replay/ReplayModReplay.java b/src/main/java/com/replaymod/replay/ReplayModReplay.java index 48d73d59..af18a138 100644 --- a/src/main/java/com/replaymod/replay/ReplayModReplay.java +++ b/src/main/java/com/replaymod/replay/ReplayModReplay.java @@ -142,6 +142,12 @@ public class ReplayModReplay { } }); + core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.quickmode", Keyboard.KEY_Q, () -> { + if (replayHandler != null) { + replayHandler.setQuickMode(!replayHandler.isQuickMode()); + } + }); + core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.rollclockwise", Keyboard.KEY_L, () -> { // Noop, actual handling logic in CameraEntity#update }); diff --git a/src/main/java/com/replaymod/replay/ReplaySender.java b/src/main/java/com/replaymod/replay/ReplaySender.java old mode 100755 new mode 100644 index 481a61e2..8e2d63d4 --- a/src/main/java/com/replaymod/replay/ReplaySender.java +++ b/src/main/java/com/replaymod/replay/ReplaySender.java @@ -1,740 +1,16 @@ package com.replaymod.replay; -import com.google.common.base.Preconditions; -import com.google.common.io.Files; -import com.replaymod.core.ReplayMod; -import com.replaymod.core.utils.Restrictions; -import com.replaymod.replay.camera.CameraEntity; -import com.replaymod.replaystudio.io.ReplayInputStream; -import com.replaymod.replaystudio.io.ReplayOutputStream; -import com.replaymod.replaystudio.replay.ReplayFile; -import com.replaymod.replaystudio.studio.ReplayStudio; -import io.netty.buffer.ByteBuf; -import io.netty.buffer.ByteBufOutputStream; -import io.netty.buffer.Unpooled; -import io.netty.channel.ChannelDuplexHandler; -import io.netty.channel.ChannelHandler.Sharable; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelPromise; import net.minecraft.client.Minecraft; -import net.minecraft.client.entity.EntityOtherPlayerMP; -import net.minecraft.client.gui.GuiDownloadTerrain; -import net.minecraft.client.gui.GuiErrorScreen; -import net.minecraft.client.resources.I18n; -import net.minecraft.entity.Entity; -import net.minecraft.entity.player.EntityPlayer; -import net.minecraft.network.*; -import net.minecraft.network.play.server.*; -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 net.minecraftforge.common.MinecraftForge; -import org.apache.commons.io.FileUtils; -import org.apache.commons.io.IOUtils; -//#if MC>=11200 -import com.replaymod.core.utils.WrappedTimer; -//#endif -//#if MC>=11002 -import net.minecraft.world.GameType; -//#else -//$$ import net.minecraft.world.WorldSettings.GameType; -//#endif -//#if MC>=10904 -import net.minecraft.util.text.ITextComponent; -//#else -//$$ import net.minecraft.util.IChatComponent; -//#endif - -//#if MC>=10800 -import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; -import net.minecraftforge.fml.common.gameevent.TickEvent; -//#else -//$$ import cpw.mods.fml.common.eventhandler.SubscribeEvent; -//$$ import cpw.mods.fml.common.gameevent.TickEvent; -//$$ 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 static com.replaymod.core.versions.MCVer.*; - -/** - * 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 ReplaySender extends ChannelDuplexHandler { - /** - * These packets are ignored completely during replay. - */ - private static final List BAD_PACKETS = Arrays.asList( - //#if MC>=11200 - SPacketRecipeBook.class, - SPacketAdvancementInfo.class, - SPacketSelectAdvancementsTab.class, - //#endif - //#if MC>=10904 - // TODO Update possibly more? - SPacketUpdateHealth.class, - SPacketOpenWindow.class, - SPacketCloseWindow.class, - SPacketSetSlot.class, - SPacketWindowItems.class, - SPacketSignEditorOpen.class, - SPacketStatistics.class, - SPacketSetExperience.class, - SPacketCamera.class, - SPacketPlayerAbilities.class, - SPacketTitle.class - //#else - //#if MC>=10800 - //$$ S43PacketCamera.class, - //$$ S45PacketTitle.class, - //#endif - //$$ S06PacketUpdateHealth.class, - //$$ S2DPacketOpenWindow.class, - //$$ S2EPacketCloseWindow.class, - //$$ S2FPacketSetSlot.class, - //$$ S30PacketWindowItems.class, - //$$ S36PacketSignEditorOpen.class, - //$$ S37PacketStatistics.class, - //$$ S1FPacketSetExperience.class, - //$$ S39PacketPlayerAbilities.class - //#endif - ); - - 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 handler context used to send packets to minecraft. - */ - protected ChannelHandlerContext ctx; - - /** - * 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; - - /** - * 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; - - /** - * The minecraft instance. - */ - protected Minecraft mc = Minecraft.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. - */ - protected boolean allowMovement; - - /** - * Directory to which resource packs are extracted. - */ - private final File tempResourcePackFolder = Files.createTempDir(); - - /** - * Create a new replay sender. - * @param file The replay file - * @param asyncMode {@code true} for async mode, {@code false} otherwise - * @see #asyncMode - */ - public ReplaySender(ReplayHandler replayHandler, ReplayFile file, boolean asyncMode) throws IOException { - this.replayHandler = replayHandler; - this.replayFile = file; - this.asyncMode = asyncMode; - this.replayLength = file.getMetaData().getDuration(); - - MinecraftForge.EVENT_BUS.register(this); - - if (asyncMode) { - new Thread(asyncSender, "replaymod-async-sender").start(); - } - } - - /** - * 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 - */ - 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; - } - } - - 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. - */ - 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 {@link Minecraft#getSystemTime()} 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 - */ - public int currentTimeStamp() { - if (asyncMode) { - int timePassed = (int) (System.currentTimeMillis() - lastPacketSent); - return lastTimeStamp + (int) (timePassed * getReplaySpeed()); - } else { - return lastTimeStamp; - } - } - - /** - * Return the total length of the replay played. - * @return Total length in milliseconds - */ - public int replayLength() { - return replayLength; - } - - /** - * Terminate this replay sender. - */ - public void terminateReplay() { - terminate = true; - MinecraftForge.EVENT_BUS.unregister(this); - try { - channelInactive(ctx); - ctx.channel().pipeline().close(); - FileUtils.deleteDirectory(tempResourcePackFolder); - } catch(Exception e) { - e.printStackTrace(); - } - } - - @SubscribeEvent - public void onWorldTick(TickEvent.ClientTickEvent event) { - // Unfortunately the WorldTickEvent doesn't seem to be emitted on the CLIENT side - if (event.phase != TickEvent.Phase.START) return; - - // 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. - if (world(mc) != null) { - for (EntityPlayer playerEntity : playerEntities(world(mc))) { - if (!playerEntity.addedToChunk && playerEntity instanceof EntityOtherPlayerMP) { - playerEntity.onLivingUpdate(); - } - } - } - } - - @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; - } - - // When a packet is sent directly, perform no filtering - if(msg instanceof Packet) { - super.channelRead(ctx, msg); - } - - if (msg instanceof byte[]) { - try { - Packet p = deserializePacket((byte[]) msg); - - if (p != null) { - p = processPacket(p); - if (p != null) { - super.channelRead(ctx, p); - } - - // 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. - if (!asyncMode && world(mc) != null) { - //#if MC>=10904 - if (p instanceof SPacketSpawnPlayer - || p instanceof SPacketSpawnObject - || p instanceof SPacketSpawnMob - || p instanceof SPacketSpawnGlobalEntity - || p instanceof SPacketSpawnPainting - || p instanceof SPacketSpawnExperienceOrb - || p instanceof SPacketDestroyEntities) { - //#else - //$$ if (p instanceof S0CPacketSpawnPlayer - //$$ || p instanceof S0EPacketSpawnObject - //$$ || p instanceof S0FPacketSpawnMob - //$$ || p instanceof S2CPacketSpawnGlobalEntity - //$$ || p instanceof S10PacketSpawnPainting - //$$ || p instanceof S11PacketSpawnExperienceOrb - //$$ || p instanceof S13PacketDestroyEntities) { - //#endif - World world = world(mc); - for (int i = 0; i < world.loadedEntityList.size(); ++i) { - Entity entity = loadedEntityList(world).get(i); - if (entity.isDead) { - int chunkX = entity.chunkCoordX; - int chunkY = entity.chunkCoordZ; - - //#if MC>=10904 - if (entity.addedToChunk && world.getChunkProvider().getLoadedChunk(chunkX, chunkY) != null) { - //#else - //$$ if (entity.addedToChunk && world.getChunkProvider().chunkExists(chunkX, chunkY)) { - //#endif - world.getChunkFromChunkCoords(chunkX, chunkY).removeEntity(entity); - } - - world.loadedEntityList.remove(i--); - world.onEntityRemoved(entity); - } - - } - } - } - } - } catch (Exception e) { - // We'd rather not have a failure parsing one packet screw up the whole replay process - e.printStackTrace(); - } - } - - } - - private Packet deserializePacket(byte[] bytes) throws IOException, IllegalAccessException, InstantiationException { - ByteBuf bb = Unpooled.wrappedBuffer(bytes); - PacketBuffer pb = new PacketBuffer(bb); - - int i = readVarInt(pb); - - //#if MC>=10800 - Packet p = EnumConnectionState.PLAY.getPacket(EnumPacketDirection.CLIENTBOUND, i); - //#else - //$$ Packet p = Packet.generatePacket(EnumConnectionState.PLAY.func_150755_b(), i); - //#endif - p.readPacketData(pb); - - return p; - } - - /** - * 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 MC>=10904 - if (p instanceof SPacketCustomPayload) { - SPacketCustomPayload packet = (SPacketCustomPayload) p; - //#else - //$$ if (p instanceof S3FPacketCustomPayload) { - //$$ S3FPacketCustomPayload packet = (S3FPacketCustomPayload) p; - //#endif - //#if MC>=10800 - String channelName = packet.getChannelName(); - //#else - //$$ String channelName = packet.func_149169_c(); - //#endif - if (Restrictions.PLUGIN_CHANNEL.equals(channelName)) { - 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 - mc.addScheduledTask(new Runnable() { - @Override - public void run() { - try { - replayHandler.endReplay(); - } catch (IOException e) { - e.printStackTrace(); - } - mc.displayGuiScreen(new GuiErrorScreen( - I18n.format("replaymod.error.unknownrestriction1"), - I18n.format("replaymod.error.unknownrestriction2", unknown) - )); - } - }); - } - } - } - //#if MC>=10904 - if (p instanceof SPacketDisconnect) { - ITextComponent reason = ((SPacketDisconnect) p).getReason(); - //#else - //$$ if (p instanceof S40PacketDisconnect) { - //#if MC>=10809 - //$$ IChatComponent reason = ((S40PacketDisconnect) p).getReason(); - //#else - //$$ IChatComponent reason = ((S40PacketDisconnect) p).func_149165_c(); - //#endif - //#endif - if ("Please update to view this replay.".equals(reason.getUnformattedText())) { - // 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 MC>=10904 - if (p instanceof SPacketCustomPayload) { - SPacketCustomPayload packet = (SPacketCustomPayload) p; - //#else - //$$ if (p instanceof S3FPacketCustomPayload) { - //$$ S3FPacketCustomPayload packet = (S3FPacketCustomPayload) p; - //#endif - //#if MC>=10800 - String channelName = packet.getChannelName(); - //#else - //$$ String channelName = packet.func_149169_c(); - //#endif - if ("MC|BOpen".equals(channelName)) { - return null; - } - //#if MC>=10800 - } - - //#if MC>=10904 - if(p instanceof SPacketResourcePackSend) { - SPacketResourcePackSend packet = (SPacketResourcePackSend) p; - String url = packet.getURL(); - //#else - //$$ if(p instanceof S48PacketResourcePackSend) { - //$$ S48PacketResourcePackSend packet = (S48PacketResourcePackSend) p; - //#if MC>=10809 - //$$ String url = packet.getURL(); - //#else - //$$ String url = packet.func_179783_a(); - //#endif - //#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)); - } - setServerResourcePack(mc.getResourcePackRepository(), file); - } - } - return null; - } - } - - //#if MC>=10904 - if(p instanceof SPacketJoinGame) { - SPacketJoinGame packet = (SPacketJoinGame) p; - int entId = packet.getPlayerId(); - //#else - //$$ if(p instanceof S01PacketJoinGame) { - //$$ S01PacketJoinGame packet = (S01PacketJoinGame) p; - //#if MC>=10800 - //$$ int entId = packet.getEntityId(); - //#else - //$$ int entId = packet.func_149197_c(); - //#endif - //#endif - 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>=10800 - int dimension = packet.getDimension(); - EnumDifficulty difficulty = packet.getDifficulty(); - int maxPlayers = packet.getMaxPlayers(); - 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 - } - - //#if MC>=10904 - if(p instanceof SPacketRespawn) { - SPacketRespawn respawn = (SPacketRespawn) p; - p = new SPacketRespawn(respawn.getDimensionID(), - respawn.getDifficulty(), respawn.getWorldType(), GameType.SPECTATOR); - //#else - //$$ if(p instanceof S07PacketRespawn) { - //$$ S07PacketRespawn respawn = (S07PacketRespawn) p; - //#if MC>=10809 - //$$ p = new S07PacketRespawn(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 - - allowMovement = true; - } - - //#if MC>=10904 - if(p instanceof SPacketPlayerPosLook) { - final SPacketPlayerPosLook ppl = (SPacketPlayerPosLook) p; - //#else - //$$ if(p instanceof S08PacketPlayerPosLook) { - //$$ final S08PacketPlayerPosLook ppl = (S08PacketPlayerPosLook) p; - //#endif - if(!hasWorldLoaded) hasWorldLoaded = true; - - if (mc.currentScreen instanceof GuiDownloadTerrain) { - // Close the world loading screen manually in case we swallow the packet - mc.displayGuiScreen(null); - } - - if(replayHandler.shouldSuppressCameraMovements()) return null; - - CameraEntity cent = replayHandler.getCameraEntity(); - - //#if MC>=10800 - //#if MC>=10904 - for (SPacketPlayerPosLook.EnumFlags relative : ppl.getFlags()) { - if (relative == SPacketPlayerPosLook.EnumFlags.X - || relative == SPacketPlayerPosLook.EnumFlags.Y - || relative == SPacketPlayerPosLook.EnumFlags.Z) { - //#else - //$$ for (Object relative : ppl.func_179834_f()) { - //$$ if (relative == S08PacketPlayerPosLook.EnumFlags.X - //$$ || relative == S08PacketPlayerPosLook.EnumFlags.Y - //$$ || relative == S08PacketPlayerPosLook.EnumFlags.Z) { - //#endif - return null; // At least one of the coordinates is relative, so we don't care - } - } - //#endif - - if(cent != null) { - //#if MC>=10809 - if(!allowMovement && !((Math.abs(cent.posX - ppl.getX()) > TP_DISTANCE_LIMIT) || - (Math.abs(cent.posZ - ppl.getZ()) > TP_DISTANCE_LIMIT))) { - //#else - //$$ if(!allowMovement && !((Math.abs(cent.posX - ppl.func_148932_c()) > TP_DISTANCE_LIMIT) || - //$$ (Math.abs(cent.posZ - ppl.func_148933_e()) > TP_DISTANCE_LIMIT))) { - //#endif - return null; - } else { - allowMovement = false; - } - } - - new Runnable() { - @Override - @SuppressWarnings("unchecked") - public void run() { - if (world(mc) == null || !mc.isCallingFromMinecraftThread()) { - ReplayMod.instance.runLater(this); - return; - } - - CameraEntity cent = replayHandler.getCameraEntity(); - //#if MC>=10809 - cent.setCameraPosition(ppl.getX(), ppl.getY(), ppl.getZ()); - //#else - //$$ cent.setCameraPosition(ppl.func_148932_c(), ppl.func_148928_d(), ppl.func_148933_e()); - //#endif - } - }.run(); - } - - //#if MC>=10904 - if(p instanceof SPacketChangeGameState) { - SPacketChangeGameState pg = (SPacketChangeGameState)p; - int reason = pg.getGameState(); - //#else - //$$ if(p instanceof S2BPacketChangeGameState) { - //$$ S2BPacketChangeGameState pg = (S2BPacketChangeGameState)p; - //#if MC>=10809 - //$$ int reason = pg.getGameState(); - //#else - //$$ int reason = pg.func_149138_c(); - //#endif - //#endif - - // 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(!(reason == 1 || reason == 2 || reason == 7 || reason == 8)) { - return null; - } - } - - //#if MC>=10904 - if (p instanceof SPacketChat) { - //#else - //$$ if (p instanceof S02PacketChat) { - //#endif - if (!ReplayModReplay.instance.getCore().getSettingsRegistry().get(Setting.SHOW_CHAT)) { - return null; - } - } - - return asyncMode ? processPacketAsync(p) : processPacketSync(p); - } - - @Override - @SuppressWarnings("unchecked") - public void channelActive(ChannelHandlerContext ctx) throws Exception { - this.ctx = ctx; - //#if MC>=10904 - ctx.channel().attr(NetworkManager.PROTOCOL_ATTRIBUTE_KEY).set(EnumConnectionState.PLAY); - //#else - //#if MC>=10800 - //$$ ctx.attr(NetworkManager.attrKeyConnectionState).set(EnumConnectionState.PLAY); - //#endif - //#endif - super.channelActive(ctx); - } - - @Override - public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { - // The embedded channel's event loop will consider every thread to be in it and as such provides no - // guarantees that only one thread is using the pipeline at any one time. - // For reading the replay sender (either sync or async) is the only thread ever writing. - // For writing it may very well happen that multiple threads want to use the pipline at the same time. - // It's unclear whether the EmbeddedChannel is supposed to be thread-safe (the behavior of the event loop - // does suggest that). However it seems like it either isn't (likely) or there is a race condition. - // See: https://www.replaymod.com/forum/thread/1752#post8045 (https://paste.replaymod.com/lotacatuwo) - // To work around this issue, we just outright drop all write/flush requests (they aren't needed anyway). - // This still leaves channel handlers upstream with the threading issue but they all seem to cope well with it. - promise.setSuccess(); - } - - @Override - public void flush(ChannelHandlerContext ctx) throws Exception { - // See write method above - } +public interface ReplaySender { + int currentTimeStamp(); /** * Whether the replay is currently paused. * @return {@code true} if it is paused, {@code false} otherwise */ - public boolean paused() { + public default boolean paused() { + Minecraft mc = Minecraft.getMinecraft(); //#if MC>=11200 return mc.timer.tickLength == Float.POSITIVE_INFINITY; //#else @@ -742,414 +18,13 @@ public class ReplaySender extends ChannelDuplexHandler { //#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 - */ - public double getReplaySpeed() { - if(!paused()) return replaySpeed; - else return 0; - } + void setReplaySpeed(double factor); + double getReplaySpeed(); - /** - * 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 - */ - public void setReplaySpeed(final double d) { - if(d != 0) this.replaySpeed = d; - //#if MC>=11200 - mc.timer.tickLength = WrappedTimer.DEFAULT_MS_PER_TICK / (float) d; - //#else - //$$ mc.timer.timerSpeed = (float) d; - //#endif - } + boolean isAsyncMode(); + void setAsyncMode(boolean async); + void setSyncModeAndWait(); - ///////////////////////////////////////////////////////// - // Asynchronous packet processing // - ///////////////////////////////////////////////////////// - - /** - * The real time at which the last packet was sent in milliseconds. - */ - private long lastPacketSent; - - /** - * 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 { - while (ctx == null && !terminate) { - Thread.sleep(10); - } - REPLAY_LOOP: - while (!terminate) { - synchronized (ReplaySender.this) { - if (replayIn == null) { - replayIn = replayFile.getPacketData(); - } - // 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) { - // Unless we are going to terminate, restart or jump - if (terminate || startFromBeginning || desiredTimeStamp != -1) { - break; - } - Thread.sleep(10); - } - - if (terminate) { - 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) { - // How much time should have passed - int timeWait = (int) Math.round((nextTimeStamp - lastTimeStamp) / replaySpeed); - // How much time did pass - long timeDiff = System.currentTimeMillis() - lastPacketSent; - // How much time we need to wait to make up for the difference - long timeToSleep = Math.max(0, timeWait - timeDiff); - - Thread.sleep(timeToSleep); - lastPacketSent = System.currentTimeMillis(); - } - - // Process packet - channelRead(ctx, nextPacket.bytes); - nextPacket = null; - - lastTimeStamp = nextTimeStamp; - - // 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 - 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); - } - break; - } catch (IOException e) { - e.printStackTrace(); - } - } - - // Restart the replay. - hasWorldLoaded = false; - lastTimeStamp = 0; - startFromBeginning = false; - nextPacket = null; - lastPacketSent = System.currentTimeMillis(); - replayHandler.restartedReplay(); - if (replayIn != null) { - replayIn.close(); - replayIn = null; - } - } - } - } 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 - */ - 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 MC>=10904 - if(p instanceof SPacketParticles) return null; - - if(p instanceof SPacketSpawnObject) { - SPacketSpawnObject pso = (SPacketSpawnObject)p; - int type = pso.getType(); - //#else - //$$ if(p instanceof S2APacketParticles) return null; - //$$ - //$$ if(p instanceof S0EPacketSpawnObject) { - //$$ S0EPacketSpawnObject pso = (S0EPacketSpawnObject)p; - //#if MC>=10809 - //$$ int type = pso.getType(); - //#else - //$$ int type = pso.func_148993_l(); - //#endif - //#endif - if(type == 76) { // Firework rocket - return null; - } - } - } - return p; - } - - ///////////////////////////////////////////////////////// - // Synchronous packet processing // - ///////////////////////////////////////////////////////// - - /** - * 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 - */ - public void sendPacketsTill(int timestamp) { - Preconditions.checkState(!asyncMode, "This method cannot be used in async mode. Use jumpToTime(int) instead."); - try { - while (ctx == null && !terminate) { // Make sure channel is ready - Thread.sleep(10); - } - - 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; - lastTimeStamp = 0; - if (replayIn != null) { - replayIn.close(); - replayIn = null; - } - startFromBeginning = false; - nextPacket = null; - replayHandler.restartedReplay(); - } - - if (replayIn == null) { - replayIn = replayFile.getPacketData(); - } - - 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) { - // We are done sending all packets - nextPacket = pd; - break; - } - - // Process packet - channelRead(ctx, pd.bytes); - } 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 - lastPacketSent = System.currentTimeMillis(); - lastTimeStamp = timestamp; - } - } catch (Exception e) { - e.printStackTrace(); - } - } - - protected Packet processPacketSync(Packet p) { - //#if MC>=10904 - if (p instanceof SPacketUnloadChunk) { - SPacketUnloadChunk packet = (SPacketUnloadChunk) p; - int x = packet.getX(); - int z = packet.getZ(); - //#else - //#if MC>=10809 - //$$ if (p instanceof S21PacketChunkData && ((S21PacketChunkData) p).getExtractedSize() == 0) { - //$$ S21PacketChunkData packet = (S21PacketChunkData) p; - //$$ int x = packet.getChunkX(); - //$$ int z = packet.getChunkZ(); - //#else - //$$ if (p instanceof S21PacketChunkData && ((S21PacketChunkData) p).func_149276_g() == 0) { - //$$ S21PacketChunkData packet = (S21PacketChunkData) p; - //$$ int x = packet.func_149273_e(); - //$$ int z = packet.func_149271_f(); - //#endif - //#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. - World world = world(mc); - IChunkProvider chunkProvider = world.getChunkProvider(); - // Get the chunk that will be unloaded - Chunk chunk = chunkProvider.provideChunk(x, z); - if (!chunk.isEmpty()) { - List entitiesInChunk = new ArrayList<>(); - // Gather all entities in that chunk - for (Collection entityList : getEntityLists(chunk)) { - entitiesInChunk.addAll(entityList); - } - for (Entity entity : entitiesInChunk) { - // Skip interpolation of position updates coming from server - // (See: newX in EntityLivingBase or otherPlayerMPX in EntityOtherPlayerMP) - // Needs to be called at least 4 times thanks to - // EntityOtherPlayerMP#otherPlayerMPPosRotationIncrements (max vanilla value is 3) - for (int i = 0; i < 4; i++) { - entity.onUpdate(); - } - - // Check whether the entity has left the chunk - int chunkX = floor(entity.posX / 16); - int chunkZ = 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; - } - } - } - } - return p; // During synchronous playback everything is sent normally - } - - private static final class PacketData { - private static final ByteBuf byteBuf = Unpooled.buffer(); - private static final ByteBufOutputStream byteBufOut = new ByteBufOutputStream(byteBuf); - private static final ReplayOutputStream encoder = new ReplayOutputStream(new ReplayStudio(), byteBufOut); - private final int timestamp; - private final byte[] bytes; - - public PacketData(ReplayInputStream in) throws IOException { - com.replaymod.replaystudio.PacketData data = in.readPacket(); - timestamp = (int) data.getTime(); - // We need to re-encode MCProtocolLib 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 (encoder) { - byteBuf.markReaderIndex(); // Mark the current reader and writer index (should be at start) - byteBuf.markWriterIndex(); - - encoder.write(data); // Re-encode packet, data will end up in byteBuf - encoder.flush(); - - byteBuf.skipBytes(8); // Skip packet length & timestamp - bytes = new byte[byteBuf.readableBytes()]; // Create bytes array of sufficient size - byteBuf.readBytes(bytes); // Read all data into bytes - - byteBuf.resetReaderIndex(); // Reset reader & writer index for next use - byteBuf.resetWriterIndex(); - } - } - } + void jumpToTime(int value); // async + void sendPacketsTill(int replayTime); // sync } diff --git a/src/main/java/com/replaymod/replay/gui/overlay/GuiReplayOverlay.java b/src/main/java/com/replaymod/replay/gui/overlay/GuiReplayOverlay.java index d27379a0..6f968243 100644 --- a/src/main/java/com/replaymod/replay/gui/overlay/GuiReplayOverlay.java +++ b/src/main/java/com/replaymod/replay/gui/overlay/GuiReplayOverlay.java @@ -128,7 +128,7 @@ public class GuiReplayOverlay extends AbstractGuiOverlay { public void run(int time) { replayHandler.doJump(time, true); } - }).setLength(replayHandler.getReplaySender().replayLength()); + }).setLength(replayHandler.getReplayDuration()); } public double getSpeedSliderValue() {