diff --git a/src/main/java/appeng/api/networking/IGrid.java b/src/main/java/appeng/api/networking/IGrid.java index 3ad575456c2..b5a868cdaf7 100644 --- a/src/main/java/appeng/api/networking/IGrid.java +++ b/src/main/java/appeng/api/networking/IGrid.java @@ -23,8 +23,11 @@ package appeng.api.networking; +import java.io.IOException; import java.util.Set; +import com.google.gson.stream.JsonWriter; + import appeng.api.networking.crafting.ICraftingService; import appeng.api.networking.energy.IEnergyService; import appeng.api.networking.events.GridEvent; @@ -177,4 +180,9 @@ default IPathingService getPathingService() { default ISpatialService getSpatialService() { return getService(ISpatialService.class); } + + /** + * Dump debug information about this grid to the given JSON writer. + */ + void export(JsonWriter jsonWriter) throws IOException; } diff --git a/src/main/java/appeng/api/networking/IGridServiceProvider.java b/src/main/java/appeng/api/networking/IGridServiceProvider.java index bd03ea41f53..5856916443d 100644 --- a/src/main/java/appeng/api/networking/IGridServiceProvider.java +++ b/src/main/java/appeng/api/networking/IGridServiceProvider.java @@ -23,6 +23,10 @@ package appeng.api.networking; +import java.io.IOException; + +import com.google.gson.stream.JsonWriter; + import org.jetbrains.annotations.Nullable; import net.minecraft.nbt.CompoundTag; @@ -101,4 +105,10 @@ default void addNode(IGridNode gridNode, @Nullable CompoundTag savedData) { */ default void saveNodeData(IGridNode gridNode, CompoundTag savedData) { } + + /** + * Write debug information about this service to the given writer. + */ + default void debugDump(JsonWriter writer) throws IOException { + } } diff --git a/src/main/java/appeng/blockentity/AEBaseBlockEntity.java b/src/main/java/appeng/blockentity/AEBaseBlockEntity.java index d0989db1175..ab9e4f45925 100644 --- a/src/main/java/appeng/blockentity/AEBaseBlockEntity.java +++ b/src/main/java/appeng/blockentity/AEBaseBlockEntity.java @@ -18,12 +18,16 @@ package appeng.blockentity; +import java.io.IOException; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import com.google.gson.stream.JsonWriter; +import com.mojang.serialization.JsonOps; + import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.MustBeInvokedByOverriders; import org.jetbrains.annotations.Nullable; @@ -57,9 +61,12 @@ import net.minecraft.world.phys.BlockHitResult; import net.neoforged.neoforge.client.model.data.ModelData; +import it.unimi.dsi.fastutil.objects.Reference2IntMap; + import appeng.api.inventories.ISegmentedInventory; import appeng.api.inventories.InternalInventory; import appeng.api.networking.GridHelper; +import appeng.api.networking.IGridNode; import appeng.api.orientation.BlockOrientation; import appeng.api.orientation.IOrientationStrategy; import appeng.api.orientation.RelativeSide; @@ -70,11 +77,13 @@ import appeng.hooks.ticking.TickHandler; import appeng.items.tools.MemoryCardItem; import appeng.util.CustomNameUtil; +import appeng.util.IDebugExportable; +import appeng.util.JsonStreamUtil; import appeng.util.SettingsFrom; import appeng.util.helpers.ItemComparisonHelper; public class AEBaseBlockEntity extends BlockEntity - implements Nameable, ISegmentedInventory, Clearable { + implements Nameable, ISegmentedInventory, Clearable, IDebugExportable { private static final Map, Item> REPRESENTATIVE_ITEMS = new HashMap<>(); @Nullable @@ -486,4 +495,19 @@ public void setBlockState(BlockState state) { onOrientationChanged(newOrientation); } } + + @Override + public void debugExport(JsonWriter writer, Reference2IntMap machineIds, Reference2IntMap nodeIds) + throws IOException { + var data = new CompoundTag(); + saveAdditional(data); + + JsonStreamUtil.writeProperties(Map.of( + "blockState", BlockState.CODEC.encodeStart(JsonOps.INSTANCE, getBlockState()).getOrThrow(false, err -> { + }), + "level", level.dimension().location().toString(), + "pos", getBlockPos(), + "data", CompoundTag.CODEC.encodeStart(JsonOps.INSTANCE, data).getOrThrow(false, err -> { + })), writer); + } } diff --git a/src/main/java/appeng/blockentity/networking/CableBusBlockEntity.java b/src/main/java/appeng/blockentity/networking/CableBusBlockEntity.java index 9a65604afd7..dfa9351ea3e 100644 --- a/src/main/java/appeng/blockentity/networking/CableBusBlockEntity.java +++ b/src/main/java/appeng/blockentity/networking/CableBusBlockEntity.java @@ -18,9 +18,12 @@ package appeng.blockentity.networking; +import java.io.IOException; import java.util.ArrayList; import java.util.List; +import com.google.gson.stream.JsonWriter; + import org.jetbrains.annotations.Nullable; import net.minecraft.core.BlockPos; @@ -41,6 +44,8 @@ import net.minecraft.world.phys.shapes.VoxelShape; import net.neoforged.neoforge.client.model.data.ModelData; +import it.unimi.dsi.fastutil.objects.Reference2IntMap; + import appeng.api.networking.IGridNode; import appeng.api.parts.IFacadeContainer; import appeng.api.parts.IPart; @@ -54,6 +59,7 @@ import appeng.core.AppEng; import appeng.helpers.AEMultiBlockEntity; import appeng.parts.CableBusContainer; +import appeng.util.IDebugExportable; import appeng.util.Platform; public class CableBusBlockEntity extends AEBaseBlockEntity implements AEMultiBlockEntity { @@ -376,4 +382,25 @@ public InteractionResult disassembleWithWrench(Player player, Level level, Block public VoxelShape getCollisionShape(CollisionContext context) { return cb.getCollisionShape(context); } + + @Override + public void debugExport(JsonWriter writer, Reference2IntMap machineIds, Reference2IntMap nodeIds) + throws IOException { + super.debugExport(writer, machineIds, nodeIds); + + writer.name("parts"); + writer.beginObject(); + for (var side : Platform.DIRECTIONS_WITH_NULL) { + var part = getPart(side); + if (part != null) { + writer.name(side == null ? "center" : side.getSerializedName()); + writer.beginObject(); + if (part instanceof IDebugExportable exportable) { + exportable.debugExport(writer, machineIds, nodeIds); + } + writer.endObject(); + } + } + writer.endObject(); + } } diff --git a/src/main/java/appeng/client/gui/me/networktool/NetworkStatusScreen.java b/src/main/java/appeng/client/gui/me/networktool/NetworkStatusScreen.java index 31b44a5ba6b..f35a5513c8c 100644 --- a/src/main/java/appeng/client/gui/me/networktool/NetworkStatusScreen.java +++ b/src/main/java/appeng/client/gui/me/networktool/NetworkStatusScreen.java @@ -24,6 +24,7 @@ import net.minecraft.ChatFormatting; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; import net.minecraft.network.chat.Component; import net.minecraft.world.entity.player.Inventory; @@ -51,6 +52,7 @@ public class NetworkStatusScreen extends AEBaseScreen { // Dimensions of each table cell private static final int CELL_WIDTH = 30; private static final int CELL_HEIGHT = 18; + private final Button exportGridButton; private NetworkStatus status = new NetworkStatus(); @@ -62,12 +64,18 @@ public NetworkStatusScreen(NetworkStatusMenu menu, Inventory playerInventory, this.scrollbar = widgets.addScrollBar("scrollbar"); this.addToLeftToolbar(CommonButtons.togglePowerUnit()); + + exportGridButton = widgets.addButton("export_grid", Component.literal("Export Grid"), menu::exportGrid); } @Override protected void updateBeforeRender() { super.updateBeforeRender(); + // Make the export button only visible if the command can actually be run. This allows tie-in with + // Prometheus or similar mods, which checking for op would not. + exportGridButton.visible = menu.canExportGrid(); + setTextContent("dialog_title", GuiText.NetworkDetails.text(status.getChannelsUsed())); setTextContent("stored_power", GuiText.StoredPower.text(Platform.formatPower(status.getStoredPower(), false))); setTextContent("max_power", GuiText.MaxPower.text(Platform.formatPower(status.getMaxStoredPower(), false))); diff --git a/src/main/java/appeng/core/network/InitNetwork.java b/src/main/java/appeng/core/network/InitNetwork.java index 4fae1fda97b..c616a1bbdca 100644 --- a/src/main/java/appeng/core/network/InitNetwork.java +++ b/src/main/java/appeng/core/network/InitNetwork.java @@ -13,6 +13,7 @@ import appeng.core.network.clientbound.CraftConfirmPlanPacket; import appeng.core.network.clientbound.CraftingJobStatusPacket; import appeng.core.network.clientbound.CraftingStatusPacket; +import appeng.core.network.clientbound.ExportedGridContent; import appeng.core.network.clientbound.GuiDataSyncPacket; import appeng.core.network.clientbound.ItemTransitionEffectPacket; import appeng.core.network.clientbound.LightningPacket; @@ -58,6 +59,7 @@ public static void init(RegisterPayloadHandlerEvent event) { clientbound(registrar, NetworkStatusPacket.class, NetworkStatusPacket::decode); clientbound(registrar, PatternAccessTerminalPacket.class, PatternAccessTerminalPacket::decode); clientbound(registrar, SetLinkStatusPacket.class, SetLinkStatusPacket::decode); + clientbound(registrar, ExportedGridContent.class, ExportedGridContent::decode); // Serverbound serverbound(registrar, ColorApplicatorSelectColorPacket.class, ColorApplicatorSelectColorPacket::decode); diff --git a/src/main/java/appeng/core/network/clientbound/ExportedGridContent.java b/src/main/java/appeng/core/network/clientbound/ExportedGridContent.java new file mode 100644 index 00000000000..37db65eba4d --- /dev/null +++ b/src/main/java/appeng/core/network/clientbound/ExportedGridContent.java @@ -0,0 +1,107 @@ +package appeng.core.network.clientbound; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.OpenOption; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Player; + +import appeng.core.network.ClientboundPacket; + +/** + * Contains data produced by {@link appeng.server.subcommands.GridsCommand} + */ +public record ExportedGridContent(int serialNumber, + Type type, + byte[] compressedData) implements ClientboundPacket { + + private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmm"); + + private static final Logger LOG = LoggerFactory.getLogger(ExportedGridContent.class); + + @Override + public void write(FriendlyByteBuf buffer) { + buffer.writeInt(serialNumber); + buffer.writeEnum(type); + buffer.writeByteArray(compressedData); + } + + public static ExportedGridContent decode(FriendlyByteBuf buffer) { + var serialNumber = buffer.readInt(); + var type = buffer.readEnum(Type.class); + var data = buffer.readByteArray(); + return new ExportedGridContent(serialNumber, type, data); + } + + @Override + public void handleOnClient(Player player) { + var saveDir = Minecraft.getInstance().gameDirectory.toPath(); + String filename; + var spServer = Minecraft.getInstance().getSingleplayerServer(); + var connection = Minecraft.getInstance().getConnection(); + if (spServer != null) { + saveDir = spServer.getServerDirectory().toPath(); + filename = "ae2_grid_"; + } else if (connection != null) { + filename = "ae2_grid_from_server_"; + } else { + LOG.error("Ignoring grid export without a connection to a server."); + return; + } + + saveDir = saveDir.toAbsolutePath().normalize(); + + filename += serialNumber + "_" + TIMESTAMP_FORMATTER.format(LocalDateTime.now()) + ".zip"; + + OpenOption[] openOptions = new OpenOption[0]; + if (type != Type.FIRST_CHUNK) { + openOptions = new OpenOption[] { StandardOpenOption.APPEND }; + } + + var tempPath = saveDir.resolve(filename + ".tmp"); + var finalPath = saveDir.resolve(filename); + try (var out = Files.newOutputStream(tempPath, openOptions)) { + out.write(compressedData); + } catch (IOException e) { + player.sendSystemMessage( + Component.literal("Failed to write exported grid data to " + tempPath) + .withStyle(ChatFormatting.RED)); + LOG.error("Failed to write exported grid data to {}", tempPath, e); + return; + } + + if (type == Type.LAST_CHUNK) { + try { + Files.move(tempPath, finalPath, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + LOG.error("Failed to move grid export {} into place", finalPath, e); + } + + player.sendSystemMessage(Component.literal("Saved grid data for grid #" + serialNumber + " from server to ") + .append(Component.literal(finalPath.toString()).withStyle(style -> { + return style.withUnderlined(true) + .withClickEvent(new ClickEvent( + ClickEvent.Action.OPEN_FILE, + finalPath.getParent().toString())); + }))); + } + } + + public enum Type { + FIRST_CHUNK, + CHUNK, + LAST_CHUNK + } +} diff --git a/src/main/java/appeng/hooks/ticking/ServerGridRepo.java b/src/main/java/appeng/hooks/ticking/ServerGridRepo.java index 7778e0e70ad..29f9508bd4f 100644 --- a/src/main/java/appeng/hooks/ticking/ServerGridRepo.java +++ b/src/main/java/appeng/hooks/ticking/ServerGridRepo.java @@ -18,7 +18,9 @@ package appeng.hooks.ticking; +import java.util.Collections; import java.util.Objects; +import java.util.Set; import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; import it.unimi.dsi.fastutil.objects.ObjectSet; @@ -86,8 +88,8 @@ synchronized void updateNetworks() { /** * Get all registered {@link Grid}s */ - public Iterable getNetworks() { - return networks; + public Set getNetworks() { + return Collections.unmodifiableSet(networks); } } diff --git a/src/main/java/appeng/hooks/ticking/TickHandler.java b/src/main/java/appeng/hooks/ticking/TickHandler.java index ededc7761c9..d0727674303 100644 --- a/src/main/java/appeng/hooks/ticking/TickHandler.java +++ b/src/main/java/appeng/hooks/ticking/TickHandler.java @@ -27,6 +27,7 @@ import java.util.Map; import java.util.Objects; import java.util.Queue; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @@ -174,7 +175,7 @@ public void removeNetwork(Grid grid) { this.grids.removeNetwork(grid); } - public Iterable getGridList() { + public Set getGridList() { Platform.assertServerThread(); return this.grids.getNetworks(); } diff --git a/src/main/java/appeng/me/Grid.java b/src/main/java/appeng/me/Grid.java index ad4bed38282..672e1911353 100644 --- a/src/main/java/appeng/me/Grid.java +++ b/src/main/java/appeng/me/Grid.java @@ -18,6 +18,7 @@ package appeng.me; +import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -29,6 +30,7 @@ import com.google.common.collect.ImmutableSet; import com.google.common.collect.MultimapBuilder; import com.google.common.collect.SetMultimap; +import com.google.gson.stream.JsonWriter; import org.jetbrains.annotations.Nullable; @@ -36,15 +38,28 @@ import net.minecraft.nbt.CompoundTag; import net.minecraft.world.level.Level; +import it.unimi.dsi.fastutil.objects.Reference2IntMap; +import it.unimi.dsi.fastutil.objects.Reference2IntOpenHashMap; + import appeng.api.networking.GridServicesInternal; import appeng.api.networking.IGrid; import appeng.api.networking.IGridNode; import appeng.api.networking.IGridNodeListener; import appeng.api.networking.IGridService; import appeng.api.networking.IGridServiceProvider; +import appeng.api.networking.crafting.ICraftingService; +import appeng.api.networking.energy.IEnergyService; import appeng.api.networking.events.GridEvent; +import appeng.api.networking.pathing.IPathingService; +import appeng.api.networking.spatial.ISpatialService; +import appeng.api.networking.storage.IStorageService; +import appeng.api.networking.ticking.ITickManager; import appeng.core.AELog; import appeng.hooks.ticking.TickHandler; +import appeng.me.service.P2PService; +import appeng.parts.AEBasePart; +import appeng.util.IDebugExportable; +import appeng.util.JsonStreamUtil; public class Grid implements IGrid { /** @@ -55,6 +70,8 @@ public class Grid implements IGrid { private final SetMultimap, IGridNode> machines = MultimapBuilder.hashKeys().hashSetValues().build(); private final Map, IGridServiceProvider> services; + // Becomes null after the last node has left the grid. + @Nullable private GridNode pivot; private int priority; // how import is this network? private final int serialNumber = nextSerial++; // useful to keep track of grids in toString() for debugging purposes @@ -280,4 +297,98 @@ public void fillCrashReportCategory(CrashReportCategory category) { public String toString() { return "Grid #" + serialNumber; } + + private static String getServiceExportKey(Class service) { + if (service == IEnergyService.class) { + return "energyService"; + } else if (service == ISpatialService.class) { + return "spatialService"; + } else if (service == IPathingService.class) { + return "pathingService"; + } else if (service == IStorageService.class) { + return "storageService"; + } else if (service == ITickManager.class) { + return "tickManager"; + } else if (service == P2PService.class) { + return "p2pService"; + } else if (service == ICraftingService.class) { + return "craftingService"; + } else { + return service.getName(); + } + } + + @Override + public void export(JsonWriter jsonWriter) throws IOException { + jsonWriter.beginObject(); + + var properties = Map.of( + "id", serialNumber, + "disposed", pivot == null); + JsonStreamUtil.writeProperties(properties, jsonWriter); + + // Assign unique IDs to all owners + var machineIdMap = new Reference2IntOpenHashMap<>(machines.size()); + for (var node : machines.values()) { + machineIdMap.put(node.getOwner(), machineIdMap.size()); + // Also assign unique IDs to part hosts + if (node.getOwner() instanceof AEBasePart part) { + machineIdMap.put(part.getBlockEntity(), machineIdMap.size()); + } + } + + // Assign unique IDs to all involved machines and nodes + var nodeIdMap = new Reference2IntOpenHashMap(machines.size()); + for (var node : machines.values()) { + nodeIdMap.put(node, nodeIdMap.size()); + } + + jsonWriter.name("machines"); + exportMachines(jsonWriter, machineIdMap, nodeIdMap); + + jsonWriter.name("nodes"); + exportNodes(jsonWriter, machineIdMap, nodeIdMap); + + jsonWriter.name("services"); + jsonWriter.beginObject(); + for (var entry : services.entrySet()) { + jsonWriter.name(getServiceExportKey(entry.getKey())); + jsonWriter.beginObject(); + entry.getValue().debugDump(jsonWriter); + jsonWriter.endObject(); + } + jsonWriter.endObject(); + + jsonWriter.endObject(); + } + + private void exportMachines(JsonWriter jsonWriter, Reference2IntMap machineIds, + Reference2IntMap nodeIds) throws IOException { + jsonWriter.beginArray(); + for (var entry : machineIds.reference2IntEntrySet()) { + jsonWriter.beginObject(); + JsonStreamUtil.writeProperties(Map.of( + "id", entry.getIntValue()), jsonWriter); + if (entry.getKey() instanceof IDebugExportable exportable) { + exportable.debugExport(jsonWriter, machineIds, nodeIds); + } + jsonWriter.endObject(); + } + jsonWriter.endArray(); + } + + private void exportNodes(JsonWriter jsonWriter, Reference2IntMap machineIds, + Reference2IntMap nodeIds) throws IOException { + // Dump nodes + jsonWriter.beginArray(); + for (var entry : nodeIds.reference2IntEntrySet()) { + var node = entry.getKey(); + ((GridNode) node).debugExport(jsonWriter, machineIds, nodeIds); + } + jsonWriter.endArray(); + } + + public int getSerialNumber() { + return serialNumber; + } } diff --git a/src/main/java/appeng/me/GridNode.java b/src/main/java/appeng/me/GridNode.java index 60ee5e410d7..8d0ecc931fe 100644 --- a/src/main/java/appeng/me/GridNode.java +++ b/src/main/java/appeng/me/GridNode.java @@ -18,6 +18,7 @@ package appeng.me; +import java.io.IOException; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; @@ -33,6 +34,7 @@ import com.google.common.collect.ClassToInstanceMap; import com.google.common.collect.ImmutableList; import com.google.common.collect.MutableClassToInstanceMap; +import com.google.gson.stream.JsonWriter; import org.jetbrains.annotations.Nullable; @@ -45,6 +47,8 @@ import net.minecraft.world.level.Level; import net.minecraft.world.level.block.entity.BlockEntity; +import it.unimi.dsi.fastutil.objects.Reference2IntMap; + import appeng.api.features.IPlayerRegistry; import appeng.api.networking.GridFlags; import appeng.api.networking.IGrid; @@ -61,8 +65,10 @@ import appeng.api.util.AEColor; import appeng.core.AELog; import appeng.me.pathfinding.IPathItem; +import appeng.util.IDebugExportable; +import appeng.util.JsonStreamUtil; -public class GridNode implements IGridNode, IPathItem { +public class GridNode implements IGridNode, IPathItem, IDebugExportable { private final ServerLevel level; /** * This is the logical host of the node, which could be any object. In many cases this will be a block entity or @@ -677,4 +683,24 @@ public void fillCrashReportCategory(CrashReportCategory category) { } } } + + @Override + public final void debugExport(JsonWriter writer, Reference2IntMap machineIds, + Reference2IntMap nodeIds) throws IOException { + writer.beginObject(); + exportProperties(writer, machineIds, nodeIds); + writer.endObject(); + } + + protected void exportProperties(JsonWriter writer, Reference2IntMap machineIds, + Reference2IntMap nodeIds) + throws IOException { + var id = nodeIds.getInt(this); + var machineId = machineIds.getInt(owner); + JsonStreamUtil.writeProperties(Map.of( + "id", id, "owner", machineId), writer); + + writer.name("level"); + writer.value(level.dimensionTypeId().location().toString()); + } } diff --git a/src/main/java/appeng/me/InWorldGridNode.java b/src/main/java/appeng/me/InWorldGridNode.java index 51171656b2b..6887b55e1b2 100644 --- a/src/main/java/appeng/me/InWorldGridNode.java +++ b/src/main/java/appeng/me/InWorldGridNode.java @@ -18,14 +18,19 @@ package appeng.me; +import java.io.IOException; import java.util.EnumSet; import java.util.Set; +import com.google.gson.stream.JsonWriter; + import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos.MutableBlockPos; import net.minecraft.core.Direction; import net.minecraft.server.level.ServerLevel; +import it.unimi.dsi.fastutil.objects.Reference2IntMap; + import appeng.api.networking.GridFlags; import appeng.api.networking.GridHelper; import appeng.api.networking.IGridNode; @@ -99,6 +104,27 @@ public String toString() { return super.toString() + " @ " + location.getX() + "," + location.getY() + "," + location.getZ(); } + @Override + protected void exportProperties(JsonWriter writer, Reference2IntMap machineIds, + Reference2IntMap nodeIds) + throws IOException { + super.exportProperties(writer, machineIds, nodeIds); + + writer.name("location"); + writer.beginArray(); + writer.value(location.getX()); + writer.value(location.getY()); + writer.value(location.getZ()); + writer.endArray(); + + writer.name("exposedSides"); + var sidesSet = new StringBuilder(); + for (var side : exposedOnSides) { + sidesSet.append(side.name().charAt(0)); + } + writer.value(sidesSet.toString()); + } + private void cleanupConnections() { // NOTE: this makes a defensive copy of the connections for (var connection : getConnections()) { diff --git a/src/main/java/appeng/me/service/StatisticsService.java b/src/main/java/appeng/me/service/StatisticsService.java index 333a7a2a11d..139f0f5e660 100644 --- a/src/main/java/appeng/me/service/StatisticsService.java +++ b/src/main/java/appeng/me/service/StatisticsService.java @@ -18,12 +18,15 @@ package appeng.me.service; +import java.io.IOException; import java.util.HashMap; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import com.google.common.collect.HashMultiset; import com.google.common.collect.Multiset; +import com.google.gson.stream.JsonWriter; import org.jetbrains.annotations.Nullable; @@ -31,7 +34,6 @@ import net.minecraft.nbt.CompoundTag; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.ChunkPos; -import net.minecraft.world.level.LevelAccessor; import appeng.api.networking.IGrid; import appeng.api.networking.IGridNode; @@ -39,6 +41,7 @@ import appeng.api.networking.IGridServiceProvider; import appeng.api.networking.events.statistics.GridChunkEvent; import appeng.me.InWorldGridNode; +import appeng.util.JsonStreamUtil; /** * A grid providing precomupted statistics about a network. @@ -53,7 +56,7 @@ public class StatisticsService implements IGridService, IGridServiceProvider { * This uses a {@link Multiset} so we can simply add or remove {@link IGridNode} without having to take into account * that others still might exist without explicitly counting these. */ - private final Map> chunks; + private final Map> chunks; public StatisticsService(IGrid g) { this.grid = g; @@ -79,40 +82,31 @@ public IGrid getGrid() { } /** - * A set of all {@link LevelAccessor} this grid spans. - * - * @return + * A set of all {@link ServerLevel} this grid spans. */ - public Set getLevels() { + public Set getLevels() { return this.chunks.keySet(); } /** * A set of chunks this grid spans in a specific level. - * - * @param level - * @return */ - public Set chunks(LevelAccessor level) { + public Set chunks(ServerLevel level) { return this.chunks.get(level).elementSet(); } - public Map> getChunks() { + public Map> getChunks() { return this.chunks; } /** * Mark the chunk of the {@link BlockPos} as location of the network. - * - * @param level - * @param pos - * @return */ - private boolean addChunk(LevelAccessor level, BlockPos pos) { + private boolean addChunk(ServerLevel level, BlockPos pos) { final ChunkPos position = new ChunkPos(pos); if (!this.getChunks(level).contains(position)) { - this.grid.postEvent(new GridChunkEvent.GridChunkAdded((ServerLevel) level, position)); + this.grid.postEvent(new GridChunkEvent.GridChunkAdded(level, position)); } return this.getChunks(level).add(position); @@ -123,17 +117,13 @@ private boolean addChunk(LevelAccessor level, BlockPos pos) { *

* This uses a {@link Multiset} to ensure it will only marked as no longer containing a grid once all other * gridnodes are removed as well. - * - * @param level - * @param pos - * @return */ - private boolean removeChunk(LevelAccessor level, BlockPos pos) { + private boolean removeChunk(ServerLevel level, BlockPos pos) { final ChunkPos position = new ChunkPos(pos); boolean ret = this.getChunks(level).remove(position); if (ret && !this.getChunks(level).contains(position)) { - this.grid.postEvent(new GridChunkEvent.GridChunkRemoved((ServerLevel) level, position)); + this.grid.postEvent(new GridChunkEvent.GridChunkRemoved(level, position)); } this.clearLevel(level); @@ -141,18 +131,27 @@ private boolean removeChunk(LevelAccessor level, BlockPos pos) { return ret; } - private Multiset getChunks(LevelAccessor level) { + private Multiset getChunks(ServerLevel level) { return this.chunks.computeIfAbsent(level, l -> HashMultiset.create()); } /** * Cleanup the map in case a whole level is unloaded - * - * @param level */ - private void clearLevel(LevelAccessor level) { + private void clearLevel(ServerLevel level) { if (this.chunks.get(level).isEmpty()) { this.chunks.remove(level); } } + + @Override + public void debugDump(JsonWriter writer) throws IOException { + JsonStreamUtil.writeProperties(Map.of("chunks", + chunks.keySet().stream().collect( + Collectors.toMap( + level -> level.dimension().location().toString(), + level -> chunks.get(level).elementSet().stream().map(JsonStreamUtil::toJson) + .toList()))), + writer); + } } diff --git a/src/main/java/appeng/me/service/StorageService.java b/src/main/java/appeng/me/service/StorageService.java index 67e4580a637..3a188cc6807 100644 --- a/src/main/java/appeng/me/service/StorageService.java +++ b/src/main/java/appeng/me/service/StorageService.java @@ -18,6 +18,7 @@ package appeng.me.service; +import java.io.IOException; import java.util.ArrayList; import java.util.HashSet; import java.util.IdentityHashMap; @@ -28,10 +29,16 @@ import com.google.common.base.Preconditions; import com.google.common.collect.HashMultimap; import com.google.common.collect.SetMultimap; +import com.google.common.math.StatsAccumulator; +import com.google.gson.Gson; +import com.google.gson.stream.JsonWriter; +import com.mojang.serialization.Dynamic; +import com.mojang.serialization.JsonOps; import org.jetbrains.annotations.Nullable; import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtOps; import it.unimi.dsi.fastutil.objects.Object2LongMap; import it.unimi.dsi.fastutil.objects.Object2LongOpenHashMap; @@ -48,8 +55,10 @@ import appeng.me.helpers.InterestManager; import appeng.me.helpers.StackWatcher; import appeng.me.storage.NetworkStorage; +import appeng.util.JsonStreamUtil; public class StorageService implements IStorageService, IGridServiceProvider { + private static final Gson GSON = new Gson(); /** * Tracks the storage service's state for each grid node that provides storage to the network. @@ -80,6 +89,8 @@ public class StorageService implements IStorageService, IGridServiceProvider { */ private final Map> watchers = new IdentityHashMap<>(); + private final StatsAccumulator inventoryRefreshStats = new StatsAccumulator(); + public StorageService() { this.storage = new NetworkStorage(); } @@ -96,38 +107,44 @@ public void onServerEndTick() { } private void updateCachedStacks() { - cachedStacksNeedUpdate = false; - - // Update cache - var previousStacks = cachedAvailableStacks; - var currentStacks = cachedAvailableStacksBackBuffer; - cachedAvailableStacks = currentStacks; - cachedAvailableStacksBackBuffer = previousStacks; - - currentStacks.clear(); - storage.getAvailableStacks(currentStacks); - - // Post watcher update for currently available stacks - for (var entry : currentStacks) { - var what = entry.getKey(); - var newAmount = entry.getLongValue(); - if (newAmount != cachedAvailableAmounts.getLong(what)) { - postWatcherUpdate(what, newAmount); + var time = System.nanoTime(); + + try { + cachedStacksNeedUpdate = false; + + // Update cache + var previousStacks = cachedAvailableStacks; + var currentStacks = cachedAvailableStacksBackBuffer; + cachedAvailableStacks = currentStacks; + cachedAvailableStacksBackBuffer = previousStacks; + + currentStacks.clear(); + storage.getAvailableStacks(currentStacks); + + // Post watcher update for currently available stacks + for (var entry : currentStacks) { + var what = entry.getKey(); + var newAmount = entry.getLongValue(); + if (newAmount != cachedAvailableAmounts.getLong(what)) { + postWatcherUpdate(what, newAmount); + } } - } - // Post watcher update for removed stacks - for (var entry : cachedAvailableAmounts.object2LongEntrySet()) { - var what = entry.getKey(); - var newAmount = currentStacks.get(what); - if (newAmount == 0) { - postWatcherUpdate(what, newAmount); + // Post watcher update for removed stacks + for (var entry : cachedAvailableAmounts.object2LongEntrySet()) { + var what = entry.getKey(); + var newAmount = currentStacks.get(what); + if (newAmount == 0) { + postWatcherUpdate(what, newAmount); + } } - } - // Update private amounts - cachedAvailableAmounts.clear(); - for (var entry : currentStacks) { - cachedAvailableAmounts.put(entry.getKey(), entry.getLongValue()); + // Update private amounts + cachedAvailableAmounts.clear(); + for (var entry : currentStacks) { + cachedAvailableAmounts.put(entry.getKey(), entry.getLongValue()); + } + } finally { + inventoryRefreshStats.add(System.nanoTime() - time); } } @@ -293,4 +310,26 @@ private void unmount(MEStorage inventory) { storage.unmount(inventory); } } + + @Override + public void debugDump(JsonWriter writer) throws IOException { + + JsonStreamUtil.writeProperties(Map.of( + "inventoryRefreshTime", JsonStreamUtil.toMap(inventoryRefreshStats)), writer); + + writer.name("cachedAvailableStacks"); + writer.beginArray(); + for (var entry : cachedAvailableStacks) { + writer.beginObject(); + writer.name("key"); + var serializedKey = entry.getKey().toTagGeneric(); + var jsonKey = Dynamic.convert(NbtOps.INSTANCE, JsonOps.INSTANCE, serializedKey); + GSON.toJson(jsonKey, writer); + writer.name("amount"); + writer.value(entry.getLongValue()); + writer.endObject(); + } + writer.endArray(); + + } } diff --git a/src/main/java/appeng/menu/implementations/SpatialAnchorMenu.java b/src/main/java/appeng/menu/implementations/SpatialAnchorMenu.java index b8d9dc1a335..4e145217c55 100644 --- a/src/main/java/appeng/menu/implementations/SpatialAnchorMenu.java +++ b/src/main/java/appeng/menu/implementations/SpatialAnchorMenu.java @@ -19,13 +19,9 @@ package appeng.menu.implementations; import java.util.HashMap; -import java.util.Map.Entry; - -import com.google.common.collect.Multiset; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.inventory.MenuType; -import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.LevelAccessor; import appeng.api.config.Settings; @@ -99,8 +95,8 @@ public void broadcastChanges() { this.allWorlds = statistics.getChunks().size(); this.allChunks = 0; - for (Entry> entry : statistics.getChunks().entrySet()) { - this.allChunks += entry.getValue().elementSet().size(); + for (var value : statistics.getChunks().values()) { + this.allChunks += value.elementSet().size(); } this.delay = 0; diff --git a/src/main/java/appeng/menu/me/networktool/NetworkStatusMenu.java b/src/main/java/appeng/menu/me/networktool/NetworkStatusMenu.java index 3ffc22688f8..9a7b7d46ca4 100644 --- a/src/main/java/appeng/menu/me/networktool/NetworkStatusMenu.java +++ b/src/main/java/appeng/menu/me/networktool/NetworkStatusMenu.java @@ -18,7 +18,9 @@ package appeng.menu.me.networktool; +import net.minecraft.client.Minecraft; import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.inventory.MenuType; @@ -29,14 +31,18 @@ import appeng.client.gui.me.networktool.NetworkStatusScreen; import appeng.core.network.clientbound.NetworkStatusPacket; import appeng.items.contents.NetworkToolMenuHost; +import appeng.me.Grid; import appeng.menu.AEBaseMenu; import appeng.menu.implementations.MenuTypeBuilder; +import appeng.server.subcommands.GridsCommand; /** * @see NetworkStatusScreen */ public class NetworkStatusMenu extends AEBaseMenu { + private static final String ACTION_EXPORT_GRID = "export_grid"; + public static final MenuType NETWORK_TOOL_TYPE = MenuTypeBuilder .create(NetworkStatusMenu::new, NetworkToolMenuHost.class) .build("networkstatus"); @@ -70,6 +76,8 @@ private void buildForGridHost(IInWorldGridNodeHost gridHost) { if (this.grid == null && isServerSide()) { this.setValidMenu(false); } + + registerClientAction(ACTION_EXPORT_GRID, this::exportGrid); } private void findNode(IInWorldGridNodeHost host, Direction d) { @@ -94,4 +102,35 @@ public void broadcastChanges() { super.broadcastChanges(); } + /** + * We run this as a command to allow the standard permission mods to control access to this. + */ + public void exportGrid() { + if (isClientSide()) { + sendClientAction(ACTION_EXPORT_GRID); + return; + } + + var serverPlayer = (ServerPlayer) getPlayer(); + var server = serverPlayer.getServer(); + + var grid = (Grid) this.grid; + + var commandSource = serverPlayer.createCommandSourceStack(); + server.getCommands().performPrefixedCommand(commandSource, + GridsCommand.buildExportCommand(grid.getSerialNumber())); + setValidMenu(false); // Close the menu + } + + public boolean canExportGrid() { + var connection = Minecraft.getInstance().getConnection(); + if (connection == null) { + return false; + } + var commands = connection.getCommands(); + var command = GridsCommand.buildExportCommand(1); + var parseResult = commands.parse(command.substring(1), connection.getSuggestionsProvider()); + // See JavaDoc for explanation as to why this is checking for a valid parse result + return !parseResult.getReader().canRead(); + } } diff --git a/src/main/java/appeng/parts/AEBasePart.java b/src/main/java/appeng/parts/AEBasePart.java index 3ea6e67e622..7db5539649d 100644 --- a/src/main/java/appeng/parts/AEBasePart.java +++ b/src/main/java/appeng/parts/AEBasePart.java @@ -18,14 +18,19 @@ package appeng.parts; +import java.io.IOException; import java.util.EnumSet; +import java.util.Map; import java.util.Objects; +import com.google.gson.stream.JsonWriter; + import org.jetbrains.annotations.MustBeInvokedByOverriders; import org.jetbrains.annotations.Nullable; import net.minecraft.CrashReportCategory; import net.minecraft.core.Direction; +import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.Tag; import net.minecraft.network.FriendlyByteBuf; @@ -40,6 +45,8 @@ import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.phys.Vec3; +import it.unimi.dsi.fastutil.objects.Reference2IntMap; + import appeng.api.implementations.IPowerChannelState; import appeng.api.implementations.items.IMemoryCard; import appeng.api.implementations.items.MemoryCardMessages; @@ -61,11 +68,13 @@ import appeng.core.definitions.AEParts; import appeng.items.tools.MemoryCardItem; import appeng.util.CustomNameUtil; +import appeng.util.IDebugExportable; import appeng.util.InteractionUtil; +import appeng.util.JsonStreamUtil; import appeng.util.SettingsFrom; public abstract class AEBasePart - implements IPart, IActionHost, ISegmentedInventory, IPowerChannelState, Nameable { + implements IPart, IActionHost, ISegmentedInventory, IPowerChannelState, Nameable, IDebugExportable { private final IManagedGridNode mainNode; private IPartItem partItem; @@ -476,4 +485,14 @@ protected boolean shouldSendPowerStateToClient() { protected boolean shouldSendMissingChannelStateToClient() { return true; } + + @Override + public void debugExport(JsonWriter writer, Reference2IntMap machineIds, Reference2IntMap nodeIds) + throws IOException { + var myId = machineIds.getOrDefault(this, -1); + JsonStreamUtil.writeProperties(Map.of( + "id", myId, + "item", BuiltInRegistries.ITEM.getKey(getPartItem().asItem()).toString(), + "mainNodeId", nodeIds.getOrDefault(mainNode.getNode(), -1)), writer); + } } diff --git a/src/main/java/appeng/parts/networking/QuartzFiberPart.java b/src/main/java/appeng/parts/networking/QuartzFiberPart.java index 07f6631f332..7c65ae1ac89 100644 --- a/src/main/java/appeng/parts/networking/QuartzFiberPart.java +++ b/src/main/java/appeng/parts/networking/QuartzFiberPart.java @@ -33,7 +33,6 @@ import appeng.api.networking.GridHelper; import appeng.api.networking.IGridNode; import appeng.api.networking.IManagedGridNode; -import appeng.api.networking.energy.IEnergyService; import appeng.api.parts.IPartCollisionHelper; import appeng.api.parts.IPartHost; import appeng.api.parts.IPartItem; @@ -44,7 +43,6 @@ import appeng.me.energy.IEnergyOverlayGridConnection; import appeng.me.service.EnergyService; import appeng.parts.AEBasePart; -import appeng.parts.AEBasePart.NodeListener; import appeng.parts.PartModel; /** @@ -81,12 +79,12 @@ public QuartzFiberPart(IPartItem partItem) { private List getOurEnergyServices() { var grid = Objects.requireNonNull(getMainNode().getGrid()); - return Collections.singletonList((EnergyService) grid.getService(IEnergyService.class)); + return Collections.singletonList((EnergyService) grid.getEnergyService()); } private List getTheirEnergyServices() { var grid = Objects.requireNonNull(outerNode.getGrid()); - return Collections.singletonList((EnergyService) grid.getService(IEnergyService.class)); + return Collections.singletonList((EnergyService) grid.getEnergyService()); } @Override diff --git a/src/main/java/appeng/server/Commands.java b/src/main/java/appeng/server/Commands.java index 537d5dc4f25..126879a7d17 100644 --- a/src/main/java/appeng/server/Commands.java +++ b/src/main/java/appeng/server/Commands.java @@ -23,6 +23,7 @@ import appeng.server.services.compass.TestCompassCommand; import appeng.server.subcommands.ChannelModeCommand; import appeng.server.subcommands.ChunkLogger; +import appeng.server.subcommands.GridsCommand; import appeng.server.subcommands.ReloadConfigCommand; import appeng.server.subcommands.SetupTestWorldCommand; import appeng.server.subcommands.SpatialStorageCommand; @@ -37,6 +38,7 @@ public enum Commands { SPATIAL(4, "spatial", new SpatialStorageCommand()), CHANNEL_MODE(4, "channelmode", new ChannelModeCommand()), TICK_MONITORING(4, "tickmonitor", new TickMonitoring()), + GRIDS(4, "grids", new GridsCommand()), // Testing COMPASS(4, "compass", new TestCompassCommand(), true), diff --git a/src/main/java/appeng/server/subcommands/GridsCommand.java b/src/main/java/appeng/server/subcommands/GridsCommand.java new file mode 100644 index 00000000000..662b95ef3ab --- /dev/null +++ b/src/main/java/appeng/server/subcommands/GridsCommand.java @@ -0,0 +1,280 @@ +package appeng.server.subcommands; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import com.google.common.base.Preconditions; +import com.google.gson.stream.JsonWriter; +import com.mojang.brigadier.LiteralMessage; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; + +import org.apache.commons.io.output.CloseShieldOutputStream; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.storage.ChunkSerializer; +import net.neoforged.neoforge.network.PacketDistributor; + +import appeng.api.networking.GridHelper; +import appeng.core.network.clientbound.ExportedGridContent; +import appeng.helpers.patternprovider.PatternProviderLogicHost; +import appeng.hooks.ticking.TickHandler; +import appeng.me.Grid; +import appeng.me.service.StatisticsService; +import appeng.parts.AEBasePart; +import appeng.parts.p2p.MEP2PTunnelPart; +import appeng.server.ISubCommand; +import appeng.util.Platform; + +public class GridsCommand implements ISubCommand { + private static final Logger LOG = LoggerFactory.getLogger(GridsCommand.class); + + public static String buildExportCommand(int gridSerial) { + return "/ae2 grids export " + gridSerial; + } + + @Override + public void addArguments(LiteralArgumentBuilder builder) { + builder.then(Commands.literal("export").executes(ctx -> { + exportGrids(ctx.getSource()); + return 1; + }).then(Commands.argument("gridSerial", IntegerArgumentType.integer()).executes(context -> { + var gridSerial = context.getArgument("gridSerial", Integer.class); + + // Find the starting grid + for (var grid : TickHandler.instance().getGridList()) { + if (grid.getSerialNumber() == gridSerial) { + exportGrid(grid, context.getSource()); + return 1; + } + } + + throw new SimpleCommandExceptionType(new LiteralMessage("No such grid found")).create(); + }))); + } + + private void exportGrids(CommandSourceStack source) throws CommandSyntaxException { + + var grids = TickHandler.instance().getGridList(); + + source.sendSystemMessage(Component.literal("Exporting " + grids.size() + " grids")); + + exportGrids(0, grids, source); + + } + + private void exportGrid(Grid startGrid, CommandSourceStack source) throws CommandSyntaxException { + + // Collect all reachable grids + var reachableGrids = Collections.newSetFromMap(new IdentityHashMap()); + reachableGrids.add(startGrid); + var openSet = Collections.newSetFromMap(new IdentityHashMap()); + openSet.add(startGrid); + + while (!openSet.isEmpty()) { + var it = openSet.iterator(); + var grid = it.next(); + it.remove(); + for (var node : grid.getNodes()) { + if (node.getOwner() instanceof AEBasePart basePart) { + visitGridInFrontOfPart(basePart, reachableGrids, openSet); + } else if (node.getOwner() instanceof PatternProviderLogicHost patternProvider) { + for (var targetSide : patternProvider.getTargets()) { + visitGridAt( + patternProvider.getBlockEntity().getLevel(), + patternProvider.getBlockEntity().getBlockPos().relative(targetSide), + reachableGrids, + openSet); + } + } else if (node.getOwner() instanceof MEP2PTunnelPart meTunnel) { + var tunnelGrid = (Grid) meTunnel.getMainNode().getGrid(); + if (tunnelGrid != null && reachableGrids.add(tunnelGrid)) { + openSet.add(tunnelGrid); + } + } + } + } + + exportGrids(startGrid.getSerialNumber(), reachableGrids, source); + } + + private static void visitGridInFrontOfPart(AEBasePart part, Set reachableGrids, Set openSet) { + var partSide = part.getSide(); + if (partSide == null) { + return; + } + // Storage buses that are attached to devices on different grids are interesting to us + var hostBe = part.getBlockEntity(); + var targetPos = hostBe.getBlockPos().relative(partSide); + visitGridAt(hostBe.getLevel(), targetPos, reachableGrids, openSet); + } + + private static void visitGridAt(Level level, BlockPos pos, Set reachableGrids, Set openSet) { + var targetGridHost = GridHelper.getNodeHost(level, pos); + if (targetGridHost != null) { + for (var side : Platform.DIRECTIONS_WITH_NULL) { + var nodeOnSide = targetGridHost.getGridNode(side); + if (nodeOnSide != null) { + var nodeGrid = (Grid) nodeOnSide.getGrid(); + if (reachableGrids.add(nodeGrid)) { + openSet.add(nodeGrid); + } + } + } + } + } + + @Override + public void call(MinecraftServer srv, CommandContext data, + CommandSourceStack sender) { + } + + private void exportGrids(int baseSerialNumber, Collection grids, CommandSourceStack source) + throws CommandSyntaxException { + source.sendSystemMessage(Component.literal("Exporting " + grids.size() + " grids")); + LOG.info("Exporting {} grids for {}", grids.size(), source); + + if (source.isPlayer()) { + var player = source.getPlayerOrException(); + PacketDistributor.PLAYER.with(source.getPlayerOrException()) + .send(new ExportedGridContent(baseSerialNumber, ExportedGridContent.Type.FIRST_CHUNK, new byte[0])); + + try (var out = new SendToPlayerStream(player, baseSerialNumber)) { + exportGrids(grids, out); + } + } else { + var targetPath = Paths.get("grids.zip"); + try (var out = Files.newOutputStream(targetPath)) { + exportGrids(grids, out); + } catch (IOException e) { + LOG.error("Failed to export grids.", e); + source.sendFailure(Component.literal("Failed to export grids: " + e)); + } + } + + } + + private void exportGrids(Iterable grids, OutputStream out) { + try (var zipOut = new ZipOutputStream(out)) { + // Collect all chunks that grids live in and dump them all later + var chunksByLevel = new HashMap>(); + + for (var grid : grids) { + var statisticsService = grid.getService(StatisticsService.class); + for (var entry : statisticsService.getChunks().entrySet()) { + chunksByLevel.computeIfAbsent(entry.getKey(), level -> new HashSet<>()) + .addAll(entry.getValue().elementSet()); + } + + var entry = new ZipEntry("grid_" + grid.getSerialNumber() + ".json"); + zipOut.putNextEntry(entry); + + try (var writer = new JsonWriter( + new OutputStreamWriter(CloseShieldOutputStream.wrap(zipOut), StandardCharsets.UTF_8))) { + writer.setIndent(" "); + grid.export(writer); + } + } + + zipOut.putNextEntry(new ZipEntry("chunks/")); + for (var entry : chunksByLevel.entrySet()) { + var level = entry.getKey(); + var chunks = entry.getValue(); + var baseName = sanitizeName(level.dimension().location().toString()); + for (var chunk : chunks) { + var serializedChunk = ChunkSerializer.write(level, level.getChunk(chunk.x, chunk.z)); + zipOut.putNextEntry(new ZipEntry("chunks/" + baseName + "_" + chunk.x + "_" + chunk.z + ".nbt")); + NbtIo.writeCompressed(serializedChunk, CloseShieldOutputStream.wrap(zipOut)); + + zipOut.putNextEntry(new ZipEntry("chunks/" + baseName + "_" + chunk.x + "_" + chunk.z + ".snbt")); + zipOut.write(NbtUtils.structureToSnbt(serializedChunk).getBytes(StandardCharsets.UTF_8)); + } + } + + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private String sanitizeName(String string) { + return string.replaceAll("[^A-Za-z0-9-,]", "_"); + } + + private static class SendToPlayerStream extends OutputStream { + private static final int FLUSH_AFTER = 512 * 1024; + private final ByteArrayOutputStream bout; // 512kb buffer + private final ServerPlayer player; + private final int baseSerialNumber; + private boolean closed; + + public SendToPlayerStream(ServerPlayer player, int baseSerialNumber) { + this.player = player; + this.baseSerialNumber = baseSerialNumber; + bout = new ByteArrayOutputStream(FLUSH_AFTER); + } + + @Override + public void write(int b) { + Preconditions.checkState(!closed, "stream already closed"); + bout.write(b); + if (bout.size() > FLUSH_AFTER) { + PacketDistributor.PLAYER.with(player) + .send(new ExportedGridContent(baseSerialNumber, ExportedGridContent.Type.CHUNK, + bout.toByteArray())); + bout.reset(); + } + } + + @Override + public void write(@NotNull byte[] b, int off, int len) { + Preconditions.checkState(!closed, "stream already closed"); + bout.write(b, off, len); + if (bout.size() > FLUSH_AFTER) { + PacketDistributor.PLAYER.with(player) + .send(new ExportedGridContent(baseSerialNumber, ExportedGridContent.Type.CHUNK, + bout.toByteArray())); + bout.reset(); + } + } + + @Override + public void close() { + if (!closed) { + closed = true; + PacketDistributor.PLAYER.with(player) + .send(new ExportedGridContent(baseSerialNumber, ExportedGridContent.Type.LAST_CHUNK, + bout.toByteArray())); + bout.reset(); + } + } + } +} diff --git a/src/main/java/appeng/server/testplots/SubnetPlots.java b/src/main/java/appeng/server/testplots/SubnetPlots.java index d49146a2d4b..13128cfe883 100644 --- a/src/main/java/appeng/server/testplots/SubnetPlots.java +++ b/src/main/java/appeng/server/testplots/SubnetPlots.java @@ -6,8 +6,6 @@ import appeng.api.config.Actionable; import appeng.api.config.PowerMultiplier; -import appeng.api.networking.energy.IEnergyService; -import appeng.api.networking.storage.IStorageService; import appeng.api.stacks.AEItemKey; import appeng.core.definitions.AEBlocks; import appeng.core.definitions.AEParts; @@ -48,7 +46,7 @@ public static void subnet(PlotBuilder plot) { .thenWaitUntil(() -> helper.getGrid(mainNetPos)) .thenExecute(() -> { var mainGrid = helper.getGrid(mainNetPos); - var storageService = mainGrid.getService(IStorageService.class); + var storageService = mainGrid.getStorageService(); var inserted = storageService.getInventory().insert( STICK, 1, @@ -64,7 +62,7 @@ public static void subnet(PlotBuilder plot) { .thenExecute(() -> { // Check again if it's retrievable var mainGrid = helper.getGrid(mainNetPos); - var storageService = mainGrid.getService(IStorageService.class); + var storageService = mainGrid.getStorageService(); var inventory = storageService.getInventory().getAvailableStacks(); helper.check(inventory.get(STICK) == 1, "stick not present in tick #10", mainNetPos); @@ -98,9 +96,9 @@ public static void energy_overlay(PlotBuilder plot) { var cellGrid = helper.getGrid(origin.west()); var noCellGrid = helper.getGrid(origin.west().west()); - var denseCellService = (EnergyService) denseCellGrid.getService(IEnergyService.class); - var cellService = (EnergyService) cellGrid.getService(IEnergyService.class); - var noCellService = (EnergyService) noCellGrid.getService(IEnergyService.class); + var denseCellService = (EnergyService) denseCellGrid.getEnergyService(); + var cellService = (EnergyService) cellGrid.getEnergyService(); + var noCellService = (EnergyService) noCellGrid.getEnergyService(); // Inject power into each of the three grids. It should always end up in the dense // cell grid, due to prioritizing grids with high storage. diff --git a/src/main/java/appeng/util/IDebugExportable.java b/src/main/java/appeng/util/IDebugExportable.java new file mode 100644 index 00000000000..c2fc8f18a64 --- /dev/null +++ b/src/main/java/appeng/util/IDebugExportable.java @@ -0,0 +1,17 @@ +package appeng.util; + +import java.io.IOException; + +import com.google.gson.stream.JsonWriter; + +import it.unimi.dsi.fastutil.objects.Reference2IntMap; + +import appeng.api.networking.IGridNode; + +/** + * Interface for objects that allow themselves to be exported to a debug export. + */ +public interface IDebugExportable { + void debugExport(JsonWriter writer, Reference2IntMap machineIds, Reference2IntMap nodeIds) + throws IOException; +} diff --git a/src/main/java/appeng/util/JsonStreamUtil.java b/src/main/java/appeng/util/JsonStreamUtil.java new file mode 100644 index 00000000000..cd7bf2b771c --- /dev/null +++ b/src/main/java/appeng/util/JsonStreamUtil.java @@ -0,0 +1,51 @@ +package appeng.util; + +import java.io.IOException; +import java.util.Map; + +import com.google.common.math.StatsAccumulator; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.stream.JsonWriter; + +import net.minecraft.world.level.ChunkPos; + +public final class JsonStreamUtil { + private static final Gson GSON = new GsonBuilder() + .serializeSpecialFloatingPointValues() + .create(); + + private JsonStreamUtil() { + } + + /** + * Writes the entries of the given map as object properties. Assumes an object is currently open on the writer. + */ + public static void writeProperties(Map properties, JsonWriter writer) throws IOException { + for (var entry : properties.entrySet()) { + writer.name(entry.getKey()); + GSON.toJson(entry.getValue(), entry.getValue().getClass(), writer); + } + } + + public static JsonElement toJson(ChunkPos pos) { + var jsonPos = new JsonArray(2); + jsonPos.add(pos.x); + jsonPos.add(pos.z); + return jsonPos; + } + + public static Map toMap(StatsAccumulator stats) { + if (stats.count() == 0) { + return Map.of("count", 0); + } + + return Map.of( + "count", stats.count(), + "min", stats.min(), + "max", stats.max(), + "mean", stats.mean()); + } +} diff --git a/src/main/resources/assets/ae2/screens/network_status.json b/src/main/resources/assets/ae2/screens/network_status.json index 85b52b65644..de1c73cf34b 100644 --- a/src/main/resources/assets/ae2/screens/network_status.json +++ b/src/main/resources/assets/ae2/screens/network_status.json @@ -44,6 +44,12 @@ } }, "widgets": { + "export_grid": { + "bottom": 0, + "left": 0, + "width": 100, + "height": 20 + }, "scrollbar": { "left": 175, "top": 39,