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/io/papermc/paper/command/PaperCommand.java b/src/main/java/io/papermc/paper/command/PaperCommand.java index 12c5b6b740eeb986e4b1acc4b2eda6c2c77b63ac..b7b0d134e3a99a630e493aceb06f6188b2c538c3 100644 --- a/src/main/java/io/papermc/paper/command/PaperCommand.java +++ b/src/main/java/io/papermc/paper/command/PaperCommand.java @@ -1,5 +1,6 @@ package io.papermc.paper.command; +import io.papermc.paper.command.subcommands.ChunkDebugCommand; import io.papermc.paper.command.subcommands.EntityCommand; import io.papermc.paper.command.subcommands.HeapDumpCommand; import io.papermc.paper.command.subcommands.ReloadCommand; @@ -40,6 +41,7 @@ public final class PaperCommand extends Command { commands.put(Set.of("entity"), new EntityCommand()); commands.put(Set.of("reload"), new ReloadCommand()); commands.put(Set.of("version"), new VersionCommand()); + commands.put(Set.of("debug", "chunkinfo"), new ChunkDebugCommand()); return commands.entrySet().stream() .flatMap(entry -> entry.getKey().stream().map(s -> Map.entry(s, entry.getValue()))) diff --git a/src/main/java/io/papermc/paper/command/subcommands/ChunkDebugCommand.java b/src/main/java/io/papermc/paper/command/subcommands/ChunkDebugCommand.java new file mode 100644 index 0000000000000000000000000000000000000000..029ad37df71e74d9feb57e4b31b3602e55d49113 --- /dev/null +++ b/src/main/java/io/papermc/paper/command/subcommands/ChunkDebugCommand.java @@ -0,0 +1,166 @@ +package io.papermc.paper.command.subcommands; + +import io.papermc.paper.command.CommandUtil; +import io.papermc.paper.command.PaperSubcommand; +import java.io.File; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import net.minecraft.server.MCUtil; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ChunkHolder; +import net.minecraft.server.level.ServerLevel; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.craftbukkit.CraftWorld; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.checkerframework.framework.qual.DefaultQualifier; + +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; + +@DefaultQualifier(NonNull.class) +public final class ChunkDebugCommand implements PaperSubcommand { + @Override + public boolean execute(final CommandSender sender, final String subCommand, final String[] args) { + switch (subCommand) { + case "debug" -> this.doDebug(sender, args); + case "chunkinfo" -> this.doChunkInfo(sender, args); + } + return true; + } + + @Override + public List tabComplete(final CommandSender sender, final String subCommand, final String[] args) { + switch (subCommand) { + case "debug" -> { + if (args.length == 1) { + return CommandUtil.getListMatchingLast(sender, args, "help", "chunks"); + } + } + case "chunkinfo" -> { + List worldNames = new ArrayList<>(); + worldNames.add("*"); + for (org.bukkit.World world : Bukkit.getWorlds()) { + worldNames.add(world.getName()); + } + if (args.length == 1) { + return CommandUtil.getListMatchingLast(sender, args, worldNames); + } + } + } + return Collections.emptyList(); + } + + private void doChunkInfo(final CommandSender sender, final String[] args) { + List worlds; + if (args.length < 1 || args[0].equals("*")) { + worlds = Bukkit.getWorlds(); + } else { + worlds = new ArrayList<>(args.length); + for (final String arg : args) { + org.bukkit.@Nullable World world = Bukkit.getWorld(arg); + if (world == null) { + sender.sendMessage(text("World '" + arg + "' is invalid", RED)); + return; + } + worlds.add(world); + } + } + + int accumulatedTotal = 0; + int accumulatedInactive = 0; + int accumulatedBorder = 0; + int accumulatedTicking = 0; + int accumulatedEntityTicking = 0; + + for (final org.bukkit.World bukkitWorld : worlds) { + final ServerLevel world = ((CraftWorld) bukkitWorld).getHandle(); + + int total = 0; + int inactive = 0; + int border = 0; + int ticking = 0; + int entityTicking = 0; + + for (final 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; + case BORDER -> ++border; + case TICKING -> ++ticking; + case ENTITY_TICKING -> ++entityTicking; + } + } + + 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(final CommandSender sender, final String[] args) { + if (args.length < 1) { + sender.sendMessage(text("Use /paper debug [chunks] help for more information on a specific command", RED)); + return; + } + + final String debugType = args[0].toLowerCase(Locale.ENGLISH); + switch (debugType) { + case "chunks" -> { + if (args.length >= 2 && args[1].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)); + } + } + // "help" & default + default -> sender.sendMessage(text("Use /paper debug [chunks] help for more information on a specific command", RED)); + } + } + +} diff --git a/src/main/java/net/minecraft/server/MCUtil.java b/src/main/java/net/minecraft/server/MCUtil.java index 162aa7718488a74980843944e0d026ccfd5a65a5..4e29c0a983727fc839a4bcde01d3286396b3587d 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 * 16); + 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); }