From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Spottedleaf <Spottedleaf@users.noreply.github.com>
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 cd4936ef114b504df8649fba8f1823d94a4bb2a2..395c43f6440c1e0e47919eef096ea8a8d552ccec 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<String> 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<String> 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<org.bukkit.World> 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<ChunkStatus> statuses = net.minecraft.server.level.ServerChunkCache.CHUNK_STATUSES;
+        for (int i = statuses.size() - 1; i >= 0; --i) {
+            ChunkStatus curr = statuses.get(i);
+            CompletableFuture<Either<ChunkAccess, ChunkHolder.ChunkLoadingFailure>> 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:<string>
+         *  -data-version:<int>
+         *  -worlds:
+         *    -name:<world name>
+         *    -view-distance:<int>
+         *    -keep-spawn-loaded:<boolean>
+         *    -keep-spawn-loaded-range:<int>
+         *    -visible-chunk-count:<int>
+         *    -loaded-chunk-count:<int>
+         *    -verified-fully-loaded-chunks:<int>
+         *    -players:<array of player>
+         *    -chunk-data:<array of chunks>
+         *
+         * Player format:
+         *  -name:<string>
+         *  -x:<double>
+         *  -y:<double>
+         *  -z:<double>
+         *
+         * Chunk Format:
+         *  -x:<integer>
+         *  -z:<integer>
+         *  -ticket-level:<integer>
+         *  -state:<string>
+         *  -queued-for-unload:<boolean>
+         *  -status:<string>
+         *  -tickets:<array of tickets>
+         *
+         *
+         * Ticket format:
+         *  -ticket-type:<string>
+         *  -ticket-level:<int>
+         *  -add-tick:<long>
+         *  -object-reason:<string> // This depends on the type of ticket. ie POST_TELEPORT -> entity id
+         */
+        List<org.bukkit.World> 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<ChunkHolder> visibleChunks = chunkMap.visibleChunkMap;
+            DistanceManager chunkMapDistance = chunkMap.distanceManager;
+            List<ChunkHolder> allChunks = new ArrayList<>(visibleChunks.values());
+            List<ServerPlayer> 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<Ticket<?>> 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);
     }
