From a8976b2f1d28d5f38d2e85dfc80ac0ef3dbaf74a Mon Sep 17 00:00:00 2001 From: NONPLAYT <76615486+NONPLAYT@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:44:36 +0300 Subject: [PATCH 1/3] update buffered linear --- .../divinemc/async/ExecutorShutdown.java | 11 - .../bxteam/divinemc/config/DivineConfig.java | 9 +- .../region/EnumRegionFileExtension.java | 2 +- .../bxteam/divinemc/region/IRegionFile.java | 4 +- .../flusher/BufferedRegionFileFlusher.java | 135 ++ .../region/type/BufferedRegionFile.java | 1105 ++++++++++------- .../region/type/LinearRegionFile.java | 5 - 7 files changed, 776 insertions(+), 495 deletions(-) create mode 100644 divinemc-server/src/main/java/org/bxteam/divinemc/region/flusher/BufferedRegionFileFlusher.java diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/async/ExecutorShutdown.java b/divinemc-server/src/main/java/org/bxteam/divinemc/async/ExecutorShutdown.java index ef386086..8ed1eb60 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/async/ExecutorShutdown.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/async/ExecutorShutdown.java @@ -5,9 +5,6 @@ import org.apache.logging.log4j.Logger; import org.bxteam.divinemc.async.pathfinding.AsyncPathProcessor; import org.bxteam.divinemc.async.tracking.MultithreadedTracker; -import org.bxteam.divinemc.config.DivineConfig; -import org.bxteam.divinemc.region.EnumRegionFileExtension; -import org.bxteam.divinemc.region.type.BufferedRegionFile; import java.util.concurrent.TimeUnit; @@ -16,14 +13,6 @@ public class ExecutorShutdown { public static final Logger LOGGER = LogManager.getLogger(ExecutorShutdown.class.getSimpleName()); public static void shutdown(MinecraftServer server) { - if (BufferedRegionFile.flusherInitialized && DivineConfig.MiscCategory.regionFileType == EnumRegionFileExtension.B_LINEAR) { - LOGGER.info("Shutting down buffered region executors..."); - - try { - BufferedRegionFile.shutdown(); - } catch (InterruptedException ignored) { } - } - if (server.mobSpawnExecutor != null && server.mobSpawnExecutor.thread.isAlive()) { LOGGER.info("Shutting down mob spawn executor..."); diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java b/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java index 58b2f603..2f646869 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java @@ -11,6 +11,7 @@ import org.bxteam.divinemc.config.annotations.Experimental; import org.bxteam.divinemc.async.pathfinding.PathfindTaskRejectPolicy; import org.bxteam.divinemc.region.EnumRegionFileExtension; +import org.bxteam.divinemc.region.flusher.BufferedRegionFileFlusher; import org.bxteam.divinemc.region.type.LinearRegionFile; import org.jetbrains.annotations.Nullable; import org.simpleyaml.configuration.comments.CommentType; @@ -628,6 +629,7 @@ public static class MiscCategory { public static int linearIoThreadCount = 6; public static int linearIoFlushDelayMs = 100; public static boolean linearUseVirtualThreads = true; + public static BufferedRegionFileFlusher bLinearFlusher = null; // Sentry public static String sentryDsn = ""; @@ -692,7 +694,7 @@ private static void regionFileExtension() { linearUseVirtualThreads = getBoolean(ConfigCategory.MISC.key("region-format.linear-use-virtual-threads"), linearUseVirtualThreads, "Whether to use virtual threads for IO operations that was introduced in Java 21."); - if (linearCompressionLevel > 22 || linearCompressionLevel < 1) { + if (linearCompressionLevel > 23 || linearCompressionLevel < 1) { LOGGER.warn("Invalid linear compression level: {}, resetting to default (1)", linearCompressionLevel); linearCompressionLevel = 1; } @@ -702,6 +704,11 @@ private static void regionFileExtension() { LinearRegionFile.SAVE_THREAD_MAX_COUNT = linearIoThreadCount; LinearRegionFile.USE_VIRTUAL_THREAD = linearUseVirtualThreads; } + + if (regionFileType == EnumRegionFileExtension.B_LINEAR) { + bLinearFlusher = new BufferedRegionFileFlusher(6, 20, 3000); // TODO: Make configurable, sort settings + Runtime.getRuntime().addShutdownHook(new Thread(() -> bLinearFlusher.shutdown())); + } } private static void sentrySettings() { diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/EnumRegionFileExtension.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/EnumRegionFileExtension.java index 17eef9a6..af9959a4 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/region/EnumRegionFileExtension.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/EnumRegionFileExtension.java @@ -9,7 +9,7 @@ public enum EnumRegionFileExtension { MCA("mca", "mca", (info) -> new RegionFile(info.info(), info.filePath(), info.folder(), info.sync())), LINEAR("linear", "linear", (info) -> new LinearRegionFile(info.info(), info.filePath(), info.folder(), info.sync(), DivineConfig.MiscCategory.linearCompressionLevel)), - B_LINEAR("b_linear", "b_linear", (info) -> new BufferedRegionFile(info.filePath(), DivineConfig.MiscCategory.linearCompressionLevel)); + B_LINEAR("b_linear", "b_linear", (info) -> new BufferedRegionFile(info.filePath(), DivineConfig.MiscCategory.linearCompressionLevel, DivineConfig.MiscCategory.bLinearFlusher)); private final String name; private final String argument; diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/IRegionFile.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/IRegionFile.java index 18708b54..9087f169 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/region/IRegionFile.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/IRegionFile.java @@ -37,5 +37,7 @@ public interface IRegionFile extends ChunkSystemRegionFile, AutoCloseable { void setOversized(int x, int z, boolean oversized) throws IOException; - int getRecalculateCount(); + default int getRecalculateCount() { + return 0; + }; } diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/flusher/BufferedRegionFileFlusher.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/flusher/BufferedRegionFileFlusher.java new file mode 100644 index 00000000..c55dd16a --- /dev/null +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/flusher/BufferedRegionFileFlusher.java @@ -0,0 +1,135 @@ +package org.bxteam.divinemc.region.flusher; + +import com.mojang.logging.LogUtils; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import it.unimi.dsi.fastutil.objects.ObjectArraySet; +import org.apache.commons.lang3.Validate; +import org.bxteam.divinemc.region.type.BufferedRegionFile; +import org.bxteam.divinemc.util.NamedAgnosticThreadFactory; +import org.slf4j.Logger; + +import java.io.IOException; +import java.util.List; +import java.util.Set; +import java.util.concurrent.*; + +public class BufferedRegionFileFlusher implements Runnable { + private static final Logger logger = LogUtils.getLogger(); + + private final Set inManagement = new ObjectArraySet<>(); + private final ScheduledFuture flusherChecker; + private final Executor ioWorkerPool; + private final long flushOfWriteTimeoutMs; + + public BufferedRegionFileFlusher(int nIoThreads, long checkIntervalMs, long flushOfWriteTimeoutMs) { + Validate.isTrue(nIoThreads > 0, "Number of I/O threads must > 0!"); + Validate.isTrue(checkIntervalMs > 0, "Check interval must > 0"); + Validate.isTrue(flushOfWriteTimeoutMs > 0, "Flush of write timeout must > 0"); + + this.ioWorkerPool = Executors.newFixedThreadPool(nIoThreads, new NamedAgnosticThreadFactory<>( + "BufferedRegionFile I/O Worker", + (group, runnable, name) -> { + Thread thread = new Thread(group, runnable, name); + thread.setDaemon(true); + return thread; + }, + Thread.NORM_PRIORITY + ) + ); + this.flusherChecker = Executors.newSingleThreadScheduledExecutor(new NamedAgnosticThreadFactory<>( + "BufferedRegionFile Flusher Checker", + (group, runnable, name) -> { + Thread thread = new Thread(group, runnable, name); + thread.setDaemon(true); + return thread; + }, + Thread.NORM_PRIORITY + ) + ).scheduleWithFixedDelay(this, checkIntervalMs, checkIntervalMs, TimeUnit.MILLISECONDS); + this.flushOfWriteTimeoutMs = flushOfWriteTimeoutMs; + } + + public void shutdown() { + this.flusherChecker.cancel(false); + + ((ExecutorService) this.ioWorkerPool).shutdown(); + for (; ; ) { + try { + if (((ExecutorService) this.ioWorkerPool).awaitTermination(100, TimeUnit.MILLISECONDS)) { + break; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + @Override + public void run() { + final long currentNanos = System.nanoTime(); + final BufferedRegionFile[] copied; + + synchronized (this) { + copied = this.inManagement.toArray(new BufferedRegionFile[0]); + } + + final List toRemove = new ObjectArrayList<>(); + for (BufferedRegionFile file : copied) { + if (!file.softReadLock()) { + continue; + } + + boolean closed; + + try { + closed = file.isClosedRaw(); + } finally { + file.releaseReadLock(); + } + + if (closed) { + toRemove.add(file); + continue; + } + + if (!file.shouldSync()) { + continue; + } + + final long lastWriteNanos = file.getLastWritten(); + final long timeElapsed = (currentNanos - lastWriteNanos) / 1_000_000; // Convert to milliseconds + + if (timeElapsed >= this.flushOfWriteTimeoutMs) { + if (!file.markAsBeingSynced()) { + continue; + } + + this.ioWorkerPool.execute(() -> { + try { + file.syncIfNeeded(); + } catch (IOException e) { + logger.error("Failed to sync master file: ", e); + } + }); + } + } + + synchronized (this) { + for (BufferedRegionFile file : toRemove) { + this.inManagement.remove(file); + } + } + } + + public void removeFile(BufferedRegionFile fileToRemove) { + synchronized (this) { + this.inManagement.remove(fileToRemove); + } + } + + public void addFile(BufferedRegionFile fileToAdd) { + synchronized (this) { + this.inManagement.add(fileToAdd); + } + } +} diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/type/BufferedRegionFile.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/type/BufferedRegionFile.java index 0e1cb86b..8650f6f8 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/region/type/BufferedRegionFile.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/type/BufferedRegionFile.java @@ -2,18 +2,21 @@ import ca.spottedleaf.concurrentutil.util.ConcurrentUtil; import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO; +import com.github.luben.zstd.ZstdInputStream; +import com.github.luben.zstd.ZstdOutputStream; +import net.jpountz.lz4.LZ4Compressor; +import net.jpountz.lz4.LZ4Factory; +import net.jpountz.lz4.LZ4FastDecompressor; import net.jpountz.xxhash.XXHash32; import net.jpountz.xxhash.XXHashFactory; import net.minecraft.nbt.CompoundTag; import net.minecraft.world.level.ChunkPos; -import org.bxteam.divinemc.region.EnumRegionFileExtension; +import org.apache.commons.lang3.Validate; import org.bxteam.divinemc.region.IRegionFile; -import org.bxteam.divinemc.config.DivineConfig; -import org.bxteam.divinemc.util.NamedAgnosticThreadFactory; +import org.bxteam.divinemc.region.flusher.BufferedRegionFileFlusher; +import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import it.unimi.dsi.fastutil.objects.ObjectArrayList; -import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet; import java.io.*; import java.lang.invoke.VarHandle; @@ -21,14 +24,10 @@ import java.nio.channels.FileChannel; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.*; -import java.util.Arrays; -import java.util.List; -import java.util.Set; /** * A buffered region file implementation that provides efficient chunk storage and retrieval @@ -39,245 +38,172 @@ */ @SuppressWarnings({"unused", "FieldMayBeFinal"}) public class BufferedRegionFile implements IRegionFile { - private static final double AUTO_COMPACT_PERCENT = 3.0 / 5.0; // 60% - private static final long AUTO_COMPACT_SIZE = 1024 * 1024; // 1 MiB - private static final long SUPER_BLOCK = 0x1145141919810L; - private static final int HASH_SEED = 0x0721; - private static final byte VERSION = 0x01; // Version 1 - - private final Path filePath; - private final ReadWriteLock fileAccessLock = new ReentrantReadWriteLock(); - private final XXHash32 xxHash32 = XXHashFactory.fastestInstance().hash32(); - private final Sector[] sectors = new Sector[1024]; - private final AtomicInteger recalculateCount = new AtomicInteger(0); - private long currentAcquiredIndex = this.headerSize(); - private byte compressionLevel = 6; - private int xxHash32Seed = HASH_SEED; - private FileChannel channel; - private boolean closed = false; + private static final double SWAP_FILE_AUTO_COMPACT_PERCENT = 3.0 / 5.0; // 60 % + private static final long SWAP_FILE_AUTO_COMPACT_SIZE = 1024 * 1024; // 1 MiB - private volatile boolean synced = true; - private volatile boolean beingSynced = false; - private volatile long lastWritten = 0L; + private static final long SWAP_FILE_SUPER_BLOCK = 0x1145141919810L; + private static final int SWAP_FILE_HASH_SEED = 0x0721; + private static final byte SWAP_FILE_VERSION = 0x02; // Version 2 - private static final Set MANAGED_FILES = new ObjectLinkedOpenHashSet<>(); - private static volatile ScheduledFuture flusherChecker; - private static volatile Executor ioWorkerPool; - private static final Object FLUSHER_LOCK = new Object(); - public static volatile boolean flusherInitialized = false; + private static final long MASTER_FILE_SUPER_BLOCK = -0x200812250269L; + private static final byte MASTER_FILE_VERSION = 0x02; // Version 2 - private static final VarHandle SYNCED_HANDLE = ConcurrentUtil.getVarHandle(BufferedRegionFile.class, "synced", boolean.class); - private static final VarHandle BEING_SYNCED_HANDLE = ConcurrentUtil.getVarHandle(BufferedRegionFile.class, "beingSynced", boolean.class); - private static final VarHandle LAST_WRITTEN_HANDLE = ConcurrentUtil.getVarHandle(BufferedRegionFile.class, "lastWritten", long.class); + private static final long LINEAR_FILE_SUPER_BLOCK = 0xc3ff13183cca9d9aL; - public BufferedRegionFile(Path filePath, int compressionLevel) throws IOException { - this(filePath); - this.compressionLevel = (byte) compressionLevel; - } + private static final StandardOpenOption[] SWAP_FILE_CHANNEL_OPTIONS = new StandardOpenOption[]{ + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.READ, + StandardOpenOption.DELETE_ON_CLOSE + }; + private static final StandardOpenOption[] TMP_FILE_CHANNEL_OPTIONS = new StandardOpenOption[]{ + StandardOpenOption.CREATE, + StandardOpenOption.WRITE + }; - public BufferedRegionFile(Path filePath) throws IOException { - this.channel = FileChannel.open( - filePath, - StandardOpenOption.CREATE, - StandardOpenOption.WRITE, - StandardOpenOption.READ - ); - this.filePath = filePath; + private final Path masterFilePath; + private final Path swapFilePath; - for (int i = 0; i < 1024; i++) { - this.sectors[i] = new Sector(i, this.headerSize(), 0); - } + private final ReadWriteLock regionObjectLock = new ReentrantReadWriteLock(); + private final XXHash32 xxHash32 = XXHashFactory.fastestInstance().hash32(); + private Sector[] sectors = new Sector[1024]; + private long currentAcquiredIndex = this.headerSize(); + private int xxHash32Seed = SWAP_FILE_HASH_SEED; + private FileChannel swapFileChannel; - this.readHeaders(); + private final byte compressionLevel; + private final LinearMasterFileParser masterFileParser = new LinearMasterFileParser(); + private final CompressingOps compressingOps = new CompressingOps(); - if (DivineConfig.MiscCategory.regionFileType == EnumRegionFileExtension.B_LINEAR) initializeFlusherIfNeeded(); - addToFlusherManagement(); - } + // managed by VarHandles following + private boolean closed = false; + private boolean beingSynced = false; + private boolean synced = false; + private long lastWritten = System.nanoTime(); - private static void initializeFlusherIfNeeded() { - if (flusherInitialized) return; + private static final VarHandle CLOSED_HANDLE = ConcurrentUtil.getVarHandle(BufferedRegionFile.class, "closed", boolean.class); + private static final VarHandle SYNCED_HANDLE = ConcurrentUtil.getVarHandle(BufferedRegionFile.class, "synced", boolean.class); + private static final VarHandle BEING_SYNCED_HANDLE = ConcurrentUtil.getVarHandle(BufferedRegionFile.class, "beingSynced", boolean.class); + private static final VarHandle LAST_WRITTEN_HANDLE = ConcurrentUtil.getVarHandle(BufferedRegionFile.class, "lastWritten", long.class); - synchronized (FLUSHER_LOCK) { - if (flusherInitialized) { - return; - } + private final BufferedRegionFileFlusher flusher; - final int nIoThreads = DivineConfig.MiscCategory.linearIoThreadCount; - final long checkIntervalMs = 20; - - ioWorkerPool = Executors.newFixedThreadPool(nIoThreads, - new NamedAgnosticThreadFactory<>( - "BufferedRegionFile I/O Worker", - (group, runnable, name) -> { - Thread thread = new Thread(group, runnable, name); - thread.setDaemon(true); - return thread; - }, - Thread.NORM_PRIORITY - ) - ); + public BufferedRegionFile(Path masterFilePath, int compressionLevel, @NotNull BufferedRegionFileFlusher flusher) throws IOException { + this.masterFilePath = masterFilePath; + this.swapFilePath = Path.of(this.masterFilePath.toString() + ".swp"); - ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor( - new NamedAgnosticThreadFactory<>( - "BufferedRegionFile Flusher Checker", - (group, runnable, name) -> { - Thread thread = new Thread(group, runnable, name); - thread.setDaemon(true); - return thread; - }, - Thread.NORM_PRIORITY - ) - ); + Validate.inclusiveBetween(1, 22, compressionLevel); + this.compressionLevel = (byte) compressionLevel; - flusherChecker = scheduler.scheduleWithFixedDelay( - BufferedRegionFile::runFlusherCheck, - checkIntervalMs, - checkIntervalMs, - TimeUnit.MILLISECONDS - ); + this.initSwapFile(); + this.loadSwapDataFromMasterFile(); - flusherInitialized = true; - } + this.flusher = flusher; + this.flusher.addFile(this); } - private static void runFlusherCheck() { - final long currentNanos = System.nanoTime(); - final BufferedRegionFile[] copied; - - synchronized (MANAGED_FILES) { - copied = Arrays.copyOf( - MANAGED_FILES.toArray(new BufferedRegionFile[0]), - MANAGED_FILES.size(), - BufferedRegionFile[].class - ); - } - - final List toRemove = new ObjectArrayList<>(); - for (BufferedRegionFile file : copied) { - if (!file.softReadLock()) { - continue; - } - - boolean closed; + public boolean markAsBeingSynced() { + return BEING_SYNCED_HANDLE.compareAndSet(this, false, true); + } - try { - closed = file.isClosedRaw(); - } finally { - file.releaseReadLock(); - } + public long getLastWritten() { + return (long) LAST_WRITTEN_HANDLE.getVolatile(this); + } - if (closed) { - toRemove.add(file); - continue; - } + public boolean shouldSync() { + return !((boolean) SYNCED_HANDLE.getVolatile(this)); + } - if (!file.shouldSync()) { - continue; - } + public boolean softReadLock() { + // not done close logic yet + return this.regionObjectLock.readLock().tryLock(); + } - final long lastWriteNanos = file.getLastWritten(); - final long timeElapsed = (currentNanos - lastWriteNanos) / 1_000_000; - final long flushTimeoutMs = DivineConfig.MiscCategory.linearIoFlushDelayMs; + public void releaseReadLock() { + this.regionObjectLock.readLock().unlock(); + } - if (timeElapsed >= flushTimeoutMs) { - if (!file.markAsBeingSynced()) { - continue; - } + public boolean isClosedRaw() { + return (boolean) CLOSED_HANDLE.getVolatile(this); + } - ioWorkerPool.execute(() -> { - try { - file.flush(); - file.syncIfNeeded(); - } catch (IOException e) { - throw new RuntimeException(e); - } - }); - } + public boolean isClosed() { + this.regionObjectLock.readLock().lock(); + try { + return (boolean) CLOSED_HANDLE.getVolatile(this); + } finally { + this.regionObjectLock.readLock().unlock(); } + } - synchronized (MANAGED_FILES) { - for (BufferedRegionFile file : toRemove) { - MANAGED_FILES.remove(file); - } + public void syncIfNeeded() throws IOException { + if (!this.regionObjectLock.readLock().tryLock()) { + BEING_SYNCED_HANDLE.setVolatile(this, false); // mark as not being synced + return; } - } - public static void shutdown() throws InterruptedException { - synchronized (FLUSHER_LOCK) { - if (!flusherInitialized) { + try { + // skip if closed already + if (this.isClosedRaw()) { return; } - if (flusherChecker != null) { - flusherChecker.cancel(false); - } - - if (ioWorkerPool instanceof ExecutorService) { - ((ExecutorService) ioWorkerPool).shutdown(); - //noinspection StatementWithEmptyBody - while (!((ExecutorService) ioWorkerPool).awaitTermination(100, TimeUnit.MILLISECONDS)); - } - - flusherInitialized = false; - } - } + this.syncToMasterFile(); + } finally { + BEING_SYNCED_HANDLE.setVolatile(this, false); // mark as not being synced - private void addToFlusherManagement() { - synchronized (MANAGED_FILES) { - MANAGED_FILES.add(this); + this.regionObjectLock.readLock().unlock(); } } - private void removeFromFlusherManagement() { - synchronized (MANAGED_FILES) { - MANAGED_FILES.remove(this); + private void syncToMasterFile() throws IOException { + // prevent multiple syncs in the same time + if (!SYNCED_HANDLE.compareAndSet(this, false, true)) { + return; } - } - - public boolean softReadLock() { - return this.fileAccessLock.readLock().tryLock(); - } - - public void releaseReadLock() { - this.fileAccessLock.readLock().unlock(); - } - public boolean isClosedRaw() { - return this.closed; - } + try { + this.masterFileParser.writeMainFile(this.masterFilePath); + } catch (Exception e) { + // set back + SYNCED_HANDLE.setVolatile(this, false); - public boolean shouldSync() { - return !(boolean) SYNCED_HANDLE.get(this); + throw new IOException("Failed to sync to master file!", e); + } } - public long getLastWritten() { - return (long) LAST_WRITTEN_HANDLE.get(this); + private void loadSwapDataFromMasterFile() throws IOException { + this.masterFileParser.parseMainFile(this.masterFilePath); } - public boolean markAsBeingSynced() { - return BEING_SYNCED_HANDLE.compareAndSet(this, false, true); - } + private void initSwapFile() throws IOException { + this.swapFileChannel = FileChannel.open( + this.swapFilePath, + SWAP_FILE_CHANNEL_OPTIONS + ); - public void syncIfNeeded() throws IOException { - if (this.channel != null && this.channel.isOpen()) { - this.channel.force(true); + // fill default sectors + for (int i = 0; i < 1024; i++) { + this.sectors[i] = new Sector(i, this.headerSize(), 0); } + + // load sectors + this.readSwapFileHeaders(); } - private void readHeaders() throws IOException { - if (this.channel.size() < this.headerSize()) { + private void readSwapFileHeaders() throws IOException { + if (this.swapFileChannel.size() < this.headerSize()) { return; } - final ByteBuffer buffer = ByteBuffer.allocateDirect(this.headerSize()); - this.channel.read(buffer, 0); + final ByteBuffer buffer = ByteBuffer.allocate(this.headerSize()); + this.swapFileChannel.read(buffer, 0); buffer.flip(); - if (buffer.getLong() != SUPER_BLOCK || buffer.get() != VERSION) { + if (buffer.getLong() != SWAP_FILE_SUPER_BLOCK || buffer.get() != SWAP_FILE_VERSION) { throw new IOException("Invalid file format or version mismatch"); } - this.compressionLevel = buffer.get(); // Compression level this.xxHash32Seed = buffer.getInt(); // XXHash32 seed this.currentAcquiredIndex = buffer.getLong(); // Acquired index @@ -289,12 +215,23 @@ private void readHeaders() throws IOException { } } - private void writeHeaders() throws IOException { - final ByteBuffer buffer = ByteBuffer.allocateDirect(this.headerSize()); + private void recalculateAcquiredIndex() { + long newValue = this.headerSize(); - buffer.putLong(SUPER_BLOCK); // Magic - buffer.put(VERSION); // Version - buffer.put(this.compressionLevel); // Compression level + for (Sector sector : this.sectors) { + if (sector.hasData()) { + newValue = Math.max(newValue, sector.offset + sector.length); + } + } + + this.currentAcquiredIndex = newValue; + } + + private void writeSwapFileHeaders(boolean forceFile, boolean forceMeta) throws IOException { + final ByteBuffer buffer = ByteBuffer.allocate(this.headerSize()); + + buffer.putLong(SWAP_FILE_SUPER_BLOCK); // Magic + buffer.put(SWAP_FILE_VERSION); // Version buffer.putInt(this.xxHash32Seed); // XXHash32 seed buffer.putLong(this.currentAcquiredIndex); // Acquired index @@ -306,7 +243,11 @@ private void writeHeaders() throws IOException { long offset = 0; while (buffer.hasRemaining()) { - offset += this.channel.write(buffer, offset); + offset += this.swapFileChannel.write(buffer, offset); + } + + if (forceFile) { + this.swapFileChannel.force(forceMeta); } } @@ -319,7 +260,6 @@ private int headerSize() { result += Long.BYTES; // Magic result += Byte.BYTES; // Version - result += Byte.BYTES; // Compression level result += Integer.BYTES; // XXHash32 seed result += Long.BYTES; // Acquired index result += this.sectorSize(); // Sectors @@ -328,143 +268,244 @@ private int headerSize() { } private void flushInternal() throws IOException { - if (this.closed) { - return; - } + this.regionObjectLock.writeLock().lock(); + try { + if (this.isClosedRaw()) { + return; + } - this.writeHeaders(); + this.writeSwapFileHeaders(true, false); - long spareSize = this.channel.size(); + long spareSize = this.swapFileChannel.size(); - spareSize -= this.headerSize(); - for (Sector sector : this.sectors) { - spareSize -= sector.length; - } + spareSize -= this.headerSize(); + for (Sector sector : this.sectors) { + if (!sector.hasData()) { + continue; + } - long sectorSize = 0; - for (Sector sector : this.sectors) { - sectorSize += sector.length; - } + spareSize -= sector.length; + } + + long sectorSize = 0; + for (Sector sector : this.sectors) { + if (!sector.hasData()) { + continue; + } + + sectorSize += sector.length; + } - if (spareSize > AUTO_COMPACT_SIZE && (double)spareSize > ((double)sectorSize) * AUTO_COMPACT_PERCENT) { - this.compact(); + boolean compacted = false; + if (spareSize > SWAP_FILE_AUTO_COMPACT_SIZE && (double) spareSize > ((double) sectorSize) * SWAP_FILE_AUTO_COMPACT_PERCENT) { + compacted = true; + this.compactSwapFile(); + } + + if (!Files.exists(this.masterFilePath) && !compacted) { + this.syncToMasterFile(); + } + } finally { + this.regionObjectLock.writeLock().unlock(); } } private void closeInternal() throws IOException { - this.closed = true; - this.writeHeaders(); - this.channel.force(true); - this.compact(); - this.channel.close(); + this.regionObjectLock.writeLock().lock(); + try { + this.markClosed(); + + try { + this.writeSwapFileHeaders(true, true); + this.syncToMasterFile(); + } finally { + this.swapFileChannel.close(); + } + } finally { + this.regionObjectLock.writeLock().unlock(); + } + } + + private void markClosed() throws IOException { + if (!CLOSED_HANDLE.compareAndSet(this, false, true)) { + throw new IOException("Already closed!"); + } + + this.flusher.removeFile(this); } - private void compact() throws IOException { - this.writeHeaders(); - this.channel.force(true); + private void compactSwapFile() throws IOException { + this.writeSwapFileHeaders(true, true); // save headers for compact + + final Sector[] newSectorsToBeReplaced = new Sector[this.sectors.length]; + + for (int i = 0; i < this.sectors.length; i++) { + final Sector old = this.sectors[i]; + + if (old.hasData()) { + newSectorsToBeReplaced[i] = old; + continue; + } + + // note: + // we reset length to 0 and this would make length <= newLength(which is >= 0) is always true. + // so that the following write operation wouldn't override the data of other sectors + // see the write method in Sector class + newSectorsToBeReplaced[i] = new Sector(i, 0, 0); + } + + long newAcquiredIndex; + + final Path targetTemp = new File(this.swapFilePath.toString() + ".tmp").toPath(); try (FileChannel tempChannel = FileChannel.open( - new File(this.filePath.toString() + ".tmp").toPath(), - StandardOpenOption.CREATE, + targetTemp, + StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, StandardOpenOption.READ - )){ - final ByteBuffer headerBuffer = ByteBuffer.allocateDirect(this.headerSize()); - this.channel.read(headerBuffer, 0); - headerBuffer.flip(); - - long offsetHeader = 0; - while (headerBuffer.hasRemaining()) { - offsetHeader += tempChannel.write(headerBuffer, offsetHeader); - } - + )) { long offsetPointer = this.headerSize(); tempChannel.position(offsetPointer); - for (Sector sector : this.sectors) { + for (Sector sector : newSectorsToBeReplaced) { if (!sector.hasData()) { continue; } - long transferred = 0; - while (transferred < sector.length) { - transferred += this.channel.transferTo( - sector.offset + transferred, - sector.length - transferred, - tempChannel); - } + sector.transferTo(this.swapFileChannel, tempChannel); final Sector newRecalculated = new Sector(sector.index, offsetPointer, sector.length); newRecalculated.hasData = true; offsetPointer += sector.length; - this.sectors[sector.index] = newRecalculated; + newSectorsToBeReplaced[sector.index] = newRecalculated; // update sector infos } tempChannel.force(true); - this.currentAcquiredIndex = offsetPointer; + + newAcquiredIndex = offsetPointer; + } catch (Exception ex) { + this.recalculateAcquiredIndex(); + Files.deleteIfExists(targetTemp); + this.markClosed(); // prevent new writing & sync operations + throw new IOException("Failed to compact swap file!", ex); } - this.channel.close(); + this.swapFileChannel.close(); - Files.move( - new File(this.filePath.toString() + ".tmp").toPath(), - this.filePath, - java.nio.file.StandardCopyOption.REPLACE_EXISTING - ); + final Path target = new File(this.swapFilePath + ".tmp").toPath(); + try { + Files.move( + target, + this.swapFilePath, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ); + } catch (Exception e) { + try { + Files.move( + target, + this.swapFilePath, + StandardCopyOption.REPLACE_EXISTING + ); + } catch (Exception ex) { + e.addSuppressed(ex); + + Files.deleteIfExists(target); + this.recalculateAcquiredIndex(); + this.reopenSwapFileChannel(); + this.markClosed(); // prevent new writing & sync opeartions + throw new IOException("Failed to replace original swap file!", e); + } + } + + this.reopenSwapFileChannel(); - this.reopenChannel(); - this.writeHeaders(); + this.sectors = newSectorsToBeReplaced; + this.currentAcquiredIndex = newAcquiredIndex; + + this.writeSwapFileHeaders(true, true); } - private void reopenChannel() throws IOException { - if (this.channel.isOpen()) { - this.channel.close(); + private void reopenSwapFileChannel() throws IOException { + if (this.swapFileChannel.isOpen()) { + this.swapFileChannel.close(); } - this.channel = FileChannel.open( - filePath, - StandardOpenOption.CREATE, - StandardOpenOption.WRITE, - StandardOpenOption.READ + this.swapFileChannel = FileChannel.open( + this.swapFilePath, + SWAP_FILE_CHANNEL_OPTIONS ); } - private void writeChunkDataRaw(int chunkOrdinal, ByteBuffer chunkData) throws IOException { - final Sector sector = this.sectors[chunkOrdinal]; + private void writeChunkDataRaw(int chunkOrdinal, ByteBuffer chunkData, boolean skipSync) throws IOException { + final ByteBuffer committed = this.compressingOps.commitSectionData(chunkData); // run compression out of lock + + this.regionObjectLock.writeLock().lock(); + try { + final Sector sector = this.sectors[chunkOrdinal]; + + sector.store(committed, this.swapFileChannel); + } finally { + this.regionObjectLock.writeLock().unlock(); + } - sector.store(chunkData, this.channel); + if (skipSync) { + return; + } - SYNCED_HANDLE.set(this, false); - LAST_WRITTEN_HANDLE.set(this, System.nanoTime()); + this.markAsToSync(); } private @Nullable ByteBuffer readChunkDataRaw(int chunkOrdinal) throws IOException { - final Sector sector = this.sectors[chunkOrdinal]; + final ByteBuffer raw; - if (!sector.hasData()) { - return null; + this.regionObjectLock.readLock().lock(); + try { + final Sector sector = this.sectors[chunkOrdinal]; + + if (!sector.hasData()) { + return null; + } + + raw = sector.read(this.swapFileChannel); + } finally { + this.regionObjectLock.readLock().unlock(); } - return sector.read(this.channel); + return this.compressingOps.fromCommitedSection(raw); } private void clearChunkData(int chunkOrdinal) throws IOException { - final Sector sector = this.sectors[chunkOrdinal]; + this.regionObjectLock.writeLock().lock(); + try { + final Sector sector = this.sectors[chunkOrdinal]; + + sector.clear(); - sector.clear(); + this.writeSwapFileHeaders(true, false); + } finally { + this.regionObjectLock.writeLock().unlock(); + } - this.writeHeaders(); + this.markAsToSync(); + } - SYNCED_HANDLE.set(this, false); - LAST_WRITTEN_HANDLE.set(this, System.nanoTime()); + private void markAsToSync() { + SYNCED_HANDLE.setVolatile(this, false); + LAST_WRITTEN_HANDLE.setVolatile(this, System.nanoTime()); } private static int getChunkIndex(int x, int z) { return (x & 31) + ((z & 31) << 5); } - private boolean hasData(int chunkOriginal) { - return this.sectors[chunkOriginal].hasData(); + private boolean hasData(int chunkOrdinal) { + this.regionObjectLock.readLock().lock(); + try { + return this.sectors[chunkOrdinal].hasData(); + } finally { + this.regionObjectLock.readLock().unlock(); + } } private void writeChunk(int x, int z, @NotNull ByteBuffer data) throws IOException { @@ -474,131 +515,35 @@ private void writeChunk(int x, int z, @NotNull ByteBuffer data) throws IOExcepti final int xxHash32OfData = this.xxHash32.hash(data, this.xxHash32Seed); data.position(oldPositionOfData); - final ByteBuffer compressedData = this.compress(this.ensureDirectBuffer(data)); - final ByteBuffer chunkSectionBuilder = ByteBuffer.allocateDirect(compressedData.remaining() + 4 + 8 + 4); + // uncompressed length(int) + timestamp(long) + xxhash32(int) + final ByteBuffer chunkSectionBuilder = ByteBuffer.allocate(data.remaining() + 4 + 8 + 4); - chunkSectionBuilder.putInt(data.remaining()); // Uncompressed length - chunkSectionBuilder.putLong(System.currentTimeMillis()); // Timestamp - chunkSectionBuilder.putInt(xxHash32OfData); // xxHash32 of the original data - chunkSectionBuilder.put(compressedData); // Compressed data + chunkSectionBuilder.putInt(data.remaining()); // Length(int) + chunkSectionBuilder.putLong(System.currentTimeMillis()); // Timestamp(long) + chunkSectionBuilder.putInt(xxHash32OfData); // xxHash32 of the original data(int) + chunkSectionBuilder.put(data); // Data(bytes) chunkSectionBuilder.flip(); - this.writeChunkDataRaw(chunkIndex, chunkSectionBuilder); + this.writeChunkDataRaw(chunkIndex, chunkSectionBuilder, false); } private @Nullable ByteBuffer readChunk(int x, int z) throws IOException { - final ByteBuffer compressed = this.readChunkDataRaw(getChunkIndex(x, z)); + final ByteBuffer data = this.readChunkDataRaw(getChunkIndex(x, z)); - if (compressed == null) { + if (data == null) { return null; } - final int uncompressedLength = compressed.getInt(); - final long timestamp = compressed.getLong(); // TODO use this timestamp for something? - final int dataXXHash32 = compressed.getInt(); + final int length = data.getInt(); // compressed length(int) + final long timestamp = data.getLong(); // TODO use this timestamp(long) for something? + final int dataXXHash32 = data.getInt(); // XXHash32 for validation(int) - final ByteBuffer decompressed = this.decompress(this.ensureDirectBuffer(compressed), uncompressedLength); - - final IOException xxHash32CheckFailedEx = this.checkXXHash32(dataXXHash32, decompressed); + final IOException xxHash32CheckFailedEx = this.checkXXHash32(dataXXHash32, data); if (xxHash32CheckFailedEx != null) { - throw xxHash32CheckFailedEx; + throw xxHash32CheckFailedEx; // prevent from loading } - return decompressed; - } - - private @NotNull ByteBuffer ensureDirectBuffer(@NotNull ByteBuffer buffer) { - if (buffer.isDirect()) { - return buffer; - } - - ByteBuffer direct = ByteBuffer.allocateDirect(buffer.remaining()); - int originalPosition = buffer.position(); - direct.put(buffer); - direct.flip(); - buffer.position(originalPosition); - - return direct; - } - - private @NotNull ByteBuffer compress(@NotNull ByteBuffer input) throws IOException { - final int originalPosition = input.position(); - final int originalLimit = input.limit(); - - try { - byte[] inputArray; - int inputLength = input.remaining(); - if (input.hasArray()) { - inputArray = input.array(); - int arrayOffset = input.arrayOffset() + input.position(); - if (arrayOffset != 0 || inputLength != inputArray.length) { - byte[] temp = new byte[inputLength]; - System.arraycopy(inputArray, arrayOffset, temp, 0, inputLength); - inputArray = temp; - } - } else { - inputArray = new byte[inputLength]; - input.get(inputArray); - input.position(originalPosition); - } - - byte[] compressed = com.github.luben.zstd.Zstd.compress(inputArray, this.compressionLevel); - - ByteBuffer result = ByteBuffer.allocateDirect(compressed.length); - result.put(compressed); - result.flip(); - - return result; - - } catch (Exception e) { - throw new IOException("Compression failed for input size: " + input.remaining(), e); - } finally { - input.position(originalPosition); - input.limit(originalLimit); - } - } - - private @NotNull ByteBuffer decompress(@NotNull ByteBuffer input, int originalSize) throws IOException { - final int originalPosition = input.position(); - final int originalLimit = input.limit(); - - try { - byte[] inputArray; - int inputLength = input.remaining(); - - if (input.hasArray()) { - inputArray = input.array(); - int arrayOffset = input.arrayOffset() + input.position(); - if (arrayOffset != 0 || inputLength != inputArray.length) { - byte[] temp = new byte[inputLength]; - System.arraycopy(inputArray, arrayOffset, temp, 0, inputLength); - inputArray = temp; - } - } else { - inputArray = new byte[inputLength]; - input.get(inputArray); - input.position(originalPosition); - } - - byte[] decompressed = com.github.luben.zstd.Zstd.decompress(inputArray, originalSize); - - if (decompressed.length != originalSize) { - throw new IOException("Decompression size mismatch: expected " + - originalSize + ", got " + decompressed.length); - } - - ByteBuffer result = ByteBuffer.allocateDirect(originalSize); - result.put(decompressed); - result.flip(); - - return result; - - } catch (Exception e) { - throw new IOException("Decompression failed", e); - } finally { - input.position(originalPosition); - input.limit(originalLimit); - } + return data; } private @Nullable IOException checkXXHash32(long originalXXHash32, @NotNull ByteBuffer input) { @@ -615,36 +560,23 @@ private void writeChunk(int x, int z, @NotNull ByteBuffer data) throws IOExcepti @Override public Path getPath() { - return this.filePath; + return this.masterFilePath; } @Override public DataInputStream getChunkDataInputStream(@NotNull ChunkPos pos) throws IOException { - this.fileAccessLock.readLock().lock(); - try { - final ByteBuffer data = this.readChunk(pos.x, pos.z); - - if (data == null) { - return null; - } + final ByteBuffer data = this.readChunk(pos.x, pos.z); - final byte[] dataBytes = new byte[data.remaining()]; - data.get(dataBytes); - - return new DataInputStream(new ByteArrayInputStream(dataBytes)); - } finally { - this.fileAccessLock.readLock().unlock(); + if (data == null) { + return null; } + + return new DataInputStream(new ByteBufferInputStream(data)); } @Override public boolean doesChunkExist(@NotNull ChunkPos pos) { - this.fileAccessLock.readLock().lock(); - try { - return this.hasData(getChunkIndex(pos.x, pos.z)); - } finally { - this.fileAccessLock.readLock().unlock(); - } + return this.hasData(getChunkIndex(pos.x, pos.z)); } @Override @@ -654,34 +586,20 @@ public DataOutputStream getChunkDataOutputStream(ChunkPos pos) { @Override public void clear(@NotNull ChunkPos pos) throws IOException { - this.fileAccessLock.writeLock().lock(); - try { - this.clearChunkData(getChunkIndex(pos.x, pos.z)); - } finally { - this.fileAccessLock.writeLock().unlock(); - } + this.clearChunkData(getChunkIndex(pos.x, pos.z)); } @Override public boolean hasChunk(@NotNull ChunkPos pos) { - this.fileAccessLock.readLock().lock(); - try { - return this.hasData(getChunkIndex(pos.x, pos.z)); - }finally { - this.fileAccessLock.readLock().unlock(); - } + return this.hasData(getChunkIndex(pos.x, pos.z)); } @Override public void write(@NotNull ChunkPos pos, ByteBuffer buf) throws IOException { - this.fileAccessLock.writeLock().lock(); - try { - this.writeChunk(pos.x, pos.z, buf); - }finally { - this.fileAccessLock.writeLock().unlock(); - } + this.writeChunk(pos.x, pos.z, buf); } + // MCC 的玩意,这东西也用不上给Linear了() @Override public CompoundTag getOversizedData(int x, int z) { return null; @@ -694,7 +612,6 @@ public boolean isOversized(int x, int z) { @Override public boolean recalculateHeader() { - this.recalculateCount.incrementAndGet(); return false; } @@ -702,57 +619,81 @@ public boolean recalculateHeader() { public void setOversized(int x, int z, boolean oversized) { } - - @Override - public int getRecalculateCount() { - return this.recalculateCount.get(); - } + // MCC end @Override public MoonriseRegionFileIO.RegionDataController.WriteData moonrise$startWrite(CompoundTag data, ChunkPos pos) { final DataOutputStream out = this.getChunkDataOutputStream(pos); - return new ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO.RegionDataController.WriteData( - data, ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO.RegionDataController.WriteData.WriteResult.WRITE, + return new MoonriseRegionFileIO.RegionDataController.WriteData( + data, MoonriseRegionFileIO.RegionDataController.WriteData.WriteResult.WRITE, out, regionFile -> out.close() ); } @Override public void flush() throws IOException { - this.fileAccessLock.writeLock().lock(); - try { - if ((boolean) SYNCED_HANDLE.get(this)) { - return; - } + this.flushInternal(); + } - if (!BEING_SYNCED_HANDLE.compareAndSet(this, false, true)) { - return; - } + @Override + public void close() throws IOException { + this.closeInternal(); + } - try { - this.flushInternal(); - SYNCED_HANDLE.set(this, true); - } finally { - BEING_SYNCED_HANDLE.set(this, false); - } - } finally { - this.fileAccessLock.writeLock().unlock(); + public static class ByteBufferInputStream extends InputStream { + protected final ByteBuffer internal; + + public ByteBufferInputStream(ByteBuffer buf) { + this.internal = buf; + } + + @Override + public int available() { + return this.internal.remaining(); + } + + @Override + public int read() throws IOException { + return this.internal.hasRemaining() ? (this.internal.get() & 0xFF) : -1; + } + + @Override + public int read(byte @NotNull [] bytes, int off, int len) throws IOException { + if (!this.internal.hasRemaining()) return -1; + len = Math.min(len, this.internal.remaining()); + this.internal.get(bytes, off, len); + return len; } } - @Override - public void close() throws IOException { - this.fileAccessLock.writeLock().lock(); - try { - removeFromFlusherManagement(); - this.closeInternal(); - } finally { - this.fileAccessLock.writeLock().unlock(); + private static class CompressingOps { + private final LZ4Compressor lz4Compressor = LZ4Factory.fastestInstance().fastCompressor(); + private final LZ4FastDecompressor lz4Decompressor = LZ4Factory.fastestInstance().fastDecompressor(); + + public @NotNull ByteBuffer commitSectionData(@NotNull ByteBuffer in) { + final int bufferLenToAllocate = this.lz4Compressor.maxCompressedLength(in.remaining()); + final ByteBuffer result = ByteBuffer.allocate(bufferLenToAllocate + 4); + + result.putInt(in.remaining()); + this.lz4Compressor.compress(in, result); + + return result.flip(); + } + + public @NotNull ByteBuffer fromCommitedSection(@NotNull ByteBuffer flippedIn) { + final int originalLen = flippedIn.getInt(); + final byte[] raw = new byte[flippedIn.remaining()]; + flippedIn.get(raw); + + final byte[] decompressed = new byte[originalLen]; + this.lz4Decompressor.decompress(raw, decompressed); + + return ByteBuffer.wrap(decompressed); } } - private class Sector { + public class Sector { private final int index; private long offset; private long length; @@ -764,30 +705,61 @@ private Sector(int index, long offset, long length) { this.length = length; } + public void transferTo(@NotNull FileChannel source, @NotNull FileChannel target) throws IOException { + long transferred = 0; + while (transferred < this.length) { + transferred += source.transferTo( + this.offset + transferred, + this.length - transferred, + target); + } + } + public @NotNull ByteBuffer read(@NotNull FileChannel channel) throws IOException { - final ByteBuffer result = ByteBuffer.allocateDirect((int) this.length); + final ByteBuffer result = ByteBuffer.allocate((int) this.length); + + int totalRead = 0; + while (totalRead < this.length) { + int read = channel.read(result, this.offset + totalRead); + if (read == -1) { + throw new IOException("Unexpected EOF while reading sector " + this.index + + ", expected " + this.length + " bytes, got " + totalRead); + } + totalRead += read; + } - channel.read(result, this.offset); result.flip(); - return result; } public void store(@NotNull ByteBuffer newData, @NotNull FileChannel channel) throws IOException { + final long oldLength = this.length; + final long newDataLength = newData.remaining(); + this.hasData = true; - this.length = newData.remaining(); - this.offset = currentAcquiredIndex; + this.length = newDataLength; + + if (newDataLength <= oldLength) { + long localOffset = this.offset; + while (newData.hasRemaining()) { + localOffset += channel.write(newData, localOffset); + } + + return; + } + + this.offset = BufferedRegionFile.this.currentAcquiredIndex; BufferedRegionFile.this.currentAcquiredIndex += this.length; - long offset = this.offset; + long localOffset = this.offset; while (newData.hasRemaining()) { - offset += channel.write(newData, offset); + localOffset += channel.write(newData, localOffset); } } private @NotNull ByteBuffer getEncoded() { - final ByteBuffer buffer = ByteBuffer.allocateDirect(sizeOfSingle()); + final ByteBuffer buffer = ByteBuffer.allocate(sizeOfSingle()); buffer.putLong(this.offset); buffer.putLong(this.length); @@ -829,13 +801,194 @@ private ChunkBufferHelper(ChunkPos pos) { @Override public void close() throws IOException { - BufferedRegionFile.this.fileAccessLock.writeLock().lock(); + ByteBuffer bytebuffer = ByteBuffer.wrap(this.buf, 0, this.count); + + BufferedRegionFile.this.writeChunk(this.pos.x, this.pos.z, bytebuffer); + BufferedRegionFile.this.flushInternal(); + } + } + + private class LinearMasterFileParser { + private void parseBufferedLinear(@NotNull DataInputStream ioStream, Path file) throws IOException { + final byte version = ioStream.readByte(); + if (version != MASTER_FILE_VERSION) + throw new RuntimeException("Invalid version: " + version + " in " + file); + + // Skip newestTimestamp (Long) + Compression level (Byte): Unused. + ioStream.skipBytes(9); + + try (final ZstdInputStream decompressStream = new ZstdInputStream(ioStream)) { + // only used as a helper stream + // the parent stream will be closed in the try-catch block upper + final DataInputStream decompressedStreamHelper = new DataInputStream(decompressStream); + + for (int index = 0; index < 1024; index++) { + int size = decompressedStreamHelper.readInt(); // len + + if (size > 0) { + byte[] sectorData = new byte[size]; + decompressedStreamHelper.readFully(sectorData, 0, size); // data + + final ByteBuffer sectorDataNioBuffer = ByteBuffer.wrap(sectorData); + + BufferedRegionFile.this.writeChunkDataRaw(index, sectorDataNioBuffer, true); + } + } + } + } + + @Contract(value = "_ -> new", pure = true) + public static int @NotNull [] coordinatesFromOrdinal(int chunkIndex) { + int x = chunkIndex & 31; + int z = (chunkIndex >> 5) & 31; + return new int[]{x, z}; + } + + private void parseLinear(@NotNull DataInputStream ioStream, Path file) throws IOException { + final byte version = ioStream.readByte(); + + if (version != 1 && version != 2) { + throw new IOException("Unsupported version for linear format : " + version); + } + + // Skip newestTimestamp (Long) + Compression level (Byte) + Chunk count (Short): Unused. + ioStream.skipBytes(11); + // Skip chunk data len(Int)(Unused). + ioStream.skipBytes(4); + // Skip data hash (Long): Unused. + ioStream.skipBytes(8); + + try (final ZstdInputStream decompressedStream = new ZstdInputStream(ioStream)) { + // only used as a helper stream + // the parent stream will be closed in the try-catch block upper + final DataInputStream bufferHelper = new DataInputStream(decompressedStream); + + final int[] chunkStarts = new int[1024]; + for (int i = 0; i < 1024; i++) { + chunkStarts[i] = bufferHelper.readInt(); + bufferHelper.skipBytes(4); // Skip timestamps (Int): Unused. + } + + for (int i = 0; i < 1024; i++) { + if (chunkStarts[i] > 0) { + int size = chunkStarts[i]; + byte[] chunkData = new byte[size]; + bufferHelper.readFully(chunkData); + + final ByteBuffer chunkDataNioBuffer = ByteBuffer.wrap(chunkData); + + final int[] posByAxis = coordinatesFromOrdinal(i); + + final int x = posByAxis[0]; + final int z = posByAxis[1]; + + BufferedRegionFile.this.writeChunk(x, z, chunkDataNioBuffer); + } + } + } + } + + public void parseMainFile(@NotNull Path mainFilePath) throws IOException { + final File file = mainFilePath.toFile(); + + if (!file.exists() || !file.canRead()) { + return; + } + + // those streams will be closed in the parse logic, or we will close it manually + final FileInputStream fileStream = new FileInputStream(file); + final DataInputStream rawDataStream = new DataInputStream(fileStream); + + final long superBlock; try { - ByteBuffer bytebuffer = ByteBuffer.wrap(this.buf, 0, this.count); + superBlock = rawDataStream.readLong(); - BufferedRegionFile.this.writeChunk(this.pos.x, this.pos.z, bytebuffer); - } finally { - BufferedRegionFile.this.fileAccessLock.writeLock().unlock(); + if (superBlock == MASTER_FILE_SUPER_BLOCK) { + this.parseBufferedLinear(rawDataStream, mainFilePath); + return; + } + + if (superBlock == LINEAR_FILE_SUPER_BLOCK) { + this.parseLinear(rawDataStream, mainFilePath); + return; + } + + } catch (Exception ex) { + // error caught during other reading logics, close directly + try { + rawDataStream.close(); + } catch (IOException ex2) { + ex.addSuppressed(ex2); + } + + throw new IOException("Failed to parse master file: " + mainFilePath, ex); + } + + // anyone non-matched, close stream and throw the error + rawDataStream.close(); + + throw new IOException("Unknown or unsupported super block : " + superBlock); + } + + public void writeMainFile(@NotNull Path mainFile) throws IOException { + final Path tmpFilePath = Path.of(mainFile + ".tmp"); + + long timestamp = System.currentTimeMillis(); + + File tempFile = tmpFilePath.toFile(); + + try (final OutputStream fileStream = Files.newOutputStream(tmpFilePath, TMP_FILE_CHANNEL_OPTIONS); + final ZstdOutputStream zstdStream = new ZstdOutputStream(fileStream, BufferedRegionFile.this.compressionLevel) + ) { + + // only used as a helper stream + // the parent stream will be closed in the try-catch block upper + final DataOutputStream fileDataStreamHelper = new DataOutputStream(fileStream); + + fileDataStreamHelper.writeLong(MASTER_FILE_SUPER_BLOCK); // super block + fileDataStreamHelper.writeByte(MASTER_FILE_VERSION); // version + fileDataStreamHelper.writeLong(timestamp); // timestamp + fileDataStreamHelper.write(BufferedRegionFile.this.compressionLevel); // compression level + fileDataStreamHelper.flush(); + + // only used as a helper stream + // the parent stream will be closed in the try-catch block upper + final DataOutputStream zstdDataStreamHelper = new DataOutputStream(zstdStream); + + for (int i = 0; i < 1024; i++) { + // read from swap file + final ByteBuffer chunkData = BufferedRegionFile.this.readChunkDataRaw(i); + + // not found + if (chunkData == null) { + zstdDataStreamHelper.writeInt(0); + continue; + } + + final byte[] buffer = new byte[chunkData.remaining()]; + chunkData.get(buffer); + + // store + zstdDataStreamHelper.writeInt(buffer.length); // len + zstdDataStreamHelper.write(buffer); // data + } + + zstdDataStreamHelper.flush(); + } + + try { + Files.move(tempFile.toPath(), masterFilePath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (Exception e) { + // retry with non-atomic move + try { + Files.move(tempFile.toPath(), masterFilePath, StandardCopyOption.REPLACE_EXISTING); + } catch (Exception ex) { + // now we are totally failed + + // fast-fail + Files.deleteIfExists(masterFilePath); + throw new IOException("Failed to replace original master file!", e); + } } } } diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/type/LinearRegionFile.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/type/LinearRegionFile.java index 5b71ff5f..1fd36037 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/region/type/LinearRegionFile.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/type/LinearRegionFile.java @@ -60,7 +60,6 @@ public class LinearRegionFile implements IRegionFile { private final LZ4FastDecompressor decompressor; private final int compressionLevel; private final Thread bindThread; - private final java.util.concurrent.atomic.AtomicInteger recalculateCount = new java.util.concurrent.atomic.AtomicInteger(); private byte[][] bucketBuffers; private boolean markedToSave = false; @@ -121,10 +120,6 @@ public Path getPath() { return this.regionFile; } - public int getRecalculateCount() { - return this.recalculateCount.get(); - } - public boolean recalculateHeader() { return false; } From 1588dfbd7308ffaeaeb40f18c49a6e8df0c96215 Mon Sep 17 00:00:00 2001 From: NONPLAYT <76615486+NONPLAYT@users.noreply.github.com> Date: Thu, 15 Jan 2026 01:12:38 +0300 Subject: [PATCH 2/3] use try/catch for defying type --- .../bxteam/divinemc/config/DivineConfig.java | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java b/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java index 2f646869..17a18143 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java @@ -671,16 +671,14 @@ private static void lagCompensation() { } private static void regionFileExtension() { - EnumRegionFileExtension configuredType = EnumRegionFileExtension.fromString(getString(ConfigCategory.MISC.key("region-format.type"), regionFileType.toString(), - "The type of region file format to use for storing chunk data.", - "Valid values:", - " - MCA: Default Minecraft region file format", - " - LINEAR: Linear region file format V2", - " - B_LINEAR: Buffered region file format (just uses Zstd)")); - - if (configuredType != null) { - regionFileType = configuredType; - } else { + try { + regionFileType = EnumRegionFileExtension.fromString(getString(ConfigCategory.MISC.key("region-format.type"), regionFileType.toString(), + "The type of region file format to use for storing chunk data.", + "Valid values:", + " - MCA: Default Minecraft region file format", + " - LINEAR: Linear region file format V2", + " - B_LINEAR: Buffered region file format (just uses Zstd)")); + } catch (IllegalArgumentException ignore) { LOGGER.warn("Invalid region file type: {}, resetting to default (MCA)", getString(ConfigCategory.MISC.key("region-format.type"), regionFileType.toString())); regionFileType = EnumRegionFileExtension.MCA; } From afe47d7d9fc6041f4de237a8ea14a6596553f965 Mon Sep 17 00:00:00 2001 From: dan28000 <84990628+dan28000@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:12:30 +0100 Subject: [PATCH 3/3] linear v3 + refractor (#46) * linear v3 + refractor * cleanup --- .../0058-Linear-region-file-format.patch | 6 +- .../paper-patches/features/0001-Rebrand.patch | 625 +++++++++--------- .../divinemc/async/ExecutorShutdown.java | 8 + .../divinemc/config/ConfigCategory.java | 3 +- .../bxteam/divinemc/config/DivineConfig.java | 233 ++++--- .../region/EnumRegionFileExtension.java | 18 +- .../org/bxteam/divinemc/region/Flusher.java | 52 ++ .../bxteam/divinemc/region/IRegionFile.java | 16 +- .../BufferedRegionFile.java | 23 +- .../BufferedRegionFileFlusher.java | 57 +- .../divinemc/region/linear/LinearBase.java | 42 ++ .../region/linear/LinearImplementation.java | 18 + .../region/linear/LinearRegionFile.java | 305 +++++++++ .../linear/LinearRegionFileFlusher.java | 40 ++ .../divinemc/region/linear/V2LinearBase.java | 132 ++++ .../region/linear/versions/V1Linear.java | 129 ++++ .../region/linear/versions/V2Linear.java | 168 +++++ .../region/linear/versions/V3Linear.java | 92 +++ .../region/linear/versions/Version.java | 18 + .../region/type/LinearRegionFile.java | 600 ----------------- 20 files changed, 1477 insertions(+), 1108 deletions(-) create mode 100644 divinemc-server/src/main/java/org/bxteam/divinemc/region/Flusher.java rename divinemc-server/src/main/java/org/bxteam/divinemc/region/{type => buffered}/BufferedRegionFile.java (98%) rename divinemc-server/src/main/java/org/bxteam/divinemc/region/{flusher => buffered}/BufferedRegionFileFlusher.java (56%) create mode 100644 divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearBase.java create mode 100644 divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearImplementation.java create mode 100644 divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearRegionFile.java create mode 100644 divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearRegionFileFlusher.java create mode 100644 divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/V2LinearBase.java create mode 100644 divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/V1Linear.java create mode 100644 divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/V2Linear.java create mode 100644 divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/V3Linear.java create mode 100644 divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/Version.java delete mode 100644 divinemc-server/src/main/java/org/bxteam/divinemc/region/type/LinearRegionFile.java diff --git a/divinemc-server/minecraft-patches/features/0058-Linear-region-file-format.patch b/divinemc-server/minecraft-patches/features/0058-Linear-region-file-format.patch index 4af535ee..5affe87a 100644 --- a/divinemc-server/minecraft-patches/features/0058-Linear-region-file-format.patch +++ b/divinemc-server/minecraft-patches/features/0058-Linear-region-file-format.patch @@ -148,7 +148,7 @@ index e4cbe8d6176c2174ed5fffb9ea28f69c12dc9f1e..70e9c7a649a7c5145ecdb679f5fe54f3 } // Paper end - rewrite chunk system diff --git a/net/minecraft/world/level/chunk/storage/RegionFileStorage.java b/net/minecraft/world/level/chunk/storage/RegionFileStorage.java -index dbea0449cebd448f5b165097aee8633e4d8bac67..9d73b706167d5d997b1af018c7ceaf0d200c7ff1 100644 +index dbea0449cebd448f5b165097aee8633e4d8bac67..e9e98d45e10a740d72842b85b09fbfd923f79966 100644 --- a/net/minecraft/world/level/chunk/storage/RegionFileStorage.java +++ b/net/minecraft/world/level/chunk/storage/RegionFileStorage.java @@ -15,11 +15,13 @@ import net.minecraft.util.FileUtil; @@ -176,7 +176,7 @@ index dbea0449cebd448f5b165097aee8633e4d8bac67..9d73b706167d5d997b1af018c7ceaf0d + + // DivineMC start - Buffered Linear region format + public static org.bxteam.divinemc.region.IRegionFile createNew(RegionStorageInfo info, Path filePath, Path folder, boolean sync) throws IOException{ -+ final org.bxteam.divinemc.region.EnumRegionFileExtension regionFormat = org.bxteam.divinemc.config.DivineConfig.MiscCategory.regionFileType; ++ final org.bxteam.divinemc.region.EnumRegionFileExtension regionFormat = org.bxteam.divinemc.config.DivineConfig.RegionSettingsCategory.regionFileType; + final String fullFileName = filePath.getFileName().toString(); + final String[] fullNameSplit = fullFileName.split("\\."); + final String extensionName = fullNameSplit[fullNameSplit.length - 1]; @@ -190,7 +190,7 @@ index dbea0449cebd448f5b165097aee8633e4d8bac67..9d73b706167d5d997b1af018c7ceaf0d + } + + public static String getExtensionName() { -+ return "." + org.bxteam.divinemc.config.DivineConfig.MiscCategory.regionFileType.getArgument(); ++ return "." + org.bxteam.divinemc.config.DivineConfig.RegionSettingsCategory.regionFileType.getArgument(); } + // DivineMC end - Buffered Linear region format diff --git a/divinemc-server/paper-patches/features/0001-Rebrand.patch b/divinemc-server/paper-patches/features/0001-Rebrand.patch index 82ade8df..0c003819 100644 --- a/divinemc-server/paper-patches/features/0001-Rebrand.patch +++ b/divinemc-server/paper-patches/features/0001-Rebrand.patch @@ -205,326 +205,311 @@ diff --git a/src/main/resources/logo.png b/src/main/resources/logo.png index 518591dd83289e041a16e2c2e7d7e7640d4b2e1b..f54753531b3bf2e8b5377f342465e727c7da98f2 100644 GIT binary patch literal 6677 -zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4rT@h2EWC1?hFhJEa{HEjtmSN`?>!lvNA9* -zC?tCX`7$t6sWC7#v@kIIVqjosc)`F>YQVtoDuIE)Y6b&?c)^@qfi?^b46y+|A+8Jz -z4AzE*1{#{~F76Sb5mpvfPWDb-4o==4-paCa1{xY-Lc;2bN^+7?DspmG=2rST`W|i` -z=BDO)>Kgib`Z`)VdK#K$#%3zYDt1+tPBi3T(HkZ(U6;;l9^VC -zTSH@S`*j8ehN#pC&op0O1}z2#1`Y;RMj-}J=rS@eGB7YoF@S=Uk%581i%}ZR4r0`R -zs$pVaU=oJ1L8=(Qzyst71_lN&Eg$sVgn>cA)YHW=q~g}wyDyzqTL~~ccwP48O4Nnm -zH%YbsukT6qoMAe%CE%4&{gx>~pEwm~viy0!|Nf6&d13SC5moa0Jnryp{8CrbxPx)y -zmwhpncP2zs)vvS2JI}cC)^+dqm!}?kD=^{Jbnl*M5Y>G&S7qA0{n}hb3U}|#eLPJj -z%lrM~sVplOS!o^lTAf=}Pc+G&5nZ@ze|<$lcb&%c##O!Xdd64v!-cN;Kko|q -zTd7d^J$UD%kX!|=OD#VPW6ZQpxJoa#Z#(VxbVtVa=f?|{25xnF1d*Q{{9zVFI(}8GFZ@uogp}^Jh$6k5icQ0O_u$t^4QDNUcK -zb;MQshoG|0*Q+~(R+#ABDmfsLxN7~8(%EjRnHLkx3|B9=uiwWcKG!+4rE5;<#?2pA -z{=c)e_~OmVqbnHv*VNTZ*;k*9DY=&(xT;b8T2aNJ`TN84Y%;%nn-;psf66OOH`m|i -z`*RqVCjGhi<$;j#{NMU)Azx-)nckZ6P9WU+(a~E19bI|}Azw0j(}JhZ(CFJ=v!E$v -zhmCq-Ujvp;P&u_}o^`NVCqPW9Q<$A_f?EcL?F!cHFutP1g4epqZpNFTTEgdNj% -z@HUz{Cg&>1<@|FuX$nc&AXU=1+w8^M=~~tMDe5RqyFPu~yevtsJGM{;#Tb -z{Je8gU((FiOtr@ZLbr2gvYvhHwNBnLe98HUCT+K`E_#_xQNr6RjQH2C3@g7VX)$^B -zN-?cFObJu9@;>^k*rFEXy6eW|=&xa`CU%Fe+HutQW0A!~tplbzg{L@OE#BxQx?k^t -z&}@&T1-|c$mj!Lz`;bHI_L2GaDFpFiBZ_L9tS0^ugd7!N6$um~HJ&YcyPoe}@?XQ377r07#oBR`D9&d@29XU-~HFm1s -zWcc0Hek1(P(vLF7qt@oVl=La*4BRTHrESeSwQ;V9-p>!*K^Kh`{J!QK`V{k6{pCA_ -zRRZFxA22^o2-EwU!yDwN$30ba_2WbHm$6$dy0jzWvZ7H=#zU^~JV&kLsf#xyuMNxL -z-lDQa`P9yc&ma7ClA>xBG_4a@w&>(dfl#h%RdbK2_FdXAtwbn%PS?7lX3xK!@%#E| -zQtQ-1OZl=k`nAtI?Z$dtWTn9LMlI>nr$l9#Q~nwYPoH~g?b@K9wkCnT2R0Qf<)7)c -z^U{hVzL%t~dYN*b4!GJr`^%a~dDlIxH@#3>l6^3C!mGBtNupVsJfuknmo^QbKQ -z#%rBlZ6&$~Vs5osxlC={8dtm~N3V_HI!DmYO&86#PCfpfduwLNDxWoS9LcGLh8(-x -zCaltqUQ_fqX5+K@Zc}@&3I(o_xITG8-vQQY0i~FVmv=Xaz75|nZIhQNV|LHgQ-@5i -zurl_rtCr}!ovR@Fu1z(-_s}Y>zNLkYt9zP03UElOmYiW->9$Tqt3C93WQ6VYEYZhG -zZ^aoOD+=M8)^t^Dfu-w;qK#I1U8cRS -z=l7dvZO~f%AXV$Y(RI!%-g>V1wDDYwjQ0H3@?9pY8oNzpOI9^@Z!Pd}o*UqMFlsMX -zgqfb1&hLIVsjJNf6Hc%G8j;oiHH+h_$(rlojN0d}zLh*%$hPxQ$d>nqR^BoQWL&d- -z`=kij>#wu9rr)=Ysb<-c6EeYj8B@%|zOxCT`==hWD&D*A*3JuZb`#I+JdwJ*D8}mQ -z0nyjYe0QH(Ziw5yK1|QHadDyHOogd8Hl+Gqh$yc#E}1SF7R;0#-Lcx%u_?yqv0Imc -zsO>&;o3?jG2eW25MX2tGxw^W@uvc35P{;)D1-E`|?hTlFc-ARa{Z~rjmv?Zw&Nx-r -z8IsQwc3I@ut+1alTV|fxkvB>CTI@I9fNQa75x&OEVWo!aelS%n-5MOYX4y>6*G4fP -zS-0>`cWT`@E6r`q&z+CBGu|uoawVCbJrnV{=&1OGx^%6p9al}n51e`?WF+)osJZT+ -z{@J&#D`l?z{w)(>-8c2=(Gk6H{W7p{CVdY7-~0+Ccmr<2jG3$+G?8$2JwO -zeR+Z{d)cLbMV70UJ&ZWMxZ$eF=cLG;*It`0niTc5IIwWd5IVKtYLP78%9y$~zb)Oq -z%62>p17)R^ucZaEtbVQ(Aur$VtR+6&4PE6kE6DE=^@o;{VX+dp`%Z_u}r(zEEayYL0 -zR;m_yc4G{)ZP(MZKA$76cIZSHT~~a$j`zD&cUR)ouTM9gi!sybeEnzM&vm>%teU$L -zu6{L&u+|oP{_39T-K~zwGr1D4Zf&?4BPn)v8Q=S2orN>IoMt?fU&s_us+(YXRwv?} -z){NHHoqwd9_^V|XJ&G{Y>OZ4-+OJPM%bLJ14+*lx}O>? -zF;3k2G%bQ%`PzxrTWg*8tJel5>Trj)AAMD3vP0_dtVyL0BHEh;Uq5y}&=j+evFuPt -z-KIC*T302zw@Oahuxwd|McDD98?^ea7H^ajJZ{Cf?rd9fhzU9dlX@^ -z`to}H1WUb+r#p82RgWlJe#B+-HD|!EZH#)Y^RSb>ARA7W#cU-jgXlUcm2CUlMXl?vnsy1;nsAK -z*NcTVSm`mY)^BhUJv!;0L|xJvqs7)z)2??VHk#Uqy-eCIYB^22!AbPsDrd%xUc9X@ -zBa(NUG#%aHF~2aUK~N|Esz%7U!%KSv!(yd3p6Zp}bdLMgH;IiL;xCQ0BL2y=MED-w -zcqhOm>W7TqLC3HQ+9Df{{o7Jtd*rNs-_xBcI`P|`*VG=~ct;>yF6{D|meyj^eNTec -zxgB7NS-`t2;inn%5hm784<{X|jJT`D5!R{czQukov+DFBQ@1sJ-HCfSB3`WbU-4-} -zTd%^JT_^tNn1(*SJ@?A7j?!mF2@```1D+=QJj<}=UGL3}QR|#_Ox9?1eckpvqT<;h -zg#*=d+IbO#e -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 +zD7IlonkOy3AIJvqJ+^v3B{iHk+09aQ#>kl{ +zmaISJQBQP8%5~(PD4NTU;H% +zN4-1eLAj|t8F9I*tOJKM3*6eu_ik{zyQXVr`+F=q1YAwrdHH=#%BbwO{yyyY;)#O9 +zGoK`Ujx6-fNTj483E1!FGtYVz6zXJkTq}>qH=7wK@z<-uMz+lx_|+jFzKR&rwU*h7 +z-SX~-k+=i6A(lrB*0r`9T8GUNIhfO&-z;_$ZArSIsvPp?xN$s|$dHe^`OM^F>B%4e +z+B+^26JapEmg)E{;0#))&Eg{uHb-Q`84A;T-g<0_D1txOzm@fAkH7Bi)a>I9KNL*w +z8u{ixFO8tyq?{r4KmQm +zSNiE?)c%L{xJxhP4&5oKhr-|T5c1%mU!3b24|v+PPTx7XiKE<3_og)W8hwd4bQ{h> +zw@BZ$Vn;F^AsxsUQYH4)WS37yHJ(jv^McSfJ!KYWI{j#p^1Z|(l4aXFHm%b)_D;p6 +zeK-F2{zQ2YFT{AfE%kmcleZA9 +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 +zKQC*8IZKJ85gXf`D@XmrZ}z0B4$-% +z#5u_aJlT~Rz#nDlIJ6PugYP20>(80*@KTjt2Lvz5mZ0M7$)CcUk@p_s$^h?o_rF$>!zEoV+I-e)}5CY2o9PEjay* +zV>Cj8ZChL}gx7qpb>?}aNx}GS69W2l#$e=$@mq@2rNQ3ZaqlTtH2F0b9YVEg_&cmp +z9vx$cf7u~q1PkZir56Xw60;6fM=X)hnQ`bvgB}QZiFos!aZPc!EHRvJhPYeZiG3_? +zjZbnKVSm#-9*)S}?Zz7Ix5mdiDb3_8pG#x{I61G8qoRfu1(?S9zqVOT5UPa0RFVoy +zxGZG6EN57Ym|95?5w#v3susU+2$|LdW!O*>lhl?!cqW?wb$~FN*e&rbyxv*?dCe5X +zA3X1$($YNfAX74Uu7Tv&Y0zVaUwg5SyFa7>J}6Pc<8{{Dz36a2cWZ@ziU|3oYtGnA +znJD06B5HU9wr-RKbRVXY{N@dMhVhN*V%+a3VjRb0wX;hVazYu=%el;UduXEp%w^7< +zb|+!8yUq+Ya!HO6tIB5eqD~poR2H$D+@pe77pwERgEIA +z0!|y>AQ6FFu;Cr?4;OGC36S8`-RHRs!ojv|9~nbh^^c7~^@OJH?SB5}xeQZzNnGTp +zU${G$vNFg^JlLjxT2*m!{P!2zieBFsmDoj7By5jYgbdVWx_kcpnE-OIb+w^e5#s*~ +DA-Nv< literal 9260 -zcmeAS@N?(olHy`uVBq!ia0y~yU^oH79Lx+1471we)-W(I{SEL5ab;j&*zEAXF5~}E -zga0u-|5FKJPi^?W!SR1V#sAsC|8uJUhfe&z&gK7d&;R>O|DV(NKOy%25rhB6YyQs+`9DA4 -z|3%gRmz4isR{DQe>fa*2|M`{wv+6)wkRP|JySE-tqjO5%zzj`~TS?|Lqq2Kd$@#pwa(% -zK_K^^)%c&<_`fa(q-#?2|HS5hrA7aNBJ|DTBbKWy~hY5xDD=KocB|K|q%k8l5Pz4U*0FUT=Ftw17C-5?)LivHi7_e`Bnd?M*c4@1G)K< -z%Kw6LP<~1toyYy8jhL|I->k9$D%QN-E1e|Cbj3KcW3UvE_eqGbnBJ#s1%828y_a -zejqE>JN>UJ_bNd -z`hTk}DC9%?{@s=Q7ux~y;~~TU&xHS5FZ;jC0u+Nf8~@j5|Np@C|AF{F*SY_Frv2Y! -z|GzT{l;pm${STV>za{PeW6}R_c>kL(|9?~FpZ}EqvqJydEdHNf_wS|P|MG@^Zp|@8 -z3=9k#N`m}?894b^+5QP>J1X;wRB*Y=c539fqD_#g}^n4fMaf -zTwj -z#M$P{hj&}bKU_GuzAohLgIm|8M*Dw#{P5<&1x@jv-(0#bA-bp -z_CUjo4HG0)n3W!|@7LHaqA~ZtsTl{DIC{Ey4vFniS-`F4qL6m}^Bg6vFK=&)iT;a! -zHaq9s{O5npSI+xi{NUa$o5knU)3tY)>Oa46XwIA`-gl&?o2=SXoRqX^_wEk{+ow&9 -zy~ioTz_d&A@wHWa;$=$i4||{FT}ioetE9_h=VXf{(S#p|JTG)_3chnII7hQk#OigJ -zy0Amso*nN_uGN)2C+X5Rr}eVeqcApw;Na=1cWzC5{?qZn42|=3pB46T$}Hd!-0t?o -zzC&j2ze}pGr*O(FUR;=cSnH;%!^OL`at700h1_}Z;>F+JJHOq@y8h59rtf1?ZTQZV -z^gFcy*HbmO&w4t?%$h^1`|0oUzOuXTUrBYBJq}x|vT^B=i8&AQZ{K5gIChM$rC@ci -zc^AjxfV3GF7KJ%EDK4)cA6aWx`HW{#*CtEFpdxn`O64 -zsd!7XFSP8LuAx)qw>Ip0d*S=|f0r+3^rv)o*7GvH`t|GAsjBjKf3NCanIKi!AkAle?Qm}5i@1Yg7_yr$&IOQSxYBq -zYlp6yewEMchbn)+<&Sq{^t=AU -zmQ&QH#T)&)6B-t_N=y55MmVdPusctKO;yR!S=GBg=}0<=#y&~vTw)yA);%TGZ)3)? -z<$+VDZ4&xobh>k$>-)C0Hv9H_5_}hx4PZmb!XcniA^R;BqHuGdHrQ%newK` -zTg={-@xAZNgGPcOl~?=hCe50td?)jwqreVnhk3RiA|h97Y`b&lsl*MXST#A<`TkRO -zdQNz)){vm3^-%wgqnA;8FLPpr{$uvpC(_T)v#q!wwWUeMoa@%a#V*_a%sajCnc%;xZUtNjM>h7&uw}0y#O>#YSxa`W>h<^+D&K0Vu -zFV1+)dOUGowf*%YwKh#UzTg|v!nbYFH?L@D -z9G+?S$l$)L=a!lNbxS2gkv2xtX*g&zIz;Y`nB&UO8{^*sui^N!6e -zm}zn%art_k@*J1jmx>hZ4+I%657fRJImPvFW$}W-Z(F-(%zXL!_2;`;dn^Nv9!uPy -z++A}nE$CY(LsI5S#!>*k?8N=zM7id^DZ@1d*)evhwpUZ>o(7= -zsctdi=j`=PpU`6OaF$JQPteihynlYC0=GLuW;VC40OvaQ4AWcNXULSty3Ou)Ij%iH -zTizkY`GicR)|5+6e%0JIR9Lx^(^gq5tY5&r({knP$@~*CwstBC#jxE@_|wk2gy+VM -zwwncN-Z@img#-r|m!H==x8M?o@>v<4D!B)XuVzIr;&i;;nDAdv&)m3D{QZpz@he|i -z4`%lkzF5V*S&X&z-QtR?O$zO&826pM(syCEudi<%+xn(xj-Ux?@BV+7;_vO=W_9^Wi%c8;gYJ+p&7?^C -zh@@G;Z%#hfwR>~o)FMZ}rE5FZIp=3_EL?5m8vo(I$kbb -zw{G#`WNCYj(!h?O9u;QR_Fl`?d_6sk?hJp~4ti!iIiO+~vGUNrX4QAUliL);YKx9` -zgvHb>(os{F*X?m%eng+U6gD@ZTC%|mb_T>u5w+e0<*$_z&myfNz2zCntzXx=a)9i%w(S1 -z{Tb1$wijmv@3$=9w)@r{hOWf0pP4TN+4hLD%zV7sET;d$+B2Fh54SIToXO75q0{CP -z=I}F*FIV&Yp-7o0Q{tycn7H4O_!juteI1)+LAY*4y~WQzchVkNedl1lx0&sCc;O-2 -zCo4REaGaeO5Z$f7dF?mDkyde*>QzieR+~>9RWdtac_zQ_h~bkD7oRFmliKRG_epM> -zi=4CD|IWy#Dy(~+$5a@8YZY?aWh%Ebf$vJ&*9ViA`_GqRc{peL22Ky1T+RPMRr{+i -zNA9=m>uYr5+Y#tB-;g~>`|0VW*6i&k0T -z4~wJ!_6P`A)n@()tZH5w_NKOakCVUcrK_!{1^wlJu8}_@{4z6gJ&!Hp+`Y#x_N>}3 -zUv%-VxXQ0v{7*RkI`+uF+`47$iW7@Of6hLxobV-lx{d7*$t#hn?QE7$Z=ODz&6i+r -zF{!(5!W^$F0rnZ^C%>Ch+tO9CsDJWWi4XI(L@kr^v?zLiZ|}BQELOLLoH`F@+b(DR -z`8(fEpy21D_F99|a{KR$py>+&}TNm&qR4jJPP;<4L-?4~!D(jxff%&tV4Q{LxN_AMsvC@Cvl?N$* -z)j$1wr@+0Xi@j;fw`Cpi=hyGgZn>Iqdv|wV%zCvaH$GRL-0f+>VO@K3hVksPOXBu4 -zgly5Nt8>>bV`96Z?CfQmxJA`tvigHxOJ<7PdiIu1FG6jL)=aecwy|IB -zk%#()R?(lY_2VYIJSv-P`Lm`@jaA`8(EknZPVDVhNa4#`!oa+3hQdW3uDgOS4+UL} -z>u{|uVPb!<)z?aYF?Tf=^Sz6wxz5>5{dtGfdwpilDSnG3@I`37yK^w$N1~yG_MzC`{}soHM_8HO{w6K -zJC4iO-}rcfXP0vAY`s*63B7uA%@AW`bimY -z16x0pZaKz(yu)t&*)YqSx$>LH+9YRvtD?Qm1l?LnG4U> -zACL_dt=``CFzBXR{jRSsb8|0vw7%l6HT(OK{eG82?a}Z@mu3`e2`h?OZ22ivY;fai -zd+{8}BCeUt*O)9W=~~21;P+utJUaR8ZoSui>he!E*c_d?b_&;w$6sXQU$8f_92T;h -zQ2*~D|0Dj;MGF_qJ-2%C8*LS-&qChC^%<{zbao{MbalkOUU-B5mt@CE^S_^0zf@2; -z?-DV|rSGIB17F;P-I5YzaiJ$3THA+hUUl#wW9^6LKc|?PuIi-+*cq&?4OMDq$WK9iO)S -zw|KM@5=(jd@5OG|7}L`5CtL5t#dU@;6EiHzb2IfaxH~y*^3NOp64>@@UDvBgkKc&2 -zeN&coIPfy8&GFyg>GDe^8?U%y$TBgjFmHm$nKe!aS6s4K-LcKMM*8$t|KI+^Y*5m4@$EceJ` -zrNOA6tju#nh~?XcrMjx&mkxYe)q5fKYO{i1*n{$op)96B44VWUX0bH>NoQCRtkGdC -zwx?2;A?W?7OU}`vwr4nm6GW$b=!mZNW9cm29MvPaCn&o_VXKovU3{bH#YbHSidc#d -zuKUv0ldCDs{;luLgoEsodu=z!iLP1t=|irIfWCNlGS8B2VG<|aYqKqC+~y^6rmpYk -z!A@Q-!w0$FwmmmJ{!&h)y5i?c!HgvdrVpHXie(sXD)Kxjuba@B$EXm+B6!gD#IM5! -zs=PTjr&)1g!>f^YAuah9&{a; -zxYRmtuFQ$&SBt8?%6BL@`!k7F&71hp+NeU>`PmC*(>ApiWihYrsxhq;X8APxrUE0& -zBOZ7CrU`E*zmSdGk*DVR^WDwVv>-Wwgo8~JEUzU-FuiX4t8mhZJxHD9$?W?-92T{# -z_2^!zy@sQQw?rZ5jHliHRkJ-W{{B|noyHdJui&1)gjd_IO;EXXp|#LY)3p*F<=ZES -zM)Ejq;>%fPxqQwYK@a|~^Wz2g-n0F>$nV9ciFOqp*D9WTtx1_%s8IgyLz53zgrGNX -zE#VlwGsz)nO3g7`$NyM&uBswOQh9g -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 -z%E>H+(#~Iu39^6niZ{|Ww56~q -z$YpnIJIK=%X4+B1GwtS7!N$o~V*XX+zs)&u&(5JFe6`dOcY*%&_GP!Ww6&!<1%b$L7G+Fg|x$!6_mNq|G;Vt&LYl417v`Bqn -zlHvrurUiKmCJ1hic5++hoMYN7bIf_2lP(M2k=o0O%jYY4EN4==d|j~N#Wl%^0#~w& -zm!EgHkUcf`@CSWA8Mg&$rks{_)=Qw@ -z-N|oNN=inDV%+;}2WnEL$XFjfs&w?~C(DN2*S$Qn9ZlAp6aB?;O}@Cbf_cKGxooQ-BbXFYi}*YwPRnv{#lH{C2Y9Q^(57{d}*MdiAVU``_=rwJbm^93b3KHfT- -zej+w-y7xZgj{=SxYL`x!*l6jz=ka6fn}I3f=^6o(l|00qH`J%{b{?p+cD}V%F-b}J -z@sS(3!s!N1_nVsg54UcU32Jd+zO;_dGtE7AoyFn8w{AVVzcVydy>-7PAhN_v(|ON5 -zE5)2Ri&IA;JEq4~h^Gozo4LIYZL|nAUi9{3)kML)8Rav5=j^?}mlz$w_^2evO-f2c -zq4eIzyN}kmtGw;Lm&57EXgf=B({gi8&uM+w9XY~x8K3b -zu9W2{P(5+F%+;-Bh3^(AOjP*Wd9!Uo8(VRQT-;f{Gw$bdYo=Vc@A>uMo9XAK1);qY -zlr;w)32R%b-4x}&GL^pxH|6}IQ6}X*{E`8-=uS?KdeqQJH6t3#KE>voVS-D -zD0X${QX$sc4?mPVNj=kcgQvy)}(K{kW6u)1>G!J8-?Hl%jjkjEJPf -z^&dV|woN*bIibpaNww9HFy-j0W*b?U)s5K%S}$IkVDw$UaYN+}6J@3L^Iw;)o6S`y -zu%uc`rTn@;(vq~5MjDH_=870j+^qP`;rWdNJ-bwIu2MT3*VMeegKKw-h0JB+Ndb?` -z4hP0+Ppi3c>A`~c4@yqR$<<|-PfUI?=e+wBCF7~@SEt@waH!Ctt>^8=;O7rYayE2+ -zR$Zk1vnqFv7{7;}^Q179+*eYgDy5PiD4>%l- -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 +z<wCdy-3tTl}j4kzaCmrni9Diizn#a{@0 +zu;<>ZBofe$`#ML)Z*Aj8mbPS3-(VgSdF3LA8LJgBPj{3IaB~uLBuylKO*>ev*i|tuS(Wff3`Ogj^{GsE +zb-&NcY_)d|4#vv+AW}skSWrN{p$KOp?oug)BPoM*O}*h=wrr-PGYCt{qwP-&I!~hn +z&+*{EI5^09slk8i%kFcTjCcO6Oc}M9iiS}cuLLw0fyMPfjZ_M`;HWDHP^ke=f*5^! +z(0!2p!N9nZ8J+AP5sfunmbw64l@2)3@53_`Q@g&4FYKeDLbz2F-Pl@Er#INAtM()n +z<;MFT;osC}L2=Y4P1?cm)V{7nauSgh +zx4)k@#lLT}(uM?{Z&NvzT8pnJk@-MDvv4wOKsY+l9S8OdOw}cex#$t*B6ppAEloQy +z8u`9L`Ky$*$*G}A=)h6BjVn=_YuPie5epXe0vLKZP=Pxps%a&uGrdg4y#R{YobnDn +zWlMl5J;9|5ev`f`BO3Uh(yuK%@i^`+fAimq+_>*)z(;wrFdXn2nVJw1y_>PcV%p-) +zt#ZCU`}~DIE5y1BiI$qSd5XIebq(uRB-FnL#^x=bDHO-ls3({4R}-PgePOPH8ggGN +z^EAdT|N11qATWCZQ}ZY#=hum&;_xeyp*|O{VN?%jM$?&+DK=8LzLP4?U!YQ_JT0`M +z`m!W@TCB5mlr9&jJ{^cX4Ye=uf(7g!BDG1LQfZM#P5u-^$L1K~rOMk7oWI24W>ZJVoZ8!*NX>jC%2pgw@7$v*amGL8JnA`*TjN)y=JPix$ +zHom^xjfr~ZFvhO?xbJHg&jfEZgboIqN@pk*%G-#&cX5DM>_>dMo{P<(#6bOBlgEyG +zzi7pMrVsz<*TT+n%Xo*+rUVN +ztJqe<9EEg`YU)i$PQ+gn%oA5sG>b*6QUjZVf+Ju4QKWr32^B^>#t1pQ*dNT#SxuC8 +zGNJ9mH6R-92O+m#!DECXMrB|3+|cZ$?^qoag;w8-vr)Z5hx@2MVoazOwqdLo2-lu( +ziwF(a3SNH{+U_=d6KFp}-N^eInrfTZ{p%Yc;K{NXO^Sl0qy(%)%sUXL +zA6Ic}&$4}O+ulD-?|#%otX@aO$zD_RW;|WXeL`u5;iz`2Ja937zabkT*wwyB&2HIU +z%>tpNcvDWCsG=IjRr(V|Z7N`9^tphbhiC}fIrVcS>=>)uMStm;g&z-HK{fFz2ud&X +zKo;y87PhB3IZGZ&D%bJ2dZ<-Z7I$fCrPWl6O0ir})>f~+rM8k@!Q(EpWCD9f!k>me +zRhs@Q3_eZ!{s3QHUMaN0uShhn$y!N+)a +z6F#K#O21}#Wz2NGar1LUq3S|3=0~&VTjxVeqcysO1QIGwgglMcjXc3^9R9i556MW! +zA%G1+Y)mPX16RcVB#B?R~Xl +zIeR6G9EBEoU+Sk|=Q=4gQdT~j9ecG~G45m|E+IkhfuBxT`M%^wZkNkXpL0AjS!$%u +zg-={lr6QW@$p<#-f}T{y|0rAEpY~$9`Ffd=BP+`1#;?ge=@mLGkdmI~u?Dai2i+}> +zr-d}7M&xTHsK7@<3$tPd^Yg3fLzxDa!oOJ_N!hGt&$}5!9&TOIuzhIP>)^&CM1CIF +zY>A-293)fzq2lbB(1wKk=>#3=G1eTT&8(iBSJab=V@AGnDSwOhxKg{m8sa~TR||^x +zH?*-f-l|Zq;GkYEt~~O`56i(Z6gi`*^TxMZuc-f=NMlvry~%u2a>kz*@R^tJ@{7%-A@wfa)Z +z0~dL|Z*tTjEX7ED(M%2gZ|Zi_L4TWr%=BR=y4%;?-COo)8t(xaW)#f?Uhc9^d-1?K +z=UDW-cu8J#%~t1$>T7o{K5NXC7Z^ +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 +zWZQ?eQkgCCqwANoAm&K4|fw*k7ipV +zB1v2pR!seE-oV-2@pAb2ne;%k;&0+LB7z16@)VH1MO9)MHU9kSp)dCNVB91j?4mmO +zv3NP2%#IeX=+W7Ni#8IjoTpc(!3T*dt7!EX8VlAzQq6uvFcs%W{$71OuAvW8Wpseg+*PVYLv?WMN=C|H@$;E_S5fVp$L;GyupU7jZ$kfvC58X?uLqEZijH!v +HqBZh=dic2S diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/async/ExecutorShutdown.java b/divinemc-server/src/main/java/org/bxteam/divinemc/async/ExecutorShutdown.java index 8ed1eb60..8fc913ad 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/async/ExecutorShutdown.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/async/ExecutorShutdown.java @@ -5,6 +5,8 @@ import org.apache.logging.log4j.Logger; import org.bxteam.divinemc.async.pathfinding.AsyncPathProcessor; import org.bxteam.divinemc.async.tracking.MultithreadedTracker; +import org.bxteam.divinemc.config.DivineConfig; +import org.bxteam.divinemc.region.Flusher; import java.util.concurrent.TimeUnit; @@ -47,5 +49,11 @@ public static void shutdown(MinecraftServer server) { AsyncPathProcessor.PATH_PROCESSING_EXECUTOR.awaitTermination(10L, TimeUnit.SECONDS); } catch (InterruptedException ignored) { } } + + final Flusher flusher = DivineConfig.RegionSettingsCategory.flusher; + if (flusher != null) { + LOGGER.info("Shutting down region flusher executor..."); + flusher.shutdown(); + } } } diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/config/ConfigCategory.java b/divinemc-server/src/main/java/org/bxteam/divinemc/config/ConfigCategory.java index 56945c50..1abeae88 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/config/ConfigCategory.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/config/ConfigCategory.java @@ -5,7 +5,8 @@ public enum ConfigCategory { PERFORMANCE("performance"), FIXES("fixes"), NETWORK("network"), - MISC("misc"); + MISC("misc"), + REGION("region-settings"); private final String name; diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java b/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java index 17a18143..d0a5cbae 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/config/DivineConfig.java @@ -11,8 +11,10 @@ import org.bxteam.divinemc.config.annotations.Experimental; import org.bxteam.divinemc.async.pathfinding.PathfindTaskRejectPolicy; import org.bxteam.divinemc.region.EnumRegionFileExtension; -import org.bxteam.divinemc.region.flusher.BufferedRegionFileFlusher; -import org.bxteam.divinemc.region.type.LinearRegionFile; +import org.bxteam.divinemc.region.Flusher; +import org.bxteam.divinemc.region.buffered.BufferedRegionFileFlusher; +import org.bxteam.divinemc.region.linear.LinearImplementation; +import org.bxteam.divinemc.region.linear.LinearRegionFileFlusher; import org.jetbrains.annotations.Nullable; import org.simpleyaml.configuration.comments.CommentType; import org.simpleyaml.configuration.file.YamlFile; @@ -36,7 +38,7 @@ public class DivineConfig { This is the main configuration file for DivineMC. If you need help with the configuration or have any questions related to DivineMC, join us in our Discord server. - + Discord: https://discord.gg/qNyybSSPm5 Docs: https://bxteam.org/docs/divinemc Downloads: https://github.com/BX-Team/DivineMC/releases"""; @@ -47,7 +49,7 @@ public class DivineConfig { private static File configFile; public static final YamlFile config = new YamlFile(); - public static void init(File configFile) { + public static void init(File configFile) { try { long begin = System.nanoTime(); LOGGER.info("Loading config..."); @@ -71,12 +73,12 @@ public static void init(File configFile) { } catch (Exception e) { LOGGER.error("Failed to load config", e); } - } + } static void readConfig(Class clazz, Object instance) throws IOException { for (Method method : clazz.getDeclaredMethods()) { - if (Modifier.isPrivate(method.getModifiers()) && - method.getParameterTypes().length == 0 && + if (Modifier.isPrivate(method.getModifiers()) && + method.getParameterTypes().length == 0 && method.getReturnType() == Void.TYPE) { try { method.setAccessible(true); @@ -93,7 +95,7 @@ static void readConfig(Class clazz, Object instance) throws IOException { if (Modifier.isStatic(innerClass.getModifiers())) { try { Object innerInstance = null; - + Method loadMethod = null; try { loadMethod = innerClass.getDeclaredMethod("load"); @@ -108,7 +110,7 @@ static void readConfig(Class clazz, Object instance) throws IOException { } catch (NoSuchMethodException e) { innerInstance = null; } - + loadMethod.setAccessible(true); loadMethod.invoke(innerInstance); } @@ -121,43 +123,43 @@ static void readConfig(Class clazz, Object instance) throws IOException { config.save(configFile); } - private static void setComment(String key, String... comment) { - if (config.contains(key)) { - config.setComment(key, String.join("\n", comment), CommentType.BLOCK); - } - } + private static void setComment(String key, String... comment) { + if (config.contains(key)) { + config.setComment(key, String.join("\n", comment), CommentType.BLOCK); + } + } private static void ensureDefault(String key, Object defaultValue, String... comment) { if (!config.contains(key)) config.set(key, defaultValue); if (comment.length > 0) config.setComment(key, String.join("\n", comment), CommentType.BLOCK); } - private static boolean getBoolean(String key, boolean defaultValue, String... comment) { - return getBoolean(key, null, defaultValue, comment); - } + private static boolean getBoolean(String key, boolean defaultValue, String... comment) { + return getBoolean(key, null, defaultValue, comment); + } - private static boolean getBoolean(String key, @Nullable String oldKey, boolean defaultValue, String... comment) { - ensureDefault(key, defaultValue, comment); - return config.getBoolean(key, defaultValue); - } + private static boolean getBoolean(String key, @Nullable String oldKey, boolean defaultValue, String... comment) { + ensureDefault(key, defaultValue, comment); + return config.getBoolean(key, defaultValue); + } - private static int getInt(String key, int defaultValue, String... comment) { - return getInt(key, null, defaultValue, comment); - } + private static int getInt(String key, int defaultValue, String... comment) { + return getInt(key, null, defaultValue, comment); + } - private static int getInt(String key, @Nullable String oldKey, int defaultValue, String... comment) { - ensureDefault(key, defaultValue, comment); - return config.getInt(key, defaultValue); - } + private static int getInt(String key, @Nullable String oldKey, int defaultValue, String... comment) { + ensureDefault(key, defaultValue, comment); + return config.getInt(key, defaultValue); + } - private static double getDouble(String key, double defaultValue, String... comment) { - return getDouble(key, null, defaultValue, comment); - } + private static double getDouble(String key, double defaultValue, String... comment) { + return getDouble(key, null, defaultValue, comment); + } - private static double getDouble(String key, @Nullable String oldKey, double defaultValue, String... comment) { - ensureDefault(key, defaultValue, comment); - return config.getDouble(key, defaultValue); - } + private static double getDouble(String key, @Nullable String oldKey, double defaultValue, String... comment) { + ensureDefault(key, defaultValue, comment); + return config.getDouble(key, defaultValue); + } private static long getLong(String key, long defaultValue, String... comment) { return getLong(key, null, defaultValue, comment); @@ -168,23 +170,23 @@ private static long getLong(String key, @Nullable String oldKey, long defaultVal return config.getLong(key, defaultValue); } - private static String getString(String key, String defaultValue, String... comment) { - return getOldString(key, null, defaultValue, comment); - } + private static String getString(String key, String defaultValue, String... comment) { + return getOldString(key, null, defaultValue, comment); + } - private static String getOldString(String key, @Nullable String oldKey, String defaultValue, String... comment) { - ensureDefault(key, defaultValue, comment); - return config.getString(key, defaultValue); - } + private static String getOldString(String key, @Nullable String oldKey, String defaultValue, String... comment) { + ensureDefault(key, defaultValue, comment); + return config.getString(key, defaultValue); + } - private static List getStringList(String key, List defaultValue, String... comment) { - return getStringList(key, null, defaultValue, comment); - } + private static List getStringList(String key, List defaultValue, String... comment) { + return getStringList(key, null, defaultValue, comment); + } - private static List getStringList(String key, @Nullable String oldKey, List defaultValue, String... comment) { - ensureDefault(key, defaultValue, comment); - return config.getStringList(key); - } + private static List getStringList(String key, @Nullable String oldKey, List defaultValue, String... comment) { + ensureDefault(key, defaultValue, comment); + return config.getStringList(key); + } public static class AsyncCategory { // Parallel world ticking settings @@ -623,14 +625,6 @@ public static class MiscCategory { public static boolean timeAcceleration = true; public static boolean randomTickSpeedAcceleration = true; - // Region Format - public static EnumRegionFileExtension regionFileType = EnumRegionFileExtension.MCA; - public static int linearCompressionLevel = 1; - public static int linearIoThreadCount = 6; - public static int linearIoFlushDelayMs = 100; - public static boolean linearUseVirtualThreads = true; - public static BufferedRegionFileFlusher bLinearFlusher = null; - // Sentry public static String sentryDsn = ""; public static String logLevel = "WARN"; @@ -643,7 +637,6 @@ public static class MiscCategory { public static void load() { secureSeed(); lagCompensation(); - regionFileExtension(); sentrySettings(); oldFeatures(); } @@ -657,7 +650,7 @@ private static void secureSeed() { } private static void lagCompensation() { - lagCompensationEnabled = getBoolean(ConfigCategory.MISC.key("lag-compensation.enabled"), lagCompensationEnabled, + lagCompensationEnabled = getBoolean(ConfigCategory.MISC.key("lag-compensation.enabled"), lagCompensationEnabled, "Improves the player experience when TPS is low"); blockEntityAcceleration = getBoolean(ConfigCategory.MISC.key("lag-compensation.block-entity-acceleration"), blockEntityAcceleration); blockBreakingAcceleration = getBoolean(ConfigCategory.MISC.key("lag-compensation.block-breaking-acceleration"), blockBreakingAcceleration); @@ -670,45 +663,6 @@ private static void lagCompensation() { randomTickSpeedAcceleration = getBoolean(ConfigCategory.MISC.key("lag-compensation.random-tick-speed-acceleration"), randomTickSpeedAcceleration); } - private static void regionFileExtension() { - try { - regionFileType = EnumRegionFileExtension.fromString(getString(ConfigCategory.MISC.key("region-format.type"), regionFileType.toString(), - "The type of region file format to use for storing chunk data.", - "Valid values:", - " - MCA: Default Minecraft region file format", - " - LINEAR: Linear region file format V2", - " - B_LINEAR: Buffered region file format (just uses Zstd)")); - } catch (IllegalArgumentException ignore) { - LOGGER.warn("Invalid region file type: {}, resetting to default (MCA)", getString(ConfigCategory.MISC.key("region-format.type"), regionFileType.toString())); - regionFileType = EnumRegionFileExtension.MCA; - } - - linearCompressionLevel = getInt(ConfigCategory.MISC.key("region-format.compression-level"), linearCompressionLevel, - "The compression level to use for the linear region file format."); - linearIoThreadCount = getInt(ConfigCategory.MISC.key("region-format.linear-io-thread-count"), linearIoThreadCount, - "The number of threads to use for IO operations."); - linearIoFlushDelayMs = getInt(ConfigCategory.MISC.key("region-format.linear-io-flush-delay-ms"), linearIoFlushDelayMs, - "The delay in milliseconds to wait before flushing IO operations."); - linearUseVirtualThreads = getBoolean(ConfigCategory.MISC.key("region-format.linear-use-virtual-threads"), linearUseVirtualThreads, - "Whether to use virtual threads for IO operations that was introduced in Java 21."); - - if (linearCompressionLevel > 23 || linearCompressionLevel < 1) { - LOGGER.warn("Invalid linear compression level: {}, resetting to default (1)", linearCompressionLevel); - linearCompressionLevel = 1; - } - - if (regionFileType == EnumRegionFileExtension.LINEAR) { - LinearRegionFile.SAVE_DELAY_MS = linearIoFlushDelayMs; - LinearRegionFile.SAVE_THREAD_MAX_COUNT = linearIoThreadCount; - LinearRegionFile.USE_VIRTUAL_THREAD = linearUseVirtualThreads; - } - - if (regionFileType == EnumRegionFileExtension.B_LINEAR) { - bLinearFlusher = new BufferedRegionFileFlusher(6, 20, 3000); // TODO: Make configurable, sort settings - Runtime.getRuntime().addShutdownHook(new Thread(() -> bLinearFlusher.shutdown())); - } - } - private static void sentrySettings() { sentryDsn = getString(ConfigCategory.MISC.key("sentry.dsn"), sentryDsn, "The DSN for Sentry, a service that provides real-time crash reporting that helps you monitor and fix crashes in real time. Leave blank to disable. Obtain link at https://sentry.io"); @@ -717,7 +671,8 @@ private static void sentrySettings() { onlyLogThrown = getBoolean(ConfigCategory.MISC.key("sentry.only-log-thrown"), onlyLogThrown, "Only log Throwable exceptions to Sentry."); - if (sentryDsn != null && !sentryDsn.isBlank()) gg.pufferfish.pufferfish.sentry.SentryManager.init(Level.getLevel(logLevel)); + if (sentryDsn != null && !sentryDsn.isBlank()) + gg.pufferfish.pufferfish.sentry.SentryManager.init(Level.getLevel(logLevel)); } private static void oldFeatures() { @@ -820,6 +775,86 @@ private static void protocols() { } } + public static class RegionSettingsCategory { + // Region Format + public static EnumRegionFileExtension regionFileType = EnumRegionFileExtension.MCA; + public static int compressionLevel = 4; + public static int threadCount = 4; + public static Flusher flusher = null; + + // Linear region file settings + public static int linearIoFlushDelayMs = 10000; + public static LinearImplementation linearImplementation = LinearImplementation.V2; + + // Buffered linear region file settings + public static int checkIntervalMs = 20; + public static int flushOfWriteTimeoutMs = 3000; + + public static void load() { + regionFileExtension(); + linear(); + buffered(); + flusher(); + } + + private static void regionFileExtension() { + try { + regionFileType = EnumRegionFileExtension.fromString(getString(ConfigCategory.REGION.key("type"), regionFileType.toString(), + "The type of region file format to use for storing chunk data.", + "Valid values:", + " - MCA: Default Minecraft region file format", + " - LINEAR: Linear region file format V2", + " - B_LINEAR: Buffered region file format (just uses Zstd)")); + } catch (IllegalArgumentException ignore) { + LOGGER.warn("Invalid region file type: {}, resetting to default (MCA)", getString(ConfigCategory.REGION.key("type"), regionFileType.toString())); + regionFileType = EnumRegionFileExtension.MCA; + } + + threadCount = getInt(ConfigCategory.REGION.key("thread-count"), threadCount, + "The number of threads to use for IO operations."); + + if (threadCount < 1) { + LOGGER.warn("Invalid thread count: {}, resetting to default (4)", threadCount); + threadCount = 4; + } + + compressionLevel = getInt(ConfigCategory.REGION.key("compression-level"), compressionLevel, + "The compression level to use for the either linear or buffered linear region file format."); + + if (compressionLevel > 23 || compressionLevel < 1) { + LOGGER.warn("Invalid compression level: {}, resetting to default (4)", compressionLevel); + compressionLevel = 4; + } + } + + private static void linear() { + linearIoFlushDelayMs = getInt(ConfigCategory.REGION.key("linear.io-flush-delay-ms"), linearIoFlushDelayMs, + "The delay in milliseconds to wait before flushing IO operations."); + + linearImplementation = LinearImplementation.valueOf(getString(ConfigCategory.REGION.key("linear.implementation"), linearImplementation.name(), + "The implementation of the linear region file format to use.", + "Valid values:", + " - V1: Basic and default linear implementation", + " - V2: Introduces a grid-based compression scheme for better data management and flexibility (default)", + " - V3: Minor improvements over V2")); + } + + private static void buffered() { + checkIntervalMs = getInt(ConfigCategory.REGION.key("b-linear.check-interval-ms"), checkIntervalMs, + "The interval in milliseconds to check for dirty region files to flush."); + flushOfWriteTimeoutMs = getInt(ConfigCategory.REGION.key("b-linear.flush-of-write-timeout-ms"), flushOfWriteTimeoutMs, + "The timeout in milliseconds to wait before forcing a flush of a region file that is being written to."); + } + + private static void flusher() { + flusher = switch (regionFileType) { + case MCA -> null; + case LINEAR -> new LinearRegionFileFlusher(threadCount, linearIoFlushDelayMs); + case B_LINEAR -> new BufferedRegionFileFlusher(threadCount, checkIntervalMs, flushOfWriteTimeoutMs); + }; + } + } + private static void checkExperimentalFeatures() { List enabledExperimentalFeatures = new ArrayList<>(); diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/EnumRegionFileExtension.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/EnumRegionFileExtension.java index af9959a4..8d9b6202 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/region/EnumRegionFileExtension.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/EnumRegionFileExtension.java @@ -2,14 +2,16 @@ import net.minecraft.world.level.chunk.storage.RegionFile; import org.bxteam.divinemc.config.DivineConfig; -import org.bxteam.divinemc.region.type.BufferedRegionFile; -import org.bxteam.divinemc.region.type.LinearRegionFile; +import org.bxteam.divinemc.region.buffered.BufferedRegionFile; +import org.bxteam.divinemc.region.buffered.BufferedRegionFileFlusher; +import org.bxteam.divinemc.region.linear.LinearRegionFile; import org.jetbrains.annotations.Nullable; +import java.util.Arrays; public enum EnumRegionFileExtension { MCA("mca", "mca", (info) -> new RegionFile(info.info(), info.filePath(), info.folder(), info.sync())), - LINEAR("linear", "linear", (info) -> new LinearRegionFile(info.info(), info.filePath(), info.folder(), info.sync(), DivineConfig.MiscCategory.linearCompressionLevel)), - B_LINEAR("b_linear", "b_linear", (info) -> new BufferedRegionFile(info.filePath(), DivineConfig.MiscCategory.linearCompressionLevel, DivineConfig.MiscCategory.bLinearFlusher)); + LINEAR("linear", "linear", (info) -> new LinearRegionFile(info.filePath(), DivineConfig.RegionSettingsCategory.compressionLevel, DivineConfig.RegionSettingsCategory.linearImplementation)), + B_LINEAR("b_linear", "b_linear", (info) -> new BufferedRegionFile(info.filePath(), DivineConfig.RegionSettingsCategory.compressionLevel, (BufferedRegionFileFlusher) DivineConfig.RegionSettingsCategory.flusher)); private final String name; private final String argument; @@ -23,13 +25,7 @@ public enum EnumRegionFileExtension { @Nullable public static EnumRegionFileExtension fromString(String string) { - for (EnumRegionFileExtension format : values()) { - if (format.name.equalsIgnoreCase(string)) { - return format; - } - } - - return null; + return Arrays.stream(values()).filter(format -> format.name.equalsIgnoreCase(string)).findFirst().orElse(null); } public IRegionCreateFunction getCreator() { diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/Flusher.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/Flusher.java new file mode 100644 index 00000000..d279a48c --- /dev/null +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/Flusher.java @@ -0,0 +1,52 @@ +package org.bxteam.divinemc.region; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.mojang.logging.LogUtils; +import org.slf4j.Logger; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public abstract class Flusher implements Runnable { + public static final Logger logger = LogUtils.getLogger(); + public final ScheduledExecutorService scheduler; + public final ExecutorService executor; + public final long flushOfWriteTimeoutMs; + + public Flusher(int maxThreadCount, long checkIntervalMs, long flushOfWriteTimeoutMs) { + this.flushOfWriteTimeoutMs = flushOfWriteTimeoutMs; + scheduler = Executors.newSingleThreadScheduledExecutor( + new ThreadFactoryBuilder() + .setNameFormat("region-flush-scheduler") + .build() + ); + + scheduler.scheduleAtFixedRate(this, 0L, checkIntervalMs, TimeUnit.MILLISECONDS); + + ThreadFactoryBuilder factory = new ThreadFactoryBuilder() + .setNameFormat("region-flusher-%d"); + + executor = Executors.newFixedThreadPool( + maxThreadCount, + factory.build() + ); + } + + public abstract void shutdown(); + + public abstract void addFile(T t); + + public abstract void removeFile(T t); + + public final void shutdownExecutor(ExecutorService executor) { + executor.shutdown(); + try { + if (!executor.awaitTermination(10, TimeUnit.MINUTES)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + } + } +} diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/IRegionFile.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/IRegionFile.java index 9087f169..ec1dca23 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/region/IRegionFile.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/IRegionFile.java @@ -29,13 +29,21 @@ public interface IRegionFile extends ChunkSystemRegionFile, AutoCloseable { void write(ChunkPos pos, ByteBuffer buf) throws IOException; - CompoundTag getOversizedData(int x, int z) throws IOException; + default CompoundTag getOversizedData(int x, int z) throws IOException { + return null; + } - boolean isOversized(int x, int z); + default boolean isOversized(int x, int z) { + return false; + } - boolean recalculateHeader() throws IOException; + default boolean recalculateHeader() throws IOException { + return false; + } - void setOversized(int x, int z, boolean oversized) throws IOException; + default void setOversized(int x, int z, boolean oversized) throws IOException { + + } default int getRecalculateCount() { return 0; diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/type/BufferedRegionFile.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/buffered/BufferedRegionFile.java similarity index 98% rename from divinemc-server/src/main/java/org/bxteam/divinemc/region/type/BufferedRegionFile.java rename to divinemc-server/src/main/java/org/bxteam/divinemc/region/buffered/BufferedRegionFile.java index 8650f6f8..71ed5db0 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/region/type/BufferedRegionFile.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/buffered/BufferedRegionFile.java @@ -1,4 +1,4 @@ -package org.bxteam.divinemc.region.type; +package org.bxteam.divinemc.region.buffered; import ca.spottedleaf.concurrentutil.util.ConcurrentUtil; import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO; @@ -13,7 +13,6 @@ import net.minecraft.world.level.ChunkPos; import org.apache.commons.lang3.Validate; import org.bxteam.divinemc.region.IRegionFile; -import org.bxteam.divinemc.region.flusher.BufferedRegionFileFlusher; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -599,26 +598,6 @@ public void write(@NotNull ChunkPos pos, ByteBuffer buf) throws IOException { this.writeChunk(pos.x, pos.z, buf); } - // MCC 的玩意,这东西也用不上给Linear了() - @Override - public CompoundTag getOversizedData(int x, int z) { - return null; - } - - @Override - public boolean isOversized(int x, int z) { - return false; - } - - @Override - public boolean recalculateHeader() { - return false; - } - - @Override - public void setOversized(int x, int z, boolean oversized) { - - } // MCC end @Override diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/flusher/BufferedRegionFileFlusher.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/buffered/BufferedRegionFileFlusher.java similarity index 56% rename from divinemc-server/src/main/java/org/bxteam/divinemc/region/flusher/BufferedRegionFileFlusher.java rename to divinemc-server/src/main/java/org/bxteam/divinemc/region/buffered/BufferedRegionFileFlusher.java index c55dd16a..f51f3b20 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/region/flusher/BufferedRegionFileFlusher.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/buffered/BufferedRegionFileFlusher.java @@ -1,67 +1,28 @@ -package org.bxteam.divinemc.region.flusher; +package org.bxteam.divinemc.region.buffered; -import com.mojang.logging.LogUtils; import it.unimi.dsi.fastutil.objects.ObjectArrayList; import it.unimi.dsi.fastutil.objects.ObjectArraySet; -import org.apache.commons.lang3.Validate; -import org.bxteam.divinemc.region.type.BufferedRegionFile; -import org.bxteam.divinemc.util.NamedAgnosticThreadFactory; -import org.slf4j.Logger; - import java.io.IOException; import java.util.List; import java.util.Set; -import java.util.concurrent.*; - -public class BufferedRegionFileFlusher implements Runnable { - private static final Logger logger = LogUtils.getLogger(); +import java.util.concurrent.TimeUnit; +import org.apache.commons.lang3.Validate; +import org.bxteam.divinemc.region.Flusher; +public class BufferedRegionFileFlusher extends Flusher { private final Set inManagement = new ObjectArraySet<>(); - private final ScheduledFuture flusherChecker; - private final Executor ioWorkerPool; - private final long flushOfWriteTimeoutMs; public BufferedRegionFileFlusher(int nIoThreads, long checkIntervalMs, long flushOfWriteTimeoutMs) { + super(nIoThreads, checkIntervalMs, flushOfWriteTimeoutMs); Validate.isTrue(nIoThreads > 0, "Number of I/O threads must > 0!"); Validate.isTrue(checkIntervalMs > 0, "Check interval must > 0"); Validate.isTrue(flushOfWriteTimeoutMs > 0, "Flush of write timeout must > 0"); - this.ioWorkerPool = Executors.newFixedThreadPool(nIoThreads, new NamedAgnosticThreadFactory<>( - "BufferedRegionFile I/O Worker", - (group, runnable, name) -> { - Thread thread = new Thread(group, runnable, name); - thread.setDaemon(true); - return thread; - }, - Thread.NORM_PRIORITY - ) - ); - this.flusherChecker = Executors.newSingleThreadScheduledExecutor(new NamedAgnosticThreadFactory<>( - "BufferedRegionFile Flusher Checker", - (group, runnable, name) -> { - Thread thread = new Thread(group, runnable, name); - thread.setDaemon(true); - return thread; - }, - Thread.NORM_PRIORITY - ) - ).scheduleWithFixedDelay(this, checkIntervalMs, checkIntervalMs, TimeUnit.MILLISECONDS); - this.flushOfWriteTimeoutMs = flushOfWriteTimeoutMs; } public void shutdown() { - this.flusherChecker.cancel(false); - - ((ExecutorService) this.ioWorkerPool).shutdown(); - for (; ; ) { - try { - if (((ExecutorService) this.ioWorkerPool).awaitTermination(100, TimeUnit.MILLISECONDS)) { - break; - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } + shutdownExecutor(scheduler); + shutdownExecutor(executor); } @Override @@ -104,7 +65,7 @@ public void run() { continue; } - this.ioWorkerPool.execute(() -> { + executor.execute(() -> { try { file.syncIfNeeded(); } catch (IOException e) { diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearBase.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearBase.java new file mode 100644 index 00000000..99d5d56e --- /dev/null +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearBase.java @@ -0,0 +1,42 @@ +package org.bxteam.divinemc.region.linear; + +import java.nio.file.Path; +import net.jpountz.lz4.LZ4Compressor; +import net.jpountz.lz4.LZ4Factory; +import net.jpountz.lz4.LZ4FastDecompressor; +import org.bxteam.divinemc.region.linear.versions.V1Linear; +import org.bxteam.divinemc.region.linear.versions.V2Linear; +import org.bxteam.divinemc.region.linear.versions.V3Linear; +import org.bxteam.divinemc.region.linear.versions.Version; +import org.slf4j.Logger; + +public abstract class LinearBase implements Version { + + protected static final Logger LOGGER = LinearRegionFile.LOGGER; + protected static final long SUPERBLOCK = LinearRegionFile.SUPERBLOCK; + protected final Path regionFilePath; + protected final byte[][] chunkCompressedBuffers = new byte[1024][]; + protected final int[] chunkUncompressedSizes = new int[1024]; + protected final long[] chunkTimestamps = new long[1024]; + protected final LZ4Compressor compressor = LZ4Factory.fastestInstance().fastCompressor(); + protected final LZ4FastDecompressor decompressor = LZ4Factory.fastestInstance().fastDecompressor(); + protected final int compressionLevel; + + public LinearBase(Path regionFilePath, int compressionLevel) { + this.regionFilePath = regionFilePath; + this.compressionLevel = compressionLevel; + } + + protected static int currentTimestamp() { + return (int) (System.currentTimeMillis() / 1000L); + } + + public static LinearBase getShared(byte version, Path regionFilePath, int compressionLevel) { + return switch (version) { + case 1, 2 -> new V1Linear(regionFilePath, compressionLevel); + case 3 -> new V2Linear(regionFilePath, compressionLevel); + case 4 -> new V3Linear(regionFilePath, compressionLevel); + default -> throw new RuntimeException("Invalid version: " + version + " file " + regionFilePath); + }; + } +} diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearImplementation.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearImplementation.java new file mode 100644 index 00000000..aa3e4f05 --- /dev/null +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearImplementation.java @@ -0,0 +1,18 @@ +package org.bxteam.divinemc.region.linear; + +public enum LinearImplementation { + V1(2), + V2(3), + V3(4), + ; + + private final int version; + + LinearImplementation(int i) { + this.version = i; + } + + public byte version() { + return (byte) version; + } +} diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearRegionFile.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearRegionFile.java new file mode 100644 index 00000000..7df584a1 --- /dev/null +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearRegionFile.java @@ -0,0 +1,305 @@ +package org.bxteam.divinemc.region.linear; + +import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO; +import com.github.luben.zstd.ZstdInputStream; +import com.mojang.logging.LogUtils; +import java.io.BufferedOutputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicBoolean; +import net.jpountz.lz4.LZ4Compressor; +import net.jpountz.lz4.LZ4Factory; +import net.jpountz.lz4.LZ4FastDecompressor; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.ChunkPos; +import org.bxteam.divinemc.config.DivineConfig; +import org.bxteam.divinemc.region.IRegionFile; +import org.bxteam.divinemc.region.linear.versions.V1Linear; +import org.bxteam.divinemc.region.linear.versions.V2Linear; +import org.bxteam.divinemc.region.linear.versions.V3Linear; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; + +public class LinearRegionFile implements IRegionFile { + protected static final int MAX_CHUNK_SIZE = 500 * 1024 * 1024; + protected static final long SUPERBLOCK = 0xc3ff13183cca9d9aL; + protected static final Logger LOGGER = LogUtils.getLogger(); + + protected final LZ4Compressor compressor = LZ4Factory.fastestInstance().fastCompressor(); + protected final LZ4FastDecompressor decompressor = LZ4Factory.fastestInstance().fastDecompressor(); + private final LinearBase linearBase; + + private final Path regionFilePath; + + private final AtomicBoolean markedToSave = new AtomicBoolean(false); + protected boolean closed = false; + + public LinearRegionFile(Path path, int compressionLevel, LinearImplementation linearImplementation) { + this.regionFilePath = path; + linearBase = switch (linearImplementation) { + case V1 -> new V1Linear(path, compressionLevel); + case V2 -> new V2Linear(path, compressionLevel); + case V3 -> new V3Linear(path, compressionLevel); + case null -> throw new IllegalStateException("Unexpected value: " + null); + }; + + File file = regionFilePath.toFile(); + if (!file.canRead()) { + return; + } + + try { + byte[] fileContent = Files.readAllBytes(regionFilePath); + ByteBuffer byteBuffer = ByteBuffer.wrap(fileContent); + + long superBlock = byteBuffer.getLong(); + if (superBlock != SUPERBLOCK) { + throw new RuntimeException("Invalid superblock: " + superBlock + " file " + regionFilePath); + } + + byte version = byteBuffer.get(); + if (version == 1) version = 2; + + if (linearBase.version() == version) { + linearBase.parse(byteBuffer); + } else { + synchronized (linearBase) { + LOGGER.info("Converting region file {} from version {} to version {}", + regionFilePath, version, linearBase.version()); + + // Create a temporary shared instance for the old version + LinearBase linearBaseWrong = LinearBase.getShared(version, regionFilePath, linearBase.compressionLevel); + + // Parse with the old format parser + linearBaseWrong.parse(byteBuffer); + + if (linearBaseWrong instanceof V2LinearBase v2LinearBase) { + v2LinearBase.extractChunksFromBuckets(); + } + + // Transfer data from old shared to current shared + for (int i = 0; i < 1024; i++) { + if (linearBaseWrong.chunkUncompressedSizes[i] > 0) { + linearBase.chunkCompressedBuffers[i] = linearBaseWrong.chunkCompressedBuffers[i]; + linearBase.chunkUncompressedSizes[i] = linearBaseWrong.chunkUncompressedSizes[i]; + linearBase.chunkTimestamps[i] = linearBaseWrong.chunkTimestamps[i]; + } + } + + LOGGER.info("Successfully converted region file {} to version {}", + regionFilePath, linearBase.version()); + markToSave(); + } + } + } catch (IOException e) { + throw new RuntimeException("Failed to open region file " + regionFilePath, e); + } + } + + private static int getChunkIndex(int x, int z) { + return (x & 31) + ((z & 31) << 5); + } + + private static int currentTimestamp() { + return (int) (System.currentTimeMillis() / 1000L); + } + + @Override + public Path getPath() { + return regionFilePath; + } + + @Override + public synchronized void flush() throws IOException { + if (isMarkedToSave()) linearBase.flush(); + } + + private void markToSave() { + ((LinearRegionFileFlusher) DivineConfig.RegionSettingsCategory.flusher).addFile(this); + markedToSave.set(true); + } + + public boolean isMarkedToSave() { + return markedToSave.getAndSet(false); + } + + public void flushWrapper() { + try { + linearBase.flush(); + } catch (IOException e) { + LOGGER.error("Failed to flush region file {}", regionFilePath.toAbsolutePath(), e); + } + } + + @Override + public synchronized boolean doesChunkExist(ChunkPos pos) { + return hasChunk(pos); + } + + @Override + public synchronized boolean hasChunk(ChunkPos pos) { + openBucketForChunk(pos.x, pos.z); + int index = getChunkIndex(pos.x, pos.z); + return linearBase.chunkUncompressedSizes[index] > 0; + } + + private void openBucketForChunk(int chunkX, int chunkZ) { + if (!(linearBase instanceof V2LinearBase v2Shared)) return; + + int modX = Math.floorMod(chunkX, 32); + int modZ = Math.floorMod(chunkZ, 32); + int bucketSize = v2Shared.bucketSize; + int gridSize = v2Shared.gridSize; + + int bucketIdx = (modX / bucketSize) * gridSize + (modZ / bucketSize); + if (v2Shared.bucketBuffers == null || v2Shared.bucketBuffers[bucketIdx] == null) { + return; + } + + try (ByteArrayInputStream bucketBAIS = new ByteArrayInputStream(v2Shared.bucketBuffers[bucketIdx]); + ZstdInputStream bucketZstdIn = new ZstdInputStream(bucketBAIS)) { + + ByteBuffer bucketBuffer = ByteBuffer.wrap(bucketZstdIn.readAllBytes()); + int cellsPerBucket = 32 / gridSize; + int bx = modX / bucketSize, bz = modZ / bucketSize; + for (int cx = 0; cx < cellsPerBucket; cx++) { + for (int cz = 0; cz < cellsPerBucket; cz++) { + int chunkIndex = (bx * cellsPerBucket + cx) + (bz * cellsPerBucket + cz) * 32; + int chunkSize = bucketBuffer.getInt(); + long timestamp = bucketBuffer.getLong(); + v2Shared.chunkTimestamps[chunkIndex] = timestamp; + + if (chunkSize > 0) { + byte[] chunkData = new byte[chunkSize - 8]; + bucketBuffer.get(chunkData); + + int maxCompressedLength = compressor.maxCompressedLength(chunkData.length); + byte[] compressed = new byte[maxCompressedLength]; + int compressedLength = compressor.compress(chunkData, 0, chunkData.length, compressed, 0, maxCompressedLength); + byte[] finalCompressed = new byte[compressedLength]; + System.arraycopy(compressed, 0, finalCompressed, 0, compressedLength); + + v2Shared.chunkCompressedBuffers[chunkIndex] = finalCompressed; + v2Shared.chunkUncompressedSizes[chunkIndex] = chunkData.length; + } + } + } + } catch (IOException ex) { + throw new RuntimeException("Region file corrupted: " + regionFilePath + " bucket: " + bucketIdx, ex); + } + v2Shared.bucketBuffers[bucketIdx] = null; + } + + @Override + public synchronized void write(ChunkPos pos, ByteBuffer buffer) { + openBucketForChunk(pos.x, pos.z); + try { + byte[] rawData = toByteArray(new ByteArrayInputStream(buffer.array())); + int uncompressedSize = rawData.length; + if (uncompressedSize > MAX_CHUNK_SIZE) { + LOGGER.error("Chunk dupe attempt {}", regionFilePath); + clear(pos); + } else { + int maxCompressedLength = compressor.maxCompressedLength(uncompressedSize); + byte[] compressed = new byte[maxCompressedLength]; + int compressedLength = compressor.compress(rawData, 0, uncompressedSize, compressed, 0, maxCompressedLength); + byte[] finalCompressed = new byte[compressedLength]; + System.arraycopy(compressed, 0, finalCompressed, 0, compressedLength); + + int index = getChunkIndex(pos.x, pos.z); + linearBase.chunkCompressedBuffers[index] = finalCompressed; + linearBase.chunkTimestamps[index] = currentTimestamp(); + linearBase.chunkUncompressedSizes[index] = uncompressedSize; + } + } catch (IOException e) { + LOGGER.error("Chunk write IOException {} {}", e, regionFilePath); + } + markToSave(); + } + + @Override + public DataOutputStream getChunkDataOutputStream(ChunkPos pos) { + openBucketForChunk(pos.x, pos.z); + return new DataOutputStream(new BufferedOutputStream(new ChunkBuffer(pos))); + } + + private byte[] toByteArray(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] tempBuffer = new byte[4096]; + int length; + while ((length = in.read(tempBuffer)) >= 0) { + out.write(tempBuffer, 0, length); + } + return out.toByteArray(); + } + + @Nullable + @Override + public synchronized DataInputStream getChunkDataInputStream(ChunkPos pos) { + openBucketForChunk(pos.x, pos.z); + int index = getChunkIndex(pos.x, pos.z); + if (linearBase.chunkUncompressedSizes[index] != 0) { + byte[] decompressed = new byte[linearBase.chunkUncompressedSizes[index]]; + decompressor.decompress(linearBase.chunkCompressedBuffers[index], 0, decompressed, 0, linearBase.chunkUncompressedSizes[index]); + return new DataInputStream(new ByteArrayInputStream(decompressed)); + } + return null; + } + + @Override + public synchronized void clear(ChunkPos pos) { + openBucketForChunk(pos.x, pos.z); + int index = getChunkIndex(pos.x, pos.z); + linearBase.chunkCompressedBuffers[index] = null; + linearBase.chunkUncompressedSizes[index] = 0; + linearBase.chunkTimestamps[index] = 0; + markToSave(); + } + + @Override + public synchronized void close() throws IOException { + if (closed) return; + closed = true; + flush(); + } + + @Override + public MoonriseRegionFileIO.RegionDataController.WriteData moonrise$startWrite(CompoundTag data, ChunkPos pos) { + DataOutputStream out = getChunkDataOutputStream(pos); + return new MoonriseRegionFileIO.RegionDataController.WriteData( + data, + MoonriseRegionFileIO.RegionDataController.WriteData.WriteResult.WRITE, + out, + regionFile -> { + try { + out.close(); + } catch (IOException e) { + LOGGER.error("Failed to close region file stream", e); + } + } + ); + } + + private class ChunkBuffer extends ByteArrayOutputStream { + private final ChunkPos pos; + + public ChunkBuffer(ChunkPos pos) { + super(); + this.pos = pos; + } + + @Override + public void close() { + ByteBuffer byteBuffer = ByteBuffer.wrap(this.buf, 0, this.count); + LinearRegionFile.this.write(this.pos, byteBuffer); + } + } +} diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearRegionFileFlusher.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearRegionFileFlusher.java new file mode 100644 index 00000000..31bfcd22 --- /dev/null +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/LinearRegionFileFlusher.java @@ -0,0 +1,40 @@ +package org.bxteam.divinemc.region.linear; + +import java.util.Queue; +import java.util.concurrent.LinkedBlockingQueue; +import org.bxteam.divinemc.region.Flusher; + +public class LinearRegionFileFlusher extends Flusher { + private final Queue savingQueue = new LinkedBlockingQueue<>(); + + public LinearRegionFileFlusher(int maxThreadCount, long flushOfWriteTimeoutMs) { + super(maxThreadCount, flushOfWriteTimeoutMs, flushOfWriteTimeoutMs); + } + + @Override + public void run() { + while (!savingQueue.isEmpty()) { + LinearRegionFile regionFile = savingQueue.poll(); + if (!regionFile.closed && regionFile.isMarkedToSave()) + executor.execute(regionFile::flushWrapper); + } + } + + @Override + public void addFile(LinearRegionFile regionFile) { + if (savingQueue.contains(regionFile)) return; + savingQueue.add(regionFile); + } + + @Override + public void removeFile(LinearRegionFile linearRegionFile) { + + } + + @Override + public void shutdown() { + run(); + shutdownExecutor(executor); + shutdownExecutor(scheduler); + } +} diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/V2LinearBase.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/V2LinearBase.java new file mode 100644 index 00000000..464850e3 --- /dev/null +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/V2LinearBase.java @@ -0,0 +1,132 @@ +package org.bxteam.divinemc.region.linear; + +import com.github.luben.zstd.ZstdInputStream; +import com.github.luben.zstd.ZstdOutputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.file.Path; + +public abstract class V2LinearBase extends LinearBase { + protected final int gridSizeDefault = 8; + protected int gridSize = gridSizeDefault; + protected int bucketSize = 4; + protected byte[][] bucketBuffers; + + public V2LinearBase(Path regionFilePath, int compressionLevel) { + super(regionFilePath, compressionLevel); + } + + protected byte[][] buildBuckets() throws IOException { + int bucketCount = gridSize * gridSize; + byte[][] buckets = new byte[bucketCount][]; + + for (int bx = 0; bx < gridSize; bx++) { + for (int bz = 0; bz < gridSize; bz++) { + int bucketIdx = bx * gridSize + bz; + if (bucketBuffers != null && bucketBuffers[bucketIdx] != null) { + buckets[bucketIdx] = bucketBuffers[bucketIdx]; + continue; + } + + try (ByteArrayOutputStream bucketBAOS = new ByteArrayOutputStream(); + ZstdOutputStream bucketZstdOut = new ZstdOutputStream(bucketBAOS, compressionLevel); + DataOutputStream bucketDataOut = new DataOutputStream(bucketZstdOut)) { + + boolean hasData = false; + int cellCount = 32 / gridSize; + for (int cx = 0; cx < cellCount; cx++) { + for (int cz = 0; cz < cellCount; cz++) { + int chunkIndex = (bx * cellCount + cx) + (bz * cellCount + cz) * 32; + if (chunkUncompressedSizes[chunkIndex] > 0) { + hasData = true; + byte[] chunkData = new byte[chunkUncompressedSizes[chunkIndex]]; + decompressor.decompress(chunkCompressedBuffers[chunkIndex], 0, chunkData, 0, chunkUncompressedSizes[chunkIndex]); + bucketDataOut.writeInt(chunkData.length + 8); + bucketDataOut.writeLong(chunkTimestamps[chunkIndex]); + bucketDataOut.write(chunkData); + } else { + bucketDataOut.writeInt(0); + bucketDataOut.writeLong(chunkTimestamps[chunkIndex]); + } + } + } + bucketDataOut.close(); + if (hasData) { + buckets[bucketIdx] = bucketBAOS.toByteArray(); + } + } + } + } + return buckets; + } + + /** + * Extracts chunk data from bucketBuffers and populates chunkCompressedBuffers, chunkUncompressedSizes, + * and chunkTimestamps arrays. + * + * @throws IOException if there's an error during decompression or reading the data + */ + protected void extractChunksFromBuckets() throws IOException { + if (bucketBuffers == null) { + return; + } + + // Reset chunk data arrays + for (int i = 0; i < 1024; i++) { + chunkCompressedBuffers[i] = null; + chunkUncompressedSizes[i] = 0; + chunkTimestamps[i] = 0; + } + + int cellCount = 32 / gridSize; + + for (int bx = 0; bx < gridSize; bx++) { + for (int bz = 0; bz < gridSize; bz++) { + int bucketIdx = bx * gridSize + bz; + byte[] bucketData = bucketBuffers[bucketIdx]; + + if (bucketData == null || bucketData.length == 0) { + continue; + } + + try (ByteArrayInputStream bucketBAIS = new ByteArrayInputStream(bucketData); + ZstdInputStream bucketZstdIn = new ZstdInputStream(bucketBAIS); + DataInputStream bucketDataIn = new DataInputStream(bucketZstdIn)) { + + for (int cx = 0; cx < cellCount; cx++) { + for (int cz = 0; cz < cellCount; cz++) { + int chunkIndex = (bx * cellCount + cx) + (bz * cellCount + cz) * 32; + + int dataSize = bucketDataIn.readInt(); + long timestamp = bucketDataIn.readLong(); + chunkTimestamps[chunkIndex] = timestamp; + + if (dataSize > 0) { + // Real size is dataSize - 8 (timestamp size) + int chunkSize = dataSize - 8; + byte[] chunkData = new byte[chunkSize]; + bucketDataIn.readFully(chunkData); + + // Compress the chunk data using LZ4 + int maxCompressedSize = compressor.maxCompressedLength(chunkSize); + byte[] compressedData = new byte[maxCompressedSize]; + int compressedSize = compressor.compress(chunkData, 0, chunkSize, + compressedData, 0, maxCompressedSize); + + // Trim the compressed data to actual size + byte[] trimmedCompressedData = new byte[compressedSize]; + System.arraycopy(compressedData, 0, trimmedCompressedData, 0, compressedSize); + + chunkCompressedBuffers[chunkIndex] = trimmedCompressedData; + chunkUncompressedSizes[chunkIndex] = chunkSize; + } + } + } + } + } + } + } +} diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/V1Linear.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/V1Linear.java new file mode 100644 index 00000000..16bee0a3 --- /dev/null +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/V1Linear.java @@ -0,0 +1,129 @@ +package org.bxteam.divinemc.region.linear.versions; + +import com.github.luben.zstd.ZstdInputStream; +import com.github.luben.zstd.ZstdOutputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import org.bxteam.divinemc.region.linear.LinearBase; +import org.bxteam.divinemc.region.linear.LinearImplementation; + +public class V1Linear extends LinearBase { + public V1Linear(Path regionFilePath, int compressionLevel) { + super(regionFilePath, compressionLevel); + } + + @Override + public LinearImplementation implementation() { + return LinearImplementation.V1; + } + + @Override + public void parse(ByteBuffer buffer) throws IOException { + final int HEADER_SIZE = 32; + final int FOOTER_SIZE = 8; + buffer.position(buffer.position() + 11); + + int dataCount = buffer.getInt(); + long fileLength = regionFilePath.toFile().length(); + if (fileLength != HEADER_SIZE + dataCount + FOOTER_SIZE) { + throw new IOException("Invalid file length: " + regionFilePath + " " + fileLength + " expected " + (HEADER_SIZE + dataCount + FOOTER_SIZE)); + } + + buffer.position(buffer.position() + 8); + + byte[] rawCompressed = new byte[dataCount]; + buffer.get(rawCompressed); + + try (ByteArrayInputStream bais = new ByteArrayInputStream(rawCompressed); + ZstdInputStream zstdIn = new ZstdInputStream(bais)) { + ByteBuffer decompressedBuffer = ByteBuffer.wrap(zstdIn.readAllBytes()); + int[] starts = new int[1024]; + for (int i = 0; i < 1024; i++) { + starts[i] = decompressedBuffer.getInt(); + decompressedBuffer.getInt(); + } + + for (int i = 0; i < 1024; i++) { + if (starts[i] > 0) { + int size = starts[i]; + byte[] chunkData = new byte[size]; + decompressedBuffer.get(chunkData); + + int maxCompressedLength = compressor.maxCompressedLength(size); + byte[] compressed = new byte[maxCompressedLength]; + int compressedLength = compressor.compress(chunkData, 0, size, compressed, 0, maxCompressedLength); + byte[] finalCompressed = new byte[compressedLength]; + System.arraycopy(compressed, 0, finalCompressed, 0, compressedLength); + + chunkCompressedBuffers[i] = finalCompressed; + chunkUncompressedSizes[i] = size; + chunkTimestamps[i] = currentTimestamp(); + } + } + } + } + + @Override + public synchronized void flush() throws IOException { + long timestamp = currentTimestamp(); + short chunkCount = 0; + File tempFile = new File(regionFilePath.toString() + ".tmp"); + + try (FileOutputStream fos = new FileOutputStream(tempFile); + ByteArrayOutputStream zstdBAOS = new ByteArrayOutputStream(); + ZstdOutputStream zstdOut = new ZstdOutputStream(zstdBAOS, compressionLevel); + DataOutputStream zstdDataOut = new DataOutputStream(zstdOut); + DataOutputStream fileDataOut = new DataOutputStream(fos)) { + + fileDataOut.writeLong(SUPERBLOCK); + fileDataOut.writeByte(version()); + fileDataOut.writeLong(timestamp); + fileDataOut.writeByte(compressionLevel); + + ArrayList decompressedChunks = new ArrayList<>(1024); + for (int i = 0; i < 1024; i++) { + if (chunkUncompressedSizes[i] != 0) { + chunkCount++; + byte[] decompressed = new byte[chunkUncompressedSizes[i]]; + decompressor.decompress(chunkCompressedBuffers[i], 0, decompressed, 0, chunkUncompressedSizes[i]); + decompressedChunks.add(decompressed); + } else { + decompressedChunks.add(null); + } + } + + for (int i = 0; i < 1024; i++) { + zstdDataOut.writeInt(chunkUncompressedSizes[i]); + zstdDataOut.writeInt((int) chunkTimestamps[i]); + } + + for (int i = 0; i < 1024; i++) { + if (decompressedChunks.get(i) != null) { + zstdDataOut.write(decompressedChunks.get(i)); + } + } + zstdDataOut.close(); + + fileDataOut.writeShort(chunkCount); + byte[] compressedZstdData = zstdBAOS.toByteArray(); + fileDataOut.writeInt(compressedZstdData.length); + fileDataOut.writeLong(0); + fileDataOut.write(compressedZstdData); + fileDataOut.writeLong(SUPERBLOCK); + + fileDataOut.flush(); + fos.getFD().sync(); + fos.getChannel().force(true); + } + Files.move(tempFile.toPath(), regionFilePath, StandardCopyOption.REPLACE_EXISTING); + } +} diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/V2Linear.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/V2Linear.java new file mode 100644 index 00000000..366b0d00 --- /dev/null +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/V2Linear.java @@ -0,0 +1,168 @@ +package org.bxteam.divinemc.region.linear.versions; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import net.openhft.hashing.LongHashFunction; +import org.bxteam.divinemc.region.linear.LinearImplementation; +import org.bxteam.divinemc.region.linear.V2LinearBase; + +public class V2Linear extends V2LinearBase { + public V2Linear(Path regionFilePath, int compressionLevel) { + super(regionFilePath, compressionLevel); + } + + @Override + public LinearImplementation implementation() { + return LinearImplementation.V2; + } + + @Override + public void parse(ByteBuffer buffer) throws IOException { + buffer.getLong(); + gridSize = buffer.get(); + if (!(gridSize == 1 || gridSize == 2 || gridSize == 4 || gridSize == 8 || gridSize == 16 || gridSize == 32)) { + throw new RuntimeException("Invalid grid size: " + gridSize + " file " + regionFilePath); + } + bucketSize = 32 / gridSize; + + buffer.getInt(); + buffer.getInt(); + + boolean[] chunkExistenceBitmap = deserializeExistenceBitmap(buffer); + + while (true) { + byte featureNameLength = buffer.get(); + if (featureNameLength == 0) break; + byte[] featureNameBytes = new byte[featureNameLength]; + buffer.get(featureNameBytes); + String featureName = new String(featureNameBytes); + int featureValue = buffer.getInt(); + } + + int bucketCount = gridSize * gridSize; + int[] bucketSizes = new int[bucketCount]; + byte[] bucketCompressionLevels = new byte[bucketCount]; + long[] bucketHashes = new long[bucketCount]; + + for (int i = 0; i < bucketCount; i++) { + bucketSizes[i] = buffer.getInt(); + bucketCompressionLevels[i] = buffer.get(); + bucketHashes[i] = buffer.getLong(); + } + + bucketBuffers = new byte[bucketCount][]; + for (int i = 0; i < bucketCount; i++) { + if (bucketSizes[i] > 0) { + bucketBuffers[i] = new byte[bucketSizes[i]]; + buffer.get(bucketBuffers[i]); + long rawHash = LongHashFunction.xx().hashBytes(bucketBuffers[i]); + if (rawHash != bucketHashes[i]) { + throw new IOException("Region file hash incorrect " + regionFilePath); + } + } + } + + long footerSuperBlock = buffer.getLong(); + if (footerSuperBlock != SUPERBLOCK) { + throw new IOException("Footer superblock invalid " + regionFilePath); + } + } + + @Override + public synchronized void flush() throws IOException { + long timestamp = currentTimestamp(); + File tempFile = new File(regionFilePath.toString() + ".tmp"); + + try (FileOutputStream fos = new FileOutputStream(tempFile); + DataOutputStream dataOut = new DataOutputStream(fos)) { + + dataOut.writeLong(SUPERBLOCK); + dataOut.writeByte(version()); + dataOut.writeLong(timestamp); + dataOut.writeByte(gridSize); + + int[] regionCoords = parseRegionCoordinates(regionFilePath.getFileName().toString()); + dataOut.writeInt(regionCoords[0]); + dataOut.writeInt(regionCoords[1]); + + boolean[] chunkExistence = new boolean[1024]; + for (int i = 0; i < 1024; i++) { + chunkExistence[i] = (chunkUncompressedSizes[i] > 0); + } + writeExistenceBitmap(dataOut, chunkExistence); + + writeNBTFeatures(dataOut); + + byte[][] buckets = buildBuckets(); + + int bucketCount = gridSize * gridSize; + for (int i = 0; i < bucketCount; i++) { + dataOut.writeInt(buckets[i] != null ? buckets[i].length : 0); + dataOut.writeByte(compressionLevel); + long bucketHash = buckets[i] != null ? LongHashFunction.xx().hashBytes(buckets[i]) : 0; + dataOut.writeLong(bucketHash); + } + for (int i = 0; i < bucketCount; i++) { + if (buckets[i] != null) { + dataOut.write(buckets[i]); + } + } + dataOut.writeLong(SUPERBLOCK); + + dataOut.flush(); + fos.getFD().sync(); + fos.getChannel().force(true); + } + Files.move(tempFile.toPath(), regionFilePath, StandardCopyOption.REPLACE_EXISTING); + } + + private boolean[] deserializeExistenceBitmap(ByteBuffer buffer) { + boolean[] result = new boolean[1024]; + for (int i = 0; i < 128; i++) { + byte b = buffer.get(); + for (int j = 0; j < 8; j++) { + result[i * 8 + j] = ((b >> (7 - j)) & 1) == 1; + } + } + return result; + } + + private void writeExistenceBitmap(DataOutputStream out, boolean[] bitmap) throws IOException { + for (int i = 0; i < 128; i++) { + byte b = 0; + for (int j = 0; j < 8; j++) { + if (bitmap[i * 8 + j]) { + b |= (1 << (7 - j)); + } + } + out.writeByte(b); + } + } + + private void writeNBTFeatures(DataOutputStream dataOut) throws IOException { + dataOut.writeByte(0); + } + + protected int[] parseRegionCoordinates(String fileName) { + int regionX = 0; + int regionZ = 0; + String[] parts = fileName.split("\\."); + if (parts.length >= 4) { + try { + regionX = Integer.parseInt(parts[1]); + regionZ = Integer.parseInt(parts[2]); + } catch (NumberFormatException e) { + LOGGER.error("Failed to parse region coordinates from file name: {}", fileName, e); + } + } else { + LOGGER.warn("Unexpected file name format: {}", fileName); + } + return new int[]{regionX, regionZ}; + } +} diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/V3Linear.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/V3Linear.java new file mode 100644 index 00000000..51e01ce5 --- /dev/null +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/V3Linear.java @@ -0,0 +1,92 @@ +package org.bxteam.divinemc.region.linear.versions; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import net.openhft.hashing.LongHashFunction; +import org.bxteam.divinemc.region.linear.LinearImplementation; +import org.bxteam.divinemc.region.linear.V2LinearBase; + +public class V3Linear extends V2LinearBase { + public V3Linear(Path regionFilePath, int compressionLevel) { + super(regionFilePath, compressionLevel); + } + + @Override + public LinearImplementation implementation() { + return LinearImplementation.V3; + } + + @Override + public void parse(ByteBuffer buffer) throws IOException { + gridSize = buffer.get(); + if (!(gridSize == 1 || gridSize == 2 || gridSize == 4 || gridSize == 8 || gridSize == 16 || gridSize == 32)) { + throw new RuntimeException("Invalid grid size: " + gridSize + " file " + regionFilePath); + } + bucketSize = 32 / gridSize; + + int bucketCount = gridSize * gridSize; + int[] bucketSizes = new int[bucketCount]; + long[] bucketHashes = new long[bucketCount]; + + for (int i = 0; i < bucketCount; i++) { + bucketSizes[i] = buffer.getInt(); + bucketHashes[i] = buffer.getLong(); + } + + bucketBuffers = new byte[bucketCount][]; + for (int i = 0; i < bucketCount; i++) { + if (bucketSizes[i] > 0) { + bucketBuffers[i] = new byte[bucketSizes[i]]; + buffer.get(bucketBuffers[i]); + long rawHash = LongHashFunction.xx().hashBytes(bucketBuffers[i]); + if (rawHash != bucketHashes[i]) { + throw new IOException("Region file hash incorrect " + regionFilePath); + } + } + } + + long footerSuperBlock = buffer.getLong(); + if (footerSuperBlock != SUPERBLOCK) { + throw new IOException("Footer superblock invalid " + regionFilePath); + } + } + + @Override + public synchronized void flush() throws IOException { + File tempFile = new File(regionFilePath.toString() + ".tmp"); + + try (FileOutputStream fos = new FileOutputStream(tempFile); + DataOutputStream dataOut = new DataOutputStream(fos)) { + + dataOut.writeLong(SUPERBLOCK); + dataOut.writeByte(version()); + dataOut.writeByte(gridSize); + + byte[][] buckets = buildBuckets(); + + int bucketCount = gridSize * gridSize; + for (int i = 0; i < bucketCount; i++) { + dataOut.writeInt(buckets[i] != null ? buckets[i].length : 0); + long bucketHash = buckets[i] != null ? LongHashFunction.xx().hashBytes(buckets[i]) : 0; + dataOut.writeLong(bucketHash); + } + for (int i = 0; i < bucketCount; i++) { + if (buckets[i] != null) { + dataOut.write(buckets[i]); + } + } + dataOut.writeLong(SUPERBLOCK); + + dataOut.flush(); + fos.getFD().sync(); + fos.getChannel().force(true); + } + Files.move(tempFile.toPath(), regionFilePath, StandardCopyOption.REPLACE_EXISTING); + } +} diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/Version.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/Version.java new file mode 100644 index 00000000..8b0c93c3 --- /dev/null +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/region/linear/versions/Version.java @@ -0,0 +1,18 @@ +package org.bxteam.divinemc.region.linear.versions; + +import java.io.IOException; +import java.nio.ByteBuffer; +import org.bxteam.divinemc.region.linear.LinearImplementation; + +public interface Version { + LinearImplementation implementation(); + + default byte version() { + return implementation().version(); + } + + void parse(ByteBuffer byteBuffer) throws IOException; + + void flush() throws IOException; + +} diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/region/type/LinearRegionFile.java b/divinemc-server/src/main/java/org/bxteam/divinemc/region/type/LinearRegionFile.java deleted file mode 100644 index 1fd36037..00000000 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/region/type/LinearRegionFile.java +++ /dev/null @@ -1,600 +0,0 @@ -package org.bxteam.divinemc.region.type; - -import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO; -import com.github.luben.zstd.ZstdInputStream; -import com.github.luben.zstd.ZstdOutputStream; -import net.jpountz.lz4.LZ4Compressor; -import net.jpountz.lz4.LZ4Factory; -import net.jpountz.lz4.LZ4FastDecompressor; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.server.MinecraftServer; -import net.minecraft.world.level.ChunkPos; -import net.minecraft.world.level.chunk.storage.RegionFileVersion; -import net.minecraft.world.level.chunk.storage.RegionStorageInfo; -import net.openhft.hashing.LongHashFunction; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.bxteam.divinemc.region.IRegionFile; -import org.checkerframework.checker.nullness.qual.Nullable; - -import java.io.BufferedOutputStream; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.DataInputStream; -import java.io.DataOutputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.LockSupport; -import java.util.concurrent.locks.ReentrantLock; - -public class LinearRegionFile implements IRegionFile { - private static final long SUPERBLOCK = 0xc3ff13183cca9d9aL; - private static final byte VERSION = 3; - private static final int HEADER_SIZE = 27; - private static final int FOOTER_SIZE = 8; - private static final Logger LOGGER = LogManager.getLogger(LinearRegionFile.class.getSimpleName()); - private static final Object saveLock = new Object(); - - public static final int MAX_CHUNK_SIZE = 500 * 1024 * 1024; - public static int SAVE_THREAD_MAX_COUNT = 6; - public static int SAVE_DELAY_MS = 100; - public static boolean USE_VIRTUAL_THREAD = true; - private static int activeSaveThreads = 0; - - public final ReentrantLock fileLock = new ReentrantLock(true); - public Path regionFile; - public boolean regionFileOpen = false; - - private final byte[][] buffer = new byte[1024][]; - private final int[] bufferUncompressedSize = new int[1024]; - private final long[] chunkTimestamps = new long[1024]; - private final Object markedToSaveLock = new Object(); - private final LZ4Compressor compressor; - private final LZ4FastDecompressor decompressor; - private final int compressionLevel; - private final Thread bindThread; - - private byte[][] bucketBuffers; - private boolean markedToSave = false; - private boolean close = false; - private int gridSize = 8; - private int bucketSize = 4; - - public LinearRegionFile(RegionStorageInfo storageKey, Path directory, Path path, boolean dsync, int compressionLevel) throws IOException { - this(storageKey, directory, path, RegionFileVersion.getCompressionFormat(), dsync, compressionLevel); - } - - public LinearRegionFile(RegionStorageInfo storageKey, Path path, Path directory, RegionFileVersion compressionFormat, boolean dsync, int compressionLevel) throws IOException { - Runnable flushCheck = () -> { - while (!close) { - synchronized (saveLock) { - if (markedToSave && activeSaveThreads < SAVE_THREAD_MAX_COUNT) { - activeSaveThreads++; - Runnable flushOperation = () -> { - try { - flush(); - } catch (IOException ex) { - LOGGER.error("Region file {} flush failed", this.regionFile.toAbsolutePath(), ex); - } finally { - synchronized (saveLock) { - activeSaveThreads--; - } - } - }; - - Thread saveThread = USE_VIRTUAL_THREAD ? - Thread.ofVirtual().name("Linear IO - " + LinearRegionFile.this.hashCode()).unstarted(flushOperation) : - Thread.ofPlatform().name("Linear IO - " + LinearRegionFile.this.hashCode()).unstarted(flushOperation); - saveThread.setPriority(Thread.NORM_PRIORITY - 3); - saveThread.start(); - } - } - LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(SAVE_DELAY_MS)); - } - }; - this.bindThread = USE_VIRTUAL_THREAD ? Thread.ofVirtual().unstarted(flushCheck) : Thread.ofPlatform().unstarted(flushCheck); - this.bindThread.setName("Linear IO Schedule - " + this.hashCode()); - this.regionFile = path; - this.compressionLevel = compressionLevel; - - this.compressor = LZ4Factory.fastestInstance().fastCompressor(); - this.decompressor = LZ4Factory.fastestInstance().fastDecompressor(); - } - - public Path getRegionFile() { - return this.regionFile; - } - - public ReentrantLock getFileLock() { - return this.fileLock; - } - - public Path getPath() { - return this.regionFile; - } - - public boolean recalculateHeader() { - return false; - } - - private int chunkToBucketIdx(int chunkX, int chunkZ) { - int bx = chunkX / bucketSize, bz = chunkZ / bucketSize; - return bx * gridSize + bz; - } - - private void openBucket(int chunkX, int chunkZ) { - chunkX = Math.floorMod(chunkX, 32); - chunkZ = Math.floorMod(chunkZ, 32); - int idx = chunkToBucketIdx(chunkX, chunkZ); - - if (bucketBuffers == null) return; - if (bucketBuffers[idx] != null) { - try { - ByteArrayInputStream bucketByteStream = new ByteArrayInputStream(bucketBuffers[idx]); - ZstdInputStream zstdStream = new ZstdInputStream(bucketByteStream); - ByteBuffer bucketBuffer = ByteBuffer.wrap(zstdStream.readAllBytes()); - - int bx = chunkX / bucketSize, bz = chunkZ / bucketSize; - - for (int cx = 0; cx < 32 / gridSize; cx++) { - for (int cz = 0; cz < 32 / gridSize; cz++) { - int chunkIndex = (bx * (32 / gridSize) + cx) + (bz * (32 / gridSize) + cz) * 32; - - int chunkSize = bucketBuffer.getInt(); - long timestamp = bucketBuffer.getLong(); - this.chunkTimestamps[chunkIndex] = timestamp; - - if (chunkSize > 0) { - byte[] chunkData = new byte[chunkSize - 8]; - bucketBuffer.get(chunkData); - - int maxCompressedLength = this.compressor.maxCompressedLength(chunkData.length); - byte[] compressed = new byte[maxCompressedLength]; - int compressedLength = this.compressor.compress(chunkData, 0, chunkData.length, compressed, 0, maxCompressedLength); - byte[] finalCompressed = new byte[compressedLength]; - System.arraycopy(compressed, 0, finalCompressed, 0, compressedLength); - - if (chunkX == cx && chunkZ == cz) { - this.buffer[chunkIndex] = finalCompressed; - this.bufferUncompressedSize[chunkIndex] = chunkData.length; - return; - } - this.buffer[chunkIndex] = finalCompressed; - this.bufferUncompressedSize[chunkIndex] = chunkData.length; - } - } - } - } catch (IOException ex) { - LOGGER.error("Region file corrupted: {} bucket: {}", regionFile, idx); - MinecraftServer.getServer().safeShutdown(true, false); - } - bucketBuffers[idx] = null; - } - } - - private synchronized void openRegionFile() { - if (regionFileOpen) return; - regionFileOpen = true; - - File regionFile = new File(this.regionFile.toString()); - - if(!regionFile.canRead()) { - this.bindThread.start(); - return; - } - - try { - byte[] fileContent = Files.readAllBytes(this.regionFile); - ByteBuffer buffer = ByteBuffer.wrap(fileContent); - - long superBlock = buffer.getLong(); - if (superBlock != SUPERBLOCK) - throw new RuntimeException("Invalid superblock: " + superBlock + " file " + this.regionFile); - - byte version = buffer.get(); - if (version == 1 || version == 2) { - parseLinearV1(buffer); - } else if (version == 3) { - parseLinearV2(buffer); - } else { - throw new RuntimeException("Invalid version: " + version + " file " + this.regionFile); - } - - this.bindThread.start(); - } catch (IOException e) { - throw new RuntimeException("Failed to open region file " + this.regionFile, e); - } - } - - private void parseLinearV1(ByteBuffer buffer) throws IOException { - final int HEADER_SIZE = 32; - final int FOOTER_SIZE = 8; - - // Skip newestTimestamp (Long) + Compression level (Byte) + Chunk count (Short): Unused. - buffer.position(buffer.position() + 11); - - int dataCount = buffer.getInt(); - long fileLength = this.regionFile.toFile().length(); - if (fileLength != HEADER_SIZE + dataCount + FOOTER_SIZE) { - throw new IOException("Invalid file length: " + this.regionFile + " " + fileLength + " " + (HEADER_SIZE + dataCount + FOOTER_SIZE)); - } - - buffer.position(buffer.position() + 8); // Skip data hash (Long): Unused. - - byte[] rawCompressed = new byte[dataCount]; - buffer.get(rawCompressed); - - ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(rawCompressed); - ZstdInputStream zstdInputStream = new ZstdInputStream(byteArrayInputStream); - ByteBuffer decompressedBuffer = ByteBuffer.wrap(zstdInputStream.readAllBytes()); - - int[] starts = new int[1024]; - for (int i = 0; i < 1024; i++) { - starts[i] = decompressedBuffer.getInt(); - decompressedBuffer.getInt(); // Skip timestamps (Int): Unused. - } - - for (int i = 0; i < 1024; i++) { - if (starts[i] > 0) { - int size = starts[i]; - byte[] chunkData = new byte[size]; - decompressedBuffer.get(chunkData); - - int maxCompressedLength = this.compressor.maxCompressedLength(size); - byte[] compressed = new byte[maxCompressedLength]; - int compressedLength = this.compressor.compress(chunkData, 0, size, compressed, 0, maxCompressedLength); - byte[] finalCompressed = new byte[compressedLength]; - System.arraycopy(compressed, 0, finalCompressed, 0, compressedLength); - - this.buffer[i] = finalCompressed; - this.bufferUncompressedSize[i] = size; - this.chunkTimestamps[i] = getTimestamp(); // Use current timestamp as we don't have the original - } - } - } - - private void parseLinearV2(ByteBuffer buffer) throws IOException { - buffer.getLong(); // Skip newestTimestamp (Long) - gridSize = buffer.get(); - if (gridSize != 1 && gridSize != 2 && gridSize != 4 && gridSize != 8 && gridSize != 16 && gridSize != 32) - throw new RuntimeException("Invalid grid size: " + gridSize + " file " + this.regionFile); - bucketSize = 32 / gridSize; - - buffer.getInt(); // Skip region_x (Int) - buffer.getInt(); // Skip region_z (Int) - - boolean[] chunkExistenceBitmap = deserializeExistenceBitmap(buffer); - - while (true) { - byte featureNameLength = buffer.get(); - if (featureNameLength == 0) break; - byte[] featureNameBytes = new byte[featureNameLength]; - buffer.get(featureNameBytes); - String featureName = new String(featureNameBytes); - int featureValue = buffer.getInt(); - // System.out.println("NBT Feature: " + featureName + " = " + featureValue); - } - - int[] bucketSizes = new int[gridSize * gridSize]; - byte[] bucketCompressionLevels = new byte[gridSize * gridSize]; - long[] bucketHashes = new long[gridSize * gridSize]; - for (int i = 0; i < gridSize * gridSize; i++) { - bucketSizes[i] = buffer.getInt(); - bucketCompressionLevels[i] = buffer.get(); - bucketHashes[i] = buffer.getLong(); - } - - bucketBuffers = new byte[gridSize * gridSize][]; - for (int i = 0; i < gridSize * gridSize; i++) { - if (bucketSizes[i] > 0) { - bucketBuffers[i] = new byte[bucketSizes[i]]; - buffer.get(bucketBuffers[i]); - long rawHash = LongHashFunction.xx().hashBytes(bucketBuffers[i]); - if (rawHash != bucketHashes[i]) throw new IOException("Region file hash incorrect " + this.regionFile); - } - } - - long footerSuperBlock = buffer.getLong(); - if (footerSuperBlock != SUPERBLOCK) - throw new IOException("Footer superblock invalid " + this.regionFile); - } - - @Override - public MoonriseRegionFileIO.RegionDataController.WriteData moonrise$startWrite(CompoundTag data, ChunkPos pos) throws IOException { - final DataOutputStream out = this.getChunkDataOutputStream(pos); - - return new ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO.RegionDataController.WriteData( - data, ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO.RegionDataController.WriteData.WriteResult.WRITE, - out, regionFile -> out.close() - ); - } - - private synchronized void markToSave() { - synchronized(markedToSaveLock) { - markedToSave = true; - } - } - - private synchronized boolean isMarkedToSave() { - synchronized(markedToSaveLock) { - if(markedToSave) { - markedToSave = false; - return true; - } - return false; - } - } - - public synchronized boolean doesChunkExist(ChunkPos pos) throws Exception { - openRegionFile(); - throw new Exception("doesChunkExist is a stub"); - } - - public synchronized boolean hasChunk(ChunkPos pos) { - openRegionFile(); - openBucket(pos.x, pos.z); - return this.bufferUncompressedSize[getChunkIndex(pos.x, pos.z)] > 0; - } - - public synchronized void write(ChunkPos pos, ByteBuffer buffer) { - openRegionFile(); - openBucket(pos.x, pos.z); - try { - byte[] b = toByteArray(new ByteArrayInputStream(buffer.array())); - int uncompressedSize = b.length; - - if (uncompressedSize > MAX_CHUNK_SIZE) { - LOGGER.error("Chunk dupe attempt {}", this.regionFile); - clear(pos); - } else { - int maxCompressedLength = this.compressor.maxCompressedLength(b.length); - byte[] compressed = new byte[maxCompressedLength]; - int compressedLength = this.compressor.compress(b, 0, b.length, compressed, 0, maxCompressedLength); - b = new byte[compressedLength]; - System.arraycopy(compressed, 0, b, 0, compressedLength); - - int index = getChunkIndex(pos.x, pos.z); - this.buffer[index] = b; - this.chunkTimestamps[index] = getTimestamp(); - this.bufferUncompressedSize[getChunkIndex(pos.x, pos.z)] = uncompressedSize; - } - } catch (IOException e) { - LOGGER.error("Chunk write IOException {} {}", e, this.regionFile); - } - markToSave(); - } - - public DataOutputStream getChunkDataOutputStream(ChunkPos pos) { - openRegionFile(); - openBucket(pos.x, pos.z); - return new DataOutputStream(new BufferedOutputStream(new LinearRegionFile.ChunkBuffer(pos))); - } - - @Nullable - public synchronized DataInputStream getChunkDataInputStream(ChunkPos pos) { - openRegionFile(); - openBucket(pos.x, pos.z); - - if(this.bufferUncompressedSize[getChunkIndex(pos.x, pos.z)] != 0) { - byte[] content = new byte[bufferUncompressedSize[getChunkIndex(pos.x, pos.z)]]; - this.decompressor.decompress(this.buffer[getChunkIndex(pos.x, pos.z)], 0, content, 0, bufferUncompressedSize[getChunkIndex(pos.x, pos.z)]); - return new DataInputStream(new ByteArrayInputStream(content)); - } - return null; - } - - public synchronized void clear(ChunkPos pos) { - openRegionFile(); - openBucket(pos.x, pos.z); - int i = getChunkIndex(pos.x, pos.z); - this.buffer[i] = null; - this.bufferUncompressedSize[i] = 0; - this.chunkTimestamps[i] = 0; - markToSave(); - } - - public synchronized void close() throws IOException { - openRegionFile(); - close = true; - try { - flush(); - } catch(IOException e) { - throw new IOException("Region flush IOException " + e + " " + this.regionFile); - } - } - - public synchronized void flush() throws IOException { - if (!isMarkedToSave()) return; - - openRegionFile(); - - long timestamp = getTimestamp(); - - long writeStart = System.nanoTime(); - File tempFile = new File(regionFile.toString() + ".tmp"); - FileOutputStream fileStream = new FileOutputStream(tempFile); - DataOutputStream dataStream = new DataOutputStream(fileStream); - - dataStream.writeLong(SUPERBLOCK); - dataStream.writeByte(VERSION); - dataStream.writeLong(timestamp); - dataStream.writeByte(gridSize); - - String fileName = regionFile.getFileName().toString(); - String[] parts = fileName.split("\\."); - int regionX = 0; - int regionZ = 0; - try { - if (parts.length >= 4) { - regionX = Integer.parseInt(parts[1]); - regionZ = Integer.parseInt(parts[2]); - } else { - LOGGER.warn("Unexpected file name format: {}", fileName); - } - } catch (NumberFormatException e) { - LOGGER.error("Failed to parse region coordinates from file name: {}", fileName, e); - } - - dataStream.writeInt(regionX); - dataStream.writeInt(regionZ); - - boolean[] chunkExistenceBitmap = new boolean[1024]; - for (int i = 0; i < 1024; i++) { - chunkExistenceBitmap[i] = (this.bufferUncompressedSize[i] > 0); - } - writeSerializedExistenceBitmap(dataStream, chunkExistenceBitmap); - - writeNBTFeatures(dataStream); - - int bucketMisses = 0; - byte[][] buckets = new byte[gridSize * gridSize][]; - for (int bx = 0; bx < gridSize; bx++) { - for (int bz = 0; bz < gridSize; bz++) { - if (bucketBuffers != null && bucketBuffers[bx * gridSize + bz] != null) { - buckets[bx * gridSize + bz] = bucketBuffers[bx * gridSize + bz]; - continue; - } - bucketMisses++; - - ByteArrayOutputStream bucketStream = new ByteArrayOutputStream(); - ZstdOutputStream zstdStream = new ZstdOutputStream(bucketStream, this.compressionLevel); - DataOutputStream bucketDataStream = new DataOutputStream(zstdStream); - - boolean hasData = false; - for (int cx = 0; cx < 32 / gridSize; cx++) { - for (int cz = 0; cz < 32 / gridSize; cz++) { - int chunkIndex = (bx * 32 / gridSize + cx) + (bz * 32 / gridSize + cz) * 32; - if (this.bufferUncompressedSize[chunkIndex] > 0) { - hasData = true; - byte[] chunkData = new byte[this.bufferUncompressedSize[chunkIndex]]; - this.decompressor.decompress(this.buffer[chunkIndex], 0, chunkData, 0, this.bufferUncompressedSize[chunkIndex]); - bucketDataStream.writeInt(chunkData.length + 8); - bucketDataStream.writeLong(this.chunkTimestamps[chunkIndex]); - bucketDataStream.write(chunkData); - } else { - bucketDataStream.writeInt(0); - bucketDataStream.writeLong(this.chunkTimestamps[chunkIndex]); - } - } - } - bucketDataStream.close(); - - if (hasData) { - buckets[bx * gridSize + bz] = bucketStream.toByteArray(); - } - } - } - - for (int i = 0; i < gridSize * gridSize; i++) { - dataStream.writeInt(buckets[i] != null ? buckets[i].length : 0); - dataStream.writeByte(this.compressionLevel); - long rawHash = 0; - if (buckets[i] != null) { - rawHash = LongHashFunction.xx().hashBytes(buckets[i]); - } - dataStream.writeLong(rawHash); - } - - for (int i = 0; i < gridSize * gridSize; i++) { - if (buckets[i] != null) { - dataStream.write(buckets[i]); - } - } - - dataStream.writeLong(SUPERBLOCK); - - dataStream.flush(); - fileStream.getFD().sync(); - fileStream.getChannel().force(true); // Ensure atomicity on Btrfs - dataStream.close(); - - fileStream.close(); - Files.move(tempFile.toPath(), this.regionFile, StandardCopyOption.REPLACE_EXISTING); - } - - private void writeNBTFeatures(DataOutputStream dataStream) throws IOException { - // writeNBTFeature(dataStream, "example", 1); - dataStream.writeByte(0); // End of NBT features - } - - private void writeNBTFeature(DataOutputStream dataStream, String featureName, int featureValue) throws IOException { - byte[] featureNameBytes = featureName.getBytes(); - dataStream.writeByte(featureNameBytes.length); - dataStream.write(featureNameBytes); - dataStream.writeInt(featureValue); - } - - private boolean[] deserializeExistenceBitmap(ByteBuffer buffer) { - boolean[] result = new boolean[1024]; - for (int i = 0; i < 128; i++) { - byte b = buffer.get(); - for (int j = 0; j < 8; j++) { - result[i * 8 + j] = ((b >> (7 - j)) & 1) == 1; - } - } - return result; - } - - private void writeSerializedExistenceBitmap(DataOutputStream out, boolean[] bitmap) throws IOException { - for (int i = 0; i < 128; i++) { - byte b = 0; - for (int j = 0; j < 8; j++) { - if (bitmap[i * 8 + j]) { - b |= (1 << (7 - j)); - } - } - out.writeByte(b); - } - } - - private byte[] toByteArray(InputStream in) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - byte[] tempBuffer = new byte[4096]; - - int length; - while ((length = in.read(tempBuffer)) >= 0) { - out.write(tempBuffer, 0, length); - } - - return out.toByteArray(); - } - - private static int getChunkIndex(int x, int z) { - return (x & 31) + ((z & 31) << 5); - } - - private static int getTimestamp() { - return (int) (System.currentTimeMillis() / 1000L); - } - - public void setOversized(int x, int z, boolean something) { } - - public CompoundTag getOversizedData(int x, int z) throws IOException { - throw new IOException("getOversizedData is a stub " + this.regionFile); - } - - public boolean isOversized(int x, int z) { - return false; - } - - private class ChunkBuffer extends ByteArrayOutputStream { - private final ChunkPos pos; - - public ChunkBuffer(ChunkPos chunkcoordintpair) { - super(); - this.pos = chunkcoordintpair; - } - - public void close() throws IOException { - ByteBuffer bytebuffer = ByteBuffer.wrap(this.buf, 0, this.count); - LinearRegionFile.this.write(this.pos, bytebuffer); - } - } -}