From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Spottedleaf Date: Sat, 1 Jun 2019 13:00:55 -0700 Subject: [PATCH] Chunk debug command Prints all chunk information to a text file into the debug folder in the root server folder. The format is in JSON, and the data format is described in MCUtil#dumpChunks(File) The command will output server version and all online players to the file as well. We do not log anything but the location, world and username of the player. Also logs the value of these config values (note not all are paper's): - keep spawn loaded value - spawn radius - view distance Each chunk has the following logged: - Coordinate - Ticket level & its corresponding state - Whether it is queued for unload - Chunk status (may be unloaded) - All tickets on the chunk Example log: https://gist.githubusercontent.com/Spottedleaf/0131e7710ffd5d531e5fd246c3367380/raw/169ae1b2e240485f99bc7a6bd8e78d90e3af7397/chunks-2019-06-01_19.57.05.txt For references on certain keywords (ticket, status, etc), please see: https://bugs.mojang.com/browse/MC-141484?focusedCommentId=528273&page=com.atlassian.jira.plugin.system.issuetabpanels%3Acomment-tabpanel#comment-528273 https://bugs.mojang.com/browse/MC-141484?focusedCommentId=528577&page=com.atlassian.jira.plugin.system.issuetabpanels%3Acomment-tabpanel#comment-528577 diff --git a/src/main/java/com/destroystokyo/paper/PaperCommand.java b/src/main/java/com/destroystokyo/paper/PaperCommand.java index c5b005e7a803c848dca31dc76d1ec83ffca42950..7f0b9f12eae3d05a21118806dfc291c7d02996d4 100644 --- a/src/main/java/com/destroystokyo/paper/PaperCommand.java +++ b/src/main/java/com/destroystokyo/paper/PaperCommand.java @@ -6,13 +6,15 @@ import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; import com.google.common.collect.Maps; -import net.minecraft.resources.ResourceLocation; import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ChunkHolder; import net.minecraft.server.level.ServerChunkCache; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; import net.minecraft.world.level.ChunkPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.MCUtil; import org.apache.commons.lang3.tuple.MutablePair; import org.apache.commons.lang3.tuple.Pair; import org.bukkit.Bukkit; @@ -39,13 +41,15 @@ import java.util.Set; import java.util.stream.Collectors; import static net.kyori.adventure.text.Component.text; +import static net.kyori.adventure.text.format.NamedTextColor.BLUE; +import static net.kyori.adventure.text.format.NamedTextColor.DARK_AQUA; import static net.kyori.adventure.text.format.NamedTextColor.GREEN; import static net.kyori.adventure.text.format.NamedTextColor.RED; import static net.kyori.adventure.text.format.NamedTextColor.YELLOW; public class PaperCommand extends Command { private static final String BASE_PERM = "bukkit.command.paper."; - private static final ImmutableSet SUBCOMMANDS = ImmutableSet.builder().add("heap", "entity", "reload", "version").build(); + private static final ImmutableSet SUBCOMMANDS = ImmutableSet.builder().add("heap", "entity", "reload", "version", "debug", "chunkinfo").build(); public PaperCommand(String name) { super(name); @@ -73,6 +77,21 @@ public class PaperCommand extends Command { if (args.length == 3) return getListMatchingLast(sender, args, Registry.ENTITY_TYPE.keySet().stream().map(ResourceLocation::toString).sorted().toArray(String[]::new)); break; + case "debug": + if (args.length == 2) { + return getListMatchingLast(sender, args, "help", "chunks"); + } + break; + case "chunkinfo": + List worldNames = new ArrayList<>(); + worldNames.add("*"); + for (org.bukkit.World world : Bukkit.getWorlds()) { + worldNames.add(world.getName()); + } + if (args.length == 2) { + return getListMatchingLast(sender, args, worldNames); + } + break; } return Collections.emptyList(); } @@ -139,6 +158,12 @@ public class PaperCommand extends Command { case "reload": doReload(sender); break; + case "debug": + doDebug(sender, args); + break; + case "chunkinfo": + doChunkInfo(sender, args); + break; case "ver": if (!testPermission(sender, "version")) break; // "ver" needs a special check because it's an alias. All other commands are checked up before the switch statement (because they are present in the SUBCOMMANDS set) case "version": @@ -156,6 +181,122 @@ public class PaperCommand extends Command { return true; } + private void doChunkInfo(CommandSender sender, String[] args) { + List worlds; + if (args.length < 2 || args[1].equals("*")) { + worlds = Bukkit.getWorlds(); + } else { + worlds = new ArrayList<>(args.length - 1); + for (int i = 1; i < args.length; ++i) { + org.bukkit.World world = Bukkit.getWorld(args[i]); + if (world == null) { + sender.sendMessage(text("World '" + args[i] + "' is invalid", RED)); + return; + } + worlds.add(world); + } + } + + int accumulatedTotal = 0; + int accumulatedInactive = 0; + int accumulatedBorder = 0; + int accumulatedTicking = 0; + int accumulatedEntityTicking = 0; + + for (org.bukkit.World bukkitWorld : worlds) { + ServerLevel world = ((CraftWorld)bukkitWorld).getHandle(); + + int total = 0; + int inactive = 0; + int border = 0; + int ticking = 0; + int entityTicking = 0; + + for (ChunkHolder chunk : world.getChunkSource().chunkMap.updatingChunkMap.values()) { + if (chunk.getFullChunkNowUnchecked() == null) { + continue; + } + + ++total; + + ChunkHolder.FullChunkStatus state = ChunkHolder.getFullChunkStatus(chunk.getTicketLevel()); + + switch (state) { + case INACCESSIBLE: + ++inactive; + continue; + case BORDER: + ++border; + continue; + case TICKING: + ++ticking; + continue; + case ENTITY_TICKING: + ++entityTicking; + continue; + } + } + + accumulatedTotal += total; + accumulatedInactive += inactive; + accumulatedBorder += border; + accumulatedTicking += ticking; + accumulatedEntityTicking += entityTicking; + + sender.sendMessage(text().append(text("Chunks in ", BLUE), text(bukkitWorld.getName(), GREEN), text(":"))); + sender.sendMessage(text().color(DARK_AQUA).append( + text("Total: ", BLUE), text(total), + text(" Inactive: ", BLUE), text(inactive), + text(" Border: ", BLUE), text(border), + text(" Ticking: ", BLUE), text(ticking), + text(" Entity: ", BLUE), text(entityTicking) + )); + } + if (worlds.size() > 1) { + sender.sendMessage(text().append(text("Chunks in ", BLUE), text("all listed worlds", GREEN), text(":", DARK_AQUA))); + sender.sendMessage(text().color(DARK_AQUA).append( + text("Total: ", BLUE), text(accumulatedTotal), + text(" Inactive: ", BLUE), text(accumulatedInactive), + text(" Border: ", BLUE), text(accumulatedBorder), + text(" Ticking: ", BLUE), text(accumulatedTicking), + text(" Entity: ", BLUE), text(accumulatedEntityTicking) + )); + } + } + + private void doDebug(CommandSender sender, String[] args) { + if (args.length < 2) { + sender.sendMessage(text("Use /paper debug [chunks] help for more information on a specific command", RED)); + return; + } + + String debugType = args[1].toLowerCase(Locale.ENGLISH); + switch (debugType) { + case "chunks": + if (args.length >= 3 && args[2].toLowerCase(Locale.ENGLISH).equals("help")) { + sender.sendMessage(text("Use /paper debug chunks [world] to dump loaded chunk information to a file", RED)); + break; + } + File file = new File(new File(new File("."), "debug"), + "chunks-" + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss").format(LocalDateTime.now()) + ".txt"); + sender.sendMessage(text("Writing chunk information dump to " + file, GREEN)); + try { + MCUtil.dumpChunks(file); + sender.sendMessage(text("Successfully written chunk information!", GREEN)); + } catch (Throwable thr) { + MinecraftServer.LOGGER.warn("Failed to dump chunk information to file " + file.toString(), thr); + sender.sendMessage(text("Failed to dump chunk information, see console", RED)); + } + + break; + case "help": + // fall through to default + default: + sender.sendMessage(text("Use /paper debug [chunks] help for more information on a specific command", RED)); + return; + } + } + /* * Ported from MinecraftForge - author: LexManos - License: LGPLv2.1 */ diff --git a/src/main/java/net/minecraft/server/MCUtil.java b/src/main/java/net/minecraft/server/MCUtil.java index 162aa7718488a74980843944e0d026ccfd5a65a5..aebfcd07170109b9cf058df0d4e285b0d95fedb5 100644 --- a/src/main/java/net/minecraft/server/MCUtil.java +++ b/src/main/java/net/minecraft/server/MCUtil.java @@ -9,13 +9,27 @@ import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.nbt.CompoundTag; import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ChunkHolder; +import net.minecraft.server.level.ChunkMap; +import net.minecraft.server.level.DistanceManager; +import net.minecraft.server.level.ServerChunkCache; import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.Ticket; import net.minecraft.world.entity.Entity; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.ClipContext; import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkStatus; import org.apache.commons.lang.exception.ExceptionUtils; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.internal.Streams; +import com.google.gson.stream.JsonWriter; import com.mojang.authlib.GameProfile; +import com.mojang.datafixers.util.Either; +import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; import org.bukkit.Location; import org.bukkit.block.BlockFace; import org.bukkit.craftbukkit.CraftWorld; @@ -24,8 +38,11 @@ import org.spigotmc.AsyncCatcher; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import java.io.*; +import java.util.ArrayList; import java.util.List; import java.util.Queue; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.LinkedBlockingQueue; @@ -541,6 +558,172 @@ public final class MCUtil { return null; } + public static ChunkStatus getChunkStatus(ChunkHolder chunk) { + List statuses = net.minecraft.server.level.ServerChunkCache.CHUNK_STATUSES; + for (int i = statuses.size() - 1; i >= 0; --i) { + ChunkStatus curr = statuses.get(i); + CompletableFuture> future = chunk.getFutureIfPresentUnchecked(curr); + if (future != ChunkHolder.UNLOADED_CHUNK_FUTURE) { + return curr; + } + } + return null; // unloaded + } + + public static void dumpChunks(File file) throws IOException { + file.getParentFile().mkdirs(); + file.createNewFile(); + /* + * Json format: + * + * Main data format: + * -server-version: + * -data-version: + * -worlds: + * -name: + * -view-distance: + * -keep-spawn-loaded: + * -keep-spawn-loaded-range: + * -visible-chunk-count: + * -loaded-chunk-count: + * -verified-fully-loaded-chunks: + * -players: + * -chunk-data: + * + * Player format: + * -name: + * -x: + * -y: + * -z: + * + * Chunk Format: + * -x: + * -z: + * -ticket-level: + * -state: + * -queued-for-unload: + * -status: + * -tickets: + * + * + * Ticket format: + * -ticket-type: + * -ticket-level: + * -add-tick: + * -object-reason: // This depends on the type of ticket. ie POST_TELEPORT -> entity id + */ + List worlds = org.bukkit.Bukkit.getWorlds(); + JsonObject data = new JsonObject(); + + data.addProperty("server-version", org.bukkit.Bukkit.getVersion()); + data.addProperty("data-version", 0); + + JsonArray worldsData = new JsonArray(); + + for (org.bukkit.World bukkitWorld : worlds) { + JsonObject worldData = new JsonObject(); + + ServerLevel world = ((org.bukkit.craftbukkit.CraftWorld)bukkitWorld).getHandle(); + ChunkMap chunkMap = world.getChunkSource().chunkMap; + Long2ObjectLinkedOpenHashMap visibleChunks = chunkMap.visibleChunkMap; + DistanceManager chunkMapDistance = chunkMap.distanceManager; + List allChunks = new ArrayList<>(visibleChunks.values()); + List players = world.players; + + int fullLoadedChunks = 0; + + for (ChunkHolder chunk : allChunks) { + if (chunk.getFullChunkNowUnchecked() != null) { + ++fullLoadedChunks; + } + } + + // sorting by coordinate makes the log easier to read + allChunks.sort((ChunkHolder v1, ChunkHolder v2) -> { + if (v1.pos.x != v2.pos.x) { + return Integer.compare(v1.pos.x, v2.pos.x); + } + return Integer.compare(v1.pos.z, v2.pos.z); + }); + + worldData.addProperty("name", world.getWorld().getName()); + worldData.addProperty("view-distance", world.spigotConfig.viewDistance); + worldData.addProperty("keep-spawn-loaded", world.keepSpawnInMemory); + worldData.addProperty("keep-spawn-loaded-range", world.paperConfig().spawn.keepSpawnLoadedRange); + worldData.addProperty("visible-chunk-count", visibleChunks.size()); + worldData.addProperty("loaded-chunk-count", chunkMap.entitiesInLevel.size()); + worldData.addProperty("verified-fully-loaded-chunks", fullLoadedChunks); + + JsonArray playersData = new JsonArray(); + + for (ServerPlayer player : players) { + JsonObject playerData = new JsonObject(); + + playerData.addProperty("name", player.getScoreboardName()); + playerData.addProperty("x", player.getX()); + playerData.addProperty("y", player.getY()); + playerData.addProperty("z", player.getZ()); + + playersData.add(playerData); + + } + + worldData.add("players", playersData); + + JsonArray chunksData = new JsonArray(); + + for (ChunkHolder playerChunk : allChunks) { + JsonObject chunkData = new JsonObject(); + + Set> tickets = chunkMapDistance.tickets.get(playerChunk.pos.longKey); + ChunkStatus status = getChunkStatus(playerChunk); + + chunkData.addProperty("x", playerChunk.pos.x); + chunkData.addProperty("z", playerChunk.pos.z); + chunkData.addProperty("ticket-level", playerChunk.getTicketLevel()); + chunkData.addProperty("state", ChunkHolder.getFullChunkStatus(playerChunk.getTicketLevel()).toString()); + chunkData.addProperty("queued-for-unload", chunkMap.toDrop.contains(playerChunk.pos.longKey)); + chunkData.addProperty("status", status == null ? "unloaded" : status.toString()); + + JsonArray ticketsData = new JsonArray(); + + if (tickets != null) { + for (Ticket ticket : tickets) { + JsonObject ticketData = new JsonObject(); + + ticketData.addProperty("ticket-type", ticket.getType().toString()); + ticketData.addProperty("ticket-level", ticket.getTicketLevel()); + ticketData.addProperty("object-reason", String.valueOf(ticket.key)); + ticketData.addProperty("add-tick", ticket.createdTick); + + ticketsData.add(ticketData); + } + } + + chunkData.add("tickets", ticketsData); + chunksData.add(chunkData); + } + + + worldData.add("chunk-data", chunksData); + worldsData.add(worldData); + } + + data.add("worlds", worldsData); + + StringWriter stringWriter = new StringWriter(); + JsonWriter jsonWriter = new JsonWriter(stringWriter); + jsonWriter.setIndent(" "); + jsonWriter.setLenient(false); + Streams.write(data, jsonWriter); + + String fileData = stringWriter.toString(); + + try (PrintStream out = new PrintStream(new FileOutputStream(file), false, "UTF-8")) { + out.print(fileData); + } + } + public static int getTicketLevelFor(net.minecraft.world.level.chunk.ChunkStatus status) { return net.minecraft.server.level.ChunkMap.MAX_VIEW_DISTANCE + net.minecraft.world.level.chunk.ChunkStatus.getDistance(status); }