diff --git a/README.md b/README.md index 47f78b8..ace7816 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ After you boot the server a new file will be generated in the path "fix_carpet_bot_view_distance": false, "ignore_sound_packets": false, "ignore_light_packets": true, + "optimize_tnt_packets": false, "pause_unloaded_chunks": false, "pause_notify_players": true, "player_recording_path": "./recordings/players", @@ -177,6 +178,8 @@ After you boot the server a new file will be generated in the path | `"fix_carpet_bot_view_distance"` |

If you are recording carpet bots you want to enable this as it sets the view distance to the server view distance. Otherwise it will only record a distance of 2 chunks around the bot. | | `"ignore_sound_packets"` |

If you are recording a large area for a timelapse it's unlikely you'll want to record any sounds, these can eat up significant storage space.

| | `"ignore_light_packets"` |

Light is calculated on the client as well as on the server so light packets are mostly redundant.

| +| `"optimize_explosion_packets"` |

This reduces the file size greatly by not sending the client explosion packets instead just sending the explosion particles and sounds.

| +| `"optimize_entity_packets"` |

This reduces the file size by letting the client handle the logic for some entities, e.g. projectiles and tnt. This may cause some inconsistencies however it will likely be negligible.

| | `"pause_unloaded_chunks"` |

If an area of chunks is being recorded and the area is unloaded and this is set to `true` then the replay will pause the recording until the chunks are loaded again.

If set to false the chunks will be recorded as if they were loaded.

| | `"pause_notify_players"` |

If `pause_unloaded_chunks` is enabled and this is enabled then when the recording for the chunk area is paused or resumed all online players will be notified.

| | `"player_recording_path"` |

The path where you want player recordings to be saved.

| diff --git a/src/main/kotlin/me/senseiwells/replay/commands/ReplayCommand.kt b/src/main/kotlin/me/senseiwells/replay/commands/ReplayCommand.kt index 69592d7..4cfbe70 100644 --- a/src/main/kotlin/me/senseiwells/replay/commands/ReplayCommand.kt +++ b/src/main/kotlin/me/senseiwells/replay/commands/ReplayCommand.kt @@ -7,7 +7,6 @@ import com.mojang.brigadier.arguments.StringArgumentType import com.mojang.brigadier.context.CommandContext import com.mojang.brigadier.suggestion.SuggestionProvider import me.lucko.fabric.api.permissions.v0.Permissions -import me.senseiwells.replay.ServerReplay import me.senseiwells.replay.chunk.ChunkArea import me.senseiwells.replay.chunk.ChunkRecorder import me.senseiwells.replay.chunk.ChunkRecorders @@ -327,13 +326,15 @@ object ReplayCommand { val secs = seconds % 60 val time = "%02d:%02d:%02d".format(hours, minutes, secs) - val built = ToStringBuilder(recorder, style) + val sub = ToStringBuilder(recorder, style) .append("name", recorder.getName()) .append("time", time) .append("raw", FileUtils.formatSize(recorder.getRawRecordingSize())) .append("compressed", FileUtils.formatSize(compressed.join())) - .toString() - builder.append(built).append("\n") + if (ReplayConfig.debug) { + sub.append("debug", recorder.getDebugPacketData()) + } + builder.append(sub.toString()).append("\n") } } else { builder.append("Not Currently Recording $type").append("\n") diff --git a/src/main/kotlin/me/senseiwells/replay/config/ReplayConfig.kt b/src/main/kotlin/me/senseiwells/replay/config/ReplayConfig.kt index 7c8225e..e11063f 100644 --- a/src/main/kotlin/me/senseiwells/replay/config/ReplayConfig.kt +++ b/src/main/kotlin/me/senseiwells/replay/config/ReplayConfig.kt @@ -32,6 +32,8 @@ object ReplayConfig { @JvmStatic var enabled: Boolean = false + @JvmStatic + var debug: Boolean = false @JvmStatic var skipWhenChunksUnloaded = false @@ -41,6 +43,8 @@ object ReplayConfig { var fixCarpetBotViewDistance = false var ignoreSoundPackets = false var ignoreLightPackets = true + var optimizeExplosionPackets = true + var optimizeEntityPackets = false var worldName = "World" var serverName = "Server" @@ -75,6 +79,9 @@ object ReplayConfig { if (json.has("enabled")) { this.enabled = json.get("enabled").asBoolean } + if (json.has("debug")) { + this.debug = json.get("debug").asBoolean + } if (json.has("world_name")) { this.worldName = json.get("world_name").asString } @@ -97,15 +104,18 @@ object ReplayConfig { if (json.has("ignore_light_packets")) { this.ignoreLightPackets = json.get("ignore_light_packets").asBoolean } + if (json.has("optimize_explosion_packets")) { + this.optimizeExplosionPackets = json.get("optimize_explosion_packets").asBoolean + } + if (json.has("optimize_entity_packets")) { + this.optimizeEntityPackets = json.get("optimize_entity_packets").asBoolean + } if (json.has("pause_unloaded_chunks")) { this.skipWhenChunksUnloaded = json.get("pause_unloaded_chunks").asBoolean } if (json.has("pause_notify_players")) { this.notifyPlayersLoadingChunks = json.get("pause_notify_players").asBoolean } - if (json.has("recording_path")) { - this.playerRecordingPath = Path.of(json.get("recording_path").asString) - } if (json.has("player_recording_path")) { this.playerRecordingPath = Path.of(json.get("player_recording_path").asString) } @@ -131,6 +141,9 @@ object ReplayConfig { try { val json = JsonObject() json.addProperty("enabled", this.enabled) + if (this.debug) { + json.addProperty("debug", true) + } json.addProperty("world_name", this.worldName) json.addProperty("server_name", this.serverName) json.addProperty("max_file_size", this.maxFileSizeString) @@ -138,6 +151,8 @@ object ReplayConfig { json.addProperty("fix_carpet_bot_view_distance", this.fixCarpetBotViewDistance) json.addProperty("ignore_sound_packets", this.ignoreSoundPackets) json.addProperty("ignore_light_packets", this.ignoreLightPackets) + json.addProperty("optimize_explosion_packets", this.optimizeExplosionPackets) + json.addProperty("optimize_entity_packets", this.optimizeEntityPackets) json.addProperty("pause_unloaded_chunks", this.skipWhenChunksUnloaded) json.addProperty("pause_notify_players", this.notifyPlayersLoadingChunks) json.addProperty("player_recording_path", this.playerRecordingPath.pathString) diff --git a/src/main/kotlin/me/senseiwells/replay/recorder/ReplayRecorder.kt b/src/main/kotlin/me/senseiwells/replay/recorder/ReplayRecorder.kt index 8c188a8..0376188 100644 --- a/src/main/kotlin/me/senseiwells/replay/recorder/ReplayRecorder.kt +++ b/src/main/kotlin/me/senseiwells/replay/recorder/ReplayRecorder.kt @@ -12,6 +12,7 @@ import io.netty.buffer.Unpooled import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap import me.senseiwells.replay.ServerReplay import me.senseiwells.replay.config.ReplayConfig +import me.senseiwells.replay.util.DebugPacketData import me.senseiwells.replay.util.FileUtils import me.senseiwells.replay.util.ReplayOptimizerUtils import me.senseiwells.replay.util.SizedZipReplayFile @@ -27,10 +28,9 @@ import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket import net.minecraft.network.protocol.game.ClientboundRespawnPacket import net.minecraft.network.protocol.login.ClientboundGameProfilePacket import net.minecraft.server.MinecraftServer +import net.minecraft.server.level.ServerLevel import net.minecraft.world.entity.EntityType -import org.apache.commons.lang3.mutable.MutableInt import org.jetbrains.annotations.ApiStatus.Internal -import org.jetbrains.annotations.VisibleForTesting import java.io.IOException import java.net.URL import java.nio.charset.StandardCharsets @@ -41,7 +41,6 @@ import java.util.* import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -import kotlin.collections.HashMap import kotlin.io.path.* import com.github.steveice10.netty.buffer.Unpooled as ReplayUnpooled import net.minecraft.network.protocol.Packet as MinecraftPacket @@ -51,6 +50,7 @@ abstract class ReplayRecorder( protected val profile: GameProfile, private val recordings: Path ) { + private val packets by lazy { Object2ObjectOpenHashMap, DebugPacketData>() } private val executor: ExecutorService private val replay: SizedZipReplayFile @@ -74,6 +74,8 @@ abstract class ReplayRecorder( val recordingPlayerUUID: UUID get() = this.profile.id + abstract val level: ServerLevel + init { this.executor = Executors.newSingleThreadExecutor() @@ -90,7 +92,10 @@ abstract class ReplayRecorder( if (!this.started) { throw IllegalStateException("Cannot record packets if recorder not started") } - if (this.ignore || this.stopped || ReplayOptimizerUtils.canIgnorePacket(outgoing)) { + if (this.ignore || this.stopped) { + return + } + if (ReplayOptimizerUtils.optimisePackets(this, outgoing)) { return } @@ -104,6 +109,12 @@ abstract class ReplayRecorder( val state = this.protocolAsState() outgoing.write(buf) + + if (ReplayConfig.debug) { + val type = outgoing::class.java + this.packets.getOrPut(type) { DebugPacketData(type, 0, 0) }.increment(buf.readableBytes()) + } + val wrapped = ReplayUnpooled.wrappedBuffer(buf.array(), buf.arrayOffset(), buf.readableBytes()) val version = ProtocolVersion.getProtocol(SharedConstants.getProtocolVersion()) @@ -152,6 +163,10 @@ abstract class ReplayRecorder( return CompletableFuture.failedFuture(IllegalStateException("Cannot stop replay after already stopped")) } + if (ReplayConfig.debug) { + ServerReplay.logger.info("Replay ${this.getName()} Debug Packet Data:\n${this.getDebugPacketData()}") + } + // We only save if the player has actually logged in... val future = this.close(save && this.protocol == ConnectionProtocol.PLAY) this.closed(future) @@ -170,6 +185,13 @@ abstract class ReplayRecorder( return CompletableFuture.supplyAsync({ this.replay.getCompressedFileSize() }, this.executor) } + @Internal + fun getDebugPacketData(): String { + return this.packets.values + .sortedByDescending { it.size } + .joinToString(separator = "\n", transform = DebugPacketData::format) + } + @Internal fun afterLogin() { this.started = true diff --git a/src/main/kotlin/me/senseiwells/replay/util/DebugPacketData.kt b/src/main/kotlin/me/senseiwells/replay/util/DebugPacketData.kt new file mode 100644 index 0000000..7adab70 --- /dev/null +++ b/src/main/kotlin/me/senseiwells/replay/util/DebugPacketData.kt @@ -0,0 +1,16 @@ +package me.senseiwells.replay.util + +data class DebugPacketData( + val type: Class<*>, + var count: Int, + var size: Long +) { + fun increment(size: Int) { + this.count++ + this.size += size + } + + fun format(): String { + return "Type: ${this.type.simpleName}, Size: ${FileUtils.formatSize(this.size)}, Count: ${this.count}" + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/senseiwells/replay/util/ReplayOptimizerUtils.kt b/src/main/kotlin/me/senseiwells/replay/util/ReplayOptimizerUtils.kt index 4652e4d..bb34a54 100644 --- a/src/main/kotlin/me/senseiwells/replay/util/ReplayOptimizerUtils.kt +++ b/src/main/kotlin/me/senseiwells/replay/util/ReplayOptimizerUtils.kt @@ -1,9 +1,17 @@ package me.senseiwells.replay.util import me.senseiwells.replay.config.ReplayConfig +import me.senseiwells.replay.recorder.ReplayRecorder +import net.minecraft.core.Holder import net.minecraft.network.protocol.Packet import net.minecraft.network.protocol.game.* import net.minecraft.network.protocol.login.ClientboundLoginCompressionPacket +import net.minecraft.server.level.ServerLevel +import net.minecraft.sounds.SoundSource +import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.item.PrimedTnt +import net.minecraft.world.entity.projectile.Projectile +import net.minecraft.world.level.Explosion object ReplayOptimizerUtils { private val IGNORED = setOf>>( @@ -25,19 +33,94 @@ object ReplayOptimizerUtils { ClientboundLoginCompressionPacket::class.java, ) - val SOUNDS = setOf>>( + private val SOUNDS = setOf>>( ClientboundSoundPacket::class.java, ClientboundSoundEntityPacket::class.java ) + private val ENTITY_MOVEMENT = setOf>>( + ClientboundMoveEntityPacket.Pos::class.java, + ClientboundTeleportEntityPacket::class.java, + ClientboundSetEntityMotionPacket::class.java, + ClientboundTeleportEntityPacket::class.java + ) + private val ENTITY_MAPPERS = HashMap, (Any, ServerLevel) -> Entity?>() - fun canIgnorePacket(packet: Packet<*>): Boolean { - val type = packet::class.java - if (ReplayConfig.ignoreLightPackets && type == ClientboundLightUpdatePacket::class.java) { + init { + this.addEntityPacket(ClientboundEntityEventPacket::class.java) { packet, level -> packet.getEntity(level) } + this.addEntityPacket(ClientboundMoveEntityPacket.Pos::class.java) { packet, level -> packet.getEntity(level) } + this.addEntityPacket(ClientboundSetEntityDataPacket::class.java) { packet, level -> level.getEntity(packet.id) } + this.addEntityPacket(ClientboundTeleportEntityPacket::class.java) { packet, level -> level.getEntity(packet.id) } + this.addEntityPacket(ClientboundSetEntityDataPacket::class.java) { packet, level -> level.getEntity(packet.id) } + this.addEntityPacket(ClientboundSetEntityMotionPacket::class.java) { packet, level -> level.getEntity(packet.id) } + this.addEntityPacket(ClientboundTeleportEntityPacket::class.java) { packet, level -> level.getEntity(packet.id) } + } + + fun optimisePackets(recorder: ReplayRecorder, packet: Packet<*>): Boolean { + if (ReplayConfig.optimizeEntityPackets) { + if (this.optimiseEntity(recorder, packet)) { + return true + } + } + + if (ReplayConfig.optimizeExplosionPackets && packet is ClientboundExplodePacket) { + this.optimiseExplosions(recorder, packet) return true } + + if (ReplayConfig.ignoreLightPackets && packet is ClientboundLightUpdatePacket) { + return true + } + + val type = packet::class.java if (ReplayConfig.ignoreSoundPackets && SOUNDS.contains(type)) { return true } return IGNORED.contains(type) } + + private fun optimiseEntity(recorder: ReplayRecorder, packet: Packet<*>): Boolean { + val type = packet::class.java + val mapper = ENTITY_MAPPERS[type] ?: return false + val entity = mapper(packet, recorder.level) ?: return false + + if (entity is PrimedTnt) { + return true + } + if (entity is Projectile && ENTITY_MOVEMENT.contains(type)) { + return true + } + return false + } + + private fun optimiseExplosions(recorder: ReplayRecorder, packet: ClientboundExplodePacket) { + // Based on Explosion#finalizeExplosion + val random = recorder.level.random + recorder.record(ClientboundSoundPacket( + Holder.direct(packet.explosionSound), + SoundSource.BLOCKS, + packet.x, packet.y, packet.z, + 4.0F, + (1 + (random.nextFloat() - random.nextFloat()) * 0.2F) * 0.7F, + random.nextLong() + )) + + val breaks = packet.blockInteraction != Explosion.BlockInteraction.KEEP + val particles = if (packet.power >= 2.0F && breaks) { + packet.largeExplosionParticles + } else { + packet.smallExplosionParticles + } + recorder.record(ClientboundLevelParticlesPacket( + particles, + particles.type.overrideLimiter, + packet.x, packet.y, packet.z, + 1.0F, 0.0F, 0.0F, + 1.0F, 0 + )) + } + + @Suppress("UNCHECKED_CAST") + private fun addEntityPacket(type: Class, getter: (T, ServerLevel) -> Entity?) { + this.ENTITY_MAPPERS[type] = getter as (Any, ServerLevel) -> Entity? + } } \ No newline at end of file