-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
086194e
commit 3b47671
Showing
7 changed files
with
291 additions
and
78 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
15 changes: 15 additions & 0 deletions
15
...in/java/me/senseiwells/replay/mixin/viewer/ClientboundPlayerInfoUpdatePacketAccessor.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
package me.senseiwells.replay.mixin.viewer; | ||
|
||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket; | ||
import org.spongepowered.asm.mixin.Mixin; | ||
import org.spongepowered.asm.mixin.Mutable; | ||
import org.spongepowered.asm.mixin.gen.Accessor; | ||
|
||
import java.util.List; | ||
|
||
@Mixin(ClientboundPlayerInfoUpdatePacket.class) | ||
public interface ClientboundPlayerInfoUpdatePacketAccessor { | ||
@Mutable | ||
@Accessor("entries") | ||
void setEntries(List<ClientboundPlayerInfoUpdatePacket.Entry> entries); | ||
} |
11 changes: 11 additions & 0 deletions
11
src/main/java/me/senseiwells/replay/mixin/viewer/EntityInvoker.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
package me.senseiwells.replay.mixin.viewer; | ||
|
||
import net.minecraft.world.entity.Entity; | ||
import org.spongepowered.asm.mixin.Mixin; | ||
import org.spongepowered.asm.mixin.gen.Invoker; | ||
|
||
@Mixin(Entity.class) | ||
public interface EntityInvoker { | ||
@Invoker("unsetRemoved") | ||
void removeRemovalReason(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
247 changes: 173 additions & 74 deletions
247
src/main/kotlin/me/senseiwells/replay/viewer/ReplayViewer.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,103 +1,202 @@ | ||
package me.senseiwells.replay.viewer | ||
|
||
import com.replaymod.replaystudio.PacketData | ||
import com.replaymod.replaystudio.lib.viaversion.api.protocol.packet.State | ||
import com.replaymod.replaystudio.lib.viaversion.api.protocol.version.ProtocolVersion | ||
import com.replaymod.replaystudio.protocol.Packet | ||
import com.replaymod.replaystudio.protocol.PacketTypeRegistry | ||
import com.replaymod.replaystudio.replay.ReplayFile | ||
import io.netty.buffer.ByteBuf | ||
import io.netty.buffer.Unpooled | ||
import me.senseiwells.replay.ducks.`ServerReplay$ReplayViewable` | ||
import net.fabricmc.fabric.impl.networking.payload.RetainedPayload | ||
import it.unimi.dsi.fastutil.ints.IntArrayList | ||
import it.unimi.dsi.fastutil.ints.IntList | ||
import it.unimi.dsi.fastutil.ints.IntOpenHashSet | ||
import it.unimi.dsi.fastutil.longs.LongOpenHashSet | ||
import kotlinx.coroutines.* | ||
import me.senseiwells.replay.mixin.viewer.EntityInvoker | ||
import me.senseiwells.replay.viewer.ReplayViewerUtils.getClientboundPlayPacketType | ||
import me.senseiwells.replay.viewer.ReplayViewerUtils.sendReplayPacket | ||
import me.senseiwells.replay.viewer.ReplayViewerUtils.setReplayViewer | ||
import me.senseiwells.replay.viewer.ReplayViewerUtils.toClientboundPlayPacket | ||
import net.minecraft.SharedConstants | ||
import net.minecraft.network.ConnectionProtocol | ||
import net.minecraft.network.FriendlyByteBuf | ||
import net.minecraft.network.protocol.PacketFlow | ||
import net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket | ||
import net.minecraft.network.protocol.game.ClientboundForgetLevelChunkPacket | ||
import net.minecraft.network.protocol.game.ClientboundLoginPacket | ||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket | ||
import net.minecraft.core.UUIDUtil | ||
import net.minecraft.network.chat.Component | ||
import net.minecraft.network.protocol.Packet | ||
import net.minecraft.network.protocol.game.* | ||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.Action | ||
import net.minecraft.server.level.ServerPlayer | ||
import net.minecraft.server.network.ServerGamePacketListenerImpl | ||
import net.minecraft.network.protocol.Packet as MinecraftPacket | ||
import net.minecraft.world.entity.Entity | ||
import net.minecraft.world.level.ChunkPos | ||
import net.minecraft.world.phys.Vec3 | ||
import java.util.* | ||
import kotlin.collections.ArrayList | ||
import kotlin.collections.HashMap | ||
|
||
class ReplayViewer( | ||
val replay: ReplayFile, | ||
val connection: ServerGamePacketListenerImpl | ||
) { | ||
fun view() { | ||
(this.connection as `ServerReplay$ReplayViewable`).`replay$setReplayViewer`(this) | ||
private var started = false | ||
|
||
this.connection.`replay$sendReplayViewerPacket`( | ||
ClientboundPlayerInfoUpdatePacket( | ||
ClientboundPlayerInfoUpdatePacket.Action.UPDATE_GAME_MODE, | ||
this.connection.player | ||
) | ||
) | ||
this.connection.player.chunkTrackingView.forEach { | ||
this.connection.`replay$sendReplayViewerPacket`(ClientboundForgetLevelChunkPacket(it)) | ||
private val coroutineScope by lazy { | ||
CoroutineScope(Dispatchers.Default + Job()) | ||
} | ||
private val chunks = Collections.synchronizedCollection(LongOpenHashSet()) | ||
private val entities = Collections.synchronizedCollection(IntOpenHashSet()) | ||
|
||
private val player: ServerPlayer | ||
get() = this.connection.player | ||
|
||
fun start() { | ||
if (this.started) { | ||
return | ||
} | ||
this.started = true | ||
this.setForReplayView() | ||
|
||
// TODO: Wtf is this??? | ||
// This is so fucking jank please fix this :) | ||
Thread { | ||
this.coroutineScope.launch { | ||
val version = ProtocolVersion.getProtocol(SharedConstants.getProtocolVersion()) | ||
// TODO: Send any configuration data (e.g. resource packs, tags, etc. to the client) | ||
val data = this.replay.getPacketData(PacketTypeRegistry.get(version, State.PLAY)) | ||
|
||
var pastLogin = false | ||
var previousTime = 0L | ||
var timed = data.readPacket() | ||
while (timed != null) { | ||
// TODO: Ew, please make this nicer | ||
val type = ConnectionProtocol.PLAY.getPacketsByIds(PacketFlow.CLIENTBOUND).get(timed.packet.id) | ||
if (type == ClientboundLoginPacket::class.java) { | ||
pastLogin = true | ||
timed.release() | ||
timed = data.readPacket() | ||
continue | ||
} else if (!pastLogin) { | ||
timed.release() | ||
timed = data.readPacket() | ||
continue | ||
val stream = replay.getPacketData(PacketTypeRegistry.get(version, State.PLAY)) | ||
|
||
var loggedIn = false | ||
var lastTime = 0L | ||
var data: PacketData? = stream.readPacket() | ||
while (data != null) { | ||
// We don't care about all the packets before the player logs in. | ||
if (!loggedIn) { | ||
val type = data.packet.getClientboundPlayPacketType() | ||
if (type != ClientboundLoginPacket::class.java) { | ||
data.release() | ||
data = stream.readPacket() | ||
continue | ||
} | ||
loggedIn = true | ||
} | ||
// TODO: Use co-routines for non-blocking delay | ||
Thread.sleep(timed.time - previousTime) | ||
this.connection.`replay$sendReplayViewerPacket`(toMinecraftPacket(timed.packet)) | ||
|
||
previousTime = timed.time | ||
timed.release() | ||
timed = data.readPacket() | ||
delay(data.time - lastTime) | ||
|
||
val packet = data.packet.toClientboundPlayPacket() | ||
send(modifyViewingPacket(packet)) | ||
|
||
lastTime = data.time | ||
data.release() | ||
data = stream.readPacket() | ||
} | ||
}.start() | ||
} | ||
} | ||
|
||
private companion object { | ||
@Suppress("UnstableApiUsage") | ||
fun toMinecraftPacket(packet: Packet): MinecraftPacket<*> { | ||
val codec = ConnectionProtocol.PLAY.codec(PacketFlow.CLIENTBOUND) | ||
val decoded = codec.createPacket(packet.id, toFriendlyByteBuf(packet.buf)) | ||
?: throw IllegalStateException("Failed to create packet with id ${packet.id}") | ||
|
||
if (decoded is ClientboundCustomPayloadPacket) { | ||
val payload = decoded.payload | ||
if (payload is RetainedPayload) { | ||
return ClientboundCustomPayloadPacket(payload.resolve(null)) | ||
} | ||
} | ||
return decoded | ||
fun stop() { | ||
this.coroutineScope.cancel() | ||
this.connection.setReplayViewer(null) | ||
|
||
this.connection.send(ClientboundRemoveEntitiesPacket(IntArrayList(this.entities))) | ||
|
||
for (chunk in this.chunks) { | ||
this.connection.send(ClientboundForgetLevelChunkPacket(ChunkPos(chunk))) | ||
} | ||
val player = this.player | ||
val level = player.serverLevel() | ||
level.addNewPlayer(player) | ||
this.connection.send(ClientboundRespawnPacket(player.createCommonSpawnInfo(level), 3.toByte())) | ||
(player as EntityInvoker).removeRemovalReason() | ||
this.connection.teleport(player.x, player.y, player.z, player.yRot, player.xRot) | ||
player.server.playerList.sendLevelInfo(player, level) | ||
} | ||
|
||
@Suppress("USELESS_IS_CHECK") | ||
fun toFriendlyByteBuf(buf: com.github.steveice10.netty.buffer.ByteBuf): FriendlyByteBuf { | ||
// When we compile we map steveice10.netty -> io.netty | ||
// We just need this check for dev environment | ||
if (buf is ByteBuf) { | ||
return FriendlyByteBuf(buf) | ||
} | ||
private fun setForReplayView() { | ||
this.connection.setReplayViewer(this) | ||
this.player.serverLevel().removePlayerImmediately(this.player, Entity.RemovalReason.CHANGED_DIMENSION); | ||
|
||
this.send( | ||
ClientboundPlayerInfoUpdatePacket( | ||
Action.UPDATE_GAME_MODE, | ||
this.player | ||
) | ||
) | ||
this.player.chunkTrackingView.forEach { | ||
this.send(ClientboundForgetLevelChunkPacket(it)) | ||
} | ||
} | ||
|
||
private fun modifyViewingPacket(packet: Packet<*>): Packet<*> { | ||
if (packet is ClientboundLevelChunkWithLightPacket) { | ||
this.chunks.add(ChunkPos.asLong(packet.x, packet.z)) | ||
} | ||
if (packet is ClientboundForgetLevelChunkPacket) { | ||
this.chunks.remove(packet.pos.toLong()) | ||
} | ||
if (packet is ClientboundAddEntityPacket) { | ||
this.entities.add(packet.id) | ||
} | ||
if (packet is ClientboundRemoveEntitiesPacket) { | ||
this.entities.removeAll(packet.entityIds) | ||
} | ||
|
||
val array = ByteArray(buf.readableBytes()) | ||
buf.readBytes(array) | ||
return FriendlyByteBuf(Unpooled.wrappedBuffer(array)) | ||
if (packet is ClientboundLoginPacket) { | ||
return ClientboundLoginPacket( | ||
VIEWER_ID, | ||
packet.hardcore, | ||
packet.levels, | ||
packet.maxPlayers, | ||
packet.chunkRadius, | ||
packet.simulationDistance, | ||
packet.reducedDebugInfo, | ||
packet.showDeathScreen, | ||
packet.doLimitedCrafting, | ||
packet.commonPlayerSpawnInfo | ||
) | ||
} | ||
if (packet is ClientboundPlayerInfoUpdatePacket) { | ||
val index = packet.entries().indexOfFirst { it.profileId == this.player.uuid } | ||
if (index >= 0) { | ||
val copy = ArrayList(packet.entries()) | ||
val previous = copy[index] | ||
copy[index] = ClientboundPlayerInfoUpdatePacket.Entry( | ||
VIEWER_UUID, | ||
previous.profile, | ||
false, | ||
previous.latency, | ||
previous.gameMode, | ||
previous.displayName, | ||
previous.chatSession | ||
) | ||
return ReplayViewerUtils.createClientboundPlayerInfoUpdatePacket(packet.actions(), copy) | ||
} | ||
} | ||
if (packet is ClientboundAddEntityPacket && packet.uuid == this.player.uuid) { | ||
return ClientboundAddEntityPacket( | ||
packet.id, | ||
VIEWER_UUID, | ||
packet.x, | ||
packet.y, | ||
packet.z, | ||
packet.yRot, | ||
packet.xRot, | ||
packet.type, | ||
packet.data, | ||
Vec3(packet.xa, packet.ya, packet.za), | ||
packet.yHeadRot.toDouble() | ||
) | ||
} | ||
if (packet is ClientboundPlayerChatPacket) { | ||
// We don't want to deal with chat validation... | ||
val message = packet.unsignedContent ?: Component.literal(packet.body.content) | ||
val type = packet.chatType.resolve(this.player.server.registryAccess()) | ||
val decorated = if (type.isPresent) { | ||
type.get().decorate(message) | ||
} else { | ||
Component.literal("<").append(packet.chatType.name).append("> ").append(message) | ||
} | ||
return ClientboundSystemChatPacket(decorated, false) | ||
} | ||
return packet | ||
} | ||
|
||
private fun send(packet: Packet<*>) { | ||
this.connection.sendReplayPacket(packet) | ||
} | ||
|
||
private companion object { | ||
const val VIEWER_ID = Int.MAX_VALUE - 10 | ||
val VIEWER_UUID: UUID = UUIDUtil.createOfflinePlayerUUID("-ViewingProfile-") | ||
} | ||
} |
Oops, something went wrong.