All Downloads are FREE. Search and download functionalities are using the official Maven repository.

net.minestom.server.instance.anvil.AnvilLoader Maven / Gradle / Ivy

There is a newer version: 7320437640
Show newest version
package net.minestom.server.instance.anvil;

import it.unimi.dsi.fastutil.ints.*;
import net.kyori.adventure.nbt.*;
import net.minestom.server.MinecraftServer;
import net.minestom.server.coordinate.CoordConversion;
import net.minestom.server.instance.Chunk;
import net.minestom.server.instance.IChunkLoader;
import net.minestom.server.instance.Instance;
import net.minestom.server.instance.Section;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.block.BlockHandler;
import net.minestom.server.instance.palette.Palettes;
import net.minestom.server.registry.DynamicRegistry;
import net.minestom.server.utils.MathUtils;
import net.minestom.server.utils.NamespaceID;
import net.minestom.server.utils.async.AsyncUtils;
import net.minestom.server.utils.validate.Check;
import net.minestom.server.world.biome.Biome;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;

public class AnvilLoader implements IChunkLoader {
    private final static Logger LOGGER = LoggerFactory.getLogger(AnvilLoader.class);
    private static final DynamicRegistry BIOME_REGISTRY = MinecraftServer.getBiomeRegistry();
    private final static int PLAINS_ID = BIOME_REGISTRY.getId(NamespaceID.from("minecraft:plains"));

    private final ReentrantLock fileCreationLock = new ReentrantLock();
    private final Map alreadyLoaded = new ConcurrentHashMap<>();
    private final Path path;
    private final Path levelPath;
    private final Path regionPath;

    private static class RegionCache extends ConcurrentHashMap> {
    }

    /**
     * Represents the chunks currently loaded per region. Used to determine when a region file can be unloaded.
     */
    private final RegionCache perRegionLoadedChunks = new RegionCache();
    private final ReentrantLock perRegionLoadedChunksLock = new ReentrantLock();

    // thread local to avoid contention issues with locks
    private final ThreadLocal> blockStateId2ObjectCacheTLS = ThreadLocal.withInitial(Int2ObjectArrayMap::new);

    public AnvilLoader(@NotNull Path path) {
        this.path = path;
        this.levelPath = path.resolve("level.dat");
        this.regionPath = path.resolve("region");
    }

    public AnvilLoader(@NotNull String path) {
        this(Path.of(path));
    }

    @Override
    public void loadInstance(@NotNull Instance instance) {
        if (!Files.exists(levelPath)) {
            return;
        }
        try (InputStream is = Files.newInputStream(levelPath)) {
            final CompoundBinaryTag tag = BinaryTagIO.reader().readNamed(is, BinaryTagIO.Compression.GZIP).getValue();
            Files.copy(levelPath, path.resolve("level.dat_old"), StandardCopyOption.REPLACE_EXISTING);
            instance.tagHandler().updateContent(tag);
        } catch (IOException e) {
            MinecraftServer.getExceptionManager().handleException(e);
        }
    }

    @Override
    public @NotNull CompletableFuture<@Nullable Chunk> loadChunk(@NotNull Instance instance, int chunkX, int chunkZ) {
        if (!Files.exists(path)) {
            // No world folder
            return CompletableFuture.completedFuture(null);
        }
        try {
            return loadMCA(instance, chunkX, chunkZ);
        } catch (Exception e) {
            MinecraftServer.getExceptionManager().handleException(e);
            return CompletableFuture.completedFuture(null);
        }
    }

    private @NotNull CompletableFuture<@Nullable Chunk> loadMCA(Instance instance, int chunkX, int chunkZ) throws IOException {
        final RegionFile mcaFile = getMCAFile(chunkX, chunkZ);
        if (mcaFile == null)
            return CompletableFuture.completedFuture(null);
        final CompoundBinaryTag chunkData = mcaFile.readChunkData(chunkX, chunkZ);
        if (chunkData == null)
            return CompletableFuture.completedFuture(null);

        // Load the chunk data (assuming it is fully generated)
        final Chunk chunk = instance.getChunkSupplier().createChunk(instance, chunkX, chunkZ);
        synchronized (chunk) { // todo: boo, synchronized
            final String status = chunkData.getString("status");

            // TODO: Should we handle other statuses?
            if (status.isEmpty() || "minecraft:full".equals(status)) {
                // TODO: Parallelize block, block entities and biome loading
                // Blocks + Biomes
                loadSections(chunk, chunkData);

                // Block entities
                loadBlockEntities(chunk, chunkData);

                chunk.loadHeightmapsFromNBT(chunkData.getCompound("Heightmaps"));
            } else {
                LOGGER.warn("Skipping partially generated chunk at {}, {} with status {}", chunkX, chunkZ, status);
            }
        }

        // Cache the index of the loaded chunk
        perRegionLoadedChunksLock.lock();
        try {
            int regionX = CoordConversion.chunkToRegion(chunkX);
            int regionZ = CoordConversion.chunkToRegion(chunkZ);
            var chunks = perRegionLoadedChunks.computeIfAbsent(new IntIntImmutablePair(regionX, regionZ), r -> new HashSet<>()); // region cache may have been removed on another thread due to unloadChunk
            chunks.add(new IntIntImmutablePair(chunkX, chunkZ));
        } finally {
            perRegionLoadedChunksLock.unlock();
        }
        return CompletableFuture.completedFuture(chunk);
    }

    private @Nullable RegionFile getMCAFile(int chunkX, int chunkZ) {
        final int regionX = CoordConversion.chunkToRegion(chunkX);
        final int regionZ = CoordConversion.chunkToRegion(chunkZ);
        return alreadyLoaded.computeIfAbsent(RegionFile.getFileName(regionX, regionZ), n -> {
            final Path regionPath = this.regionPath.resolve(n);
            if (!Files.exists(regionPath)) {
                return null;
            }
            perRegionLoadedChunksLock.lock();
            try {
                Set previousVersion = perRegionLoadedChunks.put(new IntIntImmutablePair(regionX, regionZ), new HashSet<>());
                assert previousVersion == null : "The AnvilLoader cache should not already have data for this region.";
                return new RegionFile(regionPath);
            } catch (IOException e) {
                MinecraftServer.getExceptionManager().handleException(e);
                return null;
            } finally {
                perRegionLoadedChunksLock.unlock();
            }
        });
    }

    private void loadSections(@NotNull Chunk chunk, @NotNull CompoundBinaryTag chunkData) {
        for (BinaryTag sectionTag : chunkData.getList("sections", BinaryTagTypes.COMPOUND)) {
            final CompoundBinaryTag sectionData = (CompoundBinaryTag) sectionTag;

            final int sectionY = sectionData.getInt("Y", Integer.MIN_VALUE);
            Check.stateCondition(sectionY == Integer.MIN_VALUE, "Missing section Y value");
            final int yOffset = Chunk.CHUNK_SECTION_SIZE * sectionY;

            if (sectionY < chunk.getMinSection() || sectionY >= chunk.getMaxSection()) {
                // Vanilla stores a section below and above the world for lighting, throw it out.
                continue;
            }

            final Section section = chunk.getSection(sectionY);

            // Lighting
            if (sectionData.get("SkyLight") instanceof ByteArrayBinaryTag skyLightTag && skyLightTag.size() == 2048) {
                section.setSkyLight(skyLightTag.value());
            }
            if (sectionData.get("BlockLight") instanceof ByteArrayBinaryTag blockLightTag && blockLightTag.size() == 2048) {
                section.setBlockLight(blockLightTag.value());
            }

            {   // Biomes
                final CompoundBinaryTag biomesTag = sectionData.getCompound("biomes");
                final ListBinaryTag biomePaletteTag = biomesTag.getList("palette", BinaryTagTypes.STRING);
                int[] convertedBiomePalette = loadBiomePalette(biomePaletteTag);

                if (convertedBiomePalette.length == 1) {
                    // One solid block, no need to check the data
                    section.biomePalette().fill(convertedBiomePalette[0]);
                } else if (convertedBiomePalette.length > 1) {
                    final long[] packedIndices = biomesTag.getLongArray("data");
                    Check.stateCondition(packedIndices.length == 0, "Missing packed biomes data");
                    int[] biomeIndices = new int[64];

                    int bitsPerEntry = packedIndices.length * 64 / biomeIndices.length;
                    if (bitsPerEntry > 3) bitsPerEntry = MathUtils.bitsToRepresent(convertedBiomePalette.length);
                    Palettes.unpack(biomeIndices, packedIndices, bitsPerEntry);

                    section.biomePalette().setAll((x, y, z) -> {
                        final int index = x + z * 4 + y * 16;
                        return convertedBiomePalette[biomeIndices[index]];
                    });
                }
            }

            {   // Blocks
                final CompoundBinaryTag blockStatesTag = sectionData.getCompound("block_states");
                final ListBinaryTag blockPaletteTag = blockStatesTag.getList("palette", BinaryTagTypes.COMPOUND);
                Block[] convertedPalette = loadBlockPalette(blockPaletteTag);
                if (blockPaletteTag.size() == 1) {
                    // One solid block, no need to check the data
                    section.blockPalette().fill(convertedPalette[0].stateId());
                } else if (blockPaletteTag.size() > 1) {
                    final long[] packedStates = blockStatesTag.getLongArray("data");
                    Check.stateCondition(packedStates.length == 0, "Missing packed states data");
                    int[] blockStateIndices = new int[Chunk.CHUNK_SECTION_SIZE * Chunk.CHUNK_SECTION_SIZE * Chunk.CHUNK_SECTION_SIZE];
                    Palettes.unpack(blockStateIndices, packedStates, packedStates.length * 64 / blockStateIndices.length);

                    for (int y = 0; y < Chunk.CHUNK_SECTION_SIZE; y++) {
                        for (int z = 0; z < Chunk.CHUNK_SECTION_SIZE; z++) {
                            for (int x = 0; x < Chunk.CHUNK_SECTION_SIZE; x++) {
                                try {
                                    final int blockIndex = y * Chunk.CHUNK_SECTION_SIZE * Chunk.CHUNK_SECTION_SIZE + z * Chunk.CHUNK_SECTION_SIZE + x;
                                    final int paletteIndex = blockStateIndices[blockIndex];
                                    final Block block = convertedPalette[paletteIndex];

                                    chunk.setBlock(x, y + yOffset, z, block);
                                } catch (Exception e) {
                                    MinecraftServer.getExceptionManager().handleException(e);
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    private Block[] loadBlockPalette(@NotNull ListBinaryTag paletteTag) {
        Block[] convertedPalette = new Block[paletteTag.size()];
        for (int i = 0; i < convertedPalette.length; i++) {
            CompoundBinaryTag paletteEntry = paletteTag.getCompound(i);
            String blockName = paletteEntry.getString("Name");
            if (blockName.equals("minecraft:air")) {
                convertedPalette[i] = Block.AIR;
            } else {
                Block block = Objects.requireNonNull(Block.fromNamespaceId(blockName), "Unknown block " + blockName);
                // Properties
                final Map properties = new HashMap<>();
                CompoundBinaryTag propertiesNBT = paletteEntry.getCompound("Properties");
                for (var property : propertiesNBT) {
                    if (property.getValue() instanceof StringBinaryTag propertyValue) {
                        properties.put(property.getKey(), propertyValue.value());
                    } else {
                        LOGGER.warn("Fail to parse block state properties {}, expected a string for {}, but contents were {}",
                                propertiesNBT, property.getKey(), TagStringIOExt.writeTag(property.getValue()));
                    }
                }
                if (!properties.isEmpty()) block = block.withProperties(properties);

                // Handler
                final BlockHandler handler = MinecraftServer.getBlockManager().getHandler(block.name());
                if (handler != null) block = block.withHandler(handler);

                convertedPalette[i] = block;
            }
        }
        return convertedPalette;
    }

    private int[] loadBiomePalette(@NotNull ListBinaryTag paletteTag) {
        int[] convertedPalette = new int[paletteTag.size()];
        for (int i = 0; i < convertedPalette.length; i++) {
            final String name = paletteTag.getString(i);
            int biomeId = BIOME_REGISTRY.getId(NamespaceID.from(name));
            if (biomeId == -1) biomeId = PLAINS_ID;
            convertedPalette[i] = biomeId;
        }
        return convertedPalette;
    }

    private void loadBlockEntities(@NotNull Chunk loadedChunk, @NotNull CompoundBinaryTag chunkData) {
        for (BinaryTag blockEntityTag : chunkData.getList("block_entities", BinaryTagTypes.COMPOUND)) {
            final CompoundBinaryTag blockEntity = (CompoundBinaryTag) blockEntityTag;

            final int x = blockEntity.getInt("x");
            final int y = blockEntity.getInt("y");
            final int z = blockEntity.getInt("z");
            Block block = loadedChunk.getBlock(x, y, z);

            // Load the block handler if the id is present
            if (blockEntity.get("id") instanceof StringBinaryTag blockEntityId) {
                final BlockHandler handler = MinecraftServer.getBlockManager().getHandlerOrDummy(blockEntityId.value());
                block = block.withHandler(handler);
            }

            // Remove anvil tags
            CompoundBinaryTag trimmedTag = CompoundBinaryTag.builder().put(blockEntity)
                    .remove("id").remove("keepPacked")
                    .remove("x").remove("y").remove("z")
                    .build();

            // Place block
            final var finalBlock = trimmedTag.size() > 0 ? block.withNbt(trimmedTag) : block;
            loadedChunk.setBlock(x, y, z, finalBlock);
        }
    }

    @Override
    public @NotNull CompletableFuture saveInstance(@NotNull Instance instance) {
        final CompoundBinaryTag nbt = instance.tagHandler().asCompound();
        if (nbt.size() == 0) {
            // Instance has no data
            return AsyncUtils.VOID_FUTURE;
        }
        try (OutputStream os = Files.newOutputStream(levelPath, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
            BinaryTagIO.writer().writeNamed(Map.entry("", nbt), os, BinaryTagIO.Compression.GZIP);
        } catch (IOException e) {
            MinecraftServer.getExceptionManager().handleException(e);
        }
        return AsyncUtils.VOID_FUTURE;
    }

    @Override
    public @NotNull CompletableFuture saveChunk(@NotNull Chunk chunk) {
        final int chunkX = chunk.getChunkX();
        final int chunkZ = chunk.getChunkZ();

        // Find the region file or create an empty one if missing
        RegionFile mcaFile;
        fileCreationLock.lock();
        try {
            mcaFile = getMCAFile(chunkX, chunkZ);
            if (mcaFile == null) {
                final int regionX = CoordConversion.chunkToRegion(chunkX);
                final int regionZ = CoordConversion.chunkToRegion(chunkZ);
                final String regionFileName = RegionFile.getFileName(regionX, regionZ);
                try {
                    Path regionFile = regionPath.resolve(regionFileName);
                    if (!Files.exists(regionFile)) {
                        Files.createDirectories(regionFile.getParent());
                        Files.createFile(regionFile);
                    }

                    mcaFile = new RegionFile(regionFile);
                    alreadyLoaded.put(regionFileName, mcaFile);
                } catch (IOException e) {
                    LOGGER.error("Failed to create region file for " + chunkX + ", " + chunkZ, e);
                    MinecraftServer.getExceptionManager().handleException(e);
                    return AsyncUtils.VOID_FUTURE;
                }
            }
        } finally {
            fileCreationLock.unlock();
        }

        try {
            final CompoundBinaryTag.Builder chunkData = CompoundBinaryTag.builder();

            chunkData.putInt("DataVersion", MinecraftServer.DATA_VERSION);
            chunkData.putInt("xPos", chunkX);
            chunkData.putInt("zPos", chunkZ);
            chunkData.putInt("yPos", chunk.getMinSection());
            chunkData.putString("status", "minecraft:full");
            chunkData.putLong("LastUpdate", chunk.getInstance().getWorldAge());

            saveSectionData(chunk, chunkData);

            mcaFile.writeChunkData(chunkX, chunkZ, chunkData.build());
        } catch (IOException e) {
            LOGGER.error("Failed to save chunk " + chunkX + ", " + chunkZ, e);
            MinecraftServer.getExceptionManager().handleException(e);
        }
        return AsyncUtils.VOID_FUTURE;
    }

    private void saveSectionData(@NotNull Chunk chunk, @NotNull CompoundBinaryTag.Builder chunkData) {
        final ListBinaryTag.Builder sections = ListBinaryTag.builder(BinaryTagTypes.COMPOUND);
        final ListBinaryTag.Builder blockEntities = ListBinaryTag.builder(BinaryTagTypes.COMPOUND);

        // Block & Biome arrays reused for each chunk
        List biomePalette = new ArrayList<>();
        int[] biomeIndices = new int[64];

        List blockPaletteEntries = new ArrayList<>();
        IntList blockPaletteIndices = new IntArrayList(); // Map block indices by state id to avoid doing a deep comparison on every block tag
        int[] blockIndices = new int[Chunk.CHUNK_SECTION_SIZE * Chunk.CHUNK_SECTION_SIZE * Chunk.CHUNK_SECTION_SIZE];

        synchronized (chunk) {
            for (int sectionY = chunk.getMinSection(); sectionY < chunk.getMaxSection(); sectionY++) {
                final Section section = chunk.getSection(sectionY);

                final CompoundBinaryTag.Builder sectionData = CompoundBinaryTag.builder();
                sectionData.putByte("Y", (byte) sectionY);

                // Lighting
                byte[] skyLight = section.skyLight().array();
                if (skyLight != null && skyLight.length > 0)
                    sectionData.putByteArray("SkyLight", skyLight);
                byte[] blockLight = section.blockLight().array();
                if (blockLight != null && blockLight.length > 0)
                    sectionData.putByteArray("BlockLight", blockLight);

                // Build block, biome palettes & collect block entities
                for (int sectionLocalY = 0; sectionLocalY < Chunk.CHUNK_SECTION_SIZE; sectionLocalY++) {
                    for (int z = 0; z < Chunk.CHUNK_SIZE_Z; z++) {
                        for (int x = 0; x < Chunk.CHUNK_SIZE_X; x++) {
                            final int y = sectionLocalY + (sectionY * Chunk.CHUNK_SECTION_SIZE);

                            final int blockIndex = x + sectionLocalY * 16 * 16 + z * 16;
                            final Block block = chunk.getBlock(x, y, z);

                            // Add block state
                            final int blockStateId = block.stateId();
                            final CompoundBinaryTag blockState = getBlockState(block);
                            int blockPaletteIndex = blockPaletteIndices.indexOf(blockStateId);
                            if (blockPaletteIndex == -1) {
                                blockPaletteIndex = blockPaletteEntries.size();
                                blockPaletteEntries.add(blockState);
                                blockPaletteIndices.add(blockStateId);
                            }
                            blockIndices[blockIndex] = blockPaletteIndex;

                            // Add biome (biome are stored for 4x4x4 volumes, avoid unnecessary work)
                            if (x % 4 == 0 && sectionLocalY % 4 == 0 && z % 4 == 0) {
                                int biomeIndex = (x / 4) + (sectionLocalY / 4) * 4 * 4 + (z / 4) * 4;
                                final DynamicRegistry.Key biomeKey = chunk.getBiome(x, y, z);
                                final BinaryTag biomeName = StringBinaryTag.stringBinaryTag(biomeKey.name());

                                int biomePaletteIndex = biomePalette.indexOf(biomeName);
                                if (biomePaletteIndex == -1) {
                                    biomePaletteIndex = biomePalette.size();
                                    biomePalette.add(biomeName);
                                }

                                biomeIndices[biomeIndex] = biomePaletteIndex;
                            }

                            // Add block entity if present
                            final BlockHandler handler = block.handler();
                            final CompoundBinaryTag originalNBT = block.nbt();
                            if (originalNBT != null || handler != null) {
                                CompoundBinaryTag.Builder blockEntityTag = CompoundBinaryTag.builder();
                                if (originalNBT != null) {
                                    blockEntityTag.put(originalNBT);
                                }
                                if (handler != null) {
                                    blockEntityTag.putString("id", handler.getNamespaceId().asString());
                                }
                                blockEntityTag.putInt("x", x + Chunk.CHUNK_SIZE_X * chunk.getChunkX());
                                blockEntityTag.putInt("y", y);
                                blockEntityTag.putInt("z", z + Chunk.CHUNK_SIZE_Z * chunk.getChunkZ());
                                blockEntityTag.putByte("keepPacked", (byte) 0);
                                blockEntities.add(blockEntityTag.build());
                            }
                        }
                    }
                }

                // Save the block and biome palettes
                final CompoundBinaryTag.Builder blockStates = CompoundBinaryTag.builder();
                blockStates.put("palette", ListBinaryTag.listBinaryTag(BinaryTagTypes.COMPOUND, blockPaletteEntries));
                if (blockPaletteEntries.size() > 1) {
                    // If there is only one entry we do not need to write the packed indices
                    var bitsPerEntry = (int) Math.max(1, Math.ceil(Math.log(blockPaletteEntries.size()) / Math.log(2)));
                    blockStates.putLongArray("data", Palettes.pack(blockIndices, bitsPerEntry));
                }
                sectionData.put("block_states", blockStates.build());

                final CompoundBinaryTag.Builder biomes = CompoundBinaryTag.builder();
                biomes.put("palette", ListBinaryTag.listBinaryTag(BinaryTagTypes.STRING, biomePalette));
                if (biomePalette.size() > 1) {
                    // If there is only one entry we do not need to write the packed indices
                    var bitsPerEntry = (int) Math.max(1, Math.ceil(Math.log(biomePalette.size()) / Math.log(2)));
                    biomes.putLongArray("data", Palettes.pack(biomeIndices, bitsPerEntry));
                }
                sectionData.put("biomes", biomes.build());

                biomePalette.clear();
                blockPaletteEntries.clear();
                blockPaletteIndices.clear();

                sections.add(sectionData.build());
            }
        }

        chunkData.put("sections", sections.build());
        chunkData.put("block_entities", blockEntities.build());
    }

    private CompoundBinaryTag getBlockState(final Block block) {
        return blockStateId2ObjectCacheTLS.get().computeIfAbsent(block.stateId(), _unused -> {
            final CompoundBinaryTag.Builder tag = CompoundBinaryTag.builder();
            tag.putString("Name", block.name());

            if (!block.properties().isEmpty()) {
                final Map defaultProperties = Block.fromBlockId(block.id()).properties(); // Never null
                final CompoundBinaryTag.Builder propertiesTag = CompoundBinaryTag.builder();
                for (var entry : block.properties().entrySet()) {
                    String key = entry.getKey(), value = entry.getValue();
                    if (defaultProperties.get(key).equals(value))
                        continue; // Skip default values

                    propertiesTag.putString(key, value);
                }
                var properties = propertiesTag.build();
                if (properties.size() > 0) {
                    tag.put("Properties", properties);
                }
            }
            return tag.build();
        });
    }

    /**
     * Unload a given chunk. Also unloads a region when no chunk from that region is loaded.
     *
     * @param chunk the chunk to unload
     */
    @Override
    public void unloadChunk(Chunk chunk) {
        final int regionX = CoordConversion.chunkToRegion(chunk.getChunkX());
        final int regionZ = CoordConversion.chunkToRegion(chunk.getChunkZ());
        final IntIntImmutablePair regionKey = new IntIntImmutablePair(regionX, regionZ);

        perRegionLoadedChunksLock.lock();
        try {
            Set chunks = perRegionLoadedChunks.get(regionKey);
            if (chunks != null) { // if null, trying to unload a chunk from a region that was not created by the AnvilLoader
                // don't check return value, trying to unload a chunk not created by the AnvilLoader is valid
                chunks.remove(new IntIntImmutablePair(chunk.getChunkX(), chunk.getChunkZ()));

                if (chunks.isEmpty()) {
                    perRegionLoadedChunks.remove(regionKey);
                    RegionFile regionFile = alreadyLoaded.remove(RegionFile.getFileName(regionX, regionZ));
                    if (regionFile != null) {
                        try {
                            regionFile.close();
                        } catch (IOException e) {
                            MinecraftServer.getExceptionManager().handleException(e);
                        }
                    }
                }
            }
        } finally {
            perRegionLoadedChunksLock.unlock();
        }
    }

    @Override
    public boolean supportsParallelLoading() {
        return true;
    }

    @Override
    public boolean supportsParallelSaving() {
        return true;
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy