Skip to content

Commit

Permalink
Add debug support and also added optimisations
Browse files Browse the repository at this point in the history
  • Loading branch information
senseiwells committed Feb 2, 2024
1 parent 07c8345 commit 8aaa0aa
Show file tree
Hide file tree
Showing 6 changed files with 155 additions and 15 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -177,6 +178,8 @@ After you boot the server a new file will be generated in the path
| `"fix_carpet_bot_view_distance"` | <p> 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"` | <p> 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. </p> |
| `"ignore_light_packets"` | <p> Light is calculated on the client as well as on the server so light packets are mostly redundant. </p> |
| `"optimize_explosion_packets"` | <p> This reduces the file size greatly by not sending the client explosion packets instead just sending the explosion particles and sounds. </p> |
| `"optimize_entity_packets"` | <p> 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. </p> |
| `"pause_unloaded_chunks"` | <p> 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. </p> <p> If set to false the chunks will be recorded as if they were loaded. </p> |
| `"pause_notify_players"` | <p> 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. </p> |
| `"player_recording_path"` | <p> The path where you want player recordings to be saved. </p> |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
21 changes: 18 additions & 3 deletions src/main/kotlin/me/senseiwells/replay/config/ReplayConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ object ReplayConfig {

@JvmStatic
var enabled: Boolean = false
@JvmStatic
var debug: Boolean = false

@JvmStatic
var skipWhenChunksUnloaded = false
Expand All @@ -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"
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
}
Expand All @@ -131,13 +141,18 @@ 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)
json.addProperty("restart_after_max_file_size", this.restartAfterMaxFileSize)
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)
Expand Down
30 changes: 26 additions & 4 deletions src/main/kotlin/me/senseiwells/replay/recorder/ReplayRecorder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -51,6 +50,7 @@ abstract class ReplayRecorder(
protected val profile: GameProfile,
private val recordings: Path
) {
private val packets by lazy { Object2ObjectOpenHashMap<Class<*>, DebugPacketData>() }
private val executor: ExecutorService

private val replay: SizedZipReplayFile
Expand All @@ -74,6 +74,8 @@ abstract class ReplayRecorder(
val recordingPlayerUUID: UUID
get() = this.profile.id

abstract val level: ServerLevel

init {
this.executor = Executors.newSingleThreadExecutor()

Expand All @@ -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
}

Expand All @@ -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())
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/main/kotlin/me/senseiwells/replay/util/DebugPacketData.kt
Original file line number Diff line number Diff line change
@@ -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}"
}
}
91 changes: 87 additions & 4 deletions src/main/kotlin/me/senseiwells/replay/util/ReplayOptimizerUtils.kt
Original file line number Diff line number Diff line change
@@ -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<Class<out Packet<*>>>(
Expand All @@ -25,19 +33,94 @@ object ReplayOptimizerUtils {
ClientboundLoginCompressionPacket::class.java,

)
val SOUNDS = setOf<Class<out Packet<*>>>(
private val SOUNDS = setOf<Class<out Packet<*>>>(
ClientboundSoundPacket::class.java,
ClientboundSoundEntityPacket::class.java
)
private val ENTITY_MOVEMENT = setOf<Class<out Packet<*>>>(
ClientboundMoveEntityPacket.Pos::class.java,
ClientboundTeleportEntityPacket::class.java,
ClientboundSetEntityMotionPacket::class.java,
ClientboundTeleportEntityPacket::class.java
)
private val ENTITY_MAPPERS = HashMap<Class<*>, (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 <T: Any> addEntityPacket(type: Class<T>, getter: (T, ServerLevel) -> Entity?) {
this.ENTITY_MAPPERS[type] = getter as (Any, ServerLevel) -> Entity?
}
}

0 comments on commit 8aaa0aa

Please sign in to comment.