diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 3b9a8e0ff..b9470c735 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -34,7 +34,8 @@ dependencies { implementation(project(":webmap")) compileOnly(libs.log4j) - + implementation(libs.lz4Java) + implementation(libs.zstdJni) implementation(libs.jspecifyAnnotations) implementation(libs.undertow) diff --git a/core/src/main/java/net/pl3x/map/core/renderer/task/RegionProcessor.java b/core/src/main/java/net/pl3x/map/core/renderer/task/RegionProcessor.java index 566591a31..3ca95164e 100644 --- a/core/src/main/java/net/pl3x/map/core/renderer/task/RegionProcessor.java +++ b/core/src/main/java/net/pl3x/map/core/renderer/task/RegionProcessor.java @@ -34,7 +34,10 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedDeque; -import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import net.pl3x.map.core.Pl3xMap; import net.pl3x.map.core.configuration.Config; import net.pl3x.map.core.log.Logger; @@ -47,24 +50,53 @@ @NullMarked public class RegionProcessor { + // bounds how long schedule() will wait for a world's region scan tasks + // before giving up, instead of blocking the sole processor thread + // forever with zero error output on a stuck/orphaned task + private static final long SCHEDULE_TIMEOUT_MINUTES = 60; + private final Map> regionsToScan = new ConcurrentHashMap<>(); private final Deque ticketsToScan = new ConcurrentLinkedDeque<>(); - private final Executor executor; + // guards creation/replacement of the executor field so start() and + // stop() can never race each other while swapping it out + private final Object executorLock = new Object(); + + // non-final -- stop() may shut this down permanently, and start() must + // be able to lazily create a fresh one afterward so the processor can + // actually resume/restart after a reload cycle + private ExecutorService executor; + private final Progress progress; - private CompletableFuture future; + private volatile CompletableFuture future; - private boolean paused; + private volatile boolean paused; private long timeStarted; - private boolean running; + private volatile boolean running; public RegionProcessor() { this.executor = Pl3xMap.ThreadFactory.createService("Pl3xMap-Processor"); this.progress = new Progress(); } + /** + * Ensures {@link #executor} is a live, usable executor, transparently + * replacing it with a fresh one if it was previously shut down (e.g. + * by {@link #stop()}), so this instance can be stopped and later + * resumed/restarted indefinitely. + */ + private ExecutorService getOrCreateExecutor() { + synchronized (this.executorLock) { + if (this.executor.isShutdown() || this.executor.isTerminated()) { + Logger.debug("Region processor executor was shut down; creating a fresh one."); + this.executor = Pl3xMap.ThreadFactory.createService("Pl3xMap-Processor"); + } + return this.executor; + } + } + @SuppressWarnings("BusyWait") public void checkPaused() { while (isPaused()) { @@ -92,11 +124,17 @@ public Set getQueuedWorlds() { } public void start(long delay) { + ExecutorService liveExecutor = getOrCreateExecutor(); + this.future = CompletableFuture.runAsync(() -> { // wait... try { Thread.sleep(delay); } catch (InterruptedException ignore) { + // interrupted (e.g. via a concurrent stop()) -- don't keep + // the self-rescheduling loop alive, let this chain die + // cleanly instead of calling run() and re-arming again + return; } // run the task @@ -106,15 +144,28 @@ public void start(long delay) { // rinse and repeat start(5000L); - }, this.executor); + }, liveExecutor); } + /** + * Stops the processor, genuinely interrupting any in-progress work via + * {@code shutdownNow()}. The processor remains fully reusable + * afterward: the next call to {@link #start(long)} will transparently + * create a fresh executor. + */ public void stop() { this.progress.stop(); + if (this.future != null) { boolean result = this.future.cancel(true); Logger.debug("Stopped region processor: " + result); } + + synchronized (this.executorLock) { + this.executor.shutdownNow(); + } + + this.running = false; } public void addRegions(World world, Collection regions) { @@ -151,19 +202,44 @@ private void run() { Iterator>> iter = this.regionsToScan.entrySet().iterator(); while (iter.hasNext()) { + // NEW: check for interruption BEFORE touching the next + // world, so a deliberate stop() (e.g. during reload) + // cleanly halts the ENTIRE remaining cycle instead of + // limping forward into more schedule() calls against an + // executor that may already be dead -- which previously + // threw RejectedExecutionException from world_nether's + // schedule() call, aborted this whole loop via the + // catch(Throwable) below, and silently dropped every + // world after that point (e.g. world_the_end) from the + // scan cycle entirely. + // + // NOTE: entries are deliberately left un-removed from + // regionsToScan when we bail out here, so they remain + // queued and will be picked up again on the next run() + // cycle instead of being lost. + if (Thread.currentThread().isInterrupted()) { + // clear the flag before returning: this thread belongs + // to a reusable pool, and an uncleared interrupt status + // would otherwise leak into and spuriously abort a + // completely unrelated future task run on the same + // pooled thread. + Thread.interrupted(); + Logger.debug("Region processor stopping early due to interrupt; remaining worlds will be retried next cycle."); + break; + } + Map.Entry> entry = iter.next(); iter.remove(); World world = entry.getKey(); Collection regions = entry.getValue(); process(world, regions); - } } catch (Throwable t) { Logger.severe("Region processor failed to process tickets", t); + } finally { + this.running = false; + Logger.debug("Region processor finished queuing at " + System.currentTimeMillis()); } - - this.running = false; - Logger.debug("Region processor finished queuing at " + System.currentTimeMillis()); } private void process(World world, Collection regionPositions) { @@ -213,23 +289,48 @@ private void schedule(World world, List orderedRegionsToScan) { getProgress().setTotalRegions(orderedRegionsToScan.size()); getProgress().setTotalChunks(getProgress().getTotalRegions() * 1024L); - CompletableFuture.allOf(orderedRegionsToScan.stream() - .map(pos -> CompletableFuture.runAsync(new RegionScanTask(world, pos), Pl3xMap.api().getRenderExecutor()) - .whenComplete((result, throwable) -> { - if (throwable != null) { - Logger.severe("Failed to run region scan task for %s".formatted(world.getName(), pos), throwable); - } - - // set region modified time - world.getRegionModifiedState().set(Mathf.asLong(pos), this.timeStarted); - - // run the garbage collector - if (Config.GC_WHEN_RUNNING) { - System.gc(); - } - }) - ).toArray(CompletableFuture[]::new) - ).whenComplete((result, throwable) -> { + // NEW: the stream/allOf construction itself (specifically + // CompletableFuture.runAsync(...) for each region task) can throw + // RejectedExecutionException SYNCHRONOUSLY if the shared render + // executor (Pl3xMap.api().getRenderExecutor() -- a different + // executor than this class's own, managed elsewhere and commonly + // shut down/recreated during a plugin reload) is not currently + // accepting tasks. Previously this was NOT caught here, so it + // propagated all the way up through process() into run()'s outer + // catch(Throwable), which aborted the ENTIRE remaining scan cycle + // -- silently dropping every world processed after this one (e.g. + // world_the_end never got scheduled at all). Catching it locally + // means only THIS world's tasks are skipped for now; other worlds + // still get their chance, and this world's regions remain queued + // for the next scan cycle. + CompletableFuture allFutures; + try { + allFutures = CompletableFuture.allOf(orderedRegionsToScan.stream() + .map(pos -> CompletableFuture.runAsync(new RegionScanTask(world, pos), Pl3xMap.api().getRenderExecutor()) + .whenComplete((result, throwable) -> { + if (throwable != null) { + Logger.severe("Failed to run region scan task for %s".formatted(world.getName(), pos), throwable); + } + + // set region modified time + world.getRegionModifiedState().set(Mathf.asLong(pos), this.timeStarted); + + // run the garbage collector + if (Config.GC_WHEN_RUNNING) { + System.gc(); + } + }) + ).toArray(CompletableFuture[]::new) + ); + } catch (RejectedExecutionException e) { + Logger.severe("Region processor could not schedule region scan tasks for world " + world.getName() + + " because the render executor is not currently accepting tasks (likely mid-reload). " + + "This world's regions remain queued and will be retried on the next scan cycle."); + getProgress().finish(); + return; + } + + allFutures.whenComplete((result, throwable) -> { if (throwable != null) { Logger.severe("Failed to run region scan tasks for world %s".formatted(world.getName()), throwable); } @@ -249,9 +350,31 @@ private void schedule(World world, List orderedRegionsToScan) { this.running = false; Logger.debug(world.getName() + " Region processor finished task at " + System.currentTimeMillis()); - }).join(); + }); + + try { + allFutures.get(SCHEDULE_TIMEOUT_MINUTES, TimeUnit.MINUTES); + } catch (InterruptedException e) { + // NEW: caught specifically (before the generic Throwable + // catch below) so this expected, benign consequence of a + // deliberate stop()/reload doesn't get logged as a scary + // unhandled failure with a full stack trace. The interrupt + // flag is restored here (get() consumes/clears it when + // throwing) so run()'s world-iteration loop can observe it + // and stop processing further worlds cleanly. + Thread.currentThread().interrupt(); + Logger.debug("Region processor's wait for world " + world.getName() + + " was interrupted (expected during a deliberate stop/reload)."); + } catch (TimeoutException e) { + Logger.severe("Region processor timed out after " + SCHEDULE_TIMEOUT_MINUTES + + " minutes waiting for region scan tasks to finish for world " + world.getName() + + " -- some region files may be unusually slow to read, or a task is stuck. " + + "Continuing without waiting further so the processor doesn't hang permanently.", e); + } catch (Throwable t) { + Logger.severe("Region processor failed while waiting for region scan tasks for world " + world.getName(), t); + } } private record Ticket(World world, Point region) { } -} +} \ No newline at end of file diff --git a/core/src/main/java/net/pl3x/map/core/world/BLinearV3Region.java b/core/src/main/java/net/pl3x/map/core/world/BLinearV3Region.java new file mode 100644 index 000000000..b9cb3327b --- /dev/null +++ b/core/src/main/java/net/pl3x/map/core/world/BLinearV3Region.java @@ -0,0 +1,229 @@ +package net.pl3x.map.core.world; + +import com.github.luben.zstd.ZstdInputStream; +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import net.pl3x.map.core.log.Logger; + +/** + * Read-only reader for Luminol/Shiroha "BLinear v3" (.b_linear) region files. + */ +final class BLinearV3Region { + + // set to false to silence the verbose per-field debug logging + static boolean DEBUG = false; + + static final String FILE_SUFFIX = ".b_linear"; + + private static final long SUPERBLOCK = -0x200812250269L; + private static final byte VERSION = 0x03; // MASTER_FILE_VERSION_BUCKET + + private static final int BUCKET_SHIFT = 6; + private static final int CHUNKS_PER_BUCKET = 1 << BUCKET_SHIFT; // 64 + private static final int BUCKET_COUNT = 1024 / CHUNKS_PER_BUCKET; // 16 + + // per-chunk section meta header: dataLen(int) + timestamp(long) + xxhash32(int) + private static final int SECTOR_META_SIZE = Integer.BYTES + Long.BYTES + Integer.BYTES; // 16 + + // sane upper bounds so a garbage/misaligned read fails fast instead of + // trying to allocate gigabytes or spin forever + private static final int MAX_BUCKET_LENGTH = 64 * 1024 * 1024; // 64 MB + private static final int MAX_SECTION_LENGTH = 16 * 1024 * 1024; // 16 MB + + // tracks which files we've already logged a top-level failure for, so we + // don't spam the log 1024 times (once per chunk) for the same bad file + private static final Set SUPPRESSED_FILES = + Collections.newSetFromMap(new ConcurrentHashMap<>()); + + // NEW: per-thread single-slot decompressed-bucket cache (see class + // javadoc "PERFORMANCE FIX" above). Renderer worker threads each get + // their own slot, so there is no cross-thread contention/locking. + private static final ThreadLocal BUCKET_CACHE = new ThreadLocal<>(); + + private static final class BucketCache { + RandomAccessFile raf; + long bucketOffset; + byte[] decompressed; + } + + private BLinearV3Region() { + } + + /** + * Loads a single chunk from a BLinear-v3 region file. Never throws -- + * any parsing failure is logged (once per file) and degrades to an + * {@link EmptyChunk} for the requested index, so a single bad/garbage + * chunk (or an entirely wrong-layout file) never aborts the rest of + * {@link Region#loadChunks()}'s loop. + */ + static Chunk loadChunk(Region region, ChunkLoader chunkLoader, RandomAccessFile raf, int index) { + String path = region.getRegionFile().toString(); + try { + return loadChunkUnsafe(region, chunkLoader, raf, index); + } catch (Exception e) { + if (SUPPRESSED_FILES.add(path)) { + Logger.severe("Failed to read BLinear-v3 region file (further errors for this file are suppressed): " + path, e); + } + if (DEBUG) { + Logger.debug("BLinear chunk-load failure at index " + index + " in " + path + " -> " + e); + } + return new EmptyChunk(region.getWorld(), region, index); + } + } + + private static Chunk loadChunkUnsafe(Region region, ChunkLoader chunkLoader, RandomAccessFile raf, int index) throws IOException { + String path = region.getRegionFile().toString(); + + raf.seek(0); + + long superblock = raf.readLong(); + if (superblock != SUPERBLOCK) { + throw new IOException("Invalid BLinear superblock (" + superblock + ") in " + path); + } + + byte version = raf.readByte(); + if (version != VERSION) { + throw new IOException("Unsupported BLinear version (" + version + ") in " + path); + } + + // 1-byte compressionLevel + 4-byte xxHashSeed follow the version byte + raf.skipBytes(1 + 4); + + // 16 plain, absolute, unshifted file offsets. 0 = empty bucket. + long[] bucketOffsets = new long[BUCKET_COUNT]; + for (int i = 0; i < BUCKET_COUNT; i++) { + bucketOffsets[i] = raf.readLong(); + } + if (DEBUG) { + Logger.debug("BLinear bucketOffsets=" + Arrays.toString(bucketOffsets)); + } + + int bucketIndex = index >> BUCKET_SHIFT; + int chunkInBucket = index & (CHUNKS_PER_BUCKET - 1); + + long bucketOffset = bucketOffsets[bucketIndex]; + if (bucketOffset <= 0) { + // bucket was never written -> every chunk inside it is empty + return new EmptyChunk(region.getWorld(), region, index); + } + + // NEW: check the per-thread cache before touching the file/Zstd at all. + // Region.loadChunks() walks indices 0..1023 in order, so 64 consecutive + // calls share the same bucketOffset -- this turns 64 decompressions + // into 1 for that common access pattern. + byte[] decompressed = getOrDecompressBucket(raf, bucketOffset, bucketIndex, path); + + DataInputStream bucketIn = new DataInputStream(new ByteArrayInputStream(decompressed)); + + // 64 chunk slots read strictly SEQUENTIALLY from the decompressed + // bucket bytes -- each slot's 4-byte sectionSize is immediately + // followed by that slot's full payload, then the next slot's + // sectionSize comes right after. This is all in-memory array + // navigation now (no stream skip() calls), so it's fast regardless. + for (int i = 0; i < CHUNKS_PER_BUCKET; i++) { + int sectionSize = bucketIn.readInt(); + + if (i != chunkInBucket) { + if (sectionSize > 0) { + bucketIn.skipBytes(sectionSize); + } + continue; + } + + if (sectionSize <= 0) { + return new EmptyChunk(region.getWorld(), region, index); + } + if (sectionSize > MAX_SECTION_LENGTH) { + throw new IOException("Implausible BLinear section length (" + sectionSize + ") in " + path + + " at bucket " + bucketIndex + " chunk " + chunkInBucket); + } + if (sectionSize <= SECTOR_META_SIZE) { + throw new IOException("BLinear section too short (" + sectionSize + " bytes) to contain meta header in " + + path + " at bucket " + bucketIndex + " chunk " + chunkInBucket); + } + + byte[] section = new byte[sectionSize]; + bucketIn.readFully(section); + + DataInputStream sectionIn = new DataInputStream(new ByteArrayInputStream(section)); + int dataLen = sectionIn.readInt(); + sectionIn.skipBytes(8); // timestamp, unused + sectionIn.skipBytes(4); + + if (dataLen < 0 || dataLen > MAX_SECTION_LENGTH) { + throw new IOException("Implausible BLinear chunk dataLen (" + dataLen + ") in " + path + + " at bucket " + bucketIndex + " chunk " + chunkInBucket); + } + if (SECTOR_META_SIZE + dataLen != sectionSize) { + throw new IOException("BLinear section size mismatch: header says dataLen=" + dataLen + + " (expected sectionSize=" + (SECTOR_META_SIZE + dataLen) + ") but actual sectionSize=" + sectionSize + + " in " + path + " at bucket " + bucketIndex + " chunk " + chunkInBucket); + } + + byte[] nbt = new byte[dataLen]; + sectionIn.readFully(nbt); + + return chunkLoader.load(new ByteArrayInputStream(nbt), index); + } + + return new EmptyChunk(region.getWorld(), region, index); + } + + /** + * Returns the decompressed bytes for the bucket at {@code bucketOffset}, + * using the per-thread single-slot cache when possible instead of + * re-reading and re-decompressing from disk. + */ + private static byte[] getOrDecompressBucket(RandomAccessFile raf, long bucketOffset, int bucketIndex, String path) throws IOException { + BucketCache cache = BUCKET_CACHE.get(); + if (cache != null && cache.raf == raf && cache.bucketOffset == bucketOffset) { + return cache.decompressed; + } + + raf.seek(bucketOffset); + + int decompressedSize = raf.readInt(); + int compressedSize = raf.readInt(); + + if (DEBUG) { + Logger.debug("BLinear bucket#" + bucketIndex + " seekTo=" + bucketOffset + + " decompressedSize=" + decompressedSize + " compressedSize=" + compressedSize); + } + + if (compressedSize <= 0) { + byte[] empty = new byte[0]; + updateCache(raf, bucketOffset, empty); + return empty; + } + if (compressedSize > MAX_BUCKET_LENGTH || decompressedSize > MAX_BUCKET_LENGTH * 4) { + throw new IOException("Implausible BLinear bucket size (decompressed=" + decompressedSize + + ", compressed=" + compressedSize + ") in " + path + " at bucket " + bucketIndex); + } + + byte[] compressed = new byte[compressedSize]; + raf.readFully(compressed); + + byte[] decompressed; + try (InputStream zstdIn = new ZstdInputStream(new ByteArrayInputStream(compressed))) { + decompressed = zstdIn.readAllBytes(); + } + + updateCache(raf, bucketOffset, decompressed); + return decompressed; + } + + private static void updateCache(RandomAccessFile raf, long bucketOffset, byte[] decompressed) { + BucketCache cache = new BucketCache(); + cache.raf = raf; + cache.bucketOffset = bucketOffset; + cache.decompressed = decompressed; + BUCKET_CACHE.set(cache); + } +} diff --git a/core/src/main/java/net/pl3x/map/core/world/ChunkLoader.java b/core/src/main/java/net/pl3x/map/core/world/ChunkLoader.java index 9d9a78912..fbe3f454c 100644 --- a/core/src/main/java/net/pl3x/map/core/world/ChunkLoader.java +++ b/core/src/main/java/net/pl3x/map/core/world/ChunkLoader.java @@ -28,11 +28,13 @@ import de.bluecolored.bluenbt.NamingStrategy; import de.bluecolored.bluenbt.TypeToken; import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.RandomAccessFile; import java.util.List; +import java.util.function.Supplier; import org.jspecify.annotations.Nullable; public class ChunkLoader { @@ -70,6 +72,11 @@ public ChunkLoader(World world, Region region) { private ChunkVersionLoader lastUsedLoader = CHUNK_VERSION_LOADERS.getFirst(); + /** + * Original MCA entry point (unchanged behavior): reads compression id from + * the region file at {@code offset+4}, decompresses via {@link CompressionType}, + * and hands the stream off to {@link #loadNbt}. + */ public Chunk load(RandomAccessFile raf, long offset, int index) throws IOException { raf.seek(offset + 4); int compressionTypeId = Byte.toUnsignedInt(raf.readByte()); @@ -78,18 +85,48 @@ public Chunk load(RandomAccessFile raf, long offset, int index) throws IOExcepti if (compression == null) throw new IOException("Unknown chunk compression-id: " + compressionTypeId); - // optimistic: try last used version - ChunkVersionLoader usedLoader = lastUsedLoader; - Chunk chunk; InputStream decompressedIn = new BufferedInputStream(compression.decompress(new FileInputStream(raf.getFD()))); - chunk = usedLoader.load(world, region, decompressedIn, index); + + // retry-supplier re-seeks the RandomAccessFile and re-decompresses from + // scratch - this is exactly what the old inline code did on a loader mismatch. + Supplier retrySupplier = () -> { + try { + raf.seek(offset + 5); + return new BufferedInputStream(compression.decompress(new FileInputStream(raf.getFD()))); + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + + return loadNbt(decompressedIn, retrySupplier, index); + } + + /** + * NEW: entry point for readers that already produced a fully decompressed + * NBT byte stream themselves (e.g. {@link BLinearV3Region}, or any future + * non-Anvil region format). The stream is buffered into memory once so that + * both the optimistic loader attempt and the version-mismatch retry can + * read from the same bytes without needing file-seek support. + */ + public Chunk load(InputStream decompressedNbt, int index) throws IOException { + byte[] bytes = decompressedNbt.readAllBytes(); + return loadNbt(new ByteArrayInputStream(bytes), () -> new ByteArrayInputStream(bytes), index); + } + + /** + * Shared logic (factored out of the old {@code load(RandomAccessFile, long, int)} + * body): try the optimistically-cached loader first, and if the actual data + * version indicates a better-suited loader exists, re-read and use that one. + */ + private Chunk loadNbt(InputStream decompressedIn, Supplier retrySupplier, int index) throws IOException { + ChunkVersionLoader usedLoader = lastUsedLoader; + Chunk chunk = usedLoader.load(world, region, decompressedIn, index); // check version and reload chunk if the wrong loader has been used and a better one has been found ChunkVersionLoader actualLoader = findBestLoaderForVersion(chunk.getDataVersion()); if (actualLoader != null && usedLoader != actualLoader) { - raf.seek(offset + 5); - decompressedIn = new BufferedInputStream(compression.decompress(new FileInputStream(raf.getFD()))); - chunk = actualLoader.load(world, region, decompressedIn, index); + InputStream retryIn = retrySupplier.get(); + chunk = actualLoader.load(world, region, retryIn, index); lastUsedLoader = actualLoader; } diff --git a/core/src/main/java/net/pl3x/map/core/world/Region.java b/core/src/main/java/net/pl3x/map/core/world/Region.java index 0842f3cd2..9371004bb 100644 --- a/core/src/main/java/net/pl3x/map/core/world/Region.java +++ b/core/src/main/java/net/pl3x/map/core/world/Region.java @@ -48,6 +48,10 @@ public class Region { private final int hash; + // NEW: detect BLinear-v3 (.b_linear) region files so we can branch to the + // alternate reader without touching the existing MCA sector-table logic. + private final boolean blinear; + public Region(World world, int regionX, int regionZ, Path regionFile) { this.world = world; this.regionX = regionX; @@ -57,6 +61,9 @@ public Region(World world, int regionX, int regionZ, Path regionFile) { this.chunkLoader = new ChunkLoader(world, this); this.hash = Objects.hash(world, regionX, regionZ); + + // NEW: cheap, one-time extension check. + this.blinear = this.regionFile.getName().endsWith(BLinearV3Region.FILE_SUFFIX); } public World getWorld() { @@ -110,6 +117,14 @@ public void loadChunks() throws IOException { } public Chunk loadChunk(RandomAccessFile raf, int index) throws IOException { + // NEW: BLinear-v3 files have a completely different layout (superblock + + // bucket-offset table instead of the Anvil 4KB sector table). Branch out + // early and leave the rest of this method (the MCA path) untouched. + if (this.blinear) { + Chunk chunk = BLinearV3Region.loadChunk(this, this.chunkLoader, raf, index); + return this.chunks[index] = chunk; + } + raf.seek(index * 4L); byte[] header = new byte[4]; diff --git a/core/src/main/java/net/pl3x/map/core/world/World.java b/core/src/main/java/net/pl3x/map/core/world/World.java index 633910ca6..10c584226 100644 --- a/core/src/main/java/net/pl3x/map/core/world/World.java +++ b/core/src/main/java/net/pl3x/map/core/world/World.java @@ -71,6 +71,8 @@ public abstract class World extends Keyed { public static final PathMatcher JSON_MATCHER = FileSystems.getDefault().getPathMatcher("glob:**/*.json"); public static final PathMatcher MCA_MATCHER = FileSystems.getDefault().getPathMatcher("glob:**/r.*.*.mca"); + // NEW: matcher for Luminol/EarthMe "BLinear v3" region files. + public static final PathMatcher BLINEAR_MATCHER = FileSystems.getDefault().getPathMatcher("glob:**/r.*.*." + BLinearV3Region.FILE_SUFFIX.substring(1)); public static final PathMatcher PNG_MATCHER = FileSystems.getDefault().getPathMatcher("glob:**/*_*.png"); private final Path customMarkersDirectory; @@ -365,7 +367,8 @@ public Collection getRegionFiles() { return Collections.emptySet(); } try (Stream stream = Files.list(getRegionDirectory())) { - return stream.filter(MCA_MATCHER::matches).toList(); + // NEW: also accept .b_linear (BLinear v3) region files alongside .mca + return stream.filter(p -> MCA_MATCHER.matches(p) || BLINEAR_MATCHER.matches(p)).toList(); } catch (IOException e) { throw new RuntimeException("Failed to list region files in directory '" + getRegionDirectory().toAbsolutePath() + "'", e); } @@ -389,13 +392,38 @@ public Collection listRegions(boolean ignoreTimestamp) { private Region loadRegion(long pos) { int x = Mathf.longToX(pos); int z = Mathf.longToZ(pos); - return new Region(this, x, z, getMCAFile(x, z)); + return new Region(this, x, z, resolveRegionFile(x, z)); + } + + /** + * NEW: resolves the on-disk region file for the given coordinates. + * Prefers the classic Anvil ".mca" file (preserves 100% original behavior + * when only .mca files exist), and falls back to the BLinear-v3 ".b_linear" + * file if that's what's present instead. + */ + private Path resolveRegionFile(int regionX, int regionZ) { + Path mcaFile = getMCAFile(regionX, regionZ); + if (Files.exists(mcaFile)) { + return mcaFile; + } + Path blinearFile = getBLinearFile(regionX, regionZ); + if (Files.exists(blinearFile)) { + return blinearFile; + } + // preserves original behavior: Region will report an empty/non-existent + // file and every chunk will resolve to EmptyChunk, exactly as before. + return mcaFile; } private Path getMCAFile(int regionX, int regionZ) { return getRegionDirectory().resolve("r." + regionX + "." + regionZ + ".mca"); } + // NEW + private Path getBLinearFile(int regionX, int regionZ) { + return getRegionDirectory().resolve("r." + regionX + "." + regionZ + BLinearV3Region.FILE_SUFFIX); + } + @Override public boolean equals(@Nullable Object o) { if (this == o) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 261d8586b..1073f2c1f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ minecraft="26.2" fabricApi="0.152.2+26.2" fabricLoader="0.19.3" fabricLoom="1.17-SNAPSHOT" +zstdJni = "1.5.6-4" #forge="1.20.2-48.0.6" #forgeGradle="[6.0,6.2)" @@ -49,7 +50,7 @@ paperweight-userdev = { id = "io.papermc.paperweight.userdev", version.ref = "pa run-paper = { id = "xyz.jpenilla.run-paper", version.ref = "run-paper" } [libraries] - +zstdJni = { module = "com.github.luben:zstd-jni", version.ref = "zstdJni" } minecraft = { group = "com.mojang", name = "minecraft", version.ref = "minecraft" } log4j = { group = "org.apache.logging.log4j", name = "log4j-core", version.ref = "log4j" }