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
This commit is contained in:
Jonas Herzig
2018-07-18 21:53:58 +02:00
parent 8314803cda
commit edc54ce6d4
6 changed files with 1731 additions and 1146 deletions

View File

@@ -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<Void> initPromise;
private TreeMap<Integer, Collection<TrackedThing>> thingSpawnsT = new TreeMap<>();
private ListMultimap<Integer, TrackedThing> thingSpawns = Multimaps.newListMultimap(thingSpawnsT, ArrayList::new);
private TreeMap<Integer, Collection<TrackedThing>> thingDespawnsT = new TreeMap<>();
private ListMultimap<Integer, TrackedThing> thingDespawns = Multimaps.newListMultimap(thingDespawnsT, ArrayList::new);
private List<TrackedThing> 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<Void> initialize(Consumer<Double> progress) {
if (initPromise != null) {
return initPromise;
}
SettableFuture<Void> 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<Void>() {
@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<Double> 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<UUID, PlayerListEntry> playerListEntries = new HashMap<>();
Map<Integer, Entity> activeEntities = new HashMap<>();
Map<Long, Chunk> 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<Packet<?>> spawnPackets;
List<Packet<?>> despawnPackets;
int spawnTime;
int despawnTime = Integer.MAX_VALUE;
public abstract void play(int currentTimeStamp, int replayTime, Consumer<Packet<?>> send);
public abstract void rewind(int currentTimeStamp, int replayTime, Consumer<Packet<?>> send);
}
private static class Entity extends TrackedThing {
private int id;
private NavigableMap<Integer, Location> locations = new TreeMap<>();
private Entity(int entityId, List<Packet<?>> spawnPackets) {
this.id = entityId;
this.spawnPackets = spawnPackets;
this.despawnPackets = Collections.singletonList(toMC(new ServerEntityDestroyPacket(entityId)));
}
@Override
public void play(int currentTimeStamp, int replayTime, Consumer<Packet<?>> send) {
Map.Entry<Integer, Location> 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<Packet<?>> send) {
Map.Entry<Integer, Location> 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<Integer, Collection<BlockChange>> blocksT = new TreeMap<>();
private ListMultimap<Integer, BlockChange> 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<Packet<?>> 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<Packet<?>> send) {
if (currentTimeStamp >= despawnTime) {
play(spawnTime, replayTime, send);
return;
}
blocksT.subMap(replayTime, false, currentTimeStamp, true).descendingMap().values()
.forEach(updates ->
((LinkedList<BlockChange>) 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;
}
}
}