From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: MrHua269 Date: Sun, 17 Mar 2024 02:13:30 +0000 Subject: [PATCH] Leaves Replay Mod API diff --git a/src/main/java/io/papermc/paper/util/TickThread.java b/src/main/java/io/papermc/paper/util/TickThread.java index 906f1c9e619a924c622acc76652a4569305edc8d..620e12a38c67230ad8591a4a32bcb932426356cc 100644 --- a/src/main/java/io/papermc/paper/util/TickThread.java +++ b/src/main/java/io/papermc/paper/util/TickThread.java @@ -258,7 +258,7 @@ public class TickThread extends Thread { return true; } - if (entity instanceof ServerPlayer serverPlayer) { + if ((entity instanceof ServerPlayer serverPlayer) && !(entity instanceof top.leavesmc.leaves.replay.ServerPhotographer)) { //Leaves - ReplayMod API ServerGamePacketListenerImpl conn = serverPlayer.connection; return conn != null && worldData.connections.contains(conn.connection); } else { diff --git a/src/main/java/me/earthme/luminol/utils/NullStatsCounter.java b/src/main/java/me/earthme/luminol/utils/NullStatsCounter.java new file mode 100644 index 0000000000000000000000000000000000000000..2b1354bb249bfc373a25b25e5a9c05f012e7edee --- /dev/null +++ b/src/main/java/me/earthme/luminol/utils/NullStatsCounter.java @@ -0,0 +1,37 @@ +package me.earthme.luminol.utils; + +import com.mojang.datafixers.DataFixer; +import net.minecraft.server.MinecraftServer; +import net.minecraft.stats.ServerStatsCounter; +import net.minecraft.stats.Stat; +import net.minecraft.world.entity.player.Player; + +import java.io.File; + +public class NullStatsCounter extends ServerStatsCounter { + private static final File UNKOWN_FILE = new File("NULL_STATS_REMOVE_THIS"); + + public NullStatsCounter(MinecraftServer server) { + super(server, UNKOWN_FILE); + } + + @Override + public void save() { + + } + + @Override + public void setValue(Player player, Stat stat, int value) { + + } + + @Override + public void parseLocal(DataFixer dataFixer, String json) { + + } + + @Override + public int getValue(Stat stat) { + return 0; + } +} diff --git a/src/main/java/net/minecraft/commands/arguments/EntityArgument.java b/src/main/java/net/minecraft/commands/arguments/EntityArgument.java index 8d79cfa371546996ef65f94232c1d344e7c590ec..9c262c82d9ab24bdbbe03df8cee3d5d99e8f8868 100644 --- a/src/main/java/net/minecraft/commands/arguments/EntityArgument.java +++ b/src/main/java/net/minecraft/commands/arguments/EntityArgument.java @@ -147,6 +147,7 @@ public class EntityArgument implements ArgumentType { if (icompletionprovider instanceof CommandSourceStack commandSourceStack && commandSourceStack.getEntity() instanceof ServerPlayer sourcePlayer) { collection = new java.util.ArrayList<>(); for (final ServerPlayer player : commandSourceStack.getServer().getPlayerList().getPlayers()) { + if (player instanceof top.leavesmc.leaves.replay.ServerPhotographer) continue; // Leaves - skip photographer if (sourcePlayer.getBukkitEntity().canSee(player.getBukkitEntity())) { collection.add(player.getGameProfile().getName()); } diff --git a/src/main/java/net/minecraft/commands/arguments/selector/EntitySelector.java b/src/main/java/net/minecraft/commands/arguments/selector/EntitySelector.java index 676a1499747b071515479130875157263d3a8352..e5ef298dc1df9cc42b3d349939a966b77fc0d554 100644 --- a/src/main/java/net/minecraft/commands/arguments/selector/EntitySelector.java +++ b/src/main/java/net/minecraft/commands/arguments/selector/EntitySelector.java @@ -122,6 +122,7 @@ public class EntitySelector { return this.findPlayers(source); } else if (this.playerName != null) { ServerPlayer entityplayer = source.getServer().getPlayerList().getPlayerByName(this.playerName); + entityplayer = entityplayer instanceof top.leavesmc.leaves.replay.ServerPhotographer ? null : entityplayer; // Leaves - skip photographer return (List) (entityplayer == null ? Collections.emptyList() : Lists.newArrayList(new ServerPlayer[]{entityplayer})); } else if (this.entityUUID != null) { @@ -137,6 +138,7 @@ public class EntitySelector { ServerLevel worldserver = (ServerLevel) iterator.next(); entity = worldserver.getEntity(this.entityUUID); + entity = entity instanceof top.leavesmc.leaves.replay.ServerPhotographer ? null : entity; // Leaves - skip photographer } while (entity == null); return Lists.newArrayList(new Entity[]{entity}); @@ -145,7 +147,7 @@ public class EntitySelector { Predicate predicate = this.getPredicate(vec3d); if (this.currentEntity) { - return (List) (source.getEntity() != null && predicate.test(source.getEntity()) ? Lists.newArrayList(new Entity[]{source.getEntity()}) : Collections.emptyList()); + return (List) (source.getEntity() != null && !(source.getEntity() instanceof top.leavesmc.leaves.replay.ServerPhotographer) && predicate.test(source.getEntity()) ? Lists.newArrayList(new Entity[]{source.getEntity()}) : Collections.emptyList()); // Leaves - skip photographer } else { List list = Lists.newArrayList(); @@ -160,6 +162,7 @@ public class EntitySelector { this.addEntities(list, worldserver1, vec3d, predicate); } } + list.removeIf(entity -> entity instanceof top.leavesmc.leaves.replay.ServerPhotographer); // Leaves - skip photographer return this.sortAndLimit(vec3d, list); } @@ -200,9 +203,11 @@ public class EntitySelector { if (this.playerName != null) { entityplayer = source.getServer().getPlayerList().getPlayerByName(this.playerName); + entityplayer = entityplayer instanceof top.leavesmc.leaves.replay.ServerPhotographer ? null : entityplayer; // Leaves - skip photographer return (List) (entityplayer == null ? Collections.emptyList() : Lists.newArrayList(new ServerPlayer[]{entityplayer})); } else if (this.entityUUID != null) { entityplayer = source.getServer().getPlayerList().getPlayer(this.entityUUID); + entityplayer = entityplayer instanceof top.leavesmc.leaves.replay.ServerPhotographer ? null : entityplayer; // Leaves - skip photographer return (List) (entityplayer == null ? Collections.emptyList() : Lists.newArrayList(new ServerPlayer[]{entityplayer})); } else { Vec3 vec3d = (Vec3) this.position.apply(source.getPosition()); @@ -214,7 +219,7 @@ public class EntitySelector { if (entity instanceof ServerPlayer) { ServerPlayer entityplayer1 = (ServerPlayer) entity; - if (predicate.test(entityplayer1)) { + if (predicate.test(entityplayer1) && !(entityplayer1 instanceof top.leavesmc.leaves.replay.ServerPhotographer)) { // Leaves - skip photographer return Lists.newArrayList(new ServerPlayer[]{entityplayer1}); } } @@ -225,7 +230,7 @@ public class EntitySelector { Object object; if (this.isWorldLimited()) { - object = source.getLevel().getPlayers(predicate, i); + object = source.getLevel().getPlayers((entityplayer3 -> !(entityplayer3 instanceof top.leavesmc.leaves.replay.ServerPhotographer) && predicate.test(entityplayer3)), i); // Leaves - skip photographer } else { object = Lists.newArrayList(); Iterator iterator = source.getServer().getPlayerList().getPlayers().iterator(); @@ -233,7 +238,7 @@ public class EntitySelector { while (iterator.hasNext()) { ServerPlayer entityplayer2 = (ServerPlayer) iterator.next(); - if (predicate.test(entityplayer2)) { + if (predicate.test(entityplayer2) && !(entityplayer2 instanceof top.leavesmc.leaves.replay.ServerPhotographer)) { // Leaves - skip photographer ((List) object).add(entityplayer2); if (((List) object).size() >= i) { return (List) object; diff --git a/src/main/java/net/minecraft/network/Connection.java b/src/main/java/net/minecraft/network/Connection.java index 18b8651147dedcf80d9baf04e87fb25cfbf9b89f..e9a49eb00c64d864ebf4b24d6fe84aba7b2dbed8 100644 --- a/src/main/java/net/minecraft/network/Connection.java +++ b/src/main/java/net/minecraft/network/Connection.java @@ -179,6 +179,14 @@ public class Connection extends SimpleChannelInboundHandler> { } // Folia end - region threading + // Leaves start - fakeplayer + public void setListenerForce(PacketListener packetListener) { + Validate.notNull(packetListener, "packetListener"); + this.packetListener = packetListener; + this.disconnectListener = null; + } + // Leaves end - fakeplayer + public void channelActive(ChannelHandlerContext channelhandlercontext) throws Exception { super.channelActive(channelhandlercontext); this.channel = channelhandlercontext.channel(); diff --git a/src/main/java/net/minecraft/server/MinecraftServer.java b/src/main/java/net/minecraft/server/MinecraftServer.java index c924951c3dd7652ee446b40689ff9004546e972a..776f5ce5fac57bc7ccdfc4dbab87811bc33de87d 100644 --- a/src/main/java/net/minecraft/server/MinecraftServer.java +++ b/src/main/java/net/minecraft/server/MinecraftServer.java @@ -1727,7 +1727,7 @@ public abstract class MinecraftServer extends ReentrantBlockableEventLoop list = new java.util.ArrayList<>(this.playerList.getPlayers()); // Folia - region threading + List list = new java.util.ArrayList<>(this.playerList.realPlayers); // Folia - region threading // Leaves - only real player int i = this.getMaxPlayers(); if (this.hidesOnlinePlayers()) { diff --git a/src/main/java/net/minecraft/server/PlayerAdvancements.java b/src/main/java/net/minecraft/server/PlayerAdvancements.java index 24e5993b281448734eb67c7a8439a349bbf9fd72..da840e40dbb0f0c8dab5fbb27e2cfe6a25aae498 100644 --- a/src/main/java/net/minecraft/server/PlayerAdvancements.java +++ b/src/main/java/net/minecraft/server/PlayerAdvancements.java @@ -221,6 +221,11 @@ public class PlayerAdvancements { } public boolean award(AdvancementHolder advancement, String criterionName) { + // Leaves start - photographer can't get advancement + if (player instanceof top.leavesmc.leaves.replay.ServerPhotographer) { // Leaves - and photographer + return false; + } + // Leaves end - photographer can't get advancement boolean flag = false; AdvancementProgress advancementprogress = this.getOrStartProgress(advancement); boolean flag1 = advancementprogress.isDone(); diff --git a/src/main/java/net/minecraft/server/commands/OpCommand.java b/src/main/java/net/minecraft/server/commands/OpCommand.java index 2e2a7c2cf3081187da817479a9da3eb10f662a6d..ee616fe98c98a345872c1eadf41c78524d131b61 100644 --- a/src/main/java/net/minecraft/server/commands/OpCommand.java +++ b/src/main/java/net/minecraft/server/commands/OpCommand.java @@ -20,7 +20,7 @@ public class OpCommand { return source.hasPermission(3); }).then(Commands.argument("targets", GameProfileArgument.gameProfile()).suggests((context, builder) -> { PlayerList playerList = context.getSource().getServer().getPlayerList(); - return SharedSuggestionProvider.suggest(playerList.getPlayers().stream().filter((player) -> { + return SharedSuggestionProvider.suggest(playerList.realPlayers.stream().filter((player) -> { // Leaves - only real player return !playerList.isOp(player.getGameProfile()); }).map((player) -> { return player.getGameProfile().getName(); diff --git a/src/main/java/net/minecraft/server/network/ServerGamePacketListenerImpl.java b/src/main/java/net/minecraft/server/network/ServerGamePacketListenerImpl.java index b3ff8ed6d0f0414c15b9d2e6a51a0e34c361f92a..b6e078a10adbbf5c2061b4bf448836231818b768 100644 --- a/src/main/java/net/minecraft/server/network/ServerGamePacketListenerImpl.java +++ b/src/main/java/net/minecraft/server/network/ServerGamePacketListenerImpl.java @@ -297,7 +297,13 @@ public class ServerGamePacketListenerImpl extends ServerCommonPacketListenerImpl public ServerGamePacketListenerImpl(MinecraftServer server, Connection connection, ServerPlayer player, CommonListenerCookie clientData) { super(server, connection, clientData, player); // CraftBukkit this.chunkSender = new PlayerChunkSender(connection.isMemoryConnection()); - connection.setListener(this); + //Leaves start - ReplayMod API + if(player instanceof top.leavesmc.leaves.replay.ServerPhotographer){ + connection.setListenerForce(this); + }else{ + connection.setListener(this); + } + //Leaves end this.player = player; player.connection = this; player.getTextFilter().join(); diff --git a/src/main/java/net/minecraft/server/players/PlayerList.java b/src/main/java/net/minecraft/server/players/PlayerList.java index 0f172512085e9dfc0850451d2c6bbffb18221f8f..291f646febc3b40e91056f268433f09b584f2b29 100644 --- a/src/main/java/net/minecraft/server/players/PlayerList.java +++ b/src/main/java/net/minecraft/server/players/PlayerList.java @@ -13,15 +13,7 @@ import java.net.SocketAddress; import java.nio.file.Path; import java.text.SimpleDateFormat; import java.time.Instant; -import java.util.Collection; -import java.util.EnumSet; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; +import java.util.*; import java.util.function.Function; import java.util.function.Predicate; import javax.annotation.Nullable; @@ -121,6 +113,11 @@ import org.bukkit.event.player.PlayerRespawnEvent; import org.bukkit.event.player.PlayerRespawnEvent.RespawnReason; import org.bukkit.event.player.PlayerSpawnChangeEvent; // CraftBukkit end +import top.leavesmc.leaves.replay.ServerPhotographer; +import ca.spottedleaf.concurrentutil.executor.standard.PrioritisedExecutor; +import java.util.concurrent.CompletableFuture; +import io.papermc.paper.threadedregions.RegionizedServer; +import io.papermc.paper.util.TickThread; public abstract class PlayerList { @@ -153,6 +150,7 @@ public abstract class PlayerList { private boolean allowCheatsForAllPlayers; private static final boolean ALLOW_LOGOUTIVATOR = false; private int sendAllPlayerInfoIn; + public final List realPlayers = new java.util.concurrent.CopyOnWriteArrayList(); // Leaves - replay api // CraftBukkit start private CraftServer cserver; @@ -244,6 +242,121 @@ public abstract class PlayerList { } abstract public void loadAndSaveFiles(); // Paper - fix converting txt to json file; moved from DedicatedPlayerList constructor + // Leaves start - replay api + public CompletableFuture placeNewPhotographer(Connection connection, ServerPhotographer player, ServerLevel worldserver, Location location) { + Runnable scheduledJoin = () -> { + player.isRealPlayer = true; // Paper + player.loginTime = System.currentTimeMillis(); // Paper + + ServerLevel worldserver1 = worldserver; + + player.setServerLevel(worldserver1); + player.spawnIn(worldserver1); + player.gameMode.setLevel((ServerLevel) player.level()); + + LevelData worlddata = worldserver1.getLevelData(); + + player.loadGameTypes(null); + ServerGamePacketListenerImpl playerconnection = new ServerGamePacketListenerImpl(this.server, connection, player, CommonListenerCookie.createInitial(player.gameProfile)); + //worldserver1.getCurrentWorldData().connections.add(connection); + GameRules gamerules = worldserver1.getGameRules(); + boolean flag = gamerules.getBoolean(GameRules.RULE_DO_IMMEDIATE_RESPAWN); + boolean flag1 = gamerules.getBoolean(GameRules.RULE_REDUCEDDEBUGINFO); + boolean flag2 = gamerules.getBoolean(GameRules.RULE_LIMITED_CRAFTING); + + playerconnection.send(new ClientboundLoginPacket(player.getId(), worlddata.isHardcore(), this.server.levelKeys(), this.getMaxPlayers(), worldserver1.getWorld().getSendViewDistance(), worldserver1.getWorld().getSimulationDistance(), flag1, !flag, flag2, player.createCommonSpawnInfo(worldserver1))); // Paper - replace old player chunk management + player.getBukkitEntity().sendSupportedChannels(); // CraftBukkit + playerconnection.send(new ClientboundChangeDifficultyPacket(worlddata.getDifficulty(), worlddata.isDifficultyLocked())); + playerconnection.send(new ClientboundPlayerAbilitiesPacket(player.getAbilities())); + playerconnection.send(new ClientboundSetCarriedItemPacket(player.getInventory().selected)); + playerconnection.send(new ClientboundUpdateRecipesPacket(this.server.getRecipeManager().getRecipes())); + this.sendPlayerPermissionLevel(player); + player.getStats().markAllDirty(); + player.getRecipeBook().sendInitialRecipeBook(player); + + this.server.invalidateStatus(); + + playerconnection.teleport(player.getX(), player.getY(), player.getZ(), player.getYRot(), player.getXRot()); + ServerStatus serverping = this.server.getStatus(); + + if (serverping != null) { + player.sendServerStatus(serverping); + } + + this.players.add(player); + this.playersByName.put(player.getScoreboardName().toLowerCase(java.util.Locale.ROOT), player); // Spigot + this.playersByUUID.put(player.getUUID(), player); + + player.supressTrackerForLogin = true; + worldserver1.addNewPlayer(player); + this.server.getCustomBossEvents().onPlayerConnect(player); + mountSavedVehicle(player, worldserver1, null); + CraftPlayer bukkitPlayer = player.getBukkitEntity(); + + player.containerMenu.transferTo(player.containerMenu, bukkitPlayer); + if (!player.connection.isAcceptingMessages()) { + return; + } + + top.leavesmc.leaves.protocol.core.LeavesProtocolManager.handlePlayerJoin(player); // Leaves - protocol + + final List playersCopy = new ArrayList<>(this.players); + final List onlinePlayers = Lists.newArrayListWithExpectedSize(playersCopy.size() - 1); + for (ServerPlayer entityplayer1 : playersCopy) { + if (entityplayer1 == player || !bukkitPlayer.canSee(entityplayer1.getBukkitEntity())) { + continue; + } + + onlinePlayers.add(entityplayer1); + } + + if (!onlinePlayers.isEmpty()) { + player.connection.send(ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(onlinePlayers, player)); + } + + player.sentListPacket = true; + player.supressTrackerForLogin = false; + ((ServerLevel)player.level()).getChunkSource().chunkMap.addEntity(player); + + this.sendLevelInfo(player, worldserver1); + + if (player.level() == worldserver1 && !worldserver1.players().contains(player)) { + worldserver1.addNewPlayer(player); + this.server.getCustomBossEvents().onPlayerConnect(player); + } + + worldserver1 = player.serverLevel(); + for (MobEffectInstance mobeffect : player.getActiveEffects()) { + playerconnection.send(new ClientboundUpdateMobEffectPacket(player.getId(), mobeffect)); + } + + if (player.isDeadOrDying()) { + net.minecraft.core.Holder plains = worldserver1.registryAccess().registryOrThrow(net.minecraft.core.registries.Registries.BIOME) + .getHolderOrThrow(net.minecraft.world.level.biome.Biomes.PLAINS); + player.connection.send(new net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket( + new net.minecraft.world.level.chunk.EmptyLevelChunk(worldserver1, player.chunkPosition(), plains), + worldserver1.getLightEngine(), (java.util.BitSet)null, (java.util.BitSet) null, false) + ); + } + }; + + final BlockPos pos = io.papermc.paper.util.MCUtil.toBlockPosition(location); + if (TickThread.isTickThreadFor(worldserver,pos)){ + scheduledJoin.run(); + return CompletableFuture.completedFuture(null); + } + + return CompletableFuture.runAsync(scheduledJoin,task -> + RegionizedServer.getInstance().taskQueue.queueTickTaskQueue( + worldserver, + pos.getX() >> 4, + pos.getZ() >> 4, + task + ) + ); + } + // Leaves end - replay api + public void loadSpawnForNewPlayer(final Connection connection, final ServerPlayer player, final CommonListenerCookie clientData, org.apache.commons.lang3.mutable.MutableObject data, org.apache.commons.lang3.mutable.MutableObject lastKnownName, ca.spottedleaf.concurrentutil.completable.Completable toComplete) { // Folia - region threading - rewrite login process player.isRealPlayer = true; // Paper player.loginTime = System.currentTimeMillis(); // Paper - Replace OfflinePlayer#getLastPlayed @@ -418,6 +531,7 @@ public abstract class PlayerList { // entityplayer.connection.send(ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(this.players)); // CraftBukkit - replaced with loop below this.players.add(player); + this.realPlayers.add(player); // Leaves - replay api this.playersByName.put(player.getScoreboardName().toLowerCase(java.util.Locale.ROOT), player); // Spigot this.playersByUUID.put(player.getUUID(), player); // this.broadcastAll(ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(List.of(entityplayer))); // CraftBukkit - replaced with loop below @@ -473,6 +587,12 @@ public abstract class PlayerList { continue; } + // Leaves start - skip photographer + if (entityplayer1 instanceof ServerPhotographer) { + continue; + } + // Leaves end - skip photographer + onlinePlayers.add(entityplayer1); // Paper - Use single player info update packet on join } // Paper start - Use single player info update packet on join @@ -693,6 +813,54 @@ public abstract class PlayerList { } + // Leaevs start - replay mod api + public CompletableFuture removePhotographer(ServerPhotographer entityplayer) { + ServerLevel worldserver = entityplayer.serverLevel(); + + Runnable scheduledRemove = () -> { //Folia support + entityplayer.awardStat(Stats.LEAVE_GAME); + + if (entityplayer.containerMenu != entityplayer.inventoryMenu) { + entityplayer.closeContainer(org.bukkit.event.inventory.InventoryCloseEvent.Reason.DISCONNECT); + } + + if (TickThread.isTickThreadFor(entityplayer)) entityplayer.doTick(); + + if (false && this.collideRuleTeamName != null) { //We are running as folia, so the scoreboard is unavailable now + final net.minecraft.world.scores.Scoreboard scoreBoard = this.server.getLevel(Level.OVERWORLD).getScoreboard(); + final PlayerTeam team = scoreBoard.getPlayersTeam(this.collideRuleTeamName); + if (entityplayer.getTeam() == team && team != null) { + scoreBoard.removePlayerFromTeam(entityplayer.getScoreboardName(), team); + } + } + + //worldserver.getCurrentWorldData().connections.remove(entityplayer.recorder); + worldserver.removePlayerImmediately(entityplayer, Entity.RemovalReason.UNLOADED_WITH_PLAYER); + entityplayer.retireScheduler(); + entityplayer.getAdvancements().stopListening(); + this.players.remove(entityplayer); + this.playersByName.remove(entityplayer.getScoreboardName().toLowerCase(java.util.Locale.ROOT)); + this.server.getCustomBossEvents().onPlayerDisconnect(entityplayer); + UUID uuid = entityplayer.getUUID(); + ServerPlayer entityplayer1 = this.playersByUUID.get(uuid); + + if (entityplayer1 == entityplayer) { + this.playersByUUID.remove(uuid); + } + + this.cserver.getScoreboardManager().removePlayer(entityplayer.getBukkitEntity()); + }; + + if (TickThread.isTickThreadFor(entityplayer)){ //Check if we are running on the target tick region + scheduledRemove.run(); + return CompletableFuture.completedFuture(null); + } + + //If not + return CompletableFuture.runAsync(scheduledRemove,task -> RegionizedServer.getInstance().taskQueue.queueTickTaskQueue(worldserver,(int)entityplayer.position.x >> 4,(int)entityplayer.position.z >> 4,task, PrioritisedExecutor.Priority.HIGHER)); + } + // Leaves stop - replay mod api + public net.kyori.adventure.text.Component remove(ServerPlayer entityplayer) { // CraftBukkit - return string // Paper - return Component // Paper start - Fix kick event leave message not being sent return this.remove(entityplayer, net.kyori.adventure.text.Component.translatable("multiplayer.player.left", net.kyori.adventure.text.format.NamedTextColor.YELLOW, io.papermc.paper.configuration.GlobalConfiguration.get().messages.useDisplayNameInQuitMessage ? entityplayer.getBukkitEntity().displayName() : io.papermc.paper.adventure.PaperAdventure.asAdventure(entityplayer.getDisplayName()))); @@ -761,6 +929,7 @@ public abstract class PlayerList { // Folia - region threading - move to onDisconnect of common packet listener entityplayer.getAdvancements().stopListening(); this.players.remove(entityplayer); + this.realPlayers.remove(entityplayer); // Leaves - replay api this.playersByName.remove(entityplayer.getScoreboardName().toLowerCase(java.util.Locale.ROOT)); // Spigot this.server.getCustomBossEvents().onPlayerDisconnect(entityplayer); UUID uuid = entityplayer.getUUID(); diff --git a/src/main/java/org/bukkit/craftbukkit/CraftServer.java b/src/main/java/org/bukkit/craftbukkit/CraftServer.java index dc223b536eadd2da6cf3c758a62d0ed81b5a7b3b..e0f49c7e941830236b92f6705cd2c4b96e943a96 100644 --- a/src/main/java/org/bukkit/craftbukkit/CraftServer.java +++ b/src/main/java/org/bukkit/craftbukkit/CraftServer.java @@ -308,6 +308,7 @@ public final class CraftServer implements Server { private final io.papermc.paper.logging.SysoutCatcher sysoutCatcher = new io.papermc.paper.logging.SysoutCatcher(); // Paper private final CraftPotionBrewer potionBrewer = new CraftPotionBrewer(); // Paper - Custom Potion Mixes + private final top.leavesmc.leaves.entity.CraftPhotographerManager photographerManager = new top.leavesmc.leaves.entity.CraftPhotographerManager(); //Leaves - ReplayMod API // Paper start - Folia region threading API private final io.papermc.paper.threadedregions.scheduler.FoliaRegionScheduler regionizedScheduler = new io.papermc.paper.threadedregions.scheduler.FoliaRegionScheduler(); // Folia - region threading private final io.papermc.paper.threadedregions.scheduler.FoliaAsyncScheduler asyncScheduler = new io.papermc.paper.threadedregions.scheduler.FoliaAsyncScheduler(); @@ -394,7 +395,7 @@ public final class CraftServer implements Server { public CraftServer(DedicatedServer console, PlayerList playerList) { this.console = console; this.playerList = (DedicatedPlayerList) playerList; - this.playerView = Collections.unmodifiableList(Lists.transform(playerList.players, new Function() { + this.playerView = Collections.unmodifiableList(Lists.transform(playerList.realPlayers, new Function() { // Leaves - replay api @Override public CraftPlayer apply(ServerPlayer player) { return player.getBukkitEntity(); @@ -3306,4 +3307,11 @@ public final class CraftServer implements Server { } // Paper end + + // Leaves start - replay mod api + @Override + public top.leavesmc.leaves.entity.CraftPhotographerManager getPhotographerManager() { + return photographerManager; + } + // Leaves end - replay mod api } diff --git a/src/main/java/org/bukkit/craftbukkit/entity/CraftEntity.java b/src/main/java/org/bukkit/craftbukkit/entity/CraftEntity.java index 73316307666674f9f6e7ddb964e2ec2583743c79..d15f24d91255664af4295fca8d5e237dce520201 100644 --- a/src/main/java/org/bukkit/craftbukkit/entity/CraftEntity.java +++ b/src/main/java/org/bukkit/craftbukkit/entity/CraftEntity.java @@ -57,6 +57,8 @@ import org.bukkit.util.NumberConversions; import org.bukkit.util.Vector; import net.md_5.bungee.api.chat.BaseComponent; // Spigot +import top.leavesmc.leaves.entity.CraftPhotographer; +import top.leavesmc.leaves.replay.ServerPhotographer; public abstract class CraftEntity implements org.bukkit.entity.Entity { private static PermissibleBase perm; @@ -92,6 +94,8 @@ public abstract class CraftEntity implements org.bukkit.entity.Entity { return new CraftHumanEntity(server, (net.minecraft.world.entity.player.Player) entity); } + if (entity instanceof ServerPhotographer) { return new CraftPhotographer(server, (ServerPhotographer) entity); } + // Special case complex part, since there is no extra entity type for them if (entity instanceof EnderDragonPart complexPart) { if (complexPart.parentMob instanceof EnderDragon) { diff --git a/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java b/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java index 49f207b7e06a3f939dc6c9b4a078f6db7b779618..aedf5ec139613a4ddf6cdb87cc52ae29eda323b2 100644 --- a/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java +++ b/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java @@ -2141,7 +2141,7 @@ public class CraftPlayer extends CraftHumanEntity implements Player { @Override public boolean canSee(Player player) { - return this.canSee((org.bukkit.entity.Entity) player); + return !(player instanceof top.leavesmc.leaves.entity.Photographer) && this.canSee((org.bukkit.entity.Entity) player); // Leaves - skip photographer } @Override diff --git a/src/main/java/top/leavesmc/leaves/entity/CraftPhotographer.java b/src/main/java/top/leavesmc/leaves/entity/CraftPhotographer.java new file mode 100644 index 0000000000000000000000000000000000000000..4f58b6623a6b5c726d718ced6ab106af3e665e35 --- /dev/null +++ b/src/main/java/top/leavesmc/leaves/entity/CraftPhotographer.java @@ -0,0 +1,73 @@ +package top.leavesmc.leaves.entity; + +import net.minecraft.server.level.ServerPlayer; +import org.bukkit.craftbukkit.CraftServer; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import top.leavesmc.leaves.replay.ServerPhotographer; + +import java.io.File; + +public class CraftPhotographer extends CraftPlayer implements Photographer { + + public CraftPhotographer(CraftServer server, ServerPhotographer entity) { + super(server, entity); + } + + @Override + public void stopRecording() { + this.stopRecording(true); + } + + @Override + public void stopRecording(boolean async) { + this.stopRecording(async, true); + } + + @Override + public void stopRecording(boolean async, boolean save) { + this.getHandle().remove(async, save); + } + + @Override + public void pauseRecording() { + this.getHandle().pauseRecording(); + } + + @Override + public void resumeRecording() { + this.getHandle().resumeRecording(); + } + + @Override + public void setRecordFile(@NotNull File file) { + this.getHandle().setSaveFile(file); + } + + @Override + public void setFollowPlayer(@Nullable Player player) { + ServerPlayer serverPlayer = player != null ? ((CraftPlayer) player).getHandle() : null; + this.getHandle().setFollowPlayer(serverPlayer); + } + + @Override + public @NotNull String getId() { + return this.getHandle().createState.id; + } + + @Override + public ServerPhotographer getHandle() { + return (ServerPhotographer) entity; + } + + public void setHandle(final ServerPhotographer entity) { + super.setHandle(entity); + } + + @Override + public String toString() { + return "CraftPhotographer{" + "name=" + getName() + '}'; + } +} diff --git a/src/main/java/top/leavesmc/leaves/entity/CraftPhotographerManager.java b/src/main/java/top/leavesmc/leaves/entity/CraftPhotographerManager.java new file mode 100644 index 0000000000000000000000000000000000000000..5effb1022bbd592b3bf9e23ae2efc3223762d962 --- /dev/null +++ b/src/main/java/top/leavesmc/leaves/entity/CraftPhotographerManager.java @@ -0,0 +1,82 @@ +package top.leavesmc.leaves.entity; + +import com.google.common.collect.Lists; +import org.bukkit.Location; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import top.leavesmc.leaves.replay.BukkitRecorderOption; +import top.leavesmc.leaves.replay.RecorderOption; +import top.leavesmc.leaves.replay.ServerPhotographer; + +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; + +public class CraftPhotographerManager implements PhotographerManager { + + private final Collection photographerViews = Collections.unmodifiableList(Lists.transform(ServerPhotographer.getPhotographers(), ServerPhotographer::getBukkitPlayer)); + + @Override + public @Nullable Photographer getPhotographer(@NotNull UUID uuid) { + ServerPhotographer photographer = ServerPhotographer.getPhotographer(uuid); + if (photographer != null) { + return photographer.getBukkitPlayer(); + } + return null; + } + + @Override + public @Nullable Photographer getPhotographer(@NotNull String id) { + ServerPhotographer photographer = ServerPhotographer.getPhotographer(id); + if (photographer != null) { + return photographer.getBukkitPlayer(); + } + return null; + } + + @Override + public @Nullable Photographer createPhotographer(@NotNull String id, @NotNull Location location) { + ServerPhotographer photographer = new ServerPhotographer.PhotographerCreateState(location, id, RecorderOption.createDefaultOption()).createSync(); + if (photographer != null) { + return photographer.getBukkitPlayer(); + } + return null; + } + + @Override + public @Nullable Photographer createPhotographer(@NotNull String id, @NotNull Location location, @NotNull BukkitRecorderOption recorderOption) { + ServerPhotographer photographer = new ServerPhotographer.PhotographerCreateState(location, id, RecorderOption.createFromBukkit(recorderOption)).createSync(); + if (photographer != null) { + return photographer.getBukkitPlayer(); + } + return null; + } + + @Override + public void removePhotographer(@NotNull String id) { + ServerPhotographer photographer = ServerPhotographer.getPhotographer(id); + if (photographer != null) { + photographer.remove(true); + } + } + + @Override + public void removePhotographer(@NotNull UUID uuid) { + ServerPhotographer photographer = ServerPhotographer.getPhotographer(uuid); + if (photographer != null) { + photographer.remove(true); + } + } + + @Override + public void removeAllPhotographers() { + for (ServerPhotographer photographer : ServerPhotographer.getPhotographers()) { + photographer.remove(true); + } + } + + @Override + public Collection getPhotographers() { + return photographerViews; + } +} diff --git a/src/main/java/top/leavesmc/leaves/replay/DigestOutputStream.java b/src/main/java/top/leavesmc/leaves/replay/DigestOutputStream.java new file mode 100644 index 0000000000000000000000000000000000000000..92a6478b337c4371e61b7e7878b242619398ecdd --- /dev/null +++ b/src/main/java/top/leavesmc/leaves/replay/DigestOutputStream.java @@ -0,0 +1,46 @@ +package top.leavesmc.leaves.replay; + +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.zip.Checksum; + +public class DigestOutputStream extends OutputStream { + + private final Checksum sum; + private final OutputStream out; + + public DigestOutputStream(OutputStream out, Checksum sum) { + this.out = out; + this.sum = sum; + } + + @Override + public void close() throws IOException { + out.close(); + } + + @Override + public void flush() throws IOException { + out.flush(); + } + + @Override + public void write(int b) throws IOException { + sum.update(b); + out.write(b); + } + + @Override + public void write(byte @NotNull [] b) throws IOException { + sum.update(b); + out.write(b); + } + + @Override + public void write(byte @NotNull [] b, int off, int len) throws IOException { + sum.update(b, off, len); + out.write(b, off, len); + } +} diff --git a/src/main/java/top/leavesmc/leaves/replay/RecordMetaData.java b/src/main/java/top/leavesmc/leaves/replay/RecordMetaData.java new file mode 100644 index 0000000000000000000000000000000000000000..46a86cfce4aa859b8de7c126c22f64a999a4fe7a --- /dev/null +++ b/src/main/java/top/leavesmc/leaves/replay/RecordMetaData.java @@ -0,0 +1,23 @@ +package top.leavesmc.leaves.replay; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +public class RecordMetaData { + + public static final int CURRENT_FILE_FORMAT_VERSION = 14; + + public boolean singleplayer = false; + public String serverName = "Leaves"; + public int duration = 0; + public long date; + public String mcversion; + public String fileFormat = "MCPR"; + public int fileFormatVersion; + public int protocol; + public String generator; + public int selfId = -1; + + public Set players = new HashSet<>(); +} diff --git a/src/main/java/top/leavesmc/leaves/replay/Recorder.java b/src/main/java/top/leavesmc/leaves/replay/Recorder.java new file mode 100644 index 0000000000000000000000000000000000000000..94b67eeb0018f2a31afa372fc44b1af06af6eb72 --- /dev/null +++ b/src/main/java/top/leavesmc/leaves/replay/Recorder.java @@ -0,0 +1,263 @@ +package top.leavesmc.leaves.replay; + +import io.netty.channel.local.LocalChannel; +import net.minecraft.SharedConstants; +import net.minecraft.core.LayeredRegistryAccess; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.RegistrySynchronization; +import net.minecraft.network.Connection; +import net.minecraft.network.ConnectionProtocol; +import net.minecraft.network.PacketSendListener; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket; +import net.minecraft.network.protocol.common.ClientboundDisconnectPacket; +import net.minecraft.network.protocol.common.ClientboundUpdateTagsPacket; +import net.minecraft.network.protocol.common.custom.BrandPayload; +import net.minecraft.network.protocol.configuration.ClientboundFinishConfigurationPacket; +import net.minecraft.network.protocol.configuration.ClientboundRegistryDataPacket; +import net.minecraft.network.protocol.configuration.ClientboundUpdateEnabledFeaturesPacket; +import net.minecraft.network.protocol.game.ClientboundAddEntityPacket; +import net.minecraft.network.protocol.game.ClientboundBundlePacket; +import net.minecraft.network.protocol.game.ClientboundGameEventPacket; +import net.minecraft.network.protocol.game.ClientboundPlayerChatPacket; +import net.minecraft.network.protocol.game.ClientboundSetTimePacket; +import net.minecraft.network.protocol.game.ClientboundSystemChatPacket; +import net.minecraft.network.protocol.login.ClientboundGameProfilePacket; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.RegistryLayer; +import net.minecraft.tags.TagNetworkSerialization; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.flag.FeatureFlags; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import java.io.File; +import java.io.IOException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +public class Recorder extends Connection { + + private static final Logger LOGGER = MinecraftServer.LOGGER; + + private final ReplayFile replayFile; + private final ServerPhotographer photographer; + private final RecorderOption recorderOption; + private final RecordMetaData metaData; + + private final ExecutorService saveService = Executors.newSingleThreadExecutor(); + + private boolean stopped = false; + private boolean paused = false; + private boolean resumeOnNextPacket = true; + + private long startTime; + private long lastPacket; + private long timeShift = 0; + + private boolean isSaved; + private boolean isSaving; + private ConnectionProtocol state = ConnectionProtocol.LOGIN; + + public Recorder(ServerPhotographer photographer, RecorderOption recorderOption, File replayFile) throws IOException { + super(PacketFlow.CLIENTBOUND); + + this.photographer = photographer; + this.recorderOption = recorderOption; + this.metaData = new RecordMetaData(); + this.replayFile = new ReplayFile(replayFile); + this.channel = new LocalChannel(); + } + + public void start() { + startTime = System.currentTimeMillis(); + + metaData.singleplayer = false; + metaData.serverName = recorderOption.serverName; + metaData.generator = "leaves"; + metaData.date = startTime; + metaData.mcversion = SharedConstants.getCurrentVersion().getName(); + + // TODO start event + savePacket(new ClientboundGameProfilePacket(photographer.getGameProfile()), ConnectionProtocol.LOGIN); + startConfiguration(); + + if (recorderOption.forceWeather != null) { + setWeather(recorderOption.forceWeather); + } + } + + public void startConfiguration() { + state = ConnectionProtocol.CONFIGURATION; + MinecraftServer server = MinecraftServer.getServer(); + savePacket(new ClientboundCustomPayloadPacket(new BrandPayload(server.getServerModName())), ConnectionProtocol.CONFIGURATION); + LayeredRegistryAccess layeredregistryaccess = server.registries(); + + savePacket(new ClientboundUpdateEnabledFeaturesPacket(FeatureFlags.REGISTRY.toNames(server.getWorldData().enabledFeatures())), ConnectionProtocol.CONFIGURATION); + savePacket(new ClientboundRegistryDataPacket((new RegistryAccess.ImmutableRegistryAccess(RegistrySynchronization.networkedRegistries(layeredregistryaccess))).freeze()), ConnectionProtocol.CONFIGURATION); + savePacket(new ClientboundUpdateTagsPacket(TagNetworkSerialization.serializeTagsToNetwork(layeredregistryaccess)), ConnectionProtocol.CONFIGURATION); + savePacket(new ClientboundFinishConfigurationPacket(), ConnectionProtocol.CONFIGURATION); + state = ConnectionProtocol.PLAY; + } + + @Override + public void flushChannel() { + } + + public void stop() { + stopped = true; + } + + public void pauseRecording() { + resumeOnNextPacket = false; + paused = true; + } + + public void resumeRecording() { + resumeOnNextPacket = true; + } + + public void setWeather(RecorderOption.RecordWeather weather) { + weather.getPackets().forEach(this::savePacket); + } + + public long getRecordedTime() { + final long base = System.currentTimeMillis() - startTime; + return base - timeShift; + } + + private synchronized long getCurrentTimeAndUpdate() { + long now = getRecordedTime(); + if (paused) { + if (resumeOnNextPacket) { + paused = false; + } + timeShift += now - lastPacket; + return lastPacket; + } + return lastPacket = now; + } + + @Override + public boolean isConnected() { + return true; + } + + @Override + public void send(@NotNull Packet packet, @Nullable PacketSendListener callbacks, boolean flush) { + if (!stopped) { + if (packet instanceof ClientboundBundlePacket packet1) { + packet1.subPackets().forEach(subPacket -> { + send(subPacket, null); + }); + } + + if (packet instanceof ClientboundAddEntityPacket packet1) { + if (packet1.getType() == EntityType.PLAYER) { + metaData.players.add(packet1.getUUID()); + saveMetadata(); + } + } + + if (packet instanceof ClientboundDisconnectPacket) { + return; + } + + if (recorderOption.forceDayTime != -1 && packet instanceof ClientboundSetTimePacket packet1) { + packet = new ClientboundSetTimePacket(packet1.getDayTime(), recorderOption.forceDayTime, false); + } + + if (recorderOption.forceWeather != null && packet instanceof ClientboundGameEventPacket packet1) { + ClientboundGameEventPacket.Type type = packet1.getEvent(); + if (type == ClientboundGameEventPacket.START_RAINING || type == ClientboundGameEventPacket.STOP_RAINING || type == ClientboundGameEventPacket.RAIN_LEVEL_CHANGE || type == ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE) { + return; + } + } + + if (recorderOption.ignoreChat && (packet instanceof ClientboundSystemChatPacket || packet instanceof ClientboundPlayerChatPacket)) { + return; + } + + savePacket(packet); + } + } + + private void saveMetadata() { + saveService.submit(() -> { + try { + replayFile.saveMetaData(metaData); + } catch (IOException e) { + e.printStackTrace(); + } + }); + } + + private void savePacket(Packet packet) { + this.savePacket(packet, state); + } + + private void savePacket(Packet packet, final ConnectionProtocol protocol) { + try { + final long timestamp = getCurrentTimeAndUpdate(); + saveService.submit(() -> { + try { + replayFile.savePacket(timestamp, packet, protocol); + } catch (Exception e) { + LOGGER.error("Error saving packet"); + e.printStackTrace(); + } + }); + } catch (Exception e) { + LOGGER.error("Error saving packet"); + e.printStackTrace(); + } + } + + public boolean isSaved() { + return isSaved; + } + + public CompletableFuture saveRecording(File dest, boolean save) { + isSaved = true; + if (!isSaving) { + isSaving = true; + metaData.duration = (int) lastPacket; + return CompletableFuture.runAsync(() -> { + saveMetadata(); + saveService.shutdown(); + boolean interrupted = false; + try { + saveService.awaitTermination(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + interrupted = true; + } + try { + if (save) { + replayFile.closeAndSave(dest); + } else { + replayFile.closeNotSave(); + } + } catch (IOException e) { + e.printStackTrace(); + throw new CompletionException(e); + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + }, runnable -> { + final Thread thread = new Thread(runnable, "Recording file save thread"); + thread.start(); + }); + } else { + LOGGER.warn("saveRecording() called twice"); + return CompletableFuture.supplyAsync(() -> { + throw new IllegalStateException("saveRecording() called twice"); + }); + } + } +} diff --git a/src/main/java/top/leavesmc/leaves/replay/RecorderOption.java b/src/main/java/top/leavesmc/leaves/replay/RecorderOption.java new file mode 100644 index 0000000000000000000000000000000000000000..06e7166336d621e1a8edb4a2ad88e2cb8a52abb1 --- /dev/null +++ b/src/main/java/top/leavesmc/leaves/replay/RecorderOption.java @@ -0,0 +1,57 @@ +package top.leavesmc.leaves.replay; + +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientboundGameEventPacket; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +public class RecorderOption { + + public int recordDistance = -1; + public String serverName = "Leaves"; + public RecordWeather forceWeather = null; + public int forceDayTime = -1; + public boolean ignoreChat = false; + public boolean ignoreItem = false; + + @NotNull + @Contract(" -> new") + public static RecorderOption createDefaultOption() { + return new RecorderOption(); + } + + @NotNull + public static RecorderOption createFromBukkit(@NotNull BukkitRecorderOption bukkitRecorderOption) { + RecorderOption recorderOption = new RecorderOption(); + // recorderOption.recordDistance = bukkitRecorderOption.recordDistance; + // recorderOption.ignoreItem = bukkitRecorderOption.ignoreItem; + recorderOption.serverName = bukkitRecorderOption.serverName; + recorderOption.ignoreChat = bukkitRecorderOption.ignoreChat; + recorderOption.forceDayTime = bukkitRecorderOption.forceDayTime; + recorderOption.forceWeather = switch (bukkitRecorderOption.forceWeather) { + case RAIN -> RecordWeather.RAIN; + case CLEAR -> RecordWeather.CLEAR; + case THUNDER -> RecordWeather.THUNDER; + case NULL -> null; + }; + return recorderOption; + } + + public enum RecordWeather { + CLEAR(new ClientboundGameEventPacket(ClientboundGameEventPacket.STOP_RAINING, 0), new ClientboundGameEventPacket(ClientboundGameEventPacket.RAIN_LEVEL_CHANGE, 0), new ClientboundGameEventPacket(ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE, 0)), + RAIN(new ClientboundGameEventPacket(ClientboundGameEventPacket.START_RAINING, 0), new ClientboundGameEventPacket(ClientboundGameEventPacket.RAIN_LEVEL_CHANGE, 1), new ClientboundGameEventPacket(ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE, 0)), + THUNDER(new ClientboundGameEventPacket(ClientboundGameEventPacket.START_RAINING, 0), new ClientboundGameEventPacket(ClientboundGameEventPacket.RAIN_LEVEL_CHANGE, 1), new ClientboundGameEventPacket(ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE, 1)); + + private final List> packets; + + private RecordWeather(Packet... packets) { + this.packets = List.of(packets); + } + + public List> getPackets() { + return packets; + } + } +} diff --git a/src/main/java/top/leavesmc/leaves/replay/ReplayFile.java b/src/main/java/top/leavesmc/leaves/replay/ReplayFile.java new file mode 100644 index 0000000000000000000000000000000000000000..8cb0f7e2c9f6b7455541c5e06acca0e1ceb60a11 --- /dev/null +++ b/src/main/java/top/leavesmc/leaves/replay/ReplayFile.java @@ -0,0 +1,178 @@ +package top.leavesmc.leaves.replay; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import net.minecraft.SharedConstants; +import net.minecraft.network.ConnectionProtocol; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.PacketFlow; +import org.jetbrains.annotations.NotNull; +import top.leavesmc.leaves.util.UUIDSerializer; + +import java.io.BufferedOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.UUID; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +public class ReplayFile { + + private static final String RECORDING_FILE = "recording.tmcpr"; + private static final String RECORDING_FILE_CRC32 = "recording.tmcpr.crc32"; + private static final String MARKER_FILE = "markers.json"; + private static final String META_FILE = "metaData.json"; + + private static final Gson MARKER_GSON = new GsonBuilder().registerTypeAdapter(ReplayMarker.class, new ReplayMarker.Serializer()).create(); + private static final Gson META_GSON = new GsonBuilder().registerTypeAdapter(UUID.class, new UUIDSerializer()).create(); + + private final File tmpDir; + private final DataOutputStream packetStream; + private final CRC32 crc32 = new CRC32(); + + private final File markerFile; + private final File metaFile; + + public ReplayFile(@NotNull File name) throws IOException { + this.tmpDir = new File(name.getParentFile(), name.getName() + ".tmp"); + if (tmpDir.exists()) { + if (!ReplayFile.deleteDir(tmpDir)) { + throw new IOException("Recording file " + name + " already exists!"); + } + } + + if (!tmpDir.mkdirs()) { + throw new IOException("Failed to create temp directory for recording " + tmpDir); + } + + File packetFile = new File(tmpDir, RECORDING_FILE); + metaFile = new File(tmpDir, META_FILE); + markerFile = new File(tmpDir, MARKER_FILE); + + packetStream = new DataOutputStream(new DigestOutputStream(new BufferedOutputStream(new FileOutputStream(packetFile)), crc32)); + } + + private byte @NotNull [] getPacketBytes(Packet packet, ConnectionProtocol state) { + int packetID = state.codec(PacketFlow.CLIENTBOUND).packetId(packet); + ByteBuf buf = Unpooled.buffer(); + FriendlyByteBuf packetBuf = new FriendlyByteBuf(buf); + packetBuf.writeVarInt(packetID); + packet.write(packetBuf); + + buf.readerIndex(0); + byte[] ret = new byte[buf.readableBytes()]; + buf.readBytes(ret); + buf.release(); + return ret; + } + + public void saveMarkers(List markers) throws IOException { + try (Writer writer = new OutputStreamWriter(new FileOutputStream(markerFile), StandardCharsets.UTF_8)) { + writer.write(MARKER_GSON.toJson(markers)); + } + } + + public void saveMetaData(@NotNull RecordMetaData data) throws IOException { + data.fileFormat = "MCPR"; + data.fileFormatVersion = RecordMetaData.CURRENT_FILE_FORMAT_VERSION; + data.protocol = SharedConstants.getCurrentVersion().getProtocolVersion(); + + try (Writer writer = new OutputStreamWriter(new FileOutputStream(metaFile), StandardCharsets.UTF_8)) { + writer.write(META_GSON.toJson(data)); + } + } + + public void savePacket(long timestamp, Packet packet, ConnectionProtocol protocol) throws Exception { + byte[] data = getPacketBytes(packet, protocol); + packetStream.writeInt((int) timestamp); + packetStream.writeInt(data.length); + packetStream.write(data); + } + + public synchronized void closeAndSave(File file) throws IOException { + packetStream.close(); + + String[] files = tmpDir.list(); + if (files == null) { + return; + } + + try (ZipOutputStream os = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(file)))) { + for (String fileName : files) { + os.putNextEntry(new ZipEntry(fileName)); + File f = new File(tmpDir, fileName); + copy(new FileInputStream(f), os); + } + + os.putNextEntry(new ZipEntry(RECORDING_FILE_CRC32)); + Writer writer = new OutputStreamWriter(os); + writer.write(Long.toString(crc32.getValue())); + writer.flush(); + } + + for (String fileName : files) { + File f = new File(tmpDir, fileName); + Files.delete(f.toPath()); + } + Files.delete(tmpDir.toPath()); + } + + public synchronized void closeNotSave() throws IOException { + packetStream.close(); + + String[] files = tmpDir.list(); + if (files == null) { + return; + } + + for (String fileName : files) { + File f = new File(tmpDir, fileName); + Files.delete(f.toPath()); + } + Files.delete(tmpDir.toPath()); + } + + private void copy(@NotNull InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[8192]; + int len; + while ((len = in.read(buffer)) > -1) { + out.write(buffer, 0, len); + } + in.close(); + } + + private static boolean deleteDir(File dir) { + if (dir == null || !dir.exists()) { + return false; + } + + File[] files = dir.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + deleteDir(file); + } else { + if (!file.delete()) { + return false; + } + } + } + } + + return dir.delete(); + } +} diff --git a/src/main/java/top/leavesmc/leaves/replay/ReplayMarker.java b/src/main/java/top/leavesmc/leaves/replay/ReplayMarker.java new file mode 100644 index 0000000000000000000000000000000000000000..852f2098d93d4437fe79af06e454d8494b6decf1 --- /dev/null +++ b/src/main/java/top/leavesmc/leaves/replay/ReplayMarker.java @@ -0,0 +1,43 @@ +package top.leavesmc.leaves.replay; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import java.lang.reflect.Type; + +public class ReplayMarker { + + public int time; + public String name; + public double x = 0; + public double y = 0; + public double z = 0; + public float phi = 0; + public float theta = 0; + public float varphi = 0; + + public static class Serializer implements JsonSerializer { + @Override + public JsonElement serialize(ReplayMarker src, Type typeOfSrc, JsonSerializationContext context) { + JsonObject ret = new JsonObject(); + JsonObject value = new JsonObject(); + JsonObject position = new JsonObject(); + ret.add("realTimestamp", new JsonPrimitive(src.time)); + ret.add("value", value); + + value.add("name", new JsonPrimitive(src.name)); + value.add("position", position); + + position.add("x", new JsonPrimitive(src.x)); + position.add("y", new JsonPrimitive(src.y)); + position.add("z", new JsonPrimitive(src.z)); + position.add("yaw", new JsonPrimitive(src.phi)); + position.add("pitch", new JsonPrimitive(src.theta)); + position.add("roll", new JsonPrimitive(src.varphi)); + return ret; + } + } +} diff --git a/src/main/java/top/leavesmc/leaves/replay/ServerPhotographer.java b/src/main/java/top/leavesmc/leaves/replay/ServerPhotographer.java new file mode 100644 index 0000000000000000000000000000000000000000..3941d297587e971c5bbd8f3303dbac755ab77d2c --- /dev/null +++ b/src/main/java/top/leavesmc/leaves/replay/ServerPhotographer.java @@ -0,0 +1,246 @@ +package top.leavesmc.leaves.replay; + +import com.mojang.authlib.GameProfile; +import io.papermc.paper.threadedregions.TickRegionScheduler; +import me.earthme.luminol.utils.NullStatsCounter; +import net.minecraft.network.Connection; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ClientInformation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.network.ServerGamePacketListenerImpl; +import net.minecraft.stats.ServerStatsCounter; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.level.GameType; +import net.minecraft.world.phys.Vec3; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.craftbukkit.CraftWorld; +import org.jetbrains.annotations.NotNull; +import top.leavesmc.leaves.entity.CraftPhotographer; +import top.leavesmc.leaves.entity.Photographer; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; + +public class ServerPhotographer extends ServerPlayer { + + private static final List photographers = new CopyOnWriteArrayList<>(); + + public PhotographerCreateState createState; + private ServerPlayer followPlayer; + public Recorder recorder; + private File saveFile; + private Vec3 lastPos; + + private final ServerStatsCounter stats; + + private ServerPhotographer(MinecraftServer server, ServerLevel world, GameProfile profile) { + super(server, world, profile, ClientInformation.createDefault()); + this.followPlayer = null; + this.stats = new NullStatsCounter(server); + this.lastPos = this.position(); + } + + public static boolean isCreateLegal(@NotNull String name) { + if (!name.matches("^[a-zA-Z0-9_]{4,16}$")) { + return false; + } + + if (Bukkit.getPlayer(name) != null) { + return false; + } + + return true; + } + + public static ServerPhotographer createPhotographer(@NotNull PhotographerCreateState state) throws IOException { + if (!isCreateLegal(state.id)) { + return null; + } + + MinecraftServer server = MinecraftServer.getServer(); + + ServerLevel world = ((CraftWorld) state.loc.getWorld()).getHandle(); + GameProfile profile = new GameProfile(UUID.randomUUID(), state.id); + + ServerPhotographer photographer = new ServerPhotographer(server, world, profile); + photographer.recorder = new Recorder(photographer, state.option, new File("replay", state.id)); + photographer.saveFile = new File("replay", state.id + ".mcpr"); + photographer.createState = state; + + photographer.recorder.start(); + MinecraftServer.getServer().getPlayerList().placeNewPhotographer(photographer.recorder, photographer, world, state.loc); + photographer.setGameMode(GameType.SPECTATOR); + photographer.serverLevel().chunkSource.move(photographer); + photographer.setInvisible(true); + photographers.add(photographer); + + MinecraftServer.LOGGER.info("Photographer " + state.id + " created"); + + // TODO record distance + + return photographer; + } + + @Override + public void tick() { + super.tick(); + super.doTick(); + + if (TickRegionScheduler.getCurrentRegion().getData().getCurrentTick() % 10 == 0) { + connection.resetPosition(); + this.serverLevel().chunkSource.move(this); + } + + CompletableFuture teleportAction = null; + boolean shouldUpdateCameraAfterTeleportation; + + if (this.followPlayer != null) { + if (this.getCamera() == this || this.getCamera().level() != this.level()) { + teleportAction = this.getBukkitPlayer().teleportAsync(this.getCamera().getBukkitEntity().getLocation()); + shouldUpdateCameraAfterTeleportation = true; + } else { + shouldUpdateCameraAfterTeleportation = false; + } + + if (lastPos.distanceToSqr(this.position()) > 1024D) { + teleportAction = this.getBukkitPlayer().teleportAsync(this.getCamera().getBukkitEntity().getLocation()); + } + } else { + shouldUpdateCameraAfterTeleportation = false; + } + + if (teleportAction != null){ + teleportAction.whenComplete((result,ex) -> { + if (shouldUpdateCameraAfterTeleportation){ + this.setCamera(followPlayer); + } + + lastPos = this.position(); + }); + return; + } + lastPos = this.position(); + } + + @Override + public void die(@NotNull DamageSource damageSource) { + super.die(damageSource); + remove(true); + } + + @Override + public boolean isInvulnerableTo(@NotNull DamageSource damageSource) { + return true; + } + + @Override + public boolean hurt(@NotNull DamageSource source, float amount) { + return false; + } + + @Override + public void setHealth(float health) { + } + + @NotNull + @Override + public ServerStatsCounter getStats() { + return stats; + } + + public void remove(boolean async) { + this.remove(async, true); + } + + public void remove(boolean async, boolean save) { + super.remove(RemovalReason.KILLED); + photographers.remove(this); + this.recorder.stop(); + this.server.getPlayerList().removePhotographer(this).thenAccept(nullResult -> { + MinecraftServer.LOGGER.info("Photographer " + createState.id + " removed"); + if (save && !recorder.isSaved()) { + CompletableFuture future = recorder.saveRecording(saveFile, save); + if (!async) { + future.join(); + } + } + }); + } + + public void setFollowPlayer(ServerPlayer followPlayer) { + this.setCamera(followPlayer); + this.followPlayer = followPlayer; + } + + public void setSaveFile(File saveFile) { + this.saveFile = saveFile; + } + + public void pauseRecording() { + this.recorder.pauseRecording(); + } + + public void resumeRecording() { + this.recorder.resumeRecording(); + } + + public static ServerPhotographer getPhotographer(String id) { + for (ServerPhotographer photographer : photographers) { + if (photographer.createState.id.equals(id)) { + return photographer; + } + } + return null; + } + + public static ServerPhotographer getPhotographer(UUID uuid) { + for (ServerPhotographer photographer : photographers) { + if (photographer.getUUID().equals(uuid)) { + return photographer; + } + } + return null; + } + + public static List getPhotographers() { + return photographers; + } + + public Photographer getBukkitPlayer() { + return getBukkitEntity(); + } + + @Override + @NotNull + public CraftPhotographer getBukkitEntity() { + return (CraftPhotographer) super.getBukkitEntity(); + } + + public static class PhotographerCreateState { + + public RecorderOption option; + public Location loc; + public final String id; + + public PhotographerCreateState(Location loc, String id, RecorderOption option) { + this.loc = loc; + this.id = id; + this.option = option; + } + + public ServerPhotographer createSync() { + try { + return createPhotographer(this); + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } + } +} diff --git a/src/main/java/top/leavesmc/leaves/util/UUIDSerializer.java b/src/main/java/top/leavesmc/leaves/util/UUIDSerializer.java new file mode 100644 index 0000000000000000000000000000000000000000..1329a725a2bd03d3ef6d7131d8bc77f20bf2e566 --- /dev/null +++ b/src/main/java/top/leavesmc/leaves/util/UUIDSerializer.java @@ -0,0 +1,17 @@ +package top.leavesmc.leaves.util; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.Type; +import java.util.UUID; + +public class UUIDSerializer implements JsonSerializer { + @Override + public JsonElement serialize(@NotNull UUID src, Type typeOfSrc, JsonSerializationContext context) { + return new JsonPrimitive(src.toString()); + } +}