diff --git a/README.md b/README.md index 7ff29ab..32ec4ab 100644 --- a/README.md +++ b/README.md @@ -155,11 +155,15 @@ After you boot the server a new file will be generated in the path "player_recording_path": "./recordings/players", "max_file_size": "0GB", "restart_after_max_file_size": false, + "include_compressed_in_status": true, + "fixed_daylight_cycle": -1, "pause_unloaded_chunks": false, "pause_notify_players": true, "fix_carpet_bot_view_distance": false, "ignore_sound_packets": false, "ignore_light_packets": true, + "ignore_chat_packets": false, + "ignore_scoreboard_packets": false, "optimize_explosion_packets": true, "optimize_entity_packets": false, "player_predicate": { @@ -181,9 +185,12 @@ After you boot the server a new file will be generated in the path | `"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.

| | `"restart_after_max_file_size"` |

If a max file size is set and this limit is reached then the replay recording will automatically restart creating a new replay file.

| | `"include_compressed_in_status"` |

Includes the compressed file size of the replays when you do `/replay status`, for long replays this may cause the status message to take a while to be displayed, so you can disable it.

| -| `"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. | +| `"fixed_daylight_cycle"` |

This fixes the daylight cycle in the replay if you do not want the constant day-night cycle in long timelapses. This should be set to the time of day in ticks, e.g. `6000` (midday). To disable the fixed daylight cycle set the value to `-1`.

| +| `"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.

| +| `"ignore_chat_packets"` |

Stops chat packets (from both the server and other players) from being recorded if they are not necessary for your replay.

| +| `"ignore_scoreboard_packets"` |

Stops scoreboard packets from being recorded (for example, if you have a scoreboard displaying digs then this will not appear, and player's scores will also not be recorded).

| | `"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.

| | `"player_predicate"` |

The predicate for recording players automatically, more information in the [Predicates](#predicates-config) section.

| diff --git a/build.gradle.kts b/build.gradle.kts index 354e55d..3672105 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { kotlin("jvm") kotlin("plugin.serialization") version "1.9.21" id("me.modmuss50.mod-publish-plugin") version "0.4.5" + id("com.github.johnrengelman.shadow") version "8.1.1" id("fabric-loom") `maven-publish` java @@ -10,6 +11,10 @@ plugins { group = property("maven_group")!! version = property("mod_version")!! +val releaseVersion = "${project.version}+mc${project.property("minecraft_version")}" + +val shade by configurations.creating + repositories { maven { url = uri("https://maven.parchmentmc.org/") @@ -43,8 +48,11 @@ dependencies { // I've had some issues with ReplayStudio and slf4j (in dev) // Simplest workaround that I've found is just to unzip the // jar and yeet the org.slf4j packages then rezip the jar. - include(modImplementation("com.github.ReplayMod:ReplayStudio:6cd39b0874") { + shade(modImplementation("com.github.ReplayMod:ReplayStudio:6cd39b0874") { exclude(group = "org.slf4j") + exclude(group = "it.unimi.dsi") + exclude(group = "org.apache.commons") + exclude(group = "commons-cli") exclude(group = "com.google.guava", module = "guava-jdk5") exclude(group = "com.google.guava", module = "guava") exclude(group = "com.google.code.gson", module = "gson") @@ -66,10 +74,6 @@ loom { } } -tasks.remapJar { - archiveVersion.set("${project.version}+mc${project.property("minecraft_version")}") -} - tasks { processResources { inputs.property("version", project.version) @@ -78,15 +82,37 @@ tasks { } } - jar { + remapJar { + archiveVersion.set(releaseVersion) + + inputFile.set(shadowJar.get().archiveFile) + } + + remapSourcesJar { + archiveVersion.set(releaseVersion) + } + + shadowJar { + destinationDirectory.set(File("./build/devlibs")) + isZip64 = true + from("LICENSE") + + relocate("com.github.steveice10", "shadow.server_replay.com.github.steveice10") + configurations = listOf(shade) + + archiveClassifier = "shaded" } publishMods { file = remapJar.get().archiveFile changelog.set( """ - - Port to 1.20.1 + - Added some extra meta-data to replay files (to help with debugging) + - Added extra configurations: + - `fixed_daylight_cycle` - allows you to set a fixed time of day for the recording + - `ignore_chat_packets` - ignore all chat packets + - `ignore_scoreboard_packets` - ignore all scoreboard packets """.trimIndent() ) type = STABLE diff --git a/gradle.properties b/gradle.properties index f8f3981..e044a15 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,6 +15,6 @@ permissions_version=0.3-SNAPSHOT fabric_version=0.87.2+1.19.4 carpet_version=1.4.101 -mod_version=1.0.4 +mod_version=1.0.5 org.gradle.jvmargs=-Xmx4000m \ No newline at end of file diff --git a/src/main/kotlin/me/senseiwells/replay/api/RejoinedPacketSender.kt b/src/main/kotlin/me/senseiwells/replay/api/RejoinedPacketSender.kt new file mode 100644 index 0000000..f9858f3 --- /dev/null +++ b/src/main/kotlin/me/senseiwells/replay/api/RejoinedPacketSender.kt @@ -0,0 +1,10 @@ +package me.senseiwells.replay.api + +import me.senseiwells.replay.chunk.ChunkRecorder +import me.senseiwells.replay.player.PlayerRecorder + +interface RejoinedPacketSender { + fun recordAdditionalPlayerPackets(recorder: PlayerRecorder) + + fun recordAdditionalChunkPackets(recorder: ChunkRecorder) +} \ No newline at end of file diff --git a/src/main/kotlin/me/senseiwells/replay/api/ReplaySenders.kt b/src/main/kotlin/me/senseiwells/replay/api/ReplaySenders.kt new file mode 100644 index 0000000..78d021d --- /dev/null +++ b/src/main/kotlin/me/senseiwells/replay/api/ReplaySenders.kt @@ -0,0 +1,9 @@ +package me.senseiwells.replay.api + +object ReplaySenders { + internal val senders = ArrayList() + + fun addSender(sender: RejoinedPacketSender) { + this.senders.add(sender) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecorder.kt b/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecorder.kt index d8fcad0..37af5d8 100644 --- a/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecorder.kt +++ b/src/main/kotlin/me/senseiwells/replay/chunk/ChunkRecorder.kt @@ -1,5 +1,7 @@ package me.senseiwells.replay.chunk +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive import com.mojang.authlib.GameProfile import me.senseiwells.replay.ServerReplay import me.senseiwells.replay.mixin.chunk.WitherBossAccessor @@ -20,6 +22,7 @@ import net.minecraft.world.entity.Entity import net.minecraft.world.entity.boss.wither.WitherBoss import net.minecraft.world.level.ChunkPos import net.minecraft.world.level.levelgen.Heightmap +import org.apache.commons.lang3.builder.ToStringBuilder import org.jetbrains.annotations.ApiStatus.Internal import java.nio.file.Path import java.util.concurrent.CompletableFuture @@ -101,6 +104,20 @@ class ChunkRecorder internal constructor( return super.getTimestamp() - this.totalPausedTime - this.getCurrentPause() } + override fun appendToStatus(builder: ToStringBuilder) { + builder.append("chunks_world", this.chunks.level.dimension().location()) + builder.append("chunks_from", this.chunks.from) + builder.append("chunks_to", this.chunks.to) + } + + override fun addMetadata(map: MutableMap) { + super.addMetadata(map) + map["chunks_world"] = JsonPrimitive(this.chunks.level.dimension().location().toString()) + map["chunks_from"] = JsonPrimitive(this.chunks.from.toString()) + map["chunks_to"] = JsonPrimitive(this.chunks.to.toString()) + map["paused_time"] = JsonPrimitive(this.totalPausedTime) + } + override fun canContinueRecording(): Boolean { return true } diff --git a/src/main/kotlin/me/senseiwells/replay/commands/ReplayCommand.kt b/src/main/kotlin/me/senseiwells/replay/commands/ReplayCommand.kt index 7f708b1..96fe5bb 100644 --- a/src/main/kotlin/me/senseiwells/replay/commands/ReplayCommand.kt +++ b/src/main/kotlin/me/senseiwells/replay/commands/ReplayCommand.kt @@ -14,7 +14,6 @@ import me.senseiwells.replay.chunk.ChunkRecorders import me.senseiwells.replay.config.ReplayConfig import me.senseiwells.replay.player.PlayerRecorders import me.senseiwells.replay.recorder.ReplayRecorder -import me.senseiwells.replay.util.FileUtils import net.minecraft.commands.CommandSourceStack import net.minecraft.commands.Commands import net.minecraft.commands.SharedSuggestionProvider @@ -23,8 +22,6 @@ import net.minecraft.commands.arguments.EntityArgument import net.minecraft.network.chat.Component import net.minecraft.server.level.ServerLevel import net.minecraft.world.level.ChunkPos -import org.apache.commons.lang3.builder.StandardToStringStyle -import org.apache.commons.lang3.builder.ToStringBuilder import java.util.concurrent.CompletableFuture object ReplayCommand { @@ -290,19 +287,12 @@ object ReplayCommand { } private fun status(context: CommandContext): Int { - val style = StandardToStringStyle().apply { - fieldSeparator = ", " - fieldNameValueSeparator = " = " - isUseClassName = false - isUseIdentityHashCode = false - } - val builder = StringBuilder("ServerReplay is ") .append(if (ServerReplay.config.enabled) "enabled" else "disabled") .append("\n") - val players = this.getStatusFuture("Players", PlayerRecorders.all(), style) - val chunks = this.getStatusFuture("Chunks", ChunkRecorders.all(), style) + val players = this.getStatusFuture("Players", PlayerRecorders.all()) + val chunks = this.getStatusFuture("Chunks", ChunkRecorders.all()) CompletableFuture.runAsync { for (player in players) { @@ -332,31 +322,13 @@ object ReplayCommand { private fun getStatusFuture( type: String, - recorders: Collection, - style: StandardToStringStyle, + recorders: Collection ): List> { if (recorders.isNotEmpty()) { val futures = ArrayList>() futures.add(CompletableFuture.completedFuture("Currently Recording $type:\n")) for (recorder in recorders) { - val seconds = recorder.getTotalRecordingTime() / 1000 - val hours = seconds / 3600 - val minutes = seconds % 3600 / 60 - val secs = seconds % 60 - val time = "%02d:%02d:%02d".format(hours, minutes, secs) - - val sub = ToStringBuilder(recorder, style) - .append("name", recorder.getName()) - .append("time", time) - .append("raw", FileUtils.formatSize(recorder.getRawRecordingSize())) - if (ServerReplay.config.includeCompressedReplaySizeInStatus) { - val compressed = recorder.getCompressedRecordingSize() - futures.add(compressed.thenApplyAsync { - "${sub.append("compressed", FileUtils.formatSize(it))}\n" - }) - } else { - futures.add(CompletableFuture.completedFuture("$sub\n")) - } + futures.add(recorder.getStatusWithSize()) } return futures } diff --git a/src/main/kotlin/me/senseiwells/replay/config/ReplayConfig.kt b/src/main/kotlin/me/senseiwells/replay/config/ReplayConfig.kt index e5ac30a..2ec2d65 100644 --- a/src/main/kotlin/me/senseiwells/replay/config/ReplayConfig.kt +++ b/src/main/kotlin/me/senseiwells/replay/config/ReplayConfig.kt @@ -5,9 +5,7 @@ import kotlinx.serialization.EncodeDefault.Mode import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.decodeFromStream -import kotlinx.serialization.json.encodeToStream +import kotlinx.serialization.json.* import me.senseiwells.replay.ServerReplay import me.senseiwells.replay.chunk.ChunkRecorders import me.senseiwells.replay.config.chunk.ChunkAreaConfig @@ -59,6 +57,9 @@ class ReplayConfig { @SerialName("include_compressed_in_status") var includeCompressedReplaySizeInStatus = true + @SerialName("fixed_daylight_cycle") + var fixedDaylightCycle = -1L + @SerialName("pause_unloaded_chunks") var skipWhenChunksUnloaded = false @SerialName("pause_notify_players") @@ -69,6 +70,10 @@ class ReplayConfig { var ignoreSoundPackets = false @SerialName("ignore_light_packets") var ignoreLightPackets = true + @SerialName("ignore_chat_packets") + var ignoreChatPackets = false + @SerialName("ignore_scoreboard_packets") + var ignoreScoreboardPackets = false @SerialName("optimize_explosion_packets") var optimizeExplosionPackets = true @SerialName("optimize_entity_packets") @@ -147,5 +152,9 @@ class ReplayConfig { ServerReplay.logger.error("Failed to serialize replay config", e) } } + + internal fun toJson(config: ReplayConfig): JsonElement { + return json.encodeToJsonElement(config) + } } } diff --git a/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorder.kt b/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorder.kt index 212a92b..ef20ace 100644 --- a/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorder.kt +++ b/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorder.kt @@ -1,6 +1,8 @@ package me.senseiwells.replay.player import com.mojang.authlib.GameProfile +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive import me.senseiwells.replay.mixin.rejoin.ChunkMapAccessor import me.senseiwells.replay.mixin.rejoin.TrackedEntityAccessor import me.senseiwells.replay.recorder.ChunkSender @@ -73,6 +75,11 @@ class PlayerRecorder internal constructor( this.record(ClientboundRemoveEntitiesPacket(player.id)) } + override fun addMetadata(map: MutableMap) { + super.addMetadata(map) + map["player_name"] = JsonPrimitive(this.profile.name) + } + override fun canContinueRecording(): Boolean { return this.player != null } diff --git a/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorders.kt b/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorders.kt index 10305f7..de80598 100644 --- a/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorders.kt +++ b/src/main/kotlin/me/senseiwells/replay/player/PlayerRecorders.kt @@ -2,12 +2,14 @@ package me.senseiwells.replay.player import com.mojang.authlib.GameProfile import me.senseiwells.replay.ServerReplay +import me.senseiwells.replay.api.RejoinedPacketSender import me.senseiwells.replay.recorder.ReplayRecorder import me.senseiwells.replay.rejoin.RejoinedReplayPlayer import net.minecraft.server.MinecraftServer import net.minecraft.server.level.ServerPlayer import java.util.* import java.util.concurrent.CompletableFuture +import kotlin.collections.ArrayList object PlayerRecorders { private val players = LinkedHashMap() diff --git a/src/main/kotlin/me/senseiwells/replay/recorder/ReplayRecorder.kt b/src/main/kotlin/me/senseiwells/replay/recorder/ReplayRecorder.kt index f4469bd..cb8fda9 100644 --- a/src/main/kotlin/me/senseiwells/replay/recorder/ReplayRecorder.kt +++ b/src/main/kotlin/me/senseiwells/replay/recorder/ReplayRecorder.kt @@ -10,6 +10,11 @@ import com.replaymod.replaystudio.protocol.PacketTypeRegistry import com.replaymod.replaystudio.replay.ReplayMetaData import io.netty.buffer.Unpooled import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.encodeToStream import me.senseiwells.replay.ServerReplay import me.senseiwells.replay.config.ReplayConfig import me.senseiwells.replay.util.DebugPacketData @@ -26,6 +31,8 @@ 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.builder.StandardToStringStyle +import org.apache.commons.lang3.builder.ToStringBuilder import org.jetbrains.annotations.ApiStatus.Internal import java.io.IOException import java.net.URL @@ -181,6 +188,33 @@ abstract class ReplayRecorder( return CompletableFuture.supplyAsync({ this.replay.getCompressedFileSize() }, this.executor) } + fun getStatusWithSize(): CompletableFuture { + val builder = ToStringBuilder(this, StandardToStringStyle().apply { + fieldSeparator = ", " + fieldNameValueSeparator = " = " + isUseClassName = false + isUseIdentityHashCode = false + }) + val seconds = this.getTotalRecordingTime() / 1000 + val hours = seconds / 3600 + val minutes = seconds % 3600 / 60 + val secs = seconds % 60 + val time = "%02d:%02d:%02d".format(hours, minutes, secs) + builder.append("name", this.getName()) + builder.append("time", time) + + this.appendToStatus(builder) + + builder.append("raw_size", FileUtils.formatSize(this.getRawRecordingSize())) + if (ServerReplay.config.includeCompressedReplaySizeInStatus) { + val compressed = this.getCompressedRecordingSize() + return compressed.thenApplyAsync { + "${builder.append("compressed_size", FileUtils.formatSize(it))}" + } + } + return CompletableFuture.completedFuture(builder.toString()) + } + @Internal fun getDebugPacketData(): String { return this.packets.values @@ -213,6 +247,15 @@ abstract class ReplayRecorder( return this.getTotalRecordingTime() } + protected open fun appendToStatus(builder: ToStringBuilder) { + + } + + protected open fun addMetadata(map: MutableMap) { + map["name"] = JsonPrimitive(this.getName()) + map["settings"] = ReplayConfig.toJson(ServerReplay.config) + } + abstract fun getName(): String protected abstract fun start(): Boolean @@ -334,6 +377,13 @@ abstract class ReplayRecorder( this.executor.execute { this.replay.writeMetaData(registry, this.meta) + + this.replay.write(ENTRY_SERVER_REPLAY_META).use { + val json = HashMap() + this.addMetadata(json) + @OptIn(ExperimentalSerializationApi::class) + Json.encodeToStream(json, it) + } } } @@ -414,4 +464,8 @@ abstract class ReplayRecorder( } return false } + + companion object { + private const val ENTRY_SERVER_REPLAY_META = "server_replay_meta.json" + } } \ No newline at end of file diff --git a/src/main/kotlin/me/senseiwells/replay/rejoin/RejoinedReplayPlayer.kt b/src/main/kotlin/me/senseiwells/replay/rejoin/RejoinedReplayPlayer.kt index ebba6bd..9d0e63b 100644 --- a/src/main/kotlin/me/senseiwells/replay/rejoin/RejoinedReplayPlayer.kt +++ b/src/main/kotlin/me/senseiwells/replay/rejoin/RejoinedReplayPlayer.kt @@ -1,6 +1,9 @@ package me.senseiwells.replay.rejoin +import me.senseiwells.replay.api.ReplaySenders +import me.senseiwells.replay.chunk.ChunkRecorder import me.senseiwells.replay.mixin.common.PlayerListAccessor +import me.senseiwells.replay.player.PlayerRecorder import me.senseiwells.replay.recorder.ReplayRecorder import net.fabricmc.fabric.api.networking.v1.PacketByteBufs import net.minecraft.nbt.CompoundTag @@ -113,5 +116,12 @@ class RejoinedReplayPlayer private constructor( for (mobEffectInstance in this.activeEffects) { this.recorder.record(ClientboundUpdateMobEffectPacket(this.id, mobEffectInstance)) } + + for (sender in ReplaySenders.senders) { + when (this.recorder) { + is PlayerRecorder -> sender.recordAdditionalPlayerPackets(this.recorder) + is ChunkRecorder -> sender.recordAdditionalChunkPackets(this.recorder) + } + } } } \ 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 e2db351..5bbca94 100644 --- a/src/main/kotlin/me/senseiwells/replay/util/ReplayOptimizerUtils.kt +++ b/src/main/kotlin/me/senseiwells/replay/util/ReplayOptimizerUtils.kt @@ -33,8 +33,21 @@ object ReplayOptimizerUtils { ClientboundPlayerAbilitiesPacket::class.java, ClientboundLoginCompressionPacket::class.java, ClientboundCommandSuggestionsPacket::class.java, + ClientboundCustomChatCompletionsPacket::class.java, ClientboundCommandsPacket::class.java ) + private val CHAT = setOf>>( + ClientboundPlayerChatPacket::class.java, + ClientboundDeleteChatPacket::class.java, + ClientboundSystemChatPacket::class.java, + ClientboundDisguisedChatPacket::class.java + ) + private val SCOREBOARD = setOf>>( + ClientboundSetScorePacket::class.java, + ClientboundResetScorePacket::class.java, + ClientboundSetObjectivePacket::class.java, + ClientboundSetDisplayObjectivePacket::class.java + ) private val SOUNDS = setOf>>( ClientboundSoundPacket::class.java, ClientboundSoundEntityPacket::class.java @@ -73,10 +86,22 @@ object ReplayOptimizerUtils { return true } + val time = ServerReplay.config.fixedDaylightCycle + if (time >= 0 && packet is ClientboundSetTimePacket && packet.dayTime != -time) { + recorder.record(ClientboundSetTimePacket(packet.gameTime, time, false)) + return true + } + val type = packet::class.java if (ServerReplay.config.ignoreSoundPackets && SOUNDS.contains(type)) { return true } + if (ServerReplay.config.ignoreChatPackets && CHAT.contains(type)) { + return true + } + if (ServerReplay.config.ignoreScoreboardPackets && SCOREBOARD.contains(type)) { + return true + } return IGNORED.contains(type) }