From edc54ce6d457194d59867d37c74d8ca3dd629b30 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Wed, 18 Jul 2018 21:53:58 +0200 Subject: [PATCH 01/16] Add quick replay mode Allows very quick forwards and backwards jumping at the cost of only replaying entity positions and block updates. Still needs to be backported from 1.12.2 and contains FIXMEs --- .../replaymod/replay/FullReplaySender.java | 1143 ++++++++++++++++ .../replaymod/replay/QuickReplaySender.java | 520 ++++++++ .../com/replaymod/replay/ReplayHandler.java | 59 +- .../com/replaymod/replay/ReplayModReplay.java | 6 + .../com/replaymod/replay/ReplaySender.java | 1147 +---------------- .../replay/gui/overlay/GuiReplayOverlay.java | 2 +- 6 files changed, 1731 insertions(+), 1146 deletions(-) create mode 100755 src/main/java/com/replaymod/replay/FullReplaySender.java create mode 100644 src/main/java/com/replaymod/replay/QuickReplaySender.java mode change 100755 => 100644 src/main/java/com/replaymod/replay/ReplaySender.java 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() { From 6cb207fd47deaa049120d1b9efabc81904fa1213 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Fri, 27 Jul 2018 18:49:06 +0200 Subject: [PATCH 02/16] Also track weather and time during quick replay mode --- .../replaymod/replay/QuickReplaySender.java | 90 ++++++++++++++++--- 1 file changed, 80 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/replaymod/replay/QuickReplaySender.java b/src/main/java/com/replaymod/replay/QuickReplaySender.java index fb299866..161b1794 100644 --- a/src/main/java/com/replaymod/replay/QuickReplaySender.java +++ b/src/main/java/com/replaymod/replay/QuickReplaySender.java @@ -22,7 +22,9 @@ import com.github.steveice10.mc.protocol.packet.ingame.server.entity.spawn.Serve 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.ServerNotifyClientPacket; import com.github.steveice10.mc.protocol.packet.ingame.server.world.ServerUnloadChunkPacket; +import com.github.steveice10.mc.protocol.packet.ingame.server.world.ServerUpdateTimePacket; import com.google.common.base.Throwables; import com.google.common.collect.ListMultimap; import com.google.common.collect.Multimaps; @@ -101,6 +103,8 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe private TreeMap> thingDespawnsT = new TreeMap<>(); private ListMultimap thingDespawns = Multimaps.newListMultimap(thingDespawnsT, ArrayList::new); private List activeThings = new LinkedList<>(); + private TreeMap> worldTimes = new TreeMap<>(); + private TreeMap> thunderStrengths = new TreeMap<>(); // For some reason, this isn't tied to Weather public QuickReplaySender(ReplayModReplay mod, ReplayFile replayFile) { this.mod = mod; @@ -246,10 +250,13 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe studio.setParsing(ServerBlockChangePacket.class, true); studio.setParsing(ServerMultiBlockChangePacket.class, true); studio.setParsing(ServerPlayerListEntryPacket.class, true); + studio.setParsing(ServerUpdateTimePacket.class, true); + studio.setParsing(ServerNotifyClientPacket.class, true); Map playerListEntries = new HashMap<>(); Map activeEntities = new HashMap<>(); Map activeChunks = new HashMap<>(); + Weather activeWeather = null; try (ReplayInputStream in = replayFile.getPacketData(studio)) { double duration = replayFile.getMetaData().getDuration(); @@ -337,6 +344,39 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } } else if (packet instanceof ServerRespawnPacket) { // FIXME + } else if (packet instanceof ServerUpdateTimePacket) { + worldTimes.put(time, toMC(packet)); + } else if (packet instanceof ServerNotifyClientPacket) { + ServerNotifyClientPacket p = (ServerNotifyClientPacket) packet; + switch (p.getNotification()) { + case START_RAIN: + if (activeWeather != null) { + activeWeather.despawnPackets = Collections.singletonList(toMC(packet)); + activeWeather.despawnTime = time; + thingDespawns.put(time, activeWeather); + } + activeWeather = new Weather(toMC(packet)); + activeWeather.spawnTime = time; + thingSpawns.put(time, activeWeather); + break; + case STOP_RAIN: + if (activeWeather != null) { + activeWeather.despawnPackets = Collections.singletonList(toMC(packet)); + activeWeather.despawnTime = time; + thingDespawns.put(time, activeWeather); + activeWeather = null; + } + break; + case RAIN_STRENGTH: + if (activeWeather != null) { + activeWeather.rainStrengths.put(time, toMC(packet)); + } + break; + case THUNDER_STRENGTH: + thunderStrengths.put(time, toMC(packet)); + break; + default: break; + } } if (entityId != null) { Entity entity = activeEntities.get(entityId); @@ -374,6 +414,8 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } })); activeThings.forEach(thing -> thing.play(currentTimeStamp, replayTime, ctx::fireChannelRead)); + playMap(worldTimes, currentTimeStamp, replayTime, ctx::fireChannelRead); + playMap(thunderStrengths, currentTimeStamp, replayTime, ctx::fireChannelRead); } else { activeThings.removeIf(thing -> { if (thing.spawnTime > replayTime) { @@ -391,6 +433,8 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } })); activeThings.forEach(thing -> thing.rewind(currentTimeStamp, replayTime, ctx::fireChannelRead)); + rewindMap(worldTimes, currentTimeStamp, replayTime, ctx::fireChannelRead); + rewindMap(thunderStrengths, currentTimeStamp, replayTime, ctx::fireChannelRead); } currentTimeStamp = replayTime; }); @@ -432,6 +476,20 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe return (long)x << 32 | (long)z & 0xFFFFFFFFL; } + private static void playMap(NavigableMap updates, int currentTimeStamp, int replayTime, Consumer update) { + Map.Entry lastUpdate = updates.floorEntry(replayTime); + if (lastUpdate != null && lastUpdate.getKey() > currentTimeStamp) { + update.accept(lastUpdate.getValue()); + } + } + + private static void rewindMap(NavigableMap updates, int currentTimeStamp, int replayTime, Consumer update) { + Map.Entry lastUpdate = updates.floorEntry(replayTime); + if (lastUpdate != null && !lastUpdate.getKey().equals(updates.floorKey(currentTimeStamp))) { + update.accept(lastUpdate.getValue()); + } + } + private static abstract class TrackedThing { List> spawnPackets; List> despawnPackets; @@ -454,20 +512,14 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe @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))); - } + playMap(locations, currentTimeStamp, replayTime, l -> + 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))); - } + rewindMap(locations, currentTimeStamp, replayTime, l -> + send.accept(toMC(new ServerEntityTeleportPacket(id, l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch(), false)))); } } @@ -517,4 +569,22 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe this.to = to; } } + + private static class Weather extends TrackedThing { + private TreeMap> rainStrengths = new TreeMap<>(); + + private Weather(Packet spawnPacket) { + this.spawnPackets = Collections.singletonList(spawnPacket); + } + + @Override + public void play(int currentTimeStamp, int replayTime, Consumer> send) { + playMap(rainStrengths, currentTimeStamp, replayTime, send); + } + + @Override + public void rewind(int currentTimeStamp, int replayTime, Consumer> send) { + rewindMap(rainStrengths, currentTimeStamp, replayTime, send); + } + } } From 12605f51de9e9343c2739dd5c4a9a1e745babad2 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Fri, 27 Jul 2018 19:50:40 +0200 Subject: [PATCH 03/16] Add GUI popup for initializing quick mode and signaling failures --- .../replaymod/replay/QuickReplaySender.java | 8 ++- .../com/replaymod/replay/ReplayHandler.java | 64 ++++++++++++++++++- .../com/replaymod/replay/ReplayModReplay.java | 3 +- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/replaymod/replay/QuickReplaySender.java b/src/main/java/com/replaymod/replay/QuickReplaySender.java index 161b1794..133beb94 100644 --- a/src/main/java/com/replaymod/replay/QuickReplaySender.java +++ b/src/main/java/com/replaymod/replay/QuickReplaySender.java @@ -124,6 +124,10 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe this.ctx = ctx; } + public ListenableFuture getInitializationPromise() { + return initPromise; + } + public ListenableFuture initialize(Consumer progress) { if (initPromise != null) { return initPromise; @@ -148,9 +152,9 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe return promise; } - public void ensureInitialized(Runnable body) { + private void ensureInitialized(Runnable body) { if (initPromise == null) { - // TODO progress popup + LOGGER.warn("QuickReplaySender used without prior initialization!", new Throwable()); initialize(progress -> {}); } Futures.addCallback(initPromise, new FutureCallback() { diff --git a/src/main/java/com/replaymod/replay/ReplayHandler.java b/src/main/java/com/replaymod/replay/ReplayHandler.java index 33ba056b..81d70521 100755 --- a/src/main/java/com/replaymod/replay/ReplayHandler.java +++ b/src/main/java/com/replaymod/replay/ReplayHandler.java @@ -1,7 +1,11 @@ package com.replaymod.replay; import com.google.common.base.Preconditions; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import com.replaymod.core.utils.Restrictions; +import com.replaymod.core.utils.Utils; import com.replaymod.core.utils.WrappedTimer; import com.replaymod.replay.camera.CameraEntity; import com.replaymod.replay.camera.SpectatorCameraController; @@ -11,6 +15,9 @@ import com.replaymod.replay.gui.overlay.GuiReplayOverlay; import com.replaymod.replaystudio.data.Marker; import com.replaymod.replaystudio.replay.ReplayFile; import com.replaymod.replaystudio.util.Location; +import de.johni0702.minecraft.gui.container.GuiContainer; +import de.johni0702.minecraft.gui.element.advanced.GuiProgressBar; +import de.johni0702.minecraft.gui.popup.AbstractGuiPopup; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.embedded.EmbeddedChannel; import net.minecraft.client.Minecraft; @@ -18,6 +25,7 @@ import net.minecraft.client.entity.EntityOtherPlayerMP; import net.minecraft.client.gui.GuiScreen; import net.minecraft.client.gui.ScaledResolution; import net.minecraft.client.resources.I18n; +import net.minecraft.crash.CrashReport; import net.minecraft.entity.Entity; import net.minecraft.entity.player.EntityPlayer; import net.minecraft.network.NetworkManager; @@ -47,10 +55,13 @@ import static net.minecraft.client.renderer.GlStateManager.*; //$$ import java.net.SocketAddress; //$$ //$$ import static com.replaymod.core.versions.MCVer.GlStateManager.*; -//$$ import static com.replaymod.replay.ReplayModReplay.LOGGER; //#endif +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + import static com.replaymod.core.versions.MCVer.*; +import static com.replaymod.replay.ReplayModReplay.LOGGER; import static org.lwjgl.opengl.GL11.GL_COLOR_BUFFER_BIT; import static org.lwjgl.opengl.GL11.GL_DEPTH_BUFFER_BIT; @@ -262,6 +273,57 @@ public class ReplayHandler { return overlay; } + public void ensureQuickModeInitialized(Runnable andThen) { + ListenableFuture future = quickReplaySender.getInitializationPromise(); + if (future == null) { + InitializingQuickModePopup popup = new InitializingQuickModePopup(overlay); + future = quickReplaySender.initialize(progress -> popup.progressBar.setProgress(progress.floatValue())); + Futures.addCallback(future, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + popup.close(); + } + + @Override + public void onFailure(@Nonnull Throwable t) { + String message = "Failed to initialize quick mode. It will not be available."; + Utils.error(LOGGER, overlay, CrashReport.makeCrashReport(t, message), popup::close); + } + }); + } + Futures.addCallback(future, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + andThen.run(); + } + + @Override + public void onFailure(@Nonnull Throwable t) { + // Exception already printed in callback added above + } + }); + } + + private class InitializingQuickModePopup extends AbstractGuiPopup { + private final GuiProgressBar progressBar = new GuiProgressBar(popup).setSize(300, 20) + .setI18nLabel("replaymod.gui.loadquickmode"); + + public InitializingQuickModePopup(GuiContainer container) { + super(container); + open(); + } + + @Override + public void close() { + super.close(); + } + + @Override + protected InitializingQuickModePopup getThis() { + return this; + } + } + public void setQuickMode(boolean quickMode) { if (quickMode == this.quickMode) return; if (quickMode && !fullReplaySender.isAsyncMode()) return; // Cannot activate quick mode when already in sync mode diff --git a/src/main/java/com/replaymod/replay/ReplayModReplay.java b/src/main/java/com/replaymod/replay/ReplayModReplay.java index af18a138..5ff5a373 100644 --- a/src/main/java/com/replaymod/replay/ReplayModReplay.java +++ b/src/main/java/com/replaymod/replay/ReplayModReplay.java @@ -144,7 +144,8 @@ public class ReplayModReplay { core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.quickmode", Keyboard.KEY_Q, () -> { if (replayHandler != null) { - replayHandler.setQuickMode(!replayHandler.isQuickMode()); + replayHandler.ensureQuickModeInitialized(() -> + replayHandler.setQuickMode(!replayHandler.isQuickMode())); } }); From 42a2ae35edb6fea84c04ce7ad587aff5728967fb Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Fri, 27 Jul 2018 21:26:18 +0200 Subject: [PATCH 04/16] fixup quick weather --- .../java/com/replaymod/replay/QuickReplaySender.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/replaymod/replay/QuickReplaySender.java b/src/main/java/com/replaymod/replay/QuickReplaySender.java index 133beb94..ec72a39f 100644 --- a/src/main/java/com/replaymod/replay/QuickReplaySender.java +++ b/src/main/java/com/replaymod/replay/QuickReplaySender.java @@ -10,6 +10,7 @@ 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.data.game.world.notify.ClientNotification; 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; @@ -355,17 +356,15 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe switch (p.getNotification()) { case START_RAIN: if (activeWeather != null) { - activeWeather.despawnPackets = Collections.singletonList(toMC(packet)); activeWeather.despawnTime = time; thingDespawns.put(time, activeWeather); } - activeWeather = new Weather(toMC(packet)); + activeWeather = new Weather(); activeWeather.spawnTime = time; thingSpawns.put(time, activeWeather); break; case STOP_RAIN: if (activeWeather != null) { - activeWeather.despawnPackets = Collections.singletonList(toMC(packet)); activeWeather.despawnTime = time; thingDespawns.put(time, activeWeather); activeWeather = null; @@ -577,8 +576,9 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe private static class Weather extends TrackedThing { private TreeMap> rainStrengths = new TreeMap<>(); - private Weather(Packet spawnPacket) { - this.spawnPackets = Collections.singletonList(spawnPacket); + private Weather() { + this.spawnPackets = Collections.singletonList(toMC(new ServerNotifyClientPacket(ClientNotification.START_RAIN, null))); + this.despawnPackets = Collections.singletonList(toMC(new ServerNotifyClientPacket(ClientNotification.STOP_RAIN, null))); } @Override From ec5ffe3e02fafb52f0c782a4d335b9c3166e41b4 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Sat, 28 Jul 2018 12:59:58 +0200 Subject: [PATCH 05/16] Add caching to the quick replay sender --- .../replaymod/replay/QuickReplaySender.java | 371 ++++++++++++++++-- 1 file changed, 340 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/replaymod/replay/QuickReplaySender.java b/src/main/java/com/replaymod/replay/QuickReplaySender.java index ec72a39f..9e32b5bc 100644 --- a/src/main/java/com/replaymod/replay/QuickReplaySender.java +++ b/src/main/java/com/replaymod/replay/QuickReplaySender.java @@ -26,6 +26,12 @@ import com.github.steveice10.mc.protocol.packet.ingame.server.world.ServerMultiB import com.github.steveice10.mc.protocol.packet.ingame.server.world.ServerNotifyClientPacket; import com.github.steveice10.mc.protocol.packet.ingame.server.world.ServerUnloadChunkPacket; import com.github.steveice10.mc.protocol.packet.ingame.server.world.ServerUpdateTimePacket; +import com.github.steveice10.mc.protocol.util.NetUtil; +import com.github.steveice10.packetlib.io.NetInput; +import com.github.steveice10.packetlib.io.NetOutput; +import com.github.steveice10.packetlib.io.stream.StreamNetInput; +import com.github.steveice10.packetlib.io.stream.StreamNetOutput; +import com.google.common.base.Optional; import com.google.common.base.Throwables; import com.google.common.collect.ListMultimap; import com.google.common.collect.Multimaps; @@ -57,6 +63,8 @@ import net.minecraftforge.fml.common.gameevent.TickEvent; import javax.annotation.Nullable; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -66,6 +74,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.NavigableMap; +import java.util.SortedMap; import java.util.TreeMap; import java.util.UUID; import java.util.function.Consumer; @@ -81,6 +90,9 @@ import static com.replaymod.replay.ReplayModReplay.LOGGER; */ @ChannelHandler.Sharable public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySender { + private static final String CACHE_ENTRY = "quickModeCache.bin"; + private static final int CACHE_VERSION = 0; + private final Minecraft mc = Minecraft.getMinecraft(); private final ReplayModReplay mod; @@ -99,11 +111,11 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe 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<>(); + 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<>(); private TreeMap> worldTimes = new TreeMap<>(); private TreeMap> thunderStrengths = new TreeMap<>(); // For some reason, this isn't tied to Weather @@ -138,7 +150,11 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe new Thread(() -> { try { long start = System.currentTimeMillis(); - analyseReplay(progress); + if (!tryLoadFromCache(progress)) { + double progressSplit = 0.9; // 90% of progress time for analysing, 10% for loading + analyseReplay(replayFile, d -> progress.accept(d * progressSplit)); + tryLoadFromCache(d -> progress.accept(d * (1 - progressSplit) + progressSplit)); + } LOGGER.info("Initialized quick replay sender in " + (System.currentTimeMillis() - start) + "ms"); } catch (Throwable e) { LOGGER.error("Initializing quick replay sender:", e); @@ -242,7 +258,56 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe sendPacketsTill(currentTimeStamp + replayTimePassed); } - private void analyseReplay(Consumer progress) { + private boolean tryLoadFromCache(Consumer progress) throws IOException { + Optional cacheOpt = replayFile.get(CACHE_ENTRY); + if (!cacheOpt.isPresent()) return false; + boolean success = loadFromCache(cacheOpt.get(), progress); + if (!success) { + thingSpawnsT.clear(); + thingDespawnsT.clear(); + worldTimes.clear(); + thunderStrengths.clear(); + } + return success; + } + + private boolean loadFromCache(InputStream rawIn, Consumer progress) throws IOException { + long sysTimeStart = System.currentTimeMillis(); + NetInput in = new StreamNetInput(rawIn); + int version = in.readVarInt(); + if (version > CACHE_VERSION) return false; // More recent than this version, no chance we can read that + + int size = in.readVarInt(); + int spawnTime = 0; + for (int i = 0; i < size; i++) { + progress.accept((double) i / size); + spawnTime += in.readVarInt(); + for (int j = in.readVarInt(); j > 0; j--) { + int despawnTime = in.readVarInt(); + BakedTrackedThing trackedThing; + switch (in.readVarInt()) { + case 0: trackedThing = new BakedEntity(version, in); break; + case 1: trackedThing = new BakedChunk(version, in); break; + case 2: trackedThing = new BakedWeather(version, in); break; + default: return false; + } + trackedThing.spawnTime = spawnTime; + trackedThing.despawnTime = despawnTime; + thingSpawns.put(spawnTime, trackedThing); + thingDespawns.put(despawnTime, trackedThing); + } + } + + // These should go quick, they probably won't need to update progress + progress.accept(1.0); + readFromCache(version, in, worldTimes); + readFromCache(version, in, thunderStrengths); + + LOGGER.info("Loaded quick replay from cache in " + (System.currentTimeMillis() - sysTimeStart) + "ms"); + return true; + } + + private static void analyseReplay(ReplayFile replayFile, Consumer progress) throws IOException { ReplayStudio studio = new ReplayStudio(); PacketUtils.registerAllMovementRelated(studio); studio.setParsing(ServerSpawnMobPacket.class, true); @@ -258,29 +323,42 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe studio.setParsing(ServerUpdateTimePacket.class, true); studio.setParsing(ServerNotifyClientPacket.class, true); + TreeMap> thingSpawnsT = new TreeMap<>(); + ListMultimap thingSpawns = Multimaps.newListMultimap(thingSpawnsT, ArrayList::new); + TreeMap worldTimes = new TreeMap<>(); + TreeMap thunderStrengths = new TreeMap<>(); Map playerListEntries = new HashMap<>(); Map activeEntities = new HashMap<>(); Map activeChunks = new HashMap<>(); Weather activeWeather = null; + // 90% of estimated time are for analysis, 10% for writing cache + // This heuristic is based on simply running the function on some 3h replay. + // Numbers may be significantly different for smaller replays where GC doesn't kick in. But the progress bar + // will hopefully be barely visible in that case anyway. + // May need to be updated when performance of MCProtocolLib or this code is improved. + // Same for the split between analyseReplay and loadFromCache declared in #initialize(...) + final double progressSplit = 0.9; + + double sysTimeStart = System.currentTimeMillis(); + double duration; try (ReplayInputStream in = replayFile.getPacketData(studio)) { - double duration = replayFile.getMetaData().getDuration(); + 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); + progress.accept(time / duration * progressSplit); 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 entity = new Entity(entityId, Collections.singletonList(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( @@ -289,20 +367,18 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe playerListEntries.get(((ServerSpawnPlayerPacket) packet).getUUID()) } ); - Entity entity = new Entity(entityId, Arrays.asList(toMC(listEntryPacket), toMC(packet))); + Entity entity = new Entity(entityId, Arrays.asList(listEntryPacket, 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) { @@ -314,7 +390,6 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe 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; @@ -322,7 +397,6 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe 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 : @@ -350,14 +424,13 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } else if (packet instanceof ServerRespawnPacket) { // FIXME } else if (packet instanceof ServerUpdateTimePacket) { - worldTimes.put(time, toMC(packet)); + worldTimes.put(time, packet); } else if (packet instanceof ServerNotifyClientPacket) { ServerNotifyClientPacket p = (ServerNotifyClientPacket) packet; switch (p.getNotification()) { case START_RAIN: if (activeWeather != null) { activeWeather.despawnTime = time; - thingDespawns.put(time, activeWeather); } activeWeather = new Weather(); activeWeather.spawnTime = time; @@ -366,17 +439,16 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe case STOP_RAIN: if (activeWeather != null) { activeWeather.despawnTime = time; - thingDespawns.put(time, activeWeather); activeWeather = null; } break; case RAIN_STRENGTH: if (activeWeather != null) { - activeWeather.rainStrengths.put(time, toMC(packet)); + activeWeather.rainStrengths.put(time, packet); } break; case THUNDER_STRENGTH: - thunderStrengths.put(time, toMC(packet)); + thunderStrengths.put(time, packet); break; default: break; } @@ -392,9 +464,43 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } } } - } catch (IOException e) { - e.printStackTrace(); } + LOGGER.info("Analysed replay in " + (System.currentTimeMillis() - sysTimeStart) + "ms"); + + sysTimeStart = System.currentTimeMillis(); + try (OutputStream cacheOut = replayFile.write(CACHE_ENTRY)) { + NetOutput out = new StreamNetOutput(cacheOut); + out.writeVarInt(CACHE_VERSION); + + out.writeVarInt(thingSpawnsT.size()); + int lastTime = 0; + for (Map.Entry> entry : thingSpawnsT.entrySet()) { + int time = entry.getKey(); + out.writeVarInt(time - lastTime); + lastTime = time; + + progress.accept(time / duration * (1 - progressSplit) + progressSplit); + + Collection trackedThings = entry.getValue(); + out.writeVarInt(trackedThings.size()); + for (TrackedThing trackedThing : trackedThings) { + out.writeVarInt(trackedThing.despawnTime); + if (trackedThing instanceof Entity) { + out.writeVarInt(0); + } else if (trackedThing instanceof Chunk) { + out.writeVarInt(1); + } else if (trackedThing instanceof Weather) { + out.writeVarInt(2); + } else { + throw new UnsupportedOperationException("Unknown type of tracked thing: " + trackedThing); + } + trackedThing.writeToCache(out); + } + } + writeToCache(out, worldTimes); + writeToCache(out, thunderStrengths); + } + LOGGER.info("Wrote quick replay to cache in " + (System.currentTimeMillis() - sysTimeStart) + "ms"); } @Override @@ -475,6 +581,86 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } } + private static Packet readPacketFromCache(int version, NetInput in) throws IOException { + int readerIndex = byteBuf.readerIndex(); // Mark the current reader and writer index (should be at start) + int writerIndex = byteBuf.writerIndex(); + try { + packetBuf.writeBytes(in.readBytes(in.readVarInt())); + + int packetId = packetBuf.readVarInt(); + Packet mcPacket = EnumConnectionState.PLAY.getPacket(EnumPacketDirection.CLIENTBOUND, packetId); + mcPacket.readPacketData(packetBuf); + return mcPacket; + } catch (Exception e) { + Throwables.throwIfInstanceOf(e, IOException.class); + Throwables.throwIfUnchecked(e); + throw new RuntimeException(e); + } finally { + byteBuf.readerIndex(readerIndex); // Reset reader & writer index for next use + byteBuf.writerIndex(writerIndex); + } + } + + private static List> readPacketsFromCache(int version, NetInput in) throws IOException { + int size = in.readVarInt(); + List> packets = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + packets.add(readPacketFromCache(version, in)); + } + return packets; + } + + private static void readFromCache(int version, NetInput in, SortedMap> packets) throws IOException { + int time = 0; + for (int i = in.readVarInt(); i > 0; i--) { + time += in.readVarInt(); + packets.put(time, readPacketFromCache(version, in)); + } + } + + private static void writeToCache(NetOutput out, com.github.steveice10.packetlib.packet.Packet packet) throws IOException { + 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 size = byteBuf.readableBytes(); + out.writeVarInt(size); + for (int i = 0; i < size; i++) { + out.writeByte(byteBuf.readByte()); + } + } catch (Exception e) { + Throwables.throwIfInstanceOf(e, IOException.class); + Throwables.throwIfUnchecked(e); + throw new RuntimeException(e); + } finally { + byteBuf.readerIndex(readerIndex); // Reset reader & writer index for next use + byteBuf.writerIndex(writerIndex); + } + } + + private static void writeToCache(NetOutput out, Collection packets) throws IOException { + out.writeVarInt(packets.size()); + for (com.github.steveice10.packetlib.packet.Packet packet : packets) { + writeToCache(out, packet); + } + } + + private static void writeToCache(NetOutput out, SortedMap packets) throws IOException { + out.writeVarInt(packets.size()); + int lastTime = 0; + for (Map.Entry entry : packets.entrySet()) { + int time = entry.getKey(); + out.writeVarInt(time - lastTime); + lastTime = time; + + writeToCache(out, entry.getValue()); + } + } + private static long coordToLong(int x, int z) { return (long)x << 32 | (long)z & 0xFFFFFFFFL; } @@ -494,11 +680,39 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } private static abstract class TrackedThing { + List spawnPackets; + List despawnPackets; + int spawnTime; + int despawnTime = Integer.MAX_VALUE; + + private TrackedThing(List spawnPackets, + List despawnPackets) { + this.spawnPackets = spawnPackets; + this.despawnPackets = despawnPackets; + } + + public void writeToCache(NetOutput out) throws IOException { + QuickReplaySender.writeToCache(out, spawnPackets); + QuickReplaySender.writeToCache(out, despawnPackets); + } + } + + // For quicker jumping we store MC packets. + // However, during replay analysis it's easier to use MCProtocolLib ones in part because MC packets have been + // renamed from 1.8 to 1.9 but also because we cannot easily serialize them for caching. + // Therefore during analysis we use TrackedThing which we then serialize (we'd have to do that anyway for caching) + // and afterwards unserialize in BakedTrackedThing as MC packets for replaying. + private static abstract class BakedTrackedThing { List> spawnPackets; List> despawnPackets; int spawnTime; int despawnTime = Integer.MAX_VALUE; + private BakedTrackedThing(int version, NetInput in) throws IOException { + spawnPackets = readPacketsFromCache(version, in); + despawnPackets = readPacketsFromCache(version, in); + } + public abstract void play(int currentTimeStamp, int replayTime, Consumer> send); public abstract void rewind(int currentTimeStamp, int replayTime, Consumer> send); } @@ -507,10 +721,45 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe private int id; private NavigableMap locations = new TreeMap<>(); - private Entity(int entityId, List> spawnPackets) { + private Entity(int entityId, List spawnPackets) { + super(spawnPackets, Collections.singletonList(new ServerEntityDestroyPacket(entityId))); this.id = entityId; - this.spawnPackets = spawnPackets; - this.despawnPackets = Collections.singletonList(toMC(new ServerEntityDestroyPacket(entityId))); + } + + @Override + public void writeToCache(NetOutput out) throws IOException { + super.writeToCache(out); + + out.writeVarInt(id); + out.writeVarInt(locations.size()); + int lastTime = 0; + for (Map.Entry entry : locations.entrySet()) { + int time = entry.getKey(); + Location loc = entry.getValue(); + out.writeVarInt(time - lastTime); + lastTime = time; + out.writeDouble(loc.getX()); + out.writeDouble(loc.getY()); + out.writeDouble(loc.getZ()); + out.writeFloat(loc.getYaw()); + out.writeFloat(loc.getPitch()); + } + } + } + + private static class BakedEntity extends BakedTrackedThing { + private int id; + private NavigableMap locations = new TreeMap<>(); + + private BakedEntity(int version, NetInput in) throws IOException { + super(version, in); + + id = in.readVarInt(); + int time = 0; + for (int i = in.readVarInt(); i > 0; i--) { + time += in.readVarInt(); + locations.put(time, new Location(in.readDouble(), in.readDouble(), in.readDouble(), in.readFloat(), in.readFloat())); + } } @Override @@ -532,14 +781,57 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe 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()))); + super(Collections.singletonList(new ServerChunkDataPacket(column)), + Collections.singletonList(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 writeToCache(NetOutput out) throws IOException { + super.writeToCache(out); + + out.writeVarInt(blocksT.size()); + int lastTime = 0; + for (Map.Entry> entry : blocksT.entrySet()) { + int time = entry.getKey(); + out.writeVarInt(time - lastTime); + lastTime = time; + + Collection blockChanges = entry.getValue(); + out.writeVarInt(blockChanges.size()); + for (BlockChange blockChange : blockChanges) { + NetUtil.writePosition(out, blockChange.pos); + NetUtil.writeBlockState(out, blockChange.from); + NetUtil.writeBlockState(out, blockChange.to); + } + } + } + } + + private static class BakedChunk extends BakedTrackedThing { + private TreeMap> blocksT = new TreeMap<>(); + private ListMultimap blocks = Multimaps.newListMultimap(blocksT, LinkedList::new); // LinkedList to allow .descendingIterator + + private BakedChunk(int version, NetInput in) throws IOException { + super(version, in); + + int time = 0; + for (int i = in.readVarInt(); i > 0; i--) { + time += in.readVarInt(); + + for (int j = in.readVarInt(); j > 0; j--) { + blocks.put(time, new BlockChange( + NetUtil.readPosition(in), + NetUtil.readBlockState(in), + NetUtil.readBlockState(in) + )); + } + } + } + @Override public void play(int currentTimeStamp, int replayTime, Consumer> send) { blocksT.subMap(currentTimeStamp, false, replayTime, true).values() @@ -574,11 +866,28 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } private static class Weather extends TrackedThing { - private TreeMap> rainStrengths = new TreeMap<>(); + private TreeMap rainStrengths = new TreeMap<>(); private Weather() { - this.spawnPackets = Collections.singletonList(toMC(new ServerNotifyClientPacket(ClientNotification.START_RAIN, null))); - this.despawnPackets = Collections.singletonList(toMC(new ServerNotifyClientPacket(ClientNotification.STOP_RAIN, null))); + super(Collections.singletonList(new ServerNotifyClientPacket(ClientNotification.START_RAIN, null)), + Collections.singletonList(new ServerNotifyClientPacket(ClientNotification.STOP_RAIN, null))); + } + + @Override + public void writeToCache(NetOutput out) throws IOException { + super.writeToCache(out); + + QuickReplaySender.writeToCache(out, rainStrengths); + } + } + + private static class BakedWeather extends BakedTrackedThing { + private TreeMap> rainStrengths = new TreeMap<>(); + + private BakedWeather(int version, NetInput in) throws IOException { + super(version, in); + + readFromCache(version, in, rainStrengths); } @Override From 3db9d58f8f420d95d68b367796af060dda3ddedc Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Sat, 28 Jul 2018 13:07:57 +0200 Subject: [PATCH 06/16] Handle Respawn packet in QuickReplaySender --- .../java/com/replaymod/replay/QuickReplaySender.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/replaymod/replay/QuickReplaySender.java b/src/main/java/com/replaymod/replay/QuickReplaySender.java index 9e32b5bc..34acf4af 100644 --- a/src/main/java/com/replaymod/replay/QuickReplaySender.java +++ b/src/main/java/com/replaymod/replay/QuickReplaySender.java @@ -422,7 +422,14 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } } } else if (packet instanceof ServerRespawnPacket) { - // FIXME + activeEntities.values().forEach(entity -> entity.despawnTime = time); + activeEntities.clear(); + activeChunks.values().forEach(chunk -> chunk.despawnTime = time); + activeChunks.clear(); + if (activeWeather != null) { + activeWeather.despawnTime = time; + } + activeWeather = null; } else if (packet instanceof ServerUpdateTimePacket) { worldTimes.put(time, packet); } else if (packet instanceof ServerNotifyClientPacket) { From 73c52b5cf05c101645f554188391b1caf3cd18d0 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Wed, 22 Aug 2018 17:28:06 +0200 Subject: [PATCH 07/16] Backport quick mode to 1.9.4+ and disable below --- .../java/com/replaymod/core/utils/Utils.java | 13 +++++++ .../replaymod/replay/FullReplaySender.java | 2 +- .../replaymod/replay/QuickReplaySender.java | 33 +++++++++++----- .../com/replaymod/replay/ReplayHandler.java | 38 +++++++++++++++++++ src/main/resources/assets/replaymod/lang | 2 +- 5 files changed, 77 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/replaymod/core/utils/Utils.java b/src/main/java/com/replaymod/core/utils/Utils.java index d173d1c8..cef6e9a2 100644 --- a/src/main/java/com/replaymod/core/utils/Utils.java +++ b/src/main/java/com/replaymod/core/utils/Utils.java @@ -281,4 +281,17 @@ public class Utils { super.draw(renderer, size, renderInfo); } } + + public static void throwIfInstanceOf(Throwable t, Class cls) throws T { + if (cls.isInstance(t)) { + throw cls.cast(t); + } + } + public static void throwIfUnchecked(Throwable t) { + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else if (t instanceof Error) { + throw (Error) t; + } + } } diff --git a/src/main/java/com/replaymod/replay/FullReplaySender.java b/src/main/java/com/replaymod/replay/FullReplaySender.java index c91316fc..ec0a3693 100755 --- a/src/main/java/com/replaymod/replay/FullReplaySender.java +++ b/src/main/java/com/replaymod/replay/FullReplaySender.java @@ -496,7 +496,7 @@ public class FullReplaySender extends ChannelDuplexHandler implements ReplaySend //#if MC>=10809 //$$ String url = packet.getURL(); //#else - //$$ String url = packet.func_179783_a(); + //$$ String url = packet.func_179783_a(); //#endif //#endif if (url.startsWith("replay://")) { diff --git a/src/main/java/com/replaymod/replay/QuickReplaySender.java b/src/main/java/com/replaymod/replay/QuickReplaySender.java index 34acf4af..31b376cf 100644 --- a/src/main/java/com/replaymod/replay/QuickReplaySender.java +++ b/src/main/java/com/replaymod/replay/QuickReplaySender.java @@ -1,3 +1,4 @@ +//#if MC>=10904 package com.replaymod.replay; import com.github.steveice10.mc.protocol.data.game.PlayerListEntry; @@ -32,14 +33,13 @@ import com.github.steveice10.packetlib.io.NetOutput; import com.github.steveice10.packetlib.io.stream.StreamNetInput; import com.github.steveice10.packetlib.io.stream.StreamNetOutput; import com.google.common.base.Optional; -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.core.utils.Utils; import com.replaymod.replaystudio.PacketData; import com.replaymod.replaystudio.io.ReplayInputStream; import com.replaymod.replaystudio.io.ReplayOutputStream; @@ -79,6 +79,10 @@ import java.util.TreeMap; import java.util.UUID; import java.util.function.Consumer; +//#if MC>=11200 +import com.replaymod.core.utils.WrappedTimer; +//#endif + import static com.replaymod.core.versions.MCVer.FML_BUS; import static com.replaymod.replay.ReplayModReplay.LOGGER; @@ -574,12 +578,17 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe byteBuf.skipBytes(8); // Skip packet length & timestamp - int packetId = packetBuf.readVarInt(); + int packetId = + //#if MC>=11102 + packetBuf.readVarInt(); + //#else + //$$ packetBuf.readVarIntFromBuffer(); + //#endif Packet mcPacket = EnumConnectionState.PLAY.getPacket(EnumPacketDirection.CLIENTBOUND, packetId); mcPacket.readPacketData(packetBuf); return mcPacket; } catch (Exception e) { - Throwables.throwIfUnchecked(e); + Utils.throwIfUnchecked(e); throw new RuntimeException(e); } finally { byteBuf.readerIndex(readerIndex); // Reset reader & writer index for next use @@ -594,13 +603,18 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe try { packetBuf.writeBytes(in.readBytes(in.readVarInt())); - int packetId = packetBuf.readVarInt(); + int packetId = + //#if MC>=11102 + packetBuf.readVarInt(); + //#else + //$$ packetBuf.readVarIntFromBuffer(); + //#endif Packet mcPacket = EnumConnectionState.PLAY.getPacket(EnumPacketDirection.CLIENTBOUND, packetId); mcPacket.readPacketData(packetBuf); return mcPacket; } catch (Exception e) { - Throwables.throwIfInstanceOf(e, IOException.class); - Throwables.throwIfUnchecked(e); + Utils.throwIfInstanceOf(e, IOException.class); + Utils.throwIfUnchecked(e); throw new RuntimeException(e); } finally { byteBuf.readerIndex(readerIndex); // Reset reader & writer index for next use @@ -640,8 +654,8 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe out.writeByte(byteBuf.readByte()); } } catch (Exception e) { - Throwables.throwIfInstanceOf(e, IOException.class); - Throwables.throwIfUnchecked(e); + Utils.throwIfInstanceOf(e, IOException.class); + Utils.throwIfUnchecked(e); throw new RuntimeException(e); } finally { byteBuf.readerIndex(readerIndex); // Reset reader & writer index for next use @@ -908,3 +922,4 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } } } +//#endif diff --git a/src/main/java/com/replaymod/replay/ReplayHandler.java b/src/main/java/com/replaymod/replay/ReplayHandler.java index 81d70521..45007209 100755 --- a/src/main/java/com/replaymod/replay/ReplayHandler.java +++ b/src/main/java/com/replaymod/replay/ReplayHandler.java @@ -34,6 +34,12 @@ import org.lwjgl.opengl.Display; import java.io.IOException; import java.util.*; +//#if MC<10904 +//$$ import de.johni0702.minecraft.gui.element.GuiLabel; +//$$ import de.johni0702.minecraft.gui.popup.GuiInfoPopup; +//$$ import de.johni0702.minecraft.gui.utils.Colors; +//#endif + //#if MC>=10800 import com.mojang.authlib.GameProfile; import net.minecraft.client.network.NetHandlerPlayClient; @@ -78,8 +84,12 @@ public class ReplayHandler { * Decodes and sends packets into channel. */ private final FullReplaySender fullReplaySender; + //#if MC>=10904 private final QuickReplaySender quickReplaySender; private boolean quickMode = false; + //#else + //$$ private static final String QUICK_MODE_MIN_MC = "1.9.4"; + //#endif /** * Currently active replay restrictions. @@ -117,7 +127,9 @@ public class ReplayHandler { markers = new ArrayList<>(replayFile.getMarkers().or(Collections.emptySet())); fullReplaySender = new FullReplaySender(this, replayFile, false); + //#if MC>=10904 quickReplaySender = new QuickReplaySender(ReplayModReplay.instance, replayFile); + //#endif setup(); @@ -149,7 +161,9 @@ public class ReplayHandler { FML_BUS.post(new ReplayCloseEvent.Pre(this)); fullReplaySender.terminateReplay(); + //#if MC>=10904 quickReplaySender.unregister(); + //#endif replayFile.save(); replayFile.close(); @@ -214,6 +228,9 @@ public class ReplayHandler { //$$ channel.attr(NetworkDispatcher.FML_DISPATCHER).set(networkDispatcher); //$$ //$$ channel.pipeline().addFirst("ReplayModReplay_replaySender", fullReplaySender); + //#if MC>=10904 + //$$ channel.pipeline().addFirst("ReplayModReplay_quickReplaySender", quickReplaySender); + //#endif //$$ channel.pipeline().addAfter("ReplayModReplay_replaySender", "fml:packet_handler", networkDispatcher); //$$ channel.pipeline().fireChannelActive(); //#endif @@ -266,13 +283,18 @@ public class ReplayHandler { } public ReplaySender getReplaySender() { + //#if MC>=10904 return quickMode ? quickReplaySender : fullReplaySender; + //#else + //$$ return fullReplaySender; + //#endif } public GuiReplayOverlay getOverlay() { return overlay; } + //#if MC>=10904 public void ensureQuickModeInitialized(Runnable andThen) { ListenableFuture future = quickReplaySender.getInitializationPromise(); if (future == null) { @@ -346,6 +368,20 @@ public class ReplayHandler { public boolean isQuickMode() { return quickMode; } + //#else + //$$ public void ensureQuickModeInitialized(@SuppressWarnings("unused") Runnable andThen) { + //$$ GuiInfoPopup.open(overlay, + //$$ new GuiLabel().setI18nText("replaymod.gui.noquickmode", QUICK_MODE_MIN_MC).setColor(Colors.BLACK)); + //$$ } + //$$ + //$$ public void setQuickMode(@SuppressWarnings("unused") boolean quickMode) { + //$$ throw new UnsupportedOperationException("Quick Mode not supported on this version."); + //$$ } + //$$ + //$$ public boolean isQuickMode() { + //$$ return false; + //$$ } + //#endif public int getReplayDuration() { return replayDuration; @@ -460,10 +496,12 @@ public class ReplayHandler { } public void doJump(int targetTime, boolean retainCameraPosition) { + //#if MC>=10904 if (getReplaySender() == quickReplaySender) { quickReplaySender.sendPacketsTill(targetTime); return; } + //#endif FullReplaySender replaySender = fullReplaySender; if (replaySender.isHurrying()) { diff --git a/src/main/resources/assets/replaymod/lang b/src/main/resources/assets/replaymod/lang index 692a6765..16508289 160000 --- a/src/main/resources/assets/replaymod/lang +++ b/src/main/resources/assets/replaymod/lang @@ -1 +1 @@ -Subproject commit 692a6765560bd96f02c9364798395914470c2c9e +Subproject commit 165082892e4f323d0bc5ef6bc390325664372035 From c8ecd17fe9b8a0e558125b0c28ceed7a7dcde2b9 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 18 Dec 2018 09:49:48 +0100 Subject: [PATCH 08/16] Re-write QuickReplaySender caching to be significantly more memory efficient --- .../replaymod/replay/QuickReplaySender.java | 464 +++++++++++------- 1 file changed, 293 insertions(+), 171 deletions(-) diff --git a/src/main/java/com/replaymod/replay/QuickReplaySender.java b/src/main/java/com/replaymod/replay/QuickReplaySender.java index 31b376cf..51a12bf0 100644 --- a/src/main/java/com/replaymod/replay/QuickReplaySender.java +++ b/src/main/java/com/replaymod/replay/QuickReplaySender.java @@ -32,6 +32,8 @@ import com.github.steveice10.packetlib.io.NetInput; import com.github.steveice10.packetlib.io.NetOutput; import com.github.steveice10.packetlib.io.stream.StreamNetInput; import com.github.steveice10.packetlib.io.stream.StreamNetOutput; +import com.github.steveice10.packetlib.tcp.io.ByteBufNetInput; +import com.github.steveice10.packetlib.tcp.io.ByteBufNetOutput; import com.google.common.base.Optional; import com.google.common.collect.ListMultimap; import com.google.common.collect.Multimaps; @@ -53,6 +55,7 @@ import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerAdapter; import io.netty.channel.ChannelHandlerContext; +import lombok.SneakyThrows; import net.minecraft.client.Minecraft; import net.minecraft.network.EnumConnectionState; import net.minecraft.network.EnumPacketDirection; @@ -95,6 +98,7 @@ import static com.replaymod.replay.ReplayModReplay.LOGGER; @ChannelHandler.Sharable public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySender { private static final String CACHE_ENTRY = "quickModeCache.bin"; + private static final String CACHE_INDEX_ENTRY = "quickModeCacheIndex.bin"; private static final int CACHE_VERSION = 0; private final Minecraft mc = Minecraft.getMinecraft(); @@ -115,6 +119,8 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe private ListenableFuture initPromise; + private com.github.steveice10.netty.buffer.ByteBuf buf; + private NetInput bufInput; private TreeMap> thingSpawnsT = new TreeMap<>(); private ListMultimap thingSpawns = Multimaps.newListMultimap(thingSpawnsT, ArrayList::new); private TreeMap> thingDespawnsT = new TreeMap<>(); @@ -207,7 +213,7 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe public void setReplaySpeed(double factor) { if (factor != 0) { if (paused() && asyncMode) { - lastAsyncUpdateTime = System.currentTimeMillis(); // TODO test this + lastAsyncUpdateTime = System.currentTimeMillis(); } this.replaySpeed = factor; } @@ -263,49 +269,63 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } private boolean tryLoadFromCache(Consumer progress) throws IOException { - Optional cacheOpt = replayFile.get(CACHE_ENTRY); - if (!cacheOpt.isPresent()) return false; - boolean success = loadFromCache(cacheOpt.get(), progress); - if (!success) { - thingSpawnsT.clear(); - thingDespawnsT.clear(); - worldTimes.clear(); - thunderStrengths.clear(); - } - return success; - } + boolean success = false; - private boolean loadFromCache(InputStream rawIn, Consumer progress) throws IOException { - long sysTimeStart = System.currentTimeMillis(); - NetInput in = new StreamNetInput(rawIn); - int version = in.readVarInt(); - if (version > CACHE_VERSION) return false; // More recent than this version, no chance we can read that - - int size = in.readVarInt(); - int spawnTime = 0; - for (int i = 0; i < size; i++) { - progress.accept((double) i / size); - spawnTime += in.readVarInt(); - for (int j = in.readVarInt(); j > 0; j--) { - int despawnTime = in.readVarInt(); - BakedTrackedThing trackedThing; - switch (in.readVarInt()) { - case 0: trackedThing = new BakedEntity(version, in); break; - case 1: trackedThing = new BakedChunk(version, in); break; - case 2: trackedThing = new BakedWeather(version, in); break; - default: return false; - } - trackedThing.spawnTime = spawnTime; - trackedThing.despawnTime = despawnTime; - thingSpawns.put(spawnTime, trackedThing); - thingDespawns.put(despawnTime, trackedThing); + Optional cacheIndexOpt = replayFile.get(CACHE_INDEX_ENTRY); + if (!cacheIndexOpt.isPresent()) return false; + try (InputStream indexIn = cacheIndexOpt.get()) { + Optional cacheOpt = replayFile.get(CACHE_ENTRY); + if (!cacheOpt.isPresent()) return false; + try (InputStream cacheIn = cacheOpt.get()) { + success = loadFromCache(cacheIn, indexIn, progress); + } + } finally { + if (!success) { + buf = null; + bufInput = null; + thingSpawnsT.clear(); + thingDespawnsT.clear(); + worldTimes.clear(); + thunderStrengths.clear(); } } - // These should go quick, they probably won't need to update progress - progress.accept(1.0); - readFromCache(version, in, worldTimes); - readFromCache(version, in, thunderStrengths); + return success; + } + + private boolean loadFromCache(InputStream cacheIn, InputStream rawIndexIn, Consumer progress) throws IOException { + long sysTimeStart = System.currentTimeMillis(); + + NetInput in = new StreamNetInput(rawIndexIn); + if (in.readVarInt() != CACHE_VERSION) return false; // Incompatible cache version + if (new StreamNetInput(cacheIn).readVarInt() != CACHE_VERSION) return false; // Incompatible cache version + + things: while (true) { + BakedTrackedThing trackedThing; + switch (in.readVarInt()) { + case 0: break things; + case 1: trackedThing = new BakedEntity(in); break; + case 2: trackedThing = new BakedChunk(in); break; + case 3: trackedThing = new BakedWeather(in); break; + default: return false; + } + thingSpawns.put(trackedThing.spawnTime, trackedThing); + thingDespawns.put(trackedThing.despawnTime, trackedThing); + } + + readFromCache(in, worldTimes); + readFromCache(in, thunderStrengths); + int size = in.readVarInt(); + + buf = com.github.steveice10.netty.buffer.Unpooled.buffer(size); + int read = 0; + while (true) { + int len = buf.writeBytes(cacheIn, Math.min(size - read, 4096)); + if (len <= 0) break; + read += len; + progress.accept((double) read / size); + } + bufInput = new ByteBufNetInput(buf); LOGGER.info("Loaded quick replay from cache in " + (System.currentTimeMillis() - sysTimeStart) + "ms"); return true; @@ -327,8 +347,6 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe studio.setParsing(ServerUpdateTimePacket.class, true); studio.setParsing(ServerNotifyClientPacket.class, true); - TreeMap> thingSpawnsT = new TreeMap<>(); - ListMultimap thingSpawns = Multimaps.newListMultimap(thingSpawnsT, ArrayList::new); TreeMap worldTimes = new TreeMap<>(); TreeMap thunderStrengths = new TreeMap<>(); Map playerListEntries = new HashMap<>(); @@ -336,33 +354,33 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe Map activeChunks = new HashMap<>(); Weather activeWeather = null; - // 90% of estimated time are for analysis, 10% for writing cache - // This heuristic is based on simply running the function on some 3h replay. - // Numbers may be significantly different for smaller replays where GC doesn't kick in. But the progress bar - // will hopefully be barely visible in that case anyway. - // May need to be updated when performance of MCProtocolLib or this code is improved. - // Same for the split between analyseReplay and loadFromCache declared in #initialize(...) - final double progressSplit = 0.9; - double sysTimeStart = System.currentTimeMillis(); double duration; - try (ReplayInputStream in = replayFile.getPacketData(studio)) { + try (ReplayInputStream in = replayFile.getPacketData(studio); + OutputStream cacheOut = replayFile.write(CACHE_ENTRY); + OutputStream cacheIndexOut = replayFile.write(CACHE_INDEX_ENTRY)) { + NetOutput out = new StreamNetOutput(cacheOut); + out.writeVarInt(CACHE_VERSION); + NetOutput indexOut = new StreamNetOutput(cacheIndexOut); + indexOut.writeVarInt(CACHE_VERSION); + + int index = 0; + int time = 0; 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 * progressSplit); + 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(packet)); entity.spawnTime = time; - thingSpawns.put(time, entity); Entity prev = activeEntities.put(entityId, entity); if (prev != null) { - prev.despawnTime = time; + index = prev.writeToCache(indexOut, out, time, index); } } else if (packet instanceof ServerSpawnPlayerPacket) { ServerPlayerListEntryPacket listEntryPacket = new ServerPlayerListEntryPacket( @@ -373,34 +391,30 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe ); Entity entity = new Entity(entityId, Arrays.asList(listEntryPacket, packet)); entity.spawnTime = time; - thingSpawns.put(time, entity); Entity prev = activeEntities.put(entityId, entity); if (prev != null) { - prev.despawnTime = time; + index = prev.writeToCache(indexOut, out, time, index); } } else if (packet instanceof ServerEntityDestroyPacket) { for (int id : ((ServerEntityDestroyPacket) packet).getEntityIds()) { Entity entity = activeEntities.remove(id); if (entity != null) { - entity.despawnTime = time; + index = entity.writeToCache(indexOut, out, time, index); } } } 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; + index = prev.writeToCache(indexOut, out, time, index); } } 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; + index = prev.writeToCache(indexOut, out, time, index); } } else if (packet instanceof ServerBlockChangePacket || packet instanceof ServerMultiBlockChangePacket) { for (BlockChangeRecord record : @@ -426,12 +440,16 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } } } else if (packet instanceof ServerRespawnPacket) { - activeEntities.values().forEach(entity -> entity.despawnTime = time); + for (Entity entity : activeEntities.values()) { + index = entity.writeToCache(indexOut, out, time, index); + } activeEntities.clear(); - activeChunks.values().forEach(chunk -> chunk.despawnTime = time); + for (Chunk chunk : activeChunks.values()) { + index = chunk.writeToCache(indexOut, out, time, index); + } activeChunks.clear(); if (activeWeather != null) { - activeWeather.despawnTime = time; + index = activeWeather.writeToCache(indexOut, out, time, index); } activeWeather = null; } else if (packet instanceof ServerUpdateTimePacket) { @@ -441,15 +459,14 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe switch (p.getNotification()) { case START_RAIN: if (activeWeather != null) { - activeWeather.despawnTime = time; + index = activeWeather.writeToCache(indexOut, out, time, index); } activeWeather = new Weather(); activeWeather.spawnTime = time; - thingSpawns.put(time, activeWeather); break; case STOP_RAIN: if (activeWeather != null) { - activeWeather.despawnTime = time; + index = activeWeather.writeToCache(indexOut, out, time, index); activeWeather = null; } break; @@ -475,43 +492,24 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } } } + + for (Entity entity : activeEntities.values()) { + index = entity.writeToCache(indexOut, out, time, index); + } + for (Chunk chunk : activeChunks.values()) { + index = chunk.writeToCache(indexOut, out, time, index); + } + if (activeWeather != null) { + index = activeWeather.writeToCache(indexOut, out, time, index); + } + + indexOut.writeByte(0); + writeToCache(indexOut, worldTimes); + writeToCache(indexOut, thunderStrengths); + + indexOut.writeVarInt(index); } LOGGER.info("Analysed replay in " + (System.currentTimeMillis() - sysTimeStart) + "ms"); - - sysTimeStart = System.currentTimeMillis(); - try (OutputStream cacheOut = replayFile.write(CACHE_ENTRY)) { - NetOutput out = new StreamNetOutput(cacheOut); - out.writeVarInt(CACHE_VERSION); - - out.writeVarInt(thingSpawnsT.size()); - int lastTime = 0; - for (Map.Entry> entry : thingSpawnsT.entrySet()) { - int time = entry.getKey(); - out.writeVarInt(time - lastTime); - lastTime = time; - - progress.accept(time / duration * (1 - progressSplit) + progressSplit); - - Collection trackedThings = entry.getValue(); - out.writeVarInt(trackedThings.size()); - for (TrackedThing trackedThing : trackedThings) { - out.writeVarInt(trackedThing.despawnTime); - if (trackedThing instanceof Entity) { - out.writeVarInt(0); - } else if (trackedThing instanceof Chunk) { - out.writeVarInt(1); - } else if (trackedThing instanceof Weather) { - out.writeVarInt(2); - } else { - throw new UnsupportedOperationException("Unknown type of tracked thing: " + trackedThing); - } - trackedThing.writeToCache(out); - } - } - writeToCache(out, worldTimes); - writeToCache(out, thunderStrengths); - } - LOGGER.info("Wrote quick replay to cache in " + (System.currentTimeMillis() - sysTimeStart) + "ms"); } @Override @@ -519,8 +517,8 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe ensureInitialized(() -> { if (replayTime > currentTimeStamp) { activeThings.removeIf(thing -> { - if (thing.despawnTime < replayTime) { - thing.despawnPackets.forEach(ctx::fireChannelRead); + if (thing.despawnTime <= replayTime) { + thing.despawn(this, ctx::fireChannelRead); return true; } else { return false; @@ -529,7 +527,7 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe thingSpawnsT.subMap(currentTimeStamp, false, replayTime, true).values() .forEach(things -> things.forEach(thing -> { if (thing.despawnTime > replayTime) { - thing.spawnPackets.forEach(ctx::fireChannelRead); + thing.spawn(this, ctx::fireChannelRead); activeThings.add(thing); } })); @@ -539,7 +537,7 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } else { activeThings.removeIf(thing -> { if (thing.spawnTime > replayTime) { - thing.despawnPackets.forEach(ctx::fireChannelRead); + thing.despawn(this, ctx::fireChannelRead); return true; } else { return false; @@ -548,7 +546,7 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe thingDespawnsT.subMap(replayTime, false, currentTimeStamp, true).values() .forEach(things -> things.forEach(thing -> { if (thing.spawnTime <= replayTime) { - thing.spawnPackets.forEach(ctx::fireChannelRead); + thing.spawn(this, ctx::fireChannelRead); activeThings.add(thing); } })); @@ -560,6 +558,25 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe }); } + private static final com.github.steveice10.netty.buffer.ByteBuf mcplByteBuf = com.github.steveice10.netty.buffer.Unpooled.buffer(); + private static final ByteBufNetInput byteBufNetInput = new ByteBufNetInput(mcplByteBuf); + private static final ByteBufNetOutput byteBufNetOutput = new ByteBufNetOutput(mcplByteBuf); + + private static BlockStorage copy(BlockStorage of) { + int readerIndex = byteBuf.readerIndex(); // Mark the current reader and writer index (should be at start) + int writerIndex = byteBuf.writerIndex(); + try { + of.write(byteBufNetOutput); + return new BlockStorage(byteBufNetInput); + } catch (Exception e) { + Utils.throwIfUnchecked(e); + throw new RuntimeException(e); + } finally { + byteBuf.readerIndex(readerIndex); // Reset reader & writer index for next use + byteBuf.writerIndex(writerIndex); + } + } + private static final ByteBuf byteBuf = Unpooled.buffer(); private static final ByteBufOutputStream byteBufOut = new ByteBufOutputStream(byteBuf); private static final PacketBuffer packetBuf = new PacketBuffer(byteBuf); @@ -597,7 +614,7 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } } - private static Packet readPacketFromCache(int version, NetInput in) throws IOException { + private static Packet readPacketFromCache(NetInput in) throws IOException { int readerIndex = byteBuf.readerIndex(); // Mark the current reader and writer index (should be at start) int writerIndex = byteBuf.writerIndex(); try { @@ -622,24 +639,25 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } } - private static List> readPacketsFromCache(int version, NetInput in) throws IOException { + @SneakyThrows(IOException.class) + private static List> readPacketsFromCache(NetInput in) { int size = in.readVarInt(); List> packets = new ArrayList<>(size); for (int i = 0; i < size; i++) { - packets.add(readPacketFromCache(version, in)); + packets.add(readPacketFromCache(in)); } return packets; } - private static void readFromCache(int version, NetInput in, SortedMap> packets) throws IOException { + private static void readFromCache(NetInput in, SortedMap> packets) throws IOException { int time = 0; for (int i = in.readVarInt(); i > 0; i--) { time += in.readVarInt(); - packets.put(time, readPacketFromCache(version, in)); + packets.put(time, readPacketFromCache(in)); } } - private static void writeToCache(NetOutput out, com.github.steveice10.packetlib.packet.Packet packet) throws IOException { + private static int writeToCache(NetOutput out, com.github.steveice10.packetlib.packet.Packet packet) throws IOException { int readerIndex = byteBuf.readerIndex(); // Mark the current reader and writer index (should be at start) int writerIndex = byteBuf.writerIndex(); try { @@ -649,10 +667,11 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe byteBuf.skipBytes(8); // Skip packet length & timestamp int size = byteBuf.readableBytes(); - out.writeVarInt(size); + int len = writeVarInt(out, size); for (int i = 0; i < size; i++) { out.writeByte(byteBuf.readByte()); } + return len + size; } catch (Exception e) { Utils.throwIfInstanceOf(e, IOException.class); Utils.throwIfUnchecked(e); @@ -663,23 +682,37 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } } - private static void writeToCache(NetOutput out, Collection packets) throws IOException { - out.writeVarInt(packets.size()); + private static int writeToCache(NetOutput out, Collection packets) throws IOException { + int len = writeVarInt(out, packets.size()); for (com.github.steveice10.packetlib.packet.Packet packet : packets) { - writeToCache(out, packet); + len += writeToCache(out, packet); } + return len; } - private static void writeToCache(NetOutput out, SortedMap packets) throws IOException { - out.writeVarInt(packets.size()); + private static int writeToCache(NetOutput out, SortedMap packets) throws IOException { + int len = 0; + len += writeVarInt(out, packets.size()); int lastTime = 0; for (Map.Entry entry : packets.entrySet()) { int time = entry.getKey(); - out.writeVarInt(time - lastTime); + len += writeVarInt(out, time - lastTime); lastTime = time; - writeToCache(out, entry.getValue()); + len += writeToCache(out, entry.getValue()); } + return len; + } + + private static int writeVarInt(NetOutput out, int i) throws IOException { + int len = 1; + while ((i & -128) != 0) { + out.writeByte(i & 127 | 128); + i >>>= 7; + len++; + } + out.writeByte(i); + return len; } private static long coordToLong(int x, int z) { @@ -704,7 +737,6 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe List spawnPackets; List despawnPackets; int spawnTime; - int despawnTime = Integer.MAX_VALUE; private TrackedThing(List spawnPackets, List despawnPackets) { @@ -712,26 +744,41 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe this.despawnPackets = despawnPackets; } - public void writeToCache(NetOutput out) throws IOException { - QuickReplaySender.writeToCache(out, spawnPackets); - QuickReplaySender.writeToCache(out, despawnPackets); + public int writeToCache(NetOutput indexOut, NetOutput cacheOut, int despawnTime, int index) throws IOException { + indexOut.writeVarInt(spawnTime); + indexOut.writeVarInt(despawnTime); + indexOut.writeVarInt(index); + index += QuickReplaySender.writeToCache(cacheOut, spawnPackets); + indexOut.writeVarInt(index); + index += QuickReplaySender.writeToCache(cacheOut, despawnPackets); + return index; } } - // For quicker jumping we store MC packets. - // However, during replay analysis it's easier to use MCProtocolLib ones in part because MC packets have been - // renamed from 1.8 to 1.9 but also because we cannot easily serialize them for caching. - // Therefore during analysis we use TrackedThing which we then serialize (we'd have to do that anyway for caching) - // and afterwards unserialize in BakedTrackedThing as MC packets for replaying. + // For memory efficiency we store raw packets (we might even want to consider compression or memory mapped files). + // During analysis we use TrackedThing which we then serialize (we'd have to do that anyway for caching) + // and afterwards deserialize in BakedTrackedThing as MC packets on demand for replaying. private static abstract class BakedTrackedThing { - List> spawnPackets; - List> despawnPackets; + int indexSpawnPackets; + int indexDespawnPackets; int spawnTime; - int despawnTime = Integer.MAX_VALUE; + int despawnTime; - private BakedTrackedThing(int version, NetInput in) throws IOException { - spawnPackets = readPacketsFromCache(version, in); - despawnPackets = readPacketsFromCache(version, in); + private BakedTrackedThing(NetInput in) throws IOException { + spawnTime = in.readVarInt(); + despawnTime = in.readVarInt(); + indexSpawnPackets = in.readVarInt(); + indexDespawnPackets = in.readVarInt(); + } + + public void spawn(QuickReplaySender sender, Consumer> send) { + sender.buf.readerIndex(indexSpawnPackets); + readPacketsFromCache(sender.bufInput).forEach(send); + } + + public void despawn(QuickReplaySender sender, Consumer> send) { + sender.buf.readerIndex(indexDespawnPackets); + readPacketsFromCache(sender.bufInput).forEach(send); } public abstract void play(int currentTimeStamp, int replayTime, Consumer> send); @@ -748,34 +795,53 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } @Override - public void writeToCache(NetOutput out) throws IOException { - super.writeToCache(out); + public int writeToCache(NetOutput indexOut, NetOutput cacheOut, int despawnTime, int index) throws IOException { + indexOut.writeByte(1); + index = super.writeToCache(indexOut, cacheOut, despawnTime, index); - out.writeVarInt(id); - out.writeVarInt(locations.size()); + indexOut.writeVarInt(id); + indexOut.writeVarInt(index); + index += writeVarInt(cacheOut, locations.size()); int lastTime = 0; for (Map.Entry entry : locations.entrySet()) { int time = entry.getKey(); Location loc = entry.getValue(); - out.writeVarInt(time - lastTime); + index += writeVarInt(cacheOut, time - lastTime); lastTime = time; - out.writeDouble(loc.getX()); - out.writeDouble(loc.getY()); - out.writeDouble(loc.getZ()); - out.writeFloat(loc.getYaw()); - out.writeFloat(loc.getPitch()); + cacheOut.writeDouble(loc.getX()); + cacheOut.writeDouble(loc.getY()); + cacheOut.writeDouble(loc.getZ()); + cacheOut.writeFloat(loc.getYaw()); + cacheOut.writeFloat(loc.getPitch()); + index += 32; } + + return index; } } private static class BakedEntity extends BakedTrackedThing { private int id; - private NavigableMap locations = new TreeMap<>(); + private int index; + private NavigableMap locations; - private BakedEntity(int version, NetInput in) throws IOException { - super(version, in); + private BakedEntity(NetInput in) throws IOException { + super(in); id = in.readVarInt(); + index = in.readVarInt(); + } + + @Override + @SneakyThrows(IOException.class) + public void spawn(QuickReplaySender sender, Consumer> send) { + super.spawn(sender, send); + + sender.buf.readerIndex(index); + NetInput in = sender.bufInput; + + locations = new TreeMap<>(); + int time = 0; for (int i = in.readVarInt(); i > 0; i--) { time += in.readVarInt(); @@ -783,6 +849,12 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } } + @Override + public void despawn(QuickReplaySender sender, Consumer> send) { + super.despawn(sender, send); + locations = null; + } + @Override public void play(int currentTimeStamp, int replayTime, Consumer> send) { playMap(locations, currentTimeStamp, replayTime, l -> @@ -806,38 +878,57 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe Collections.singletonList(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(); + currentBlockState[i] = chunks[i] == null ? new BlockStorage() : copy(chunks[i].getBlocks()); } } @Override - public void writeToCache(NetOutput out) throws IOException { - super.writeToCache(out); + public int writeToCache(NetOutput indexOut, NetOutput cacheOut, int despawnTime, int index) throws IOException { + indexOut.writeByte(2); + index = super.writeToCache(indexOut, cacheOut, despawnTime, index); - out.writeVarInt(blocksT.size()); + indexOut.writeVarInt(index); + index += writeVarInt(cacheOut, blocksT.size()); int lastTime = 0; for (Map.Entry> entry : blocksT.entrySet()) { int time = entry.getKey(); - out.writeVarInt(time - lastTime); + index += writeVarInt(cacheOut, time - lastTime); lastTime = time; Collection blockChanges = entry.getValue(); - out.writeVarInt(blockChanges.size()); + index += writeVarInt(cacheOut, blockChanges.size()); for (BlockChange blockChange : blockChanges) { - NetUtil.writePosition(out, blockChange.pos); - NetUtil.writeBlockState(out, blockChange.from); - NetUtil.writeBlockState(out, blockChange.to); + NetUtil.writePosition(cacheOut, blockChange.pos); + index += 8; + index += writeVarInt(cacheOut, blockChange.from.getId() << 4 | blockChange.from.getData() & 15); + index += writeVarInt(cacheOut, blockChange.to.getId() << 4 | blockChange.to.getData() & 15); } } + + return index; } } private static class BakedChunk extends BakedTrackedThing { - private TreeMap> blocksT = new TreeMap<>(); - private ListMultimap blocks = Multimaps.newListMultimap(blocksT, LinkedList::new); // LinkedList to allow .descendingIterator + private int index; + private TreeMap> blocksT; - private BakedChunk(int version, NetInput in) throws IOException { - super(version, in); + private BakedChunk(NetInput in) throws IOException { + super(in); + + index = in.readVarInt(); + } + + @Override + @SneakyThrows(IOException.class) + public void spawn(QuickReplaySender sender, Consumer> send) { + super.spawn(sender, send); + + sender.buf.readerIndex(index); + NetInput in = sender.bufInput; + + blocksT = new TreeMap<>(); + ListMultimap blocks = Multimaps.newListMultimap(blocksT, LinkedList::new); // LinkedList to allow .descendingIterator int time = 0; for (int i = in.readVarInt(); i > 0; i--) { @@ -853,6 +944,13 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } } + @Override + public void despawn(QuickReplaySender sender, Consumer> send) { + super.despawn(sender, send); + + blocksT = null; + } + @Override public void play(int currentTimeStamp, int replayTime, Consumer> send) { blocksT.subMap(currentTimeStamp, false, replayTime, true).values() @@ -895,20 +993,44 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } @Override - public void writeToCache(NetOutput out) throws IOException { - super.writeToCache(out); + public int writeToCache(NetOutput indexOut, NetOutput cacheOut, int despawnTime, int index) throws IOException { + indexOut.writeByte(3); + index = super.writeToCache(indexOut, cacheOut, despawnTime, index); - QuickReplaySender.writeToCache(out, rainStrengths); + indexOut.writeVarInt(index); + index += QuickReplaySender.writeToCache(cacheOut, rainStrengths); + + return index; } } private static class BakedWeather extends BakedTrackedThing { - private TreeMap> rainStrengths = new TreeMap<>(); + private int index; + private TreeMap> rainStrengths; - private BakedWeather(int version, NetInput in) throws IOException { - super(version, in); + private BakedWeather(NetInput in) throws IOException { + super(in); - readFromCache(version, in, rainStrengths); + index = in.readVarInt(); + } + + @Override + @SneakyThrows(IOException.class) + public void spawn(QuickReplaySender sender, Consumer> send) { + super.spawn(sender, send); + + sender.buf.readerIndex(index); + + rainStrengths = new TreeMap<>(); + + readFromCache(sender.bufInput, rainStrengths); + } + + @Override + public void despawn(QuickReplaySender sender, Consumer> send) { + super.despawn(sender, send); + + rainStrengths = null; } @Override From e989b60ea861e379f5df403944dab6986c649af9 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 18 Dec 2018 09:50:50 +0100 Subject: [PATCH 09/16] Properly handle non-full chunks in QuickReplaySender --- .../replaymod/replay/QuickReplaySender.java | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/replaymod/replay/QuickReplaySender.java b/src/main/java/com/replaymod/replay/QuickReplaySender.java index 51a12bf0..cc05c787 100644 --- a/src/main/java/com/replaymod/replay/QuickReplaySender.java +++ b/src/main/java/com/replaymod/replay/QuickReplaySender.java @@ -404,11 +404,40 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } } else if (packet instanceof ServerChunkDataPacket) { Column column = ((ServerChunkDataPacket) packet).getColumn(); - Chunk chunk = new Chunk(column); - chunk.spawnTime = time; - Chunk prev = activeChunks.put(coordToLong(column.getX(), column.getZ()), chunk); - if (prev != null) { - index = prev.writeToCache(indexOut, out, time, index); + if (column.hasBiomeData()) { + Chunk chunk = new Chunk(column); + chunk.spawnTime = time; + Chunk prev = activeChunks.put(coordToLong(column.getX(), column.getZ()), chunk); + if (prev != null) { + index = prev.writeToCache(indexOut, out, time, index); + } + } else { + Chunk chunk = activeChunks.get(coordToLong(column.getX(), column.getZ())); + if (chunk != null) { + int sectionY = 0; + for (com.github.steveice10.mc.protocol.data.game.chunk.Chunk section : column.getChunks()) { + if (section == null) { + sectionY++; + continue; + } + BlockStorage toBlocks = section.getBlocks(); + BlockStorage fromBlocks = chunk.currentBlockState[sectionY]; + for (int y = 0; y < 16; y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + BlockState fromState = fromBlocks.get(x, y, z); + BlockState toState = toBlocks.get(x, y, z); + if (!fromState.equals(toState)) { + Position pos = new Position(column.getX() << 4 | x, sectionY << 4 | y, column.getZ() << 4 | z); + chunk.blocks.put(time, new BlockChange(pos, fromState, toState)); + } + } + } + } + chunk.currentBlockState[sectionY] = toBlocks; + sectionY++; + } + } } } else if (packet instanceof ServerUnloadChunkPacket) { ServerUnloadChunkPacket p = (ServerUnloadChunkPacket) packet; From c30520ad3d85030e3b2587740ba6d24af6f6f15b Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 18 Dec 2018 09:59:45 +0100 Subject: [PATCH 10/16] Fix previous block state of BlockChange being loaded incorrectly --- src/main/java/com/replaymod/replay/QuickReplaySender.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/replaymod/replay/QuickReplaySender.java b/src/main/java/com/replaymod/replay/QuickReplaySender.java index cc05c787..8aa50645 100644 --- a/src/main/java/com/replaymod/replay/QuickReplaySender.java +++ b/src/main/java/com/replaymod/replay/QuickReplaySender.java @@ -451,10 +451,10 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe ? new BlockChangeRecord[]{ ((ServerBlockChangePacket) packet).getRecord() } : ((ServerMultiBlockChangePacket) packet).getRecords()) { Position pos = record.getPosition(); - Chunk chunk = activeChunks.get(coordToLong(pos.getX() / 16, pos.getZ() / 16)); + Chunk chunk = activeChunks.get(coordToLong(pos.getX() >> 4, pos.getZ() >> 4)); 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); + BlockStorage blockStorage = chunk.currentBlockState[pos.getY() >> 4]; + int x = pos.getX() & 15, y = pos.getY() & 15, z = pos.getZ() & 15; BlockState prevState = blockStorage.get(x, y, z); BlockState newState = record.getBlock(); blockStorage.set(x, y, z, newState); From 1670ed4e8068dbc93d168a3b95289dc076a2ccfb Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 18 Dec 2018 10:44:42 +0100 Subject: [PATCH 11/16] Fix dead lock when switching from async to quick mode --- .../java/com/replaymod/replay/ReplayHandler.java | 12 +++++++----- .../java/com/replaymod/replay/ReplayModReplay.java | 8 ++++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/replaymod/replay/ReplayHandler.java b/src/main/java/com/replaymod/replay/ReplayHandler.java index 45007209..c54081a9 100755 --- a/src/main/java/com/replaymod/replay/ReplayHandler.java +++ b/src/main/java/com/replaymod/replay/ReplayHandler.java @@ -348,20 +348,22 @@ public class ReplayHandler { public void setQuickMode(boolean quickMode) { if (quickMode == this.quickMode) return; - if (quickMode && !fullReplaySender.isAsyncMode()) return; // Cannot activate quick mode when already in sync mode + if (quickMode && fullReplaySender.isAsyncMode()) { + // If this method is called via runLater, then it cannot switch to sync mode by itself as there might be + // some rogue packets in the task queue after it. Instead the caller must switch to sync mode first and + // use runLater until all packets have been processed (when using setAsyncModeAndWait, one runLater should + // be sufficient). + throw new IllegalStateException("Cannot switch to quick mode while in async 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); } } diff --git a/src/main/java/com/replaymod/replay/ReplayModReplay.java b/src/main/java/com/replaymod/replay/ReplayModReplay.java index 5ff5a373..0032acea 100644 --- a/src/main/java/com/replaymod/replay/ReplayModReplay.java +++ b/src/main/java/com/replaymod/replay/ReplayModReplay.java @@ -144,8 +144,12 @@ public class ReplayModReplay { core.getKeyBindingRegistry().registerKeyBinding("replaymod.input.quickmode", Keyboard.KEY_Q, () -> { if (replayHandler != null) { - replayHandler.ensureQuickModeInitialized(() -> - replayHandler.setQuickMode(!replayHandler.isQuickMode())); + replayHandler.getReplaySender().setSyncModeAndWait(); + core.runLater(() -> + replayHandler.ensureQuickModeInitialized(() -> { + replayHandler.setQuickMode(!replayHandler.isQuickMode()); + replayHandler.getReplaySender().setAsyncMode(true); + })); } }); From 27370778286ad1595b5185503d1f7eebb96f95ba Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 18 Dec 2018 10:44:57 +0100 Subject: [PATCH 12/16] Retain camera position when switching to and from quick mode --- src/main/java/com/replaymod/replay/ReplayHandler.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/com/replaymod/replay/ReplayHandler.java b/src/main/java/com/replaymod/replay/ReplayHandler.java index c54081a9..59c06672 100755 --- a/src/main/java/com/replaymod/replay/ReplayHandler.java +++ b/src/main/java/com/replaymod/replay/ReplayHandler.java @@ -356,6 +356,14 @@ public class ReplayHandler { throw new IllegalStateException("Cannot switch to quick mode while in async mode."); } this.quickMode = quickMode; + + CameraEntity cam = getCameraEntity(); + if (cam != null) { + targetCameraPosition = new Location(cam.posX, cam.posY, cam.posZ, cam.rotationYaw, cam.rotationPitch); + } else { + targetCameraPosition = null; + } + if (quickMode) { quickReplaySender.register(); quickReplaySender.restart(); @@ -365,6 +373,8 @@ public class ReplayHandler { fullReplaySender.sendPacketsTill(0); fullReplaySender.sendPacketsTill(quickReplaySender.currentTimeStamp()); } + + moveCameraToTargetPosition(); } public boolean isQuickMode() { From 60ef8df5234a6b692ffdf4f4f8226ded410f57a1 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 18 Dec 2018 10:48:31 +0100 Subject: [PATCH 13/16] Add Quick Mode keybinding translation key --- src/main/resources/assets/replaymod/lang | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/assets/replaymod/lang b/src/main/resources/assets/replaymod/lang index 16508289..2dbb2d67 160000 --- a/src/main/resources/assets/replaymod/lang +++ b/src/main/resources/assets/replaymod/lang @@ -1 +1 @@ -Subproject commit 165082892e4f323d0bc5ef6bc390325664372035 +Subproject commit 2dbb2d6705b4f54bdfbed0de4a2647ceef14b651 From 5366758cf667cf6656dd113bc43450f43fef4fbb Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 18 Dec 2018 12:43:39 +0100 Subject: [PATCH 14/16] Compress packets to reduce quick mode memory usage by ~80% --- .../replaymod/replay/QuickReplaySender.java | 56 +++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/replaymod/replay/QuickReplaySender.java b/src/main/java/com/replaymod/replay/QuickReplaySender.java index 8aa50645..991d717a 100644 --- a/src/main/java/com/replaymod/replay/QuickReplaySender.java +++ b/src/main/java/com/replaymod/replay/QuickReplaySender.java @@ -81,6 +81,8 @@ import java.util.SortedMap; import java.util.TreeMap; import java.util.UUID; import java.util.function.Consumer; +import java.util.zip.Deflater; +import java.util.zip.Inflater; //#if MC>=11200 import com.replaymod.core.utils.WrappedTimer; @@ -317,6 +319,7 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe readFromCache(in, thunderStrengths); int size = in.readVarInt(); + LOGGER.info("Creating quick mode buffer of size: {}KB", size / 1024); buf = com.github.steveice10.netty.buffer.Unpooled.buffer(size); int read = 0; while (true) { @@ -610,6 +613,8 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe 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 final Inflater inflater = new Inflater(); + private static final Deflater deflater = new Deflater(); 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 @@ -647,7 +652,21 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe int readerIndex = byteBuf.readerIndex(); // Mark the current reader and writer index (should be at start) int writerIndex = byteBuf.writerIndex(); try { - packetBuf.writeBytes(in.readBytes(in.readVarInt())); + int prefix = in.readVarInt(); + int len = prefix >> 1; + if ((prefix & 1) == 1) { + int fullLen = in.readVarInt(); + byteBuf.writeBytes(in.readBytes(len)); + byteBuf.capacity(byteBuf.writerIndex() + fullLen); + + inflater.setInput(byteBuf.array(), byteBuf.arrayOffset() + byteBuf.readerIndex(), len); + inflater.inflate(byteBuf.array(), byteBuf.arrayOffset() + byteBuf.writerIndex(), fullLen); + + byteBuf.readerIndex(byteBuf.readerIndex() + len); + byteBuf.writerIndex(byteBuf.writerIndex() + fullLen); + } else { + byteBuf.writeBytes(in.readBytes(len)); + } int packetId = //#if MC>=11102 @@ -665,6 +684,7 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } finally { byteBuf.readerIndex(readerIndex); // Reset reader & writer index for next use byteBuf.writerIndex(writerIndex); + inflater.reset(); } } @@ -695,12 +715,37 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe byteBuf.skipBytes(8); // Skip packet length & timestamp + int rawIndex = byteBuf.readerIndex(); int size = byteBuf.readableBytes(); - int len = writeVarInt(out, size); - for (int i = 0; i < size; i++) { - out.writeByte(byteBuf.readByte()); + + byteBuf.ensureWritable(size); + deflater.setInput(byteBuf.array(), byteBuf.arrayOffset() + byteBuf.readerIndex(), size); + deflater.finish(); + int compressedSize = 0; + while (!deflater.finished() && compressedSize < size) { + compressedSize += deflater.deflate( + byteBuf.array(), + byteBuf.arrayOffset() + byteBuf.writerIndex() + compressedSize, + size - compressedSize + ); } - return len + size; + + int len = 0; + if (compressedSize < size) { + byteBuf.readerIndex(rawIndex + size); + byteBuf.writerIndex(rawIndex + size + compressedSize); + len += writeVarInt(out, compressedSize << 1 | 1); + len += writeVarInt(out, size); + } else { + byteBuf.readerIndex(rawIndex); + byteBuf.writerIndex(rawIndex + size); + len += writeVarInt(out, size << 1); + } + while (byteBuf.isReadable()) { + out.writeByte(byteBuf.readByte()); + len += 1; + } + return len; } catch (Exception e) { Utils.throwIfInstanceOf(e, IOException.class); Utils.throwIfUnchecked(e); @@ -708,6 +753,7 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe } finally { byteBuf.readerIndex(readerIndex); // Reset reader & writer index for next use byteBuf.writerIndex(writerIndex); + deflater.reset(); } } From 1da7060bb2095060c2538bd4e8cd8741fb9f1543 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 18 Dec 2018 12:55:51 +0100 Subject: [PATCH 15/16] Fix entity head rotation in quick mode --- .../com/replaymod/replay/QuickReplaySender.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/replaymod/replay/QuickReplaySender.java b/src/main/java/com/replaymod/replay/QuickReplaySender.java index 991d717a..8ec020a1 100644 --- a/src/main/java/com/replaymod/replay/QuickReplaySender.java +++ b/src/main/java/com/replaymod/replay/QuickReplaySender.java @@ -15,6 +15,7 @@ import com.github.steveice10.mc.protocol.data.game.world.notify.ClientNotificati 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.ServerEntityHeadLookPacket; 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; @@ -932,14 +933,18 @@ public class QuickReplaySender extends ChannelHandlerAdapter implements ReplaySe @Override public void play(int currentTimeStamp, int replayTime, Consumer> send) { - playMap(locations, currentTimeStamp, replayTime, l -> - send.accept(toMC(new ServerEntityTeleportPacket(id, l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch(), false)))); + playMap(locations, currentTimeStamp, replayTime, l -> { + send.accept(toMC(new ServerEntityTeleportPacket(id, l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch(), false))); + send.accept(toMC(new ServerEntityHeadLookPacket(id, l.getYaw()))); + }); } @Override public void rewind(int currentTimeStamp, int replayTime, Consumer> send) { - rewindMap(locations, currentTimeStamp, replayTime, l -> - send.accept(toMC(new ServerEntityTeleportPacket(id, l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch(), false)))); + rewindMap(locations, currentTimeStamp, replayTime, l -> { + send.accept(toMC(new ServerEntityTeleportPacket(id, l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch(), false))); + send.accept(toMC(new ServerEntityHeadLookPacket(id, l.getYaw()))); + }); } } From c1b4964978818ff3f7147d3b161f8c4328b65b52 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 18 Dec 2018 13:32:26 +0100 Subject: [PATCH 16/16] Handle entity position interpolation when jumping in quick mode --- .../com/replaymod/replay/ReplayHandler.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/main/java/com/replaymod/replay/ReplayHandler.java b/src/main/java/com/replaymod/replay/ReplayHandler.java index 59c06672..3317045b 100755 --- a/src/main/java/com/replaymod/replay/ReplayHandler.java +++ b/src/main/java/com/replaymod/replay/ReplayHandler.java @@ -510,7 +510,48 @@ public class ReplayHandler { public void doJump(int targetTime, boolean retainCameraPosition) { //#if MC>=10904 if (getReplaySender() == quickReplaySender) { + // Always round to full tick + targetTime = targetTime + targetTime % 50; + + if (targetTime >= 50) { + // Jump to time of previous tick first + quickReplaySender.sendPacketsTill(targetTime - 50); + } + + // Update all entity positions (especially prev/lastTick values) + for (Entity entity : loadedEntityList(world(mc))) { + if (entity instanceof EntityOtherPlayerMP) { + EntityOtherPlayerMP e = (EntityOtherPlayerMP) entity; + e.setPosition(e.otherPlayerMPX, e.otherPlayerMPY, e.otherPlayerMPZ); + e.rotationYaw = (float) e.otherPlayerMPYaw; + e.rotationPitch = (float) e.otherPlayerMPPitch; + } + entity.lastTickPosX = entity.prevPosX = entity.posX; + entity.lastTickPosY = entity.prevPosY = entity.posY; + entity.lastTickPosZ = entity.prevPosZ = entity.posZ; + entity.prevRotationYaw = entity.rotationYaw; + entity.prevRotationPitch = entity.rotationPitch; + } + + // Run previous tick + try { + mc.runTick(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + // Jump to target tick quickReplaySender.sendPacketsTill(targetTime); + + // Immediately apply player teleport interpolation + for (Entity entity : loadedEntityList(world(mc))) { + if (entity instanceof EntityOtherPlayerMP) { + EntityOtherPlayerMP e = (EntityOtherPlayerMP) entity; + e.setPosition(e.otherPlayerMPX, e.otherPlayerMPY, e.otherPlayerMPZ); + e.rotationYaw = (float) e.otherPlayerMPYaw; + e.rotationPitch = (float) e.otherPlayerMPPitch; + } + } return; } //#endif