From 3fda32fa17df31d128a05630f1597b88cbf46b53 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sun, 26 Jul 2026 20:57:39 +0100 Subject: [PATCH 01/57] Major refactor of world, dimension, and chunk all are now abstract --- .../ui/controller/ChunkyFxController.java | 32 +- .../chunky/ui/render/tabs/GeneralTab.java | 15 +- .../src/java/se/llbit/chunky/world/Chunk.java | 349 +---------------- .../se/llbit/chunky/world/CubicDimension.java | 9 +- .../java/se/llbit/chunky/world/Dimension.java | 262 +++++-------- .../se/llbit/chunky/world/EmptyChunk.java | 3 +- .../se/llbit/chunky/world/EmptyDimension.java | 65 +++- .../llbit/chunky/world/EmptyRegionChunk.java | 5 +- .../chunky/world/ImposterCubicChunk.java | 3 +- .../llbit/chunky/world/PlayerEntityData.java | 2 +- .../src/java/se/llbit/chunky/world/World.java | 197 +--------- .../se/llbit/chunky/world/java/JavaChunk.java | 365 ++++++++++++++++++ .../chunky/world/java/JavaDimension.java | 138 +++++++ .../se/llbit/chunky/world/java/JavaWorld.java | 217 +++++++++++ .../llbit/chunky/world/region/MCRegion.java | 8 +- .../world/region/MCRegionChangeWatcher.java | 4 +- 16 files changed, 931 insertions(+), 743 deletions(-) create mode 100644 chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java create mode 100644 chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java create mode 100644 chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java diff --git a/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java b/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java index c93aa97ffd..68849c873e 100644 --- a/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java +++ b/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java @@ -32,6 +32,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; +import it.unimi.dsi.fastutil.ints.IntIntPair; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; @@ -459,19 +460,14 @@ public File getSceneFile(String fileName) { } if (!reloaded) { ignoreYUpdate.set(true); - if (mapLoader.getWorld().getVersionId() >= World.VERSION_21W06A) { - yMin.setRange(-64, 320); - yMin.set(-64); - yMax.setRange(-64, 320); - yMax.set(320); - mapView.setYMinMax(-64, 320); - } else { - yMin.setRange(0, 256); - yMin.set(0); - yMax.setRange(0, 256); - yMax.set(256); - mapView.setYMinMax(0, 256); - } + IntIntPair heightRange = mapLoader.getWorld().currentDimension().heightRange(); + int min = heightRange.firstInt(); + int max = heightRange.secondInt(); + yMin.setRange(min, max); + yMin.set(min); + yMax.setRange(min, max); + yMax.set(max); + mapView.setYMinMax(min, max); yMin.getStyleClass().removeAll("invalid"); yMax.getStyleClass().removeAll("invalid"); ignoreYUpdate.set(false); @@ -657,13 +653,9 @@ public File getSceneFile(String fileName) { mapOverlay.setOnKeyReleased(map::onKeyReleased); mapLoader.loadWorld(PersistentSettings.getLastWorld()); - if (mapLoader.getWorld().getVersionId() >= World.VERSION_21W06A) { - mapView.setYMin(-64); - mapView.setYMax(320); - } else { - mapView.setYMin(0); - mapView.setYMax(256); - } + IntIntPair heightRange = mapLoader.getWorld().currentDimension().heightRange(); + mapView.setYMin(heightRange.firstInt()); + mapView.setYMax(heightRange.secondInt()); canvas = new RenderCanvasFx(this, chunky.getSceneManager().getScene(), chunky.getRenderController().getRenderManager()); diff --git a/chunky/src/java/se/llbit/chunky/ui/render/tabs/GeneralTab.java b/chunky/src/java/se/llbit/chunky/ui/render/tabs/GeneralTab.java index 095e056f55..d70afedbe1 100644 --- a/chunky/src/java/se/llbit/chunky/ui/render/tabs/GeneralTab.java +++ b/chunky/src/java/se/llbit/chunky/ui/render/tabs/GeneralTab.java @@ -17,6 +17,7 @@ */ package se.llbit.chunky.ui.render.tabs; +import it.unimi.dsi.fastutil.ints.IntIntPair; import javafx.beans.binding.Bindings; import javafx.beans.value.ChangeListener; import javafx.fxml.FXML; @@ -592,12 +593,14 @@ private void updateCanvasCrop() { } private void updateYClipSlidersRanges(World world) { - if (world != null && world.getVersionId() >= World.VERSION_21W06A) { - yMin.setRange(-64, 320); - yMax.setRange(-64, 320); - } else { - yMin.setRange(0, 256); - yMax.setRange(0, 256); + if (world != null) { + IntIntPair heightRange = world.currentDimension().heightRange(); + int min = heightRange.firstInt(); + int max = heightRange.secondInt(); + yMin.setRange(min, max); + yMin.set(min); + yMax.setRange(min, max); + yMax.set(max); } } } diff --git a/chunky/src/java/se/llbit/chunky/world/Chunk.java b/chunky/src/java/se/llbit/chunky/world/Chunk.java index 55599bbab8..7bed3349e7 100644 --- a/chunky/src/java/se/llbit/chunky/world/Chunk.java +++ b/chunky/src/java/se/llbit/chunky/world/Chunk.java @@ -17,35 +17,17 @@ package se.llbit.chunky.world; import se.llbit.chunky.block.minecraft.Air; -import it.unimi.dsi.fastutil.ints.IntIntImmutablePair; import se.llbit.chunky.block.Block; import se.llbit.chunky.block.minecraft.Lava; import se.llbit.chunky.block.minecraft.Water; -import se.llbit.chunky.block.legacy.LegacyBlocks; import se.llbit.chunky.chunk.BlockPalette; import se.llbit.chunky.chunk.ChunkData; import se.llbit.chunky.chunk.ChunkLoadingException; -import se.llbit.chunky.chunk.EmptyChunkData; -import se.llbit.chunky.chunk.biome.BiomeDataFactory; import se.llbit.chunky.map.*; -import se.llbit.chunky.world.biome.ArrayBiomePalette; import se.llbit.chunky.world.biome.BiomePalette; -import se.llbit.chunky.world.region.MCRegion; -import se.llbit.chunky.world.region.Region; -import se.llbit.log.Log; -import se.llbit.math.QuickMath; -import se.llbit.nbt.*; -import se.llbit.util.BitBuffer; import se.llbit.util.Mutable; import se.llbit.util.annotation.NotNull; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - -import static se.llbit.util.NbtUtil.getTagFromNames; -import static se.llbit.util.NbtUtil.tagFromMap; - /** * This class represents a loaded or not-yet-loaded chunk in the world. *

@@ -53,7 +35,7 @@ * * @author Jesper Öqvist (jesper@llbit.se) */ -public class Chunk { +public abstract class Chunk { public static final String DATAVERSION = ".DataVersion"; public static final String LEVEL_HEIGHTMAP = ".Level.HeightMap"; @@ -87,7 +69,7 @@ public class Chunk { protected volatile AbstractLayer surface = IconLayer.UNKNOWN; protected volatile AbstractLayer biomes = IconLayer.UNKNOWN; - private final Dimension dimension; + protected final Dimension dimension; protected int dataTimestamp = 0; protected int surfaceTimestamp = 0; @@ -112,27 +94,6 @@ public int biomeColor() { return biomes.getAvgColor(); } - /** - * @param request fresh request set - * @return loaded data, or null if something went wrong - */ - private Map getChunkTags(Set request) throws ChunkLoadingException { - MCRegion region = (MCRegion) dimension.getRegion(position.getRegionPosition()); - Mutable timestamp = new Mutable<>(dataTimestamp); - Map chunkTags = region.getChunkTags(this.position, request, timestamp); - this.dataTimestamp = timestamp.get(); - return chunkTags; - } - - /** - * @param request fresh request set - * @return loaded data, or null if something went wrong - */ - private Map getEntityTags(Set request) throws ChunkLoadingException { - MCRegion region = (MCRegion) dimension.getRegion(position.getRegionPosition()); - return region.getEntityTags(this.position, request); - } - /** * Reset the rendered layers in this chunk. */ @@ -148,208 +109,11 @@ public ChunkPosition getPosition() { } /** - * Parse the chunk from the region file and render the current - * layer, surface and cave maps. + * Parse the chunk from the region file and render the current layer, surface and cave maps. + * * @return whether the input chunkdata was modified */ - public synchronized boolean loadChunk(@NotNull Mutable chunkData, int yMin, int yMax) { - if (!shouldReloadChunk()) { - return false; - } - - Set request = new HashSet<>(); - request.add(Chunk.DATAVERSION); - request.add(Chunk.LEVEL_SECTIONS); - request.add(Chunk.SECTIONS_POST_21W39A); - request.add(Chunk.LEVEL_BIOMES); - request.add(Chunk.BIOMES_POST_21W39A); - request.add(Chunk.LEVEL_HEIGHTMAP); - - Map dataMap; - try { - dataMap = getChunkTags(request); - } catch (ChunkLoadingException e) { // we don't want to crash the map view if a chunk fails to load, so we warn the user - Log.warn(String.format("Failed to load chunk %s", position), e); - return false; - } - // TODO: improve error handling here. - if (dataMap == null) { - return false; - } - Tag data = tagFromMap(dataMap); - - surfaceTimestamp = dataTimestamp; - version = chunkVersion(data); - IntIntImmutablePair chunkBounds = inclusiveChunkBounds(data); - chunkData.set(this.dimension.createChunkData(chunkData.get(), chunkBounds.leftInt(), chunkBounds.rightInt())); - loadSurface(data, chunkData.get(), yMin, yMax); - biomesTimestamp = dataTimestamp; - - dimension.chunkUpdated(position); - return true; - } - - private void loadSurface(@NotNull Tag data, ChunkData chunkData, int yMin, int yMax) { - if (data == null) { - surface = IconLayer.CORRUPT; - return; - } - - Heightmap heightmap = dimension.getHeightmap(); - Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); - if (sections.isList()) { - if (version == ChunkVersion.PRE_FLATTENING || version == ChunkVersion.POST_FLATTENING) { - BiomePalette biomePalette = new ArrayBiomePalette(); - BiomeDataFactory.loadBiomeData(chunkData, data, biomePalette, yMin, yMax); - biomes = new BiomeLayer(chunkData, biomePalette); - - BlockPalette palette = new BlockPalette(); - palette.unsynchronize(); //only this RegionParser will use this palette - loadBlockData(data, chunkData, palette, yMin, yMax); - - int[] heightmapData = extractHeightmapData(data, chunkData); - updateHeightmap(heightmap, position, chunkData, heightmapData, palette, yMax); - - surface = new SurfaceLayer(dimension.getDimensionId(), chunkData, palette, biomePalette, yMin, yMax, heightmapData); - queueTopography(); - } - } else { - surface = IconLayer.CORRUPT; - } - } - - private int[] extractHeightmapData(@NotNull Tag data, ChunkData chunkData) { - Tag heightmapTag = data.get(LEVEL_HEIGHTMAP); - if (heightmapTag.isIntArray(X_MAX * Z_MAX)) { - return heightmapTag.intArray(); - } else { - int[] fallback = new int[X_MAX * Z_MAX]; - for (int i = 0; i < fallback.length; ++i) { - fallback[i] = chunkData.maxY(); - } - return fallback; - } - } - - /** Detect Minecraft version that generated the chunk. */ - private static ChunkVersion chunkVersion(@NotNull Tag data) { - Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); - if (sections.isList()) { - for (SpecificTag section : sections.asList()) { - if (!section.get("Palette").isList()) { - if (section.get("Blocks").isByteArray(SECTION_BYTES)) { - return ChunkVersion.PRE_FLATTENING; - } - } - } - return ChunkVersion.POST_FLATTENING; - } - return ChunkVersion.UNKNOWN; - } - - private static void loadBlockData(@NotNull Tag data, @NotNull ChunkData chunkData, - BlockPalette blockPalette, int minY, int maxY) { - - Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); - if (sections.isList()) { - for (SpecificTag section : sections.asList()) { - Tag yTag = section.get("Y"); - int sectionY = yTag.byteValue(); - int sectionMinBlockY = sectionY << 4; - - if(sectionY < minY >> 4 || sectionY-1 > (maxY >> 4)+1) - continue; //skip parsing sections that are outside requested bounds - - Tag blockPaletteTag = getTagFromNames(section, "Palette", "block_states\\palette"); - if (blockPaletteTag.isList()) { - ListTag localBlockPalette = blockPaletteTag.asList(); - // Bits per block: - int bpb = 4; - if (localBlockPalette.size() > 16) { - bpb = QuickMath.log2(QuickMath.nextPow2(localBlockPalette.size())); - } - - int dataSize = (4096 * bpb) / 64; - Tag blockStates = getTagFromNames(section, "BlockStates", "block_states\\data"); - - if (blockStates.isLongArray(dataSize)) { - // since 20w17a, block states are aligned to 64-bit boundaries, so there are 64 % bpb - // unused bits per block state; if so, the array is longer than the expected data size - boolean isAligned = data.get(DATAVERSION).intValue() >= DATAVERSION_20W17A; - if (isAligned) { - // entries are 64-bit-padded, re-calculate the bits per block - // this is the dataSize calculation from above reverted, we know the actual data size - bpb = blockStates.longArray().length / 64; - } - - int[] subpalette = new int[localBlockPalette.size()]; - int paletteIndex = 0; - for (Tag item : localBlockPalette.asList()) { - subpalette[paletteIndex] = blockPalette.put(item); - paletteIndex += 1; - } - BitBuffer buffer = new BitBuffer(blockStates.longArray(), bpb, isAligned); - for (int y = 0; y < SECTION_Y_MAX; y++) { - int blockY = sectionMinBlockY + y; - for (int z = 0; z < Z_MAX; z++) { - for(int x = 0; x < X_MAX; x++) { - int b0 = buffer.read(); - if (b0 < subpalette.length) { - chunkData.setBlockAt(x, blockY, z, subpalette[b0]); - } - } - } - } - } else { - // Single block palette - if (localBlockPalette.size() == 1) { - // Check it is not air block - int block = blockPalette.put(localBlockPalette.get(0)); - if (block != blockPalette.airId) { - // Set the entire section - for (int y = 0; y < SECTION_Y_MAX; y++) { - int blockY = sectionMinBlockY + y; - for (int z = 0; z < Z_MAX; z++) { - for(int x = 0; x < X_MAX; x++) { - chunkData.setBlockAt(x, blockY, z, block); - } - } - } - } - } - } - } else { - int yOffset = sectionY & 0xFF; - - Tag dataTag = section.get("Data"); - byte[] blockDataBytes = new byte[(Chunk.X_MAX * Chunk.Y_MAX * Chunk.Z_MAX) / 2]; - if (dataTag.isByteArray(SECTION_HALF_NIBBLES)) { - System.arraycopy(dataTag.byteArray(), 0, blockDataBytes, SECTION_HALF_NIBBLES * yOffset, - SECTION_HALF_NIBBLES); - } - - Tag blocksTag = section.get("Blocks"); - if (blocksTag.isByteArray(SECTION_BYTES)) { - byte[] blocksBytes = new byte[Chunk.X_MAX * Chunk.Y_MAX * Chunk.Z_MAX]; - System.arraycopy(blocksTag.byteArray(), 0, blocksBytes, SECTION_BYTES * yOffset, - SECTION_BYTES); - - int offset = SECTION_BYTES * yOffset; - for (int y = 0; y < SECTION_Y_MAX; y++) { - int blockY = sectionMinBlockY + y; - for (int z = 0; z < Z_MAX; z++) { - for (int x = 0; x < X_MAX; x++) { - chunkData.setBlockAt(x, blockY, z, blockPalette.put( - LegacyBlocks.getTag(offset, blocksBytes, blockDataBytes))); - offset += 1; - } - } - } - } - } - } - } - } + public abstract boolean loadChunk(@NotNull Mutable chunkData, int yMin, int yMax); /** * Load heightmap information from a chunk heightmap array @@ -370,17 +134,6 @@ protected static void updateHeightmap(Heightmap heightmap, ChunkPosition pos, Ch } } - protected boolean shouldReloadChunk() { - int timestamp = Integer.MAX_VALUE; - timestamp = Math.min(timestamp, surfaceTimestamp); - timestamp = Math.min(timestamp, biomesTimestamp); - if (timestamp == 0) { - return true; - } - Region region = dimension.getRegion(position.getRegionPosition()); - return region.chunkChangedSince(position, timestamp); - } - protected void queueTopography() { for (int x = -1; x <= 1; ++x) { for (int z = -1; z <= 1; ++z) { @@ -445,97 +198,7 @@ public synchronized void renderTopography() { * @param maxY The requested maximum Y to be loaded into the chunkData object. The chunk implementation does NOT have to respect it * @throws ChunkLoadingException If there is an issue loading the chunk, and it should be aborted */ - public synchronized void getChunkData(@NotNull Mutable reuseChunkData, BlockPalette palette, BiomePalette biomePalette, int minY, int maxY) throws ChunkLoadingException { - Set request = new HashSet<>(); - request.add(DATAVERSION); - request.add(LEVEL_SECTIONS); - request.add(SECTIONS_POST_21W39A); - request.add(LEVEL_BIOMES); - request.add(BIOMES_POST_21W39A); - request.add(LEVEL_ENTITIES); - request.add(LEVEL_TILEENTITIES); - request.add(BLOCK_ENTITIES_POST_21W43A); - Map dataMap = getChunkTags(request); - // TODO: improve error handling here. - if (dataMap == null) { - throw new ChunkLoadingException(String.format("Got null data for chunk %s", this.position)); - } - Tag data = tagFromMap(dataMap); - - int dataVersion = data.get(DATAVERSION).intValue(); - - IntIntImmutablePair chunkBounds = inclusiveChunkBounds(data); - - if(reuseChunkData.get() == null || reuseChunkData.get() instanceof EmptyChunkData) { - reuseChunkData.set(dimension.createChunkData(reuseChunkData.get(), chunkBounds.leftInt(), chunkBounds.rightInt())); - } else { - reuseChunkData.get().clear(); - } - ChunkData chunkData = reuseChunkData.get(); //unwrap mutable, for ease of use - - version = chunkVersion(data); - Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); - Tag entitiesTag = data.get(LEVEL_ENTITIES); - Tag tileEntitiesTag = getTagFromNames(data, LEVEL_TILEENTITIES, BLOCK_ENTITIES_POST_21W43A); - - BiomeDataFactory.loadBiomeData(chunkData, data, biomePalette, minY, maxY); - if (sections.isList()) { - loadBlockData(data, chunkData, palette, minY, maxY); - - if (entitiesTag.isList()) { - for (SpecificTag tag : (ListTag) entitiesTag) { - if (tag.isCompoundTag()) - chunkData.addEntity((CompoundTag) tag); - } - } - - if (tileEntitiesTag.isList()) { - for (SpecificTag tag : (ListTag) tileEntitiesTag) { - if (tag.isCompoundTag()) - chunkData.addTileEntity((CompoundTag) tag); - } - } - } - - // post 20w45A entities - if (dataVersion >= DATAVERSION_20W45A) { - Set entitiesRequest = new HashSet<>(); - entitiesRequest.add(ENTITIES_POST_20W45A); - - Map entitiesMap = getEntityTags(entitiesRequest); - if (entitiesMap != null) { - entitiesTag = entitiesMap.get(".Entities"); - if (entitiesTag.isList()) { - for (SpecificTag tag : (ListTag) entitiesTag) { - if (tag.isCompoundTag()) - chunkData.addEntity((CompoundTag) tag); - } - } - } - } - } - - /** - * @return The min and max blockY for a given section array - */ - private IntIntImmutablePair inclusiveChunkBounds(Tag chunkData) { - Tag sections = getTagFromNames(chunkData, LEVEL_SECTIONS, SECTIONS_POST_21W39A); - int minSectionY = Integer.MAX_VALUE; - int maxSectionY = Integer.MIN_VALUE; - if (sections.isList()) { - for (SpecificTag section : sections.asList()) { - byte sectionY = (byte) section.get("Y").byteValue(); - if (sectionY < minSectionY) { - minSectionY = sectionY; - } - if (sectionY > maxSectionY) { - maxSectionY = sectionY; - } - } - } - - return new IntIntImmutablePair(minSectionY << 4, (maxSectionY << 4) + 15); - } + public abstract void getChunkData(@NotNull Mutable reuseChunkData, BlockPalette palette, BiomePalette biomePalette, int minY, int maxY) throws ChunkLoadingException; /** * @return Integer index into a chunk YXZ array diff --git a/chunky/src/java/se/llbit/chunky/world/CubicDimension.java b/chunky/src/java/se/llbit/chunky/world/CubicDimension.java index 778f3a69d1..8aa0824f53 100644 --- a/chunky/src/java/se/llbit/chunky/world/CubicDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/CubicDimension.java @@ -3,6 +3,8 @@ import se.llbit.chunky.chunk.ChunkData; import se.llbit.chunky.chunk.GenericChunkData; import se.llbit.chunky.chunk.biome.BiomeData2d; +import se.llbit.chunky.world.java.JavaDimension; +import se.llbit.chunky.world.java.JavaWorld; import se.llbit.chunky.world.region.EmptyRegion; import se.llbit.chunky.world.region.ImposterCubicRegion; import se.llbit.chunky.world.region.Region; @@ -17,12 +19,12 @@ import static se.llbit.chunky.world.region.ImposterCubicRegion.blockToCube; import static se.llbit.chunky.world.region.ImposterCubicRegion.cubeToCubicRegion; -public class CubicDimension extends Dimension { +public class CubicDimension extends JavaDimension { /** * @param dimensionDirectory Minecraft world directory. */ - protected CubicDimension(World world, Dimension.Identifier dimensionId, File dimensionDirectory, Set playerEntities) { + public CubicDimension(JavaWorld world, Dimension.Identifier dimensionId, File dimensionDirectory, Set playerEntities) { super(world, dimensionId, dimensionDirectory, playerEntities); } @@ -64,8 +66,7 @@ public synchronized Region getRegionWithinRange(RegionPosition pos, int minY, in @Override public boolean regionExists(RegionPosition pos) { File regionDirectory = getRegionDirectory(); - try { - Stream list = Files.list(regionDirectory.toPath()); + try (Stream list = Files.list(regionDirectory.toPath())) { return list.anyMatch(path -> { String[] split = path.getFileName().toString().split("[.]"); if(split.length == 4) { diff --git a/chunky/src/java/se/llbit/chunky/world/Dimension.java b/chunky/src/java/se/llbit/chunky/world/Dimension.java index 34c0b2b8ee..cdba0d4ba6 100644 --- a/chunky/src/java/se/llbit/chunky/world/Dimension.java +++ b/chunky/src/java/se/llbit/chunky/world/Dimension.java @@ -1,5 +1,6 @@ package se.llbit.chunky.world; +import it.unimi.dsi.fastutil.ints.IntIntPair; import it.unimi.dsi.fastutil.longs.Long2ObjectMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import se.llbit.chunky.PersistentSettings; @@ -23,7 +24,7 @@ /** * */ -public class Dimension { +public abstract class Dimension { public record Identifier(String namespace, String name) { public static Identifier OVERWORLD = new Identifier("minecraft", "overworld"); public static Identifier THE_NETHER = new Identifier("minecraft", "the_nether"); @@ -63,28 +64,23 @@ public String toString() { } } - private final World world; - - protected final Long2ObjectMap regionMap = new Long2ObjectOpenHashMap<>(); - protected final File dimensionDirectory; - private Set playerEntities; + protected final Set playerEntities; - private final Heightmap heightmap = new Heightmap(); + protected final Heightmap heightmap = new Heightmap(); - private final Identifier dimensionId; + protected final Identifier dimensionId; - private final Collection chunkDeletionListeners = new LinkedList<>(); - private final Collection chunkTopographyListeners = new LinkedList<>(); - private final Collection chunkUpdateListeners = new LinkedList<>(); + protected final Collection chunkDeletionListeners = new LinkedList<>(); + protected final Collection chunkTopographyListeners = new LinkedList<>(); + protected final Collection chunkUpdateListeners = new LinkedList<>(); - private Vector3i spawnPos = null; + protected Vector3i spawnPos = null; /** * @param dimensionDirectory Minecraft world directory. */ - protected Dimension(World world, Identifier dimensionId, File dimensionDirectory, Set playerEntities) { - this.world = world; + protected Dimension(Identifier dimensionId, File dimensionDirectory, Set playerEntities) { this.dimensionId = dimensionId; this.dimensionDirectory = dimensionDirectory; this.playerEntities = playerEntities; @@ -94,46 +90,10 @@ public Identifier getDimensionId() { return dimensionId; } - /** - * Reload player data. - * - * @return {@code true} if player data was reloaded. - */ - public synchronized boolean reloadPlayerData() { - return this.world.reloadPlayerData(); - } - - /** - * Add a chunk deletion listener. - */ - public void addChunkDeletionListener(ChunkDeletionListener listener) { - synchronized (chunkDeletionListeners) { - chunkDeletionListeners.add(listener); - } - } - - /** - * Add a region discovery listener. - */ - public void addChunkUpdateListener(ChunkUpdateListener listener) { - synchronized (chunkUpdateListeners) { - chunkUpdateListeners.add(listener); - } - } - - private void fireChunkDeleted(ChunkPosition chunk) { - synchronized (chunkDeletionListeners) { - for (ChunkDeletionListener listener : chunkDeletionListeners) - listener.chunkDeleted(chunk); - } - } - /** * @return The chunk at the given position */ - public synchronized Chunk getChunk(ChunkPosition pos) { - return getRegion(pos.getRegionPosition()).getChunk(pos); - } + public abstract Chunk getChunk(ChunkPosition pos); /** * Returns a ChunkData instance that is compatible with the given chunk version. @@ -153,48 +113,23 @@ public ChunkData createChunkData(@Nullable ChunkData chunkData, int minY, int ma return new GenericChunkData(); } - public Region createRegion(RegionPosition pos) { - return new MCRegion(pos, this); - } + public abstract Region createRegion(RegionPosition pos); - public RegionChangeWatcher createRegionChangeWatcher(WorldMapLoader worldMapLoader, MapView mapView) { - return new MCRegionChangeWatcher(worldMapLoader, mapView); - } + public abstract RegionChangeWatcher createRegionChangeWatcher(WorldMapLoader worldMapLoader, MapView mapView); /** * @param pos Region position * @return The region at the given position */ - public synchronized Region getRegion(RegionPosition pos) { - return regionMap.computeIfAbsent(pos.getLong(), p -> { - // check if the region is present in the world directory - Region region = EmptyRegion.instance; - if (regionExists(pos)) { - region = createRegion(pos); - } - return region; - }); - } + public abstract Region getRegion(RegionPosition pos); - public Region getRegionWithinRange(RegionPosition pos, int yMin, int yMax) { - return getRegion(pos); - } - - /** - * Set the region for the given position. - */ - public synchronized void setRegion(RegionPosition pos, Region region) { - regionMap.put(pos.getLong(), region); - } + public abstract Region getRegionWithinRange(RegionPosition pos, int yMin, int yMax); /** * @param pos region position * @return {@code true} if a region file exists for the given position */ - public boolean regionExists(RegionPosition pos) { - File regionFile = new File(getRegionDirectory(), pos.getMcaName()); - return regionFile.exists(); - } + public abstract boolean regionExists(RegionPosition pos); /** * @param pos Position of the region to load @@ -202,98 +137,125 @@ public boolean regionExists(RegionPosition pos) { * @param maxY Maximum block Y (exclusive) * @return Whether the region exists */ - public boolean regionExistsWithinRange(RegionPosition pos, int minY, int maxY) { - return this.regionExists(pos); - } + public abstract boolean regionExistsWithinRange(RegionPosition pos, int minY, int maxY); /** - * Get the data directory for the given dimension. + * WARNING: In some dimensions this could be from {@link Integer#MIN_VALUE} to {@link Integer#MAX_VALUE} + *

+ * Lower bound is inclusive, upper is exclusive * - * @return File object pointing to the data directory + * @return The height range of the dimension. */ - protected synchronized File getDimensionDirectory() { - return dimensionDirectory; - } + public abstract IntIntPair heightRange(); /** - * @return File object pointing to the region file directory + * @return The chunk heightmap */ - public synchronized File getRegionDirectory() { - return new File(getDimensionDirectory(), "region"); + public Heightmap getHeightmap() { + return heightmap; + } + + @Override + public String toString() { + return dimensionDirectory.getName(); + } + + public Optional getSpawnPosition() { + return Optional.ofNullable(this.spawnPos); + } + + public Date getLastModified() { + return new Date(this.dimensionDirectory.lastModified()); } + /** + * Reload player data. + * + * @return {@code true} if player data was reloaded. + */ + public abstract boolean reloadPlayerData(); + /** * Get the current player position as an optional vector. * *

The result is empty if this is not a single player world. */ - public synchronized Optional getPlayerPos() { - if (!playerEntities.isEmpty()) { - return world.getSingleplayerPlayerUuid() - .flatMap(uuid -> playerEntities.stream() - .filter(player -> player.uuid.equals(uuid)) - .map(pos -> new Vector3(pos.x, pos.y, pos.z)) - .findFirst()); - } else { - return Optional.empty(); - } - } + public abstract Optional getPlayerPos(); /** - * @return The chunk heightmap + * Load entities from world the file. + * This is usually the single player entity in a local save. */ - public Heightmap getHeightmap() { - return heightmap; + public synchronized Collection getPlayerEntities() { + Collection list = new LinkedList<>(); + if (PersistentSettings.getLoadPlayers()) { + for (PlayerEntityData data : playerEntities) { + list.add(new PlayerEntity(data)); + } + } + return list; + } + + public synchronized void setPlayerEntities(Set playerEntities) { + this.playerEntities.clear(); + this.playerEntities.addAll(playerEntities); + } + + public synchronized Collection getPlayerPositions() { + return Collections.unmodifiableSet(playerEntities); } /** - * Called when a new region has been discovered by the region parser. + * Add a chunk deletion listener. */ - public void regionDiscovered(RegionPosition pos) { - synchronized (this) { - regionMap.computeIfAbsent(pos.getLong(), p -> createRegion(pos)); + public void addChunkDeletionListener(ChunkDeletionListener listener) { + synchronized (chunkDeletionListeners) { + chunkDeletionListeners.add(listener); } } /** - * Notify region update listeners. + * Called when chunks have been deleted from this world. + * Triggers the chunk deletion listeners. + * + * @param pos Position of deleted chunk */ - private void fireChunkUpdated(ChunkPosition chunk) { - synchronized (chunkUpdateListeners) { - for (ChunkUpdateListener listener : chunkUpdateListeners) { - listener.chunkUpdated(chunk); - } + public void chunkDeleted(ChunkPosition pos) { + synchronized (chunkDeletionListeners) { + for (ChunkDeletionListener listener : chunkDeletionListeners) + listener.chunkDeleted(pos); } } /** - * Notify region update listeners. + * Add a region discovery listener. */ - private void fireRegionUpdated(RegionPosition region) { + public void addChunkUpdateListener(ChunkUpdateListener listener) { synchronized (chunkUpdateListeners) { - for (ChunkUpdateListener listener : chunkUpdateListeners) { - listener.regionUpdated(region); - } + chunkUpdateListeners.add(listener); } } - @Override - public String toString() { - return dimensionDirectory.getName(); - } - /** * Called when a chunk has been updated. */ public void chunkUpdated(ChunkPosition chunk) { - fireChunkUpdated(chunk); + synchronized (chunkUpdateListeners) { + for (ChunkUpdateListener listener : chunkUpdateListeners) { + listener.chunkUpdated(chunk); + } + } } /** * Called when a chunk has been updated. */ public void regionUpdated(RegionPosition region) { - fireRegionUpdated(region); + synchronized (chunkUpdateListeners) { + for (ChunkUpdateListener listener : chunkUpdateListeners) { + listener.regionUpdated(region); + } + } } /** @@ -324,48 +286,4 @@ public void chunkTopographyUpdated(Chunk chunk) { listener.chunksTopographyUpdated(chunk); } } - - public Optional getSpawnPosition() { - return Optional.ofNullable(this.spawnPos); - } - - public void setSpawnPos(@Nullable Vector3i spawnPos) { - this.spawnPos = spawnPos; - } - - /** - * Called when chunks have been deleted from this world. - * Triggers the chunk deletion listeners. - * - * @param pos Position of deleted chunk - */ - public void chunkDeleted(ChunkPosition pos) { - fireChunkDeleted(pos); - } - - public Date getLastModified() { - return new Date(this.dimensionDirectory.lastModified()); - } - - /** - * Load entities from world the file. - * This is usually the single player entity in a local save. - */ - public synchronized Collection getPlayerEntities() { - Collection list = new LinkedList<>(); - if (PersistentSettings.getLoadPlayers()) { - for (PlayerEntityData data : playerEntities) { - list.add(new PlayerEntity(data)); - } - } - return list; - } - - public synchronized Collection getPlayerPositions() { - return Collections.unmodifiableSet(playerEntities); - } - - public synchronized void setPlayerEntities(Set playerEntities) { - this.playerEntities = playerEntities; - } } diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyChunk.java b/chunky/src/java/se/llbit/chunky/world/EmptyChunk.java index dd1ed4a263..7065e224cb 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyChunk.java @@ -24,6 +24,7 @@ import se.llbit.chunky.ui.ChunkMap; import se.llbit.chunky.world.biome.BiomePalette; import se.llbit.util.Mutable; +import se.llbit.util.annotation.NotNull; /** * Empty or non-existent chunk in a region that does exist. @@ -86,7 +87,7 @@ private void renderEmpty(MapTile tile) { // Do nothing. } - @Override public synchronized boolean loadChunk(Mutable chunkData, int yMin, int yMax) { + @Override public synchronized boolean loadChunk(@NotNull Mutable chunkData, int yMin, int yMax) { return false; } diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java b/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java index cf112ff995..89699afac8 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java @@ -1,14 +1,77 @@ package se.llbit.chunky.world; +import it.unimi.dsi.fastutil.ints.IntIntImmutablePair; +import it.unimi.dsi.fastutil.ints.IntIntPair; +import se.llbit.chunky.map.MapView; +import se.llbit.chunky.map.WorldMapLoader; +import se.llbit.chunky.world.region.EmptyRegion; +import se.llbit.chunky.world.region.Region; +import se.llbit.chunky.world.region.RegionChangeWatcher; +import se.llbit.math.Vector3; + import java.util.Collections; +import java.util.Optional; public class EmptyDimension extends Dimension { EmptyDimension() { - super(EmptyWorld.INSTANCE, Dimension.Identifier.OVERWORLD, null, Collections.emptySet()); + super(Dimension.Identifier.OVERWORLD, null, Collections.emptySet()); + } + + @Override + public Chunk getChunk(ChunkPosition pos) { + return EmptyChunk.INSTANCE; + } + + @Override + public Region createRegion(RegionPosition pos) { + return EmptyRegion.instance; + } + + @Override + public RegionChangeWatcher createRegionChangeWatcher(WorldMapLoader worldMapLoader, MapView mapView) { + return new RegionChangeWatcher(worldMapLoader, mapView, "Empty Region Change Watcher") { + @Override + public void run() {} + }; + } + + @Override + public Region getRegion(RegionPosition pos) { + return EmptyRegion.instance; + } + + @Override + public Region getRegionWithinRange(RegionPosition pos, int yMin, int yMax) { + return EmptyRegion.instance; + } + + @Override + public boolean regionExists(RegionPosition pos) { + return false; + } + + @Override + public boolean regionExistsWithinRange(RegionPosition pos, int minY, int maxY) { + return false; + } + + @Override + public IntIntPair heightRange() { + return new IntIntImmutablePair(0, 0); } @Override public String toString() { return "[empty dimension]"; } + + @Override + public boolean reloadPlayerData() { + return false; + } + + @Override + public Optional getPlayerPos() { + return Optional.empty(); + } } diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyRegionChunk.java b/chunky/src/java/se/llbit/chunky/world/EmptyRegionChunk.java index 029f664585..ff7739a4c2 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyRegionChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyRegionChunk.java @@ -23,6 +23,7 @@ import se.llbit.chunky.ui.ChunkMap; import se.llbit.chunky.world.biome.BiomePalette; import se.llbit.util.Mutable; +import se.llbit.util.annotation.NotNull; /** * Empty or non-existent chunk in a region that does not exist. @@ -48,7 +49,7 @@ private EmptyRegionChunk() { surface = IconLayer.CORRUPT; } - @Override public synchronized void getChunkData(Mutable reuseChunkData, BlockPalette palette, BiomePalette biomePalette, int yMin, int yMax) { } + @Override public synchronized void getChunkData(@NotNull Mutable reuseChunkData, BlockPalette palette, BiomePalette biomePalette, int yMin, int yMax) { } @Override public void renderSurface(MapTile tile) { renderEmpty(tile); @@ -80,7 +81,7 @@ private void renderEmpty(MapTile tile) { // do nothing } - @Override public synchronized boolean loadChunk(Mutable chunkData, int yMin, int yMax) { + @Override public synchronized boolean loadChunk(@NotNull Mutable chunkData, int yMin, int yMax) { return false; } diff --git a/chunky/src/java/se/llbit/chunky/world/ImposterCubicChunk.java b/chunky/src/java/se/llbit/chunky/world/ImposterCubicChunk.java index 1ea4ed8460..14c2e2f7cb 100644 --- a/chunky/src/java/se/llbit/chunky/world/ImposterCubicChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/ImposterCubicChunk.java @@ -9,6 +9,7 @@ import se.llbit.chunky.world.biome.ArrayBiomePalette; import se.llbit.chunky.world.biome.BiomePalette; import se.llbit.chunky.world.biome.Biomes; +import se.llbit.chunky.world.java.JavaChunk; import se.llbit.chunky.world.region.ImposterCubicRegion; import se.llbit.nbt.CompoundTag; import se.llbit.nbt.ListTag; @@ -26,7 +27,7 @@ * * Represents an infinitely tall column of 16x16x16 Cubes */ -public class ImposterCubicChunk extends Chunk { +public class ImposterCubicChunk extends JavaChunk { private final CubicDimension dimension; public ImposterCubicChunk(ChunkPosition pos, Dimension dimension) { diff --git a/chunky/src/java/se/llbit/chunky/world/PlayerEntityData.java b/chunky/src/java/se/llbit/chunky/world/PlayerEntityData.java index df2ad653ac..493c0c8e97 100644 --- a/chunky/src/java/se/llbit/chunky/world/PlayerEntityData.java +++ b/chunky/src/java/se/llbit/chunky/world/PlayerEntityData.java @@ -93,7 +93,7 @@ public PlayerEntityData(Tag player) { } } - static UUID getUuid(Tag playerTag) { + public static UUID getUuid(Tag playerTag) { final UUID uuid; long uuidHi; long uuidLo; diff --git a/chunky/src/java/se/llbit/chunky/world/World.java b/chunky/src/java/se/llbit/chunky/world/World.java index 51653becde..be4ffe49a4 100644 --- a/chunky/src/java/se/llbit/chunky/world/World.java +++ b/chunky/src/java/se/llbit/chunky/world/World.java @@ -16,6 +16,7 @@ */ package se.llbit.chunky.world; +import se.llbit.chunky.world.java.JavaWorld; import se.llbit.log.Log; import se.llbit.math.Vector3i; import se.llbit.nbt.NamedTag; @@ -37,32 +38,20 @@ * * @author Jesper Öqvist */ -public class World implements Comparable { - - /** The currently supported NBT version of level.dat files. */ - public static final int NBT_VERSION = 19133; - +public abstract class World implements Comparable { /** Default sea water level. */ public static final int SEA_LEVEL = 63; - /** Minimum level.dat data version of tall worlds (21w06a). */ - public static final int VERSION_21W06A = 2694; - public static final int VERSION_1_12_2 = 1343; - - private final File worldDirectory; + protected final File worldDirectory; protected Dimension currentDimension; - private final String levelName; - private int gameMode = 0; - private final long seed; - - private int versionId; + protected final String levelName; + protected int gameMode = 0; + protected final long seed; /** Timestamp for level.dat when player data was last loaded. */ - private long timestamp; - - private UUID singleplayerPlayerUuid; + protected long timestamp; /** * @param levelName name of the world (not the world directory). @@ -82,11 +71,6 @@ public enum LoggedWarnings { SILENT } - public void loadDimension(Dimension.Identifier dimensionId) { - currentDimension = loadDimension(this, this.worldDirectory, dimensionId, Collections.emptySet()); - currentDimension.reloadPlayerData(); - } - /** * Parse player location and level name. * @@ -96,165 +80,11 @@ public static World loadWorld(File worldDirectory, Dimension.Identifier dimensio if (worldDirectory == null) { return EmptyWorld.INSTANCE; } - String levelName = worldDirectory.getName(); // Default level name. - File worldFile = new File(worldDirectory, "level.dat"); - long modtime = worldFile.lastModified(); - try (FileInputStream fin = new FileInputStream(worldFile); - InputStream gzin = new GZIPInputStream(fin); - DataInputStream in = new DataInputStream(gzin)) { - Set request = new HashSet<>(); - request.add(".Data.version"); - request.add(".Data.Version.Id"); - request.add(".Data.RandomSeed"); - request.add(".Data.Player"); - request.add(".Data.singleplayer_uuid"); - request.add(".Data.LevelName"); - request.add(".Data.GameType"); - request.add(".Data.isCubicWorld"); - Map result = NamedTag.quickParse(in, request); - - Tag version = result.get(".Data.version"); - if (warnings == LoggedWarnings.NORMAL && version.intValue() != NBT_VERSION) { - Log.warnf("The world format for the world %s is not supported by Chunky.\n" + "Will attempt to load the world anyway.", - levelName); - } - Tag versionId = result.get(".Data.Version.Id"); - Tag player = result.get(".Data.Player"); - Tag spawnX = player.get("SpawnX"); - Tag spawnY = player.get("SpawnY"); - Tag spawnZ = player.get("SpawnZ"); - Tag singleplayerUuid = result.get(".Data.singleplayer_uuid"); - Tag gameType = result.get(".Data.GameType"); - Tag randomSeed = result.get(".Data.RandomSeed"); - levelName = MinecraftText.removeFormatChars(result.get(".Data.LevelName").stringValue(levelName)); - - long seed = randomSeed.longValue(0); - - Set playerEntities = getPlayerEntityData(worldDirectory, dimensionId, player); - - World world = new World(levelName, worldDirectory, seed, modtime); - world.gameMode = gameType.intValue(0); - world.versionId = versionId.intValue(); - if (singleplayerUuid.isIntArray(4)) { - world.singleplayerPlayerUuid = UuidUtil.intsToUuid(singleplayerUuid.intArray()); - } else if (!player.isError()) { - world.singleplayerPlayerUuid = PlayerEntityData.getUuid(player); - } - - Dimension dimension = loadDimension(world, worldDirectory, dimensionId, playerEntities); - - boolean haveSpawnPos = !(spawnX.isError() || spawnY.isError() || spawnZ.isError()); - if (haveSpawnPos) { - dimension.setSpawnPos(new Vector3i(spawnX.intValue(0), spawnY.intValue(0), spawnZ.intValue(0))); - } - - world.currentDimension = dimension; - - return world; - } catch (FileNotFoundException e) { - if (warnings == LoggedWarnings.NORMAL) { - Log.infof("Could not find level.dat file for world %s!", levelName); - } - } catch (IOException e) { - if (warnings == LoggedWarnings.NORMAL) { - Log.infof("Could not read the level.dat file for world %s!", levelName); - } - } - return EmptyWorld.INSTANCE; + return JavaWorld.loadWorld(worldDirectory, dimensionId, warnings); } - @NotNull - private static Dimension loadDimension(World world, File worldDirectory, Dimension.Identifier dimensionId, Set playerEntities) { - File dimensionDirectory = Path.of(worldDirectory.getPath(), "dimensions", dimensionId.namespace(), dimensionId.name()).toFile(); - if (dimensionDirectory.exists()) { - // 26.1-snapshot-6 or later - return new Dimension(world, dimensionId, dimensionDirectory, playerEntities); - } + public abstract void loadDimension(Dimension.Identifier dimensionId); - dimensionDirectory = switch (dimensionId.getNamespacedName()) { // TODO in Java 21+ we can use `switch (dimensionId)` here - case "minecraft:the_nether" -> new File(worldDirectory, "DIM-1"); - case "minecraft:the_end" -> new File(worldDirectory, "DIM1"); - default -> worldDirectory; - }; - if (new File(dimensionDirectory, "region3d").exists()) { - return new CubicDimension(world, dimensionId, dimensionDirectory, playerEntities); - } else { - return new Dimension(world, dimensionId, dimensionDirectory, playerEntities); - } - } - - @NotNull - private static Set getPlayerEntityData(File worldDirectory, Dimension.Identifier dimensionId, Tag player) { - Set playerEntities = new HashSet<>(); - if (!player.isError()) { - playerEntities.add(new PlayerEntityData(player)); - } - loadAdditionalPlayers(worldDirectory, playerEntities); - // Filter for the players only within the requested dimension - playerEntities = playerEntities.stream().filter(playerData -> playerData.dimension.equals(dimensionId)).collect(Collectors.toSet()); - return playerEntities; - } - - /** - * Reload player data for the current dimension. This method is not in Dimension because players are per-world, not per-dimension - * @return {@code true} if player data was reloaded. - */ - synchronized boolean reloadPlayerData() { - if (worldDirectory == null) { - return false; - } - File worldFile = new File(worldDirectory, "level.dat"); - long lastModified = worldFile.lastModified(); - if (lastModified == timestamp) { - return false; - } - Log.infof("world %s: timestamp updated: reading player data", levelName); - timestamp = lastModified; - - try (FileInputStream fin = new FileInputStream(worldFile); - InputStream gzin = new GZIPInputStream(fin); - DataInputStream in = new DataInputStream(gzin)) { - Set request = new HashSet<>(); - request.add(".Data.Player"); - request.add(".Data.singleplayer_uuid"); - Map result = NamedTag.quickParse(in, request); - Tag player = result.get(".Data.Player"); - Tag singleplayerUuid = result.get(".Data.singleplayer_uuid"); - if (singleplayerUuid.isIntArray(4)) { - singleplayerPlayerUuid = UuidUtil.intsToUuid(singleplayerUuid.intArray()); - } else if (!player.isError()) { - singleplayerPlayerUuid = PlayerEntityData.getUuid(player); - } - - currentDimension.setPlayerEntities(getPlayerEntityData(worldDirectory, currentDimension.getDimensionId(), player)); - } catch (IOException e) { - Log.infof("Could not read the level.dat file for world %s while trying to reload player data!", levelName); - return false; - } - return true; - } - - private static void loadAdditionalPlayers(File worldDirectory, Set playerEntities) { - loadPlayerData(new File(worldDirectory, "players"), playerEntities); - loadPlayerData(new File(worldDirectory, "playerdata"), playerEntities); - loadPlayerData(new File(new File(worldDirectory, "players"), "data"), playerEntities); // 26.1-snapshot-6 or later - } - - private static void loadPlayerData(File playerdata, Set playerEntities) { - if (playerdata.isDirectory()) { - File[] players = playerdata.listFiles(); - if (players != null) { - for (File player : players) { - try (DataInputStream in = new DataInputStream( - new GZIPInputStream(new FileInputStream(player)))) { - playerEntities.add(new PlayerEntityData(NamedTag.read(in).unpack())); - } catch (IOException e) { - Log.infof("Could not read player data file '%s'", player.getAbsolutePath()); - } - } - } - } - } /** * @return The current dimension @@ -263,10 +93,6 @@ public synchronized Dimension currentDimension() { return this.currentDimension; } - public Optional getSingleplayerPlayerUuid() { - return Optional.ofNullable(singleplayerPlayerUuid); - } - /** * @return The world directory */ @@ -283,10 +109,6 @@ public String levelName() { return levelName; } - public int getVersionId() { - return versionId; - } - /** * @return true if the given directory exists and * contains a level.dat file @@ -310,7 +132,6 @@ public String gameMode() { default -> "Unknown"; }; } - @Override public int compareTo(World o) { // Compares world names and directories. return toString().compareToIgnoreCase(o.toString()); diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java b/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java new file mode 100644 index 0000000000..cf0e2726da --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java @@ -0,0 +1,365 @@ +package se.llbit.chunky.world.java; + +import it.unimi.dsi.fastutil.ints.IntIntImmutablePair; +import se.llbit.chunky.block.legacy.LegacyBlocks; +import se.llbit.chunky.chunk.BlockPalette; +import se.llbit.chunky.chunk.ChunkData; +import se.llbit.chunky.chunk.ChunkLoadingException; +import se.llbit.chunky.chunk.EmptyChunkData; +import se.llbit.chunky.chunk.biome.BiomeDataFactory; +import se.llbit.chunky.map.BiomeLayer; +import se.llbit.chunky.map.IconLayer; +import se.llbit.chunky.map.SurfaceLayer; +import se.llbit.chunky.world.*; +import se.llbit.chunky.world.biome.ArrayBiomePalette; +import se.llbit.chunky.world.biome.BiomePalette; +import se.llbit.chunky.world.region.MCRegion; +import se.llbit.chunky.world.region.Region; +import se.llbit.log.Log; +import se.llbit.math.QuickMath; +import se.llbit.nbt.CompoundTag; +import se.llbit.nbt.ListTag; +import se.llbit.nbt.SpecificTag; +import se.llbit.nbt.Tag; +import se.llbit.util.BitBuffer; +import se.llbit.util.Mutable; +import se.llbit.util.annotation.NotNull; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static se.llbit.util.NbtUtil.getTagFromNames; +import static se.llbit.util.NbtUtil.tagFromMap; + +public class JavaChunk extends Chunk { + public JavaChunk(ChunkPosition pos, Dimension dimension) { + super(pos, dimension); + } + + /** + * @param request fresh request set + * @return loaded data, or null if something went wrong + */ + private Map getChunkTags(Set request) throws ChunkLoadingException { + MCRegion region = (MCRegion) dimension.getRegion(position.getRegionPosition()); + Mutable timestamp = new Mutable<>(dataTimestamp); + Map chunkTags = region.getChunkTags(this.position, request, timestamp); + this.dataTimestamp = timestamp.get(); + return chunkTags; + } + + /** + * @param request fresh request set + * @return loaded data, or null if something went wrong + */ + private Map getEntityTags(Set request) throws ChunkLoadingException { + MCRegion region = (MCRegion) dimension.getRegion(position.getRegionPosition()); + return region.getEntityTags(this.position, request); + } + + @Override + public synchronized boolean loadChunk(@NotNull Mutable chunkData, int yMin, int yMax) { + if (!shouldReloadChunk()) { + return false; + } + + Set request = new HashSet<>(); + request.add(Chunk.DATAVERSION); + request.add(Chunk.LEVEL_SECTIONS); + request.add(Chunk.SECTIONS_POST_21W39A); + request.add(Chunk.LEVEL_BIOMES); + request.add(Chunk.BIOMES_POST_21W39A); + request.add(Chunk.LEVEL_HEIGHTMAP); + + Map dataMap; + try { + dataMap = getChunkTags(request); + } catch (ChunkLoadingException e) { // we don't want to crash the map view if a chunk fails to load, so we warn the user + Log.warn(String.format("Failed to load chunk %s", position), e); + return false; + } + // TODO: improve error handling here. + if (dataMap == null) { + return false; + } + Tag data = tagFromMap(dataMap); + + surfaceTimestamp = dataTimestamp; + version = chunkVersion(data); + IntIntImmutablePair chunkBounds = inclusiveChunkBounds(data); + chunkData.set(this.dimension.createChunkData(chunkData.get(), chunkBounds.leftInt(), chunkBounds.rightInt())); + loadSurface(data, chunkData.get(), yMin, yMax); + biomesTimestamp = dataTimestamp; + + dimension.chunkUpdated(position); + return true; + } + + private void loadSurface(@NotNull Tag data, ChunkData chunkData, int yMin, int yMax) { + if (data == null) { + surface = IconLayer.CORRUPT; + return; + } + + Heightmap heightmap = dimension.getHeightmap(); + Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); + if (sections.isList()) { + if (version == ChunkVersion.PRE_FLATTENING || version == ChunkVersion.POST_FLATTENING) { + BiomePalette biomePalette = new ArrayBiomePalette(); + BiomeDataFactory.loadBiomeData(chunkData, data, biomePalette, yMin, yMax); + biomes = new BiomeLayer(chunkData, biomePalette); + + BlockPalette palette = new BlockPalette(); + palette.unsynchronize(); //only this RegionParser will use this palette + loadBlockData(data, chunkData, palette, yMin, yMax); + + int[] heightmapData = extractHeightmapData(data, chunkData); + updateHeightmap(heightmap, position, chunkData, heightmapData, palette, yMax); + + surface = new SurfaceLayer(dimension.getDimensionId(), chunkData, palette, biomePalette, yMin, yMax, heightmapData); + queueTopography(); + } + } else { + surface = IconLayer.CORRUPT; + } + } + + private int[] extractHeightmapData(@NotNull Tag data, ChunkData chunkData) { + Tag heightmapTag = data.get(LEVEL_HEIGHTMAP); + if (heightmapTag.isIntArray(X_MAX * Z_MAX)) { + return heightmapTag.intArray(); + } else { + int[] fallback = new int[X_MAX * Z_MAX]; + for (int i = 0; i < fallback.length; ++i) { + fallback[i] = chunkData.maxY(); + } + return fallback; + } + } + + protected boolean shouldReloadChunk() { + int timestamp = Integer.MAX_VALUE; + timestamp = Math.min(timestamp, surfaceTimestamp); + timestamp = Math.min(timestamp, biomesTimestamp); + if (timestamp == 0) { + return true; + } + Region region = dimension.getRegion(position.getRegionPosition()); + return region.chunkChangedSince(position, timestamp); + } + + /** Detect Minecraft version that generated the chunk. */ + private static ChunkVersion chunkVersion(@NotNull Tag data) { + Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); + if (sections.isList()) { + for (SpecificTag section : sections.asList()) { + if (!section.get("Palette").isList()) { + if (section.get("Blocks").isByteArray(SECTION_BYTES)) { + return ChunkVersion.PRE_FLATTENING; + } + } + } + return ChunkVersion.POST_FLATTENING; + } + return ChunkVersion.UNKNOWN; + } + + private static void loadBlockData(@NotNull Tag data, @NotNull ChunkData chunkData, + BlockPalette blockPalette, int minY, int maxY) { + + Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); + if (sections.isList()) { + for (SpecificTag section : sections.asList()) { + Tag yTag = section.get("Y"); + int sectionY = yTag.byteValue(); + int sectionMinBlockY = sectionY << 4; + + if(sectionY < minY >> 4 || sectionY-1 > (maxY >> 4)+1) + continue; //skip parsing sections that are outside requested bounds + + Tag blockPaletteTag = getTagFromNames(section, "Palette", "block_states\\palette"); + if (blockPaletteTag.isList()) { + ListTag localBlockPalette = blockPaletteTag.asList(); + // Bits per block: + int bpb = 4; + if (localBlockPalette.size() > 16) { + bpb = QuickMath.log2(QuickMath.nextPow2(localBlockPalette.size())); + } + + int dataSize = (4096 * bpb) / 64; + Tag blockStates = getTagFromNames(section, "BlockStates", "block_states\\data"); + + if (blockStates.isLongArray(dataSize)) { + // since 20w17a, block states are aligned to 64-bit boundaries, so there are 64 % bpb + // unused bits per block state; if so, the array is longer than the expected data size + boolean isAligned = data.get(DATAVERSION).intValue() >= DATAVERSION_20W17A; + if (isAligned) { + // entries are 64-bit-padded, re-calculate the bits per block + // this is the dataSize calculation from above reverted, we know the actual data size + bpb = blockStates.longArray().length / 64; + } + + int[] subpalette = new int[localBlockPalette.size()]; + int paletteIndex = 0; + for (Tag item : localBlockPalette.asList()) { + subpalette[paletteIndex] = blockPalette.put(item); + paletteIndex += 1; + } + BitBuffer buffer = new BitBuffer(blockStates.longArray(), bpb, isAligned); + for (int y = 0; y < SECTION_Y_MAX; y++) { + int blockY = sectionMinBlockY + y; + for (int z = 0; z < Z_MAX; z++) { + for(int x = 0; x < X_MAX; x++) { + int b0 = buffer.read(); + if (b0 < subpalette.length) { + chunkData.setBlockAt(x, blockY, z, subpalette[b0]); + } + } + } + } + } else { + // Single block palette + if (localBlockPalette.size() == 1) { + // Check it is not air block + int block = blockPalette.put(localBlockPalette.get(0)); + if (block != blockPalette.airId) { + // Set the entire section + for (int y = 0; y < SECTION_Y_MAX; y++) { + int blockY = sectionMinBlockY + y; + for (int z = 0; z < Z_MAX; z++) { + for(int x = 0; x < X_MAX; x++) { + chunkData.setBlockAt(x, blockY, z, block); + } + } + } + } + } + } + } else { + int yOffset = sectionY & 0xFF; + + Tag dataTag = section.get("Data"); + byte[] blockDataBytes = new byte[(Chunk.X_MAX * Chunk.Y_MAX * Chunk.Z_MAX) / 2]; + if (dataTag.isByteArray(SECTION_HALF_NIBBLES)) { + System.arraycopy(dataTag.byteArray(), 0, blockDataBytes, SECTION_HALF_NIBBLES * yOffset, + SECTION_HALF_NIBBLES); + } + + Tag blocksTag = section.get("Blocks"); + if (blocksTag.isByteArray(SECTION_BYTES)) { + byte[] blocksBytes = new byte[Chunk.X_MAX * Chunk.Y_MAX * Chunk.Z_MAX]; + System.arraycopy(blocksTag.byteArray(), 0, blocksBytes, SECTION_BYTES * yOffset, + SECTION_BYTES); + + int offset = SECTION_BYTES * yOffset; + for (int y = 0; y < SECTION_Y_MAX; y++) { + int blockY = sectionMinBlockY + y; + for (int z = 0; z < Z_MAX; z++) { + for (int x = 0; x < X_MAX; x++) { + chunkData.setBlockAt(x, blockY, z, blockPalette.put( + LegacyBlocks.getTag(offset, blocksBytes, blockDataBytes))); + offset += 1; + } + } + } + } + } + } + } + } + + @Override + public synchronized void getChunkData(@NotNull Mutable reuseChunkData, BlockPalette palette, BiomePalette biomePalette, int minY, int maxY) throws ChunkLoadingException { + Set request = new HashSet<>(); + request.add(DATAVERSION); + request.add(LEVEL_SECTIONS); + request.add(SECTIONS_POST_21W39A); + request.add(LEVEL_BIOMES); + request.add(BIOMES_POST_21W39A); + request.add(LEVEL_ENTITIES); + request.add(LEVEL_TILEENTITIES); + request.add(BLOCK_ENTITIES_POST_21W43A); + Map dataMap = getChunkTags(request); + // TODO: improve error handling here. + if (dataMap == null) { + throw new ChunkLoadingException(String.format("Got null data for chunk %s", this.position)); + } + Tag data = tagFromMap(dataMap); + + int dataVersion = data.get(DATAVERSION).intValue(); + + IntIntImmutablePair chunkBounds = inclusiveChunkBounds(data); + + if(reuseChunkData.get() == null || reuseChunkData.get() instanceof EmptyChunkData) { + reuseChunkData.set(dimension.createChunkData(reuseChunkData.get(), chunkBounds.leftInt(), chunkBounds.rightInt())); + } else { + reuseChunkData.get().clear(); + } + ChunkData chunkData = reuseChunkData.get(); //unwrap mutable, for ease of use + + version = chunkVersion(data); + Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); + Tag entitiesTag = data.get(LEVEL_ENTITIES); + Tag tileEntitiesTag = getTagFromNames(data, LEVEL_TILEENTITIES, BLOCK_ENTITIES_POST_21W43A); + + BiomeDataFactory.loadBiomeData(chunkData, data, biomePalette, minY, maxY); + if (sections.isList()) { + loadBlockData(data, chunkData, palette, minY, maxY); + + if (entitiesTag.isList()) { + for (SpecificTag tag : (ListTag) entitiesTag) { + if (tag.isCompoundTag()) + chunkData.addEntity((CompoundTag) tag); + } + } + + if (tileEntitiesTag.isList()) { + for (SpecificTag tag : (ListTag) tileEntitiesTag) { + if (tag.isCompoundTag()) + chunkData.addTileEntity((CompoundTag) tag); + } + } + } + + // post 20w45A entities + if (dataVersion >= DATAVERSION_20W45A) { + Set entitiesRequest = new HashSet<>(); + entitiesRequest.add(ENTITIES_POST_20W45A); + + Map entitiesMap = getEntityTags(entitiesRequest); + if (entitiesMap != null) { + entitiesTag = entitiesMap.get(".Entities"); + if (entitiesTag.isList()) { + for (SpecificTag tag : (ListTag) entitiesTag) { + if (tag.isCompoundTag()) + chunkData.addEntity((CompoundTag) tag); + } + } + } + } + } + + /** + * @return The min and max blockY for a given section array + */ + private IntIntImmutablePair inclusiveChunkBounds(Tag chunkData) { + Tag sections = getTagFromNames(chunkData, LEVEL_SECTIONS, SECTIONS_POST_21W39A); + int minSectionY = Integer.MAX_VALUE; + int maxSectionY = Integer.MIN_VALUE; + if (sections.isList()) { + for (SpecificTag section : sections.asList()) { + byte sectionY = (byte) section.get("Y").byteValue(); + if (sectionY < minSectionY) { + minSectionY = sectionY; + } + if (sectionY > maxSectionY) { + maxSectionY = sectionY; + } + } + } + + return new IntIntImmutablePair(minSectionY << 4, (maxSectionY << 4) + 15); + } + +} diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java new file mode 100644 index 0000000000..9b57390805 --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java @@ -0,0 +1,138 @@ +package se.llbit.chunky.world.java; + +import it.unimi.dsi.fastutil.ints.IntIntImmutablePair; +import it.unimi.dsi.fastutil.ints.IntIntPair; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import se.llbit.chunky.map.MapView; +import se.llbit.chunky.map.WorldMapLoader; +import se.llbit.chunky.world.*; +import se.llbit.chunky.world.region.*; +import se.llbit.math.Vector3; +import se.llbit.math.Vector3i; +import se.llbit.util.annotation.Nullable; + +import java.io.File; +import java.util.Optional; +import java.util.Set; + +public class JavaDimension extends Dimension { + protected final JavaWorld world; + protected final Long2ObjectMap regionMap = new Long2ObjectOpenHashMap<>(); + + /** + * @param world + * @param dimensionId + * @param dimensionDirectory Minecraft world directory. + * @param playerEntities + */ + protected JavaDimension(JavaWorld world, Identifier dimensionId, File dimensionDirectory, Set playerEntities) { + super(dimensionId, dimensionDirectory, playerEntities); + this.world = world; + } + + @Override + public RegionChangeWatcher createRegionChangeWatcher(WorldMapLoader worldMapLoader, MapView mapView) { + return new MCRegionChangeWatcher(worldMapLoader, mapView); + } + + @Override + public Region createRegion(RegionPosition pos) { + return new MCRegion(pos, this); + } + + /** + * Set the region for the given position. + */ + public synchronized void setRegion(RegionPosition pos, Region region) { + regionMap.put(pos.getLong(), region); + } + + @Override + public synchronized Region getRegion(RegionPosition pos) { + return regionMap.computeIfAbsent(pos.getLong(), p -> { + // check if the region is present in the world directory + Region region = EmptyRegion.instance; + if (regionExists(pos)) { + region = createRegion(pos); + } + return region; + }); + } + + @Override + public Region getRegionWithinRange(RegionPosition pos, int yMin, int yMax) { + return getRegion(pos); + } + + @Override + public boolean regionExists(RegionPosition pos) { + File regionFile = new File(getRegionDirectory(), pos.getMcaName()); + return regionFile.exists(); + } + + @Override + public boolean regionExistsWithinRange(RegionPosition pos, int minY, int maxY) { + return this.regionExists(pos); + } + + @Override + public IntIntPair heightRange() { + return this.world.versionId >= JavaWorld.VERSION_21W06A ? + new IntIntImmutablePair(-64, 320) : + new IntIntImmutablePair(0, 256); + } + + + @Override + public synchronized Chunk getChunk(ChunkPosition pos) { + return getRegion(pos.getRegionPosition()).getChunk(pos); + } + + @Override + public synchronized boolean reloadPlayerData() { + return this.world.reloadPlayerData(); + } + + @Override + public synchronized Optional getPlayerPos() { + if (!playerEntities.isEmpty()) { + return world.getSingleplayerPlayerUuid() + .flatMap(uuid -> playerEntities.stream() + .filter(player -> player.uuid.equals(uuid)) + .map(pos -> new Vector3(pos.x, pos.y, pos.z)) + .findFirst()); + } else { + return Optional.empty(); + } + } + + public void setSpawnPos(@Nullable Vector3i spawnPos) { + this.spawnPos = spawnPos; + } + + /** + * Called when a new region has been discovered by the region parser. + */ + public void regionDiscovered(RegionPosition pos) { + synchronized (this) { + regionMap.computeIfAbsent(pos.getLong(), p -> createRegion(pos)); + } + } + + /** + * Get the data directory for the given dimension. + * + * @return File object pointing to the data directory + */ + protected synchronized File getDimensionDirectory() { + return dimensionDirectory; + } + + /** + * @return File object pointing to the region file directory + */ + public synchronized File getRegionDirectory() { + return new File(getDimensionDirectory(), "region"); + } +} diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java new file mode 100644 index 0000000000..72bbda3aa7 --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java @@ -0,0 +1,217 @@ +package se.llbit.chunky.world.java; + +import se.llbit.chunky.world.*; +import se.llbit.log.Log; +import se.llbit.math.Vector3i; +import se.llbit.nbt.NamedTag; +import se.llbit.nbt.Tag; +import se.llbit.util.MinecraftText; +import se.llbit.util.UuidUtil; +import se.llbit.util.annotation.NotNull; + +import java.io.*; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Collectors; +import java.util.zip.GZIPInputStream; + +public class JavaWorld extends World { + /** The currently supported NBT version of level.dat files. */ + public static final int NBT_VERSION = 19133; + + /** Minimum level.dat data version of tall worlds (21w06a). */ + public static final int VERSION_21W06A = 2694; + public static final int VERSION_1_12_2 = 1343; + + protected final int versionId; + protected UUID singleplayerPlayerUuid; + + public Optional getSingleplayerPlayerUuid() { + return Optional.ofNullable(singleplayerPlayerUuid); + } + + /** + * @param levelName name of the world (not the world directory). + * @param worldDirectory Minecraft world directory. + * @param seed + * @param timestamp + */ + protected JavaWorld(String levelName, File worldDirectory, long seed, long timestamp, int versionId) { + super(levelName, worldDirectory, seed, timestamp); + this.versionId = versionId; + } + + /** + * Parse player location and level name. + * + * @return {@code true} if the world data was loaded + */ + public static World loadWorld(File worldDirectory, Dimension.Identifier dimensionId, LoggedWarnings warnings) { + if (worldDirectory == null) { + return EmptyWorld.INSTANCE; + } + String levelName = worldDirectory.getName(); // Default level name. + File worldFile = new File(worldDirectory, "level.dat"); + long modtime = worldFile.lastModified(); + try (FileInputStream fin = new FileInputStream(worldFile); + InputStream gzin = new GZIPInputStream(fin); + DataInputStream in = new DataInputStream(gzin)) { + Set request = new HashSet<>(); + request.add(".Data.version"); + request.add(".Data.Version.Id"); + request.add(".Data.RandomSeed"); + request.add(".Data.Player"); + request.add(".Data.singleplayer_uuid"); + request.add(".Data.LevelName"); + request.add(".Data.GameType"); + request.add(".Data.isCubicWorld"); + Map result = NamedTag.quickParse(in, request); + + Tag version = result.get(".Data.version"); + if (warnings == LoggedWarnings.NORMAL && version.intValue() != NBT_VERSION) { + Log.warnf("The world format for the world %s is not supported by Chunky.\n" + "Will attempt to load the world anyway.", + levelName); + } + Tag versionId = result.get(".Data.Version.Id"); + Tag player = result.get(".Data.Player"); + Tag spawnX = player.get("SpawnX"); + Tag spawnY = player.get("SpawnY"); + Tag spawnZ = player.get("SpawnZ"); + Tag singleplayerUuid = result.get(".Data.singleplayer_uuid"); + Tag gameType = result.get(".Data.GameType"); + Tag randomSeed = result.get(".Data.RandomSeed"); + levelName = MinecraftText.removeFormatChars(result.get(".Data.LevelName").stringValue(levelName)); + + long seed = randomSeed.longValue(0); + + Set playerEntities = getPlayerEntityData(worldDirectory, dimensionId, player); + + JavaWorld world = new JavaWorld(levelName, worldDirectory, seed, modtime, versionId.intValue()); + world.gameMode = gameType.intValue(0); + if (singleplayerUuid.isIntArray(4)) { + world.singleplayerPlayerUuid = UuidUtil.intsToUuid(singleplayerUuid.intArray()); + } else if (!player.isError()) { + world.singleplayerPlayerUuid = PlayerEntityData.getUuid(player); + } + + JavaDimension dimension = (JavaDimension) loadDimension(world, worldDirectory, dimensionId, playerEntities); + + boolean haveSpawnPos = !(spawnX.isError() || spawnY.isError() || spawnZ.isError()); + if (haveSpawnPos) { + dimension.setSpawnPos(new Vector3i(spawnX.intValue(0), spawnY.intValue(0), spawnZ.intValue(0))); + } + + world.currentDimension = dimension; + + return world; + } catch (FileNotFoundException e) { + if (warnings == LoggedWarnings.NORMAL) { + Log.infof("Could not find level.dat file for world %s!", levelName); + } + } catch (IOException e) { + if (warnings == LoggedWarnings.NORMAL) { + Log.infof("Could not read the level.dat file for world %s!", levelName); + } + } + return EmptyWorld.INSTANCE; + } + + @Override + public void loadDimension(Dimension.Identifier dimensionId) { + currentDimension = loadDimension(this, this.worldDirectory, dimensionId, Collections.emptySet()); + currentDimension.reloadPlayerData(); + } + + @NotNull + private static Dimension loadDimension(JavaWorld world, File worldDirectory, Dimension.Identifier dimensionId, Set playerEntities) { + File dimensionDirectory = Path.of(worldDirectory.getPath(), "dimensions", dimensionId.namespace(), dimensionId.name()).toFile(); + if (dimensionDirectory.exists()) { + // 26.1-snapshot-6 or later + return new JavaDimension(world, dimensionId, dimensionDirectory, playerEntities); + } + + dimensionDirectory = switch (dimensionId.getNamespacedName()) { // TODO in Java 21+ we can use `switch (dimensionId)` here + case "minecraft:the_nether" -> new File(worldDirectory, "DIM-1"); + case "minecraft:the_end" -> new File(worldDirectory, "DIM1"); + default -> worldDirectory; + }; + if (new File(dimensionDirectory, "region3d").exists()) { + return new CubicDimension(world, dimensionId, dimensionDirectory, playerEntities); + } else { + return new JavaDimension(world, dimensionId, dimensionDirectory, playerEntities); + } + } + + @NotNull + private static Set getPlayerEntityData(File worldDirectory, Dimension.Identifier dimensionId, Tag player) { + Set playerEntities = new HashSet<>(); + if (!player.isError()) { + playerEntities.add(new PlayerEntityData(player)); + } + loadAdditionalPlayers(worldDirectory, playerEntities); + // Filter for the players only within the requested dimension + playerEntities = playerEntities.stream().filter(playerData -> playerData.dimension.equals(dimensionId)).collect(Collectors.toSet()); + return playerEntities; + } + + private static void loadAdditionalPlayers(File worldDirectory, Set playerEntities) { + loadPlayerData(new File(worldDirectory, "players"), playerEntities); + loadPlayerData(new File(worldDirectory, "playerdata"), playerEntities); + loadPlayerData(new File(new File(worldDirectory, "players"), "data"), playerEntities); // 26.1-snapshot-6 or later + } + + private static void loadPlayerData(File playerdata, Set playerEntities) { + if (playerdata.isDirectory()) { + File[] players = playerdata.listFiles(); + if (players != null) { + for (File player : players) { + try (DataInputStream in = new DataInputStream( + new GZIPInputStream(new FileInputStream(player)))) { + playerEntities.add(new PlayerEntityData(NamedTag.read(in).unpack())); + } catch (IOException e) { + Log.infof("Could not read player data file '%s'", player.getAbsolutePath()); + } + } + } + } + } + + /** + * Reload player data for the current dimension. This method is not in Dimension because players are per-world, not per-dimension + * @return {@code true} if player data was reloaded. + */ + synchronized boolean reloadPlayerData() { + if (worldDirectory == null) { + return false; + } + File worldFile = new File(worldDirectory, "level.dat"); + long lastModified = worldFile.lastModified(); + if (lastModified == timestamp) { + return false; + } + Log.infof("world %s: timestamp updated: reading player data", levelName); + timestamp = lastModified; + + try (FileInputStream fin = new FileInputStream(worldFile); + InputStream gzin = new GZIPInputStream(fin); + DataInputStream in = new DataInputStream(gzin)) { + Set request = new HashSet<>(); + request.add(".Data.Player"); + request.add(".Data.singleplayer_uuid"); + Map result = NamedTag.quickParse(in, request); + Tag player = result.get(".Data.Player"); + Tag singleplayerUuid = result.get(".Data.singleplayer_uuid"); + if (singleplayerUuid.isIntArray(4)) { + singleplayerPlayerUuid = UuidUtil.intsToUuid(singleplayerUuid.intArray()); + } else if (!player.isError()) { + singleplayerPlayerUuid = PlayerEntityData.getUuid(player); + } + + currentDimension.setPlayerEntities(getPlayerEntityData(worldDirectory, currentDimension.getDimensionId(), player)); + } catch (IOException e) { + Log.infof("Could not read the level.dat file for world %s while trying to reload player data!", levelName); + return false; + } + return true; + } +} diff --git a/chunky/src/java/se/llbit/chunky/world/region/MCRegion.java b/chunky/src/java/se/llbit/chunky/world/region/MCRegion.java index e6cee2dda4..b9d36aaf42 100644 --- a/chunky/src/java/se/llbit/chunky/world/region/MCRegion.java +++ b/chunky/src/java/se/llbit/chunky/world/region/MCRegion.java @@ -24,6 +24,8 @@ import se.llbit.chunky.chunk.ChunkLoadingException; import se.llbit.chunky.plugin.PluginApi; import se.llbit.chunky.world.*; +import se.llbit.chunky.world.java.JavaChunk; +import se.llbit.chunky.world.java.JavaDimension; import se.llbit.log.Log; import se.llbit.nbt.ErrorTag; import se.llbit.nbt.NamedTag; @@ -63,7 +65,7 @@ public class MCRegion implements Region { private final Chunk[] chunks = new Chunk[NUM_CHUNKS]; private final RegionPosition position; - private final Dimension dimension; + private final JavaDimension dimension; private final String fileName; private long regionFileTime = 0; private final int[] chunkTimestamps = new int[NUM_CHUNKS]; @@ -80,7 +82,7 @@ private static int getMCAChunkIndex(ChunkPosition chunkPos) { * * @param pos the region position */ - public MCRegion(RegionPosition pos, Dimension dimension) { + public MCRegion(RegionPosition pos, JavaDimension dimension) { this.dimension = dimension; fileName = pos.getMcaName(); position = pos; @@ -156,7 +158,7 @@ public synchronized void parse(int minY, int maxY) { int loc = file.readInt(); if (loc != 0) { if (chunk.isEmpty()) { - chunk = new Chunk(pos, dimension); + chunk = new JavaChunk(pos, dimension); setChunk(pos, chunk); } } else { diff --git a/chunky/src/java/se/llbit/chunky/world/region/MCRegionChangeWatcher.java b/chunky/src/java/se/llbit/chunky/world/region/MCRegionChangeWatcher.java index b3e9e3bcc0..c989ee5c46 100644 --- a/chunky/src/java/se/llbit/chunky/world/region/MCRegionChangeWatcher.java +++ b/chunky/src/java/se/llbit/chunky/world/region/MCRegionChangeWatcher.java @@ -24,6 +24,7 @@ import se.llbit.chunky.world.ChunkView; import se.llbit.chunky.world.Dimension; import se.llbit.chunky.world.RegionPosition; +import se.llbit.chunky.world.java.JavaDimension; /** * Monitors filesystem for changes to region files. @@ -39,7 +40,8 @@ public MCRegionChangeWatcher(WorldMapLoader loader, MapView mapView) { try { while (!isInterrupted()) { sleep(3000); - Dimension dimension = mapLoader.getWorld().currentDimension(); + // MCRegionChangeWatcher is only created by JavaDimension, so this cast is always safe. + JavaDimension dimension = (JavaDimension) mapLoader.getWorld().currentDimension(); if (dimension.reloadPlayerData()) { if (PersistentSettings.getFollowPlayer()) { Platform.runLater(() -> dimension.getPlayerPos().ifPresent(mapView::panTo)); From b7b0f7c03a52d15a918abd078b136ff8fa2aca38 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sun, 26 Jul 2026 21:01:27 +0100 Subject: [PATCH 02/57] Basics of WorldFormat interface --- .../se/llbit/chunky/map/WorldMapLoader.java | 3 +- .../se/llbit/chunky/renderer/scene/Scene.java | 3 +- .../src/java/se/llbit/chunky/ui/ChunkMap.java | 22 +++--- .../ui/controller/WorldChooserController.java | 20 +++-- .../se/llbit/chunky/world/CubicDimension.java | 6 +- .../java/se/llbit/chunky/world/Dimension.java | 25 +++--- .../se/llbit/chunky/world/EmptyDimension.java | 2 +- .../llbit/chunky/world/EmptyRegionChunk.java | 2 +- .../se/llbit/chunky/world/EmptyWorld.java | 12 ++- .../src/java/se/llbit/chunky/world/World.java | 22 +++--- .../chunky/world/java/JavaDimension.java | 31 +++----- .../se/llbit/chunky/world/java/JavaWorld.java | 79 +++++++++++++------ .../world/worldformat/JavaWorldFormat.java | 20 +++++ .../chunky/world/worldformat/WorldFormat.java | 19 +++++ 14 files changed, 177 insertions(+), 89 deletions(-) create mode 100644 chunky/src/java/se/llbit/chunky/world/worldformat/JavaWorldFormat.java create mode 100644 chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java diff --git a/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java b/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java index 1119340ea5..a7e3cb3aff 100644 --- a/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java +++ b/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java @@ -21,6 +21,7 @@ import se.llbit.chunky.renderer.ChunkViewListener; import se.llbit.chunky.ui.controller.ChunkyFxController; import se.llbit.chunky.world.*; +import se.llbit.chunky.world.java.JavaWorld; import se.llbit.chunky.world.region.RegionChangeWatcher; import se.llbit.chunky.world.region.RegionParser; import se.llbit.chunky.world.region.RegionQueue; @@ -68,7 +69,7 @@ public WorldMapLoader(ChunkyFxController controller, MapView mapView) { * This is called when a new world is loaded */ public void loadWorld(File worldDir) { - if (World.isWorldDir(worldDir)) { + if (JavaWorld.isWorldDir(worldDir)) { if (world != null) { world.currentDimension().removeChunkTopographyListener(this); } diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java index bc4bc6bf61..12fb31f74d 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java @@ -53,6 +53,7 @@ import se.llbit.chunky.world.biome.Biome; import se.llbit.chunky.world.biome.BiomePalette; import se.llbit.chunky.world.biome.Biomes; +import se.llbit.chunky.world.java.JavaWorld; import se.llbit.chunky.world.region.MCRegion; import se.llbit.json.*; import se.llbit.log.Log; @@ -547,7 +548,7 @@ public synchronized void loadScene(RenderContext context, String sceneName, Task loadedWorld = EmptyWorld.INSTANCE; if (!worldPath.isEmpty()) { File worldDirectory = new File(worldPath); - if (World.isWorldDir(worldDirectory)) { + if (JavaWorld.isWorldDir(worldDirectory)) { loadedWorld = World.loadWorld(worldDirectory, worldDimension, World.LoggedWarnings.NORMAL); } else { Log.info("Could not load world: " + worldPath); diff --git a/chunky/src/java/se/llbit/chunky/ui/ChunkMap.java b/chunky/src/java/se/llbit/chunky/ui/ChunkMap.java index 270374e554..2384440e8e 100644 --- a/chunky/src/java/se/llbit/chunky/ui/ChunkMap.java +++ b/chunky/src/java/se/llbit/chunky/ui/ChunkMap.java @@ -596,18 +596,16 @@ private void drawPlayers(GraphicsContext gc) { World world = mapLoader.getWorld(); double blockScale = mapView.scale / 16.; for (PlayerEntityData player : world.currentDimension().getPlayerPositions()) { - if (player.dimension.equals(world.currentDimension().getDimensionId())) { - int px = (int) QuickMath.floor(player.x * blockScale); - int py = (int) QuickMath.floor(player.y); - int pz = (int) QuickMath.floor(player.z * blockScale); - int ppx = px - (int) QuickMath.floor(mapView.x0 * mapView.scale); - int ppy = pz - (int) QuickMath.floor(mapView.z0 * mapView.scale); - int pw = (int) QuickMath.max(16, QuickMath.min(32, blockScale * 4)); - ppx = Math.min(mapView.width - pw, Math.max(0, ppx - pw / 2)); - ppy = Math.min(mapView.height - pw, Math.max(0, ppy - pw / 2)); - - gc.drawImage(Icon.player.fxImage(), ppx, ppy, pw, pw); - } + int px = (int) QuickMath.floor(player.x * blockScale); + int py = (int) QuickMath.floor(player.y); + int pz = (int) QuickMath.floor(player.z * blockScale); + int ppx = px - (int) QuickMath.floor(mapView.x0 * mapView.scale); + int ppy = pz - (int) QuickMath.floor(mapView.z0 * mapView.scale); + int pw = (int) QuickMath.max(16, QuickMath.min(32, blockScale * 4)); + ppx = Math.min(mapView.width - pw, Math.max(0, ppx - pw / 2)); + ppy = Math.min(mapView.height - pw, Math.max(0, ppy - pw / 2)); + + gc.drawImage(Icon.player.fxImage(), ppx, ppy, pw, pw); } } diff --git a/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java b/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java index 5705e9e625..d01c4292be 100644 --- a/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java +++ b/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java @@ -31,16 +31,18 @@ import se.llbit.chunky.map.WorldMapLoader; import se.llbit.chunky.resources.MinecraftFinder; import se.llbit.chunky.resources.ResourcePackLoader; -import se.llbit.chunky.resources.TexturePackLoader; import se.llbit.chunky.ui.TableSortConfigSerializer; +import se.llbit.chunky.world.EmptyWorld; import se.llbit.chunky.world.Dimension; import se.llbit.chunky.world.EmptyWorld; import se.llbit.chunky.world.World; +import se.llbit.chunky.world.worldformat.WorldFormat; import se.llbit.fxutil.Dialogs; import se.llbit.json.JsonArray; import se.llbit.log.Log; import java.io.File; +import java.io.IOException; import java.net.URL; import java.text.DateFormat; import java.util.*; @@ -188,7 +190,7 @@ private void fillWorldList(final File worldSavesDir) { statusLabel.setText("Loading worlds list..."); disableControls(true); - Task> loadWorldsTask = new Task>() { + Task> loadWorldsTask = new Task<>() { @Override protected List call() { List worlds = new ArrayList<>(); @@ -196,10 +198,16 @@ protected List call() { File[] worldDirs = worldSavesDir.listFiles(); if (worldDirs != null) { for (File dir : worldDirs) { - if (World.isWorldDir(dir)) { - World world = World.loadWorld(dir, Dimension.Identifier.OVERWORLD, World.LoggedWarnings.SILENT); - if (world != EmptyWorld.INSTANCE) { - worlds.add(world); + for (WorldFormat worldFormat : WorldFormat.worldFormats) { + if (worldFormat.isValid(dir.toPath())) { + try { + World world = worldFormat.loadWorld(dir.toPath(), Dimension.Identifier.OVERWORLD); + if (world != EmptyWorld.INSTANCE) { + worlds.add(world); + } + } catch (IOException e) { + throw new RuntimeException(e); + } } } } diff --git a/chunky/src/java/se/llbit/chunky/world/CubicDimension.java b/chunky/src/java/se/llbit/chunky/world/CubicDimension.java index 8aa0824f53..c08acc9770 100644 --- a/chunky/src/java/se/llbit/chunky/world/CubicDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/CubicDimension.java @@ -8,6 +8,8 @@ import se.llbit.chunky.world.region.EmptyRegion; import se.llbit.chunky.world.region.ImposterCubicRegion; import se.llbit.chunky.world.region.Region; +import se.llbit.math.Vector3i; +import se.llbit.util.annotation.Nullable; import java.io.File; import java.io.IOException; @@ -24,8 +26,8 @@ public class CubicDimension extends JavaDimension { /** * @param dimensionDirectory Minecraft world directory. */ - public CubicDimension(JavaWorld world, Dimension.Identifier dimensionId, File dimensionDirectory, Set playerEntities) { - super(world, dimensionId, dimensionDirectory, playerEntities); + public CubicDimension(JavaWorld world, Dimension.Identifier dimensionId, File dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { + super(world, dimensionId, dimensionDirectory, playerEntities, spawnPos); } /** diff --git a/chunky/src/java/se/llbit/chunky/world/Dimension.java b/chunky/src/java/se/llbit/chunky/world/Dimension.java index cdba0d4ba6..0142caedb5 100644 --- a/chunky/src/java/se/llbit/chunky/world/Dimension.java +++ b/chunky/src/java/se/llbit/chunky/world/Dimension.java @@ -65,31 +65,41 @@ public String toString() { } protected final File dimensionDirectory; - protected final Set playerEntities; protected final Heightmap heightmap = new Heightmap(); protected final Identifier dimensionId; + @Nullable protected final Vector3i spawnPos; + protected final Set playerEntities; + protected final Collection chunkDeletionListeners = new LinkedList<>(); protected final Collection chunkTopographyListeners = new LinkedList<>(); protected final Collection chunkUpdateListeners = new LinkedList<>(); - protected Vector3i spawnPos = null; - /** * @param dimensionDirectory Minecraft world directory. */ - protected Dimension(Identifier dimensionId, File dimensionDirectory, Set playerEntities) { + protected Dimension(Identifier dimensionId, File dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { this.dimensionId = dimensionId; - this.dimensionDirectory = dimensionDirectory; this.playerEntities = playerEntities; + this.dimensionDirectory = dimensionDirectory; + this.spawnPos = spawnPos; } public Identifier getDimensionId() { return dimensionId; } + /** + * Get the data directory for the given dimension. + * + * @return File object pointing to the data directory + */ + protected synchronized File getDimensionDirectory() { + return dimensionDirectory; + } + /** * @return The chunk at the given position */ @@ -196,11 +206,6 @@ public synchronized Collection getPlayerEntities() { return list; } - public synchronized void setPlayerEntities(Set playerEntities) { - this.playerEntities.clear(); - this.playerEntities.addAll(playerEntities); - } - public synchronized Collection getPlayerPositions() { return Collections.unmodifiableSet(playerEntities); } diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java b/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java index 89699afac8..d0de1b1dc9 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java @@ -14,7 +14,7 @@ public class EmptyDimension extends Dimension { EmptyDimension() { - super(Dimension.Identifier.OVERWORLD, null, Collections.emptySet()); + super(Dimension.Identifier.OVERWORLD, null, Collections.emptySet(), null); } @Override diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyRegionChunk.java b/chunky/src/java/se/llbit/chunky/world/EmptyRegionChunk.java index ff7739a4c2..c4b2f533ca 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyRegionChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyRegionChunk.java @@ -27,7 +27,7 @@ /** * Empty or non-existent chunk in a region that does not exist. - * In the {@link ChunkMap map view} an {@link EmptyChunk} is represented as gray. + * In the {@link ChunkMap map view} an {@link EmptyRegionChunk} is represented as gray. * * @author Jesper Öqvist */ diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java b/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java index d302150b49..46824638e9 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java @@ -16,6 +16,9 @@ */ package se.llbit.chunky.world; +import java.util.Collections; +import java.util.Set; + /** * Represents an empty or non-existent world. * @@ -32,8 +35,13 @@ private EmptyWorld() { } @Override - public void loadDimension(Dimension.Identifier dimensionId) { - // no-op + public Set listDimensions() { + return Collections.emptySet(); + } + + @Override + public Dimension loadDimension(Dimension.Identifier dimensionId) { + return this.currentDimension; } @Override diff --git a/chunky/src/java/se/llbit/chunky/world/World.java b/chunky/src/java/se/llbit/chunky/world/World.java index be4ffe49a4..6ec34ed98c 100644 --- a/chunky/src/java/se/llbit/chunky/world/World.java +++ b/chunky/src/java/se/llbit/chunky/world/World.java @@ -76,6 +76,7 @@ public enum LoggedWarnings { * * @return {@code true} if the world data was loaded */ + @Deprecated public static World loadWorld(File worldDirectory, Dimension.Identifier dimensionId, LoggedWarnings warnings) { if (worldDirectory == null) { return EmptyWorld.INSTANCE; @@ -83,8 +84,15 @@ public static World loadWorld(File worldDirectory, Dimension.Identifier dimensio return JavaWorld.loadWorld(worldDirectory, dimensionId, warnings); } - public abstract void loadDimension(Dimension.Identifier dimensionId); + /** + * The dimensions returned here are later provided to {@link #loadDimension(Dimension.Identifier)} when requesting a dimension be + * loaded. + * + * @return List the viewable dimensions within the world. + */ + public abstract Set listDimensions(); + public abstract Dimension loadDimension(Dimension.Identifier dimensionId); /** * @return The current dimension @@ -109,18 +117,6 @@ public String levelName() { return levelName; } - /** - * @return true if the given directory exists and - * contains a level.dat file - */ - public static boolean isWorldDir(File worldDir) { - if (worldDir != null && worldDir.isDirectory()) { - File levelDat = new File(worldDir, "level.dat"); - return levelDat.exists() && levelDat.isFile(); - } - return false; - } - /** * @return String describing the game-mode of this world */ diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java index 9b57390805..2cda0cfa68 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java @@ -15,6 +15,7 @@ import java.io.File; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; public class JavaDimension extends Dimension { protected final JavaWorld world; @@ -26,8 +27,8 @@ public class JavaDimension extends Dimension { * @param dimensionDirectory Minecraft world directory. * @param playerEntities */ - protected JavaDimension(JavaWorld world, Identifier dimensionId, File dimensionDirectory, Set playerEntities) { - super(dimensionId, dimensionDirectory, playerEntities); + protected JavaDimension(JavaWorld world, Identifier dimensionId, File dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { + super(dimensionId, dimensionDirectory, playerEntities, spawnPos); this.world = world; } @@ -91,14 +92,21 @@ public synchronized Chunk getChunk(ChunkPosition pos) { @Override public synchronized boolean reloadPlayerData() { - return this.world.reloadPlayerData(); + boolean changed = this.world.reloadPlayerData(); + if (changed) { + this.playerEntities.clear(); + this.playerEntities.addAll(this.world.playerEntities.stream() + .filter(player -> player.dimension.equals(this.dimensionId)) + .collect(Collectors.toSet())); + } + return changed; } @Override public synchronized Optional getPlayerPos() { - if (!playerEntities.isEmpty()) { + if (!this.playerEntities.isEmpty()) { return world.getSingleplayerPlayerUuid() - .flatMap(uuid -> playerEntities.stream() + .flatMap(uuid -> this.playerEntities.stream() .filter(player -> player.uuid.equals(uuid)) .map(pos -> new Vector3(pos.x, pos.y, pos.z)) .findFirst()); @@ -107,10 +115,6 @@ public synchronized Optional getPlayerPos() { } } - public void setSpawnPos(@Nullable Vector3i spawnPos) { - this.spawnPos = spawnPos; - } - /** * Called when a new region has been discovered by the region parser. */ @@ -120,15 +124,6 @@ public void regionDiscovered(RegionPosition pos) { } } - /** - * Get the data directory for the given dimension. - * - * @return File object pointing to the data directory - */ - protected synchronized File getDimensionDirectory() { - return dimensionDirectory; - } - /** * @return File object pointing to the region file directory */ diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java index 72bbda3aa7..59d77cdfde 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java @@ -8,6 +8,7 @@ import se.llbit.util.MinecraftText; import se.llbit.util.UuidUtil; import se.llbit.util.annotation.NotNull; +import se.llbit.util.annotation.Nullable; import java.io.*; import java.nio.file.Path; @@ -23,7 +24,18 @@ public class JavaWorld extends World { public static final int VERSION_21W06A = 2694; public static final int VERSION_1_12_2 = 1343; - protected final int versionId; + protected int versionId; + + /** + * In a java world spawn position is per-world and not per-dimension, so we store it here. + */ + protected final Vector3i spawnPos; + + /** + * In a java world player data is per-world and not per-dimension, so we store it here. + */ + protected final Collection playerEntities; + protected UUID singleplayerPlayerUuid; public Optional getSingleplayerPlayerUuid() { @@ -36,9 +48,11 @@ public Optional getSingleplayerPlayerUuid() { * @param seed * @param timestamp */ - protected JavaWorld(String levelName, File worldDirectory, long seed, long timestamp, int versionId) { + protected JavaWorld(String levelName, File worldDirectory, long seed, long timestamp, int versionId, Set playerEntities, Vector3i spawnPos) { super(levelName, worldDirectory, seed, timestamp); this.versionId = versionId; + this.playerEntities = playerEntities; + this.spawnPos = spawnPos; } /** @@ -84,9 +98,15 @@ public static World loadWorld(File worldDirectory, Dimension.Identifier dimensio long seed = randomSeed.longValue(0); - Set playerEntities = getPlayerEntityData(worldDirectory, dimensionId, player); + Set playerEntities = getPlayerEntityData(worldDirectory, player); + + boolean haveSpawnPos = !(spawnX.isError() || spawnY.isError() || spawnZ.isError()); + Vector3i spawnPos = new Vector3i(); + if (haveSpawnPos) { + spawnPos = new Vector3i(spawnX.intValue(0), spawnY.intValue(0), spawnZ.intValue(0)); + } - JavaWorld world = new JavaWorld(levelName, worldDirectory, seed, modtime, versionId.intValue()); + JavaWorld world = new JavaWorld(levelName, worldDirectory, seed, modtime, versionId.intValue(), playerEntities, spawnPos); world.gameMode = gameType.intValue(0); if (singleplayerUuid.isIntArray(4)) { world.singleplayerPlayerUuid = UuidUtil.intsToUuid(singleplayerUuid.intArray()); @@ -94,14 +114,7 @@ public static World loadWorld(File worldDirectory, Dimension.Identifier dimensio world.singleplayerPlayerUuid = PlayerEntityData.getUuid(player); } - JavaDimension dimension = (JavaDimension) loadDimension(world, worldDirectory, dimensionId, playerEntities); - - boolean haveSpawnPos = !(spawnX.isError() || spawnY.isError() || spawnZ.isError()); - if (haveSpawnPos) { - dimension.setSpawnPos(new Vector3i(spawnX.intValue(0), spawnY.intValue(0), spawnZ.intValue(0))); - } - - world.currentDimension = dimension; + world.currentDimension = loadDimension(world, worldDirectory, dimensionId, playerEntities, spawnPos); return world; } catch (FileNotFoundException e) { @@ -117,17 +130,32 @@ public static World loadWorld(File worldDirectory, Dimension.Identifier dimensio } @Override - public void loadDimension(Dimension.Identifier dimensionId) { - currentDimension = loadDimension(this, this.worldDirectory, dimensionId, Collections.emptySet()); + public Set listDimensions() { + return Set.of(Dimension.Identifier.OVERWORLD, + Dimension.Identifier.THE_NETHER, + Dimension.Identifier.THE_END + ); + } + + @Override + public Dimension loadDimension(Dimension.Identifier dimensionId) { + currentDimension = loadDimension( + this, + this.worldDirectory, + dimensionId, + this.playerEntities.stream().filter(player -> player.dimension.equals(dimensionId)).collect(Collectors.toSet()), + this.spawnPos + ); currentDimension.reloadPlayerData(); + return currentDimension; } @NotNull - private static Dimension loadDimension(JavaWorld world, File worldDirectory, Dimension.Identifier dimensionId, Set playerEntities) { + private static Dimension loadDimension(JavaWorld world, File worldDirectory, Dimension.Identifier dimensionId, Set playerEntities, @Nullable Vector3i spawnPos) { File dimensionDirectory = Path.of(worldDirectory.getPath(), "dimensions", dimensionId.namespace(), dimensionId.name()).toFile(); if (dimensionDirectory.exists()) { // 26.1-snapshot-6 or later - return new JavaDimension(world, dimensionId, dimensionDirectory, playerEntities); + return new JavaDimension(world, dimensionId, dimensionDirectory, playerEntities, spawnPos); } dimensionDirectory = switch (dimensionId.getNamespacedName()) { // TODO in Java 21+ we can use `switch (dimensionId)` here @@ -136,21 +164,19 @@ private static Dimension loadDimension(JavaWorld world, File worldDirectory, Dim default -> worldDirectory; }; if (new File(dimensionDirectory, "region3d").exists()) { - return new CubicDimension(world, dimensionId, dimensionDirectory, playerEntities); + return new CubicDimension(world, dimensionId, dimensionDirectory, playerEntities, spawnPos); } else { - return new JavaDimension(world, dimensionId, dimensionDirectory, playerEntities); + return new JavaDimension(world, dimensionId, dimensionDirectory, playerEntities, spawnPos); } } @NotNull - private static Set getPlayerEntityData(File worldDirectory, Dimension.Identifier dimensionId, Tag player) { + private static Set getPlayerEntityData(File worldDirectory, Tag player) { Set playerEntities = new HashSet<>(); if (!player.isError()) { playerEntities.add(new PlayerEntityData(player)); } loadAdditionalPlayers(worldDirectory, playerEntities); - // Filter for the players only within the requested dimension - playerEntities = playerEntities.stream().filter(playerData -> playerData.dimension.equals(dimensionId)).collect(Collectors.toSet()); return playerEntities; } @@ -207,11 +233,20 @@ synchronized boolean reloadPlayerData() { singleplayerPlayerUuid = PlayerEntityData.getUuid(player); } - currentDimension.setPlayerEntities(getPlayerEntityData(worldDirectory, currentDimension.getDimensionId(), player)); + this.playerEntities.clear(); + this.playerEntities.addAll(getPlayerEntityData(worldDirectory, player)); } catch (IOException e) { Log.infof("Could not read the level.dat file for world %s while trying to reload player data!", levelName); return false; } return true; } + + public static boolean isWorldDir(File worldDir) { + if (worldDir != null && worldDir.isDirectory()) { + File levelDat = new File(worldDir, "level.dat"); + return levelDat.exists() && levelDat.isFile(); + } + return false; + } } diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/JavaWorldFormat.java b/chunky/src/java/se/llbit/chunky/world/worldformat/JavaWorldFormat.java new file mode 100644 index 0000000000..9424db9639 --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/JavaWorldFormat.java @@ -0,0 +1,20 @@ +package se.llbit.chunky.world.worldformat; + +import se.llbit.chunky.world.Dimension; +import se.llbit.chunky.world.java.JavaWorld; +import se.llbit.chunky.world.World; + +import java.io.IOException; +import java.nio.file.Path; + +public class JavaWorldFormat implements WorldFormat { + @Override + public boolean isValid(Path path) { + return JavaWorld.isWorldDir(path.toFile()); + } + + @Override + public World loadWorld(Path path, Dimension.Identifier dimension) throws IOException { + return JavaWorld.loadWorld(path.toFile(), dimension, World.LoggedWarnings.SILENT); + } +} diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java new file mode 100644 index 0000000000..c01904b90e --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java @@ -0,0 +1,19 @@ +package se.llbit.chunky.world.worldformat; + +import se.llbit.chunky.world.Dimension; +import se.llbit.chunky.world.World; +import se.llbit.chunky.world.java.JavaWorld; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; + +public interface WorldFormat { + // TODO: Registerable + Collection worldFormats = List.of(new JavaWorldFormat()); + + boolean isValid(Path path); + + World loadWorld(Path path, Dimension.Identifier dimension) throws IOException; +} From 7e899da2f71bb3fc87c1a0c70d07267df50130f2 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Wed, 20 Aug 2025 02:02:28 +0100 Subject: [PATCH 03/57] Replace all world loading with worldformat --- .../se/llbit/chunky/map/WorldMapLoader.java | 66 ++++++++++++------- .../se/llbit/chunky/renderer/scene/Scene.java | 14 ++-- .../ui/controller/ChunkyFxController.java | 4 +- .../ui/controller/WorldChooserController.java | 18 +---- .../java/se/llbit/chunky/world/Dimension.java | 10 +-- .../se/llbit/chunky/world/EmptyDimension.java | 3 +- .../se/llbit/chunky/world/EmptyWorld.java | 9 ++- .../src/java/se/llbit/chunky/world/World.java | 25 ++++--- .../chunky/world/java/JavaDimension.java | 5 ++ .../se/llbit/chunky/world/java/JavaWorld.java | 12 ++-- .../world/worldformat/JavaWorldFormat.java | 9 ++- .../chunky/world/worldformat/WorldFormat.java | 52 +++++++++++++-- 12 files changed, 149 insertions(+), 78 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java b/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java index a7e3cb3aff..ccbaa3f3d2 100644 --- a/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java +++ b/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java @@ -16,6 +16,7 @@ */ package se.llbit.chunky.map; +import java.util.Optional; import java.util.function.BiConsumer; import se.llbit.chunky.PersistentSettings; import se.llbit.chunky.renderer.ChunkViewListener; @@ -26,6 +27,9 @@ import se.llbit.chunky.world.region.RegionParser; import se.llbit.chunky.world.region.RegionQueue; import se.llbit.chunky.world.listeners.ChunkTopographyListener; +import se.llbit.chunky.world.worldformat.WorldFormat; +import se.llbit.log.Log; +import se.llbit.util.annotation.Nullable; import java.io.File; import java.util.ArrayList; @@ -65,28 +69,44 @@ public WorldMapLoader(ChunkyFxController controller, MapView mapView) { topographyUpdater.start(); } + public void loadWorldFromDirectory(@Nullable File worldLocation) { + if (worldLocation == null) { + return; + } + this.loadWorld(WorldFormat.loadWorld(worldLocation).orElse(EmptyWorld.INSTANCE)); + } /** * This is called when a new world is loaded */ - public void loadWorld(File worldDir) { - if (JavaWorld.isWorldDir(worldDir)) { - if (world != null) { - world.currentDimension().removeChunkTopographyListener(this); - } - boolean isSameWorld = !(world instanceof EmptyWorld) && worldDir.equals(world.getWorldDirectory()); - World newWorld = World.loadWorld(worldDir, currentDimensionId, World.LoggedWarnings.NORMAL); - newWorld.currentDimension().addChunkTopographyListener(this); - synchronized (this) { - world = newWorld; - updateRegionChangeWatcher(newWorld.currentDimension()); - - File newWorldDir = world.getWorldDirectory(); - if (newWorldDir != null && !newWorldDir.equals(PersistentSettings.getLastWorld())) { - PersistentSettings.setLastWorld(newWorldDir); - } + public void loadWorld(World newWorld) { + if (this.world != null) { + this.world.currentDimension().removeChunkTopographyListener(this); + } + boolean isSameWorld = !(this.world instanceof EmptyWorld) && newWorld.getWorldDirectory().equals(this.world.getWorldDirectory()); + + Optional dimensionToLoad = Optional.of(world.currentDimension()) + .map(Dimension::getDimensionId) + .filter(dimension -> newWorld.availableDimensions().contains(dimension)) + .or(newWorld::defaultDimension) + .or(() -> newWorld.availableDimensions().stream().findFirst()); + + if (dimensionToLoad.isEmpty()) { + Log.infof("No dimension loaded for world %s", newWorld.toString()); + return; + } + + Dimension loadedDim = newWorld.loadDimension(dimensionToLoad.get()); + loadedDim.addChunkTopographyListener(this); + synchronized (this) { + this.world = newWorld; + updateRegionChangeWatcher(loadedDim); + + File newWorldDir = this.world.getWorldDirectory(); + if (!newWorldDir.equals(PersistentSettings.getLastWorld())) { + PersistentSettings.setLastWorld(newWorldDir); } - worldLoadListeners.forEach(listener -> listener.accept(newWorld, isSameWorld)); } + worldLoadListeners.forEach(listener -> listener.accept(newWorld, isSameWorld)); } /** @@ -152,14 +172,12 @@ public void regionUpdated(RegionPosition region) { public void reloadWorld() { topographyUpdater.clearQueue(); world.currentDimension().removeChunkTopographyListener(this); - World newWorld = World.loadWorld(world.getWorldDirectory(), currentDimensionId, - World.LoggedWarnings.NORMAL); - newWorld.currentDimension().addChunkTopographyListener(this); + world.loadDimension(currentDimensionId); + world.currentDimension().addChunkTopographyListener(this); synchronized (this) { - world = newWorld; - updateRegionChangeWatcher(newWorld.currentDimension()); + updateRegionChangeWatcher(world.currentDimension()); } - worldLoadListeners.forEach(listener -> listener.accept(newWorld, true)); + worldLoadListeners.forEach(listener -> listener.accept(world, true)); viewUpdated(mapView.getMapView()); // update visible chunks immediately } @@ -174,6 +192,8 @@ private void updateRegionChangeWatcher(Dimension dimension) { /** * Set the current dimension. + * + * @param value Must be a valid dimension see {@link World#availableDimensions()} */ public void setDimension(Dimension.Identifier value) { if (value != currentDimensionId) { diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java index 12fb31f74d..0a3fa1c551 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java @@ -55,6 +55,7 @@ import se.llbit.chunky.world.biome.Biomes; import se.llbit.chunky.world.java.JavaWorld; import se.llbit.chunky.world.region.MCRegion; +import se.llbit.chunky.world.worldformat.WorldFormat; import se.llbit.json.*; import se.llbit.log.Log; import se.llbit.math.*; @@ -548,10 +549,13 @@ public synchronized void loadScene(RenderContext context, String sceneName, Task loadedWorld = EmptyWorld.INSTANCE; if (!worldPath.isEmpty()) { File worldDirectory = new File(worldPath); - if (JavaWorld.isWorldDir(worldDirectory)) { - loadedWorld = World.loadWorld(worldDirectory, worldDimension, World.LoggedWarnings.NORMAL); + Optional newWorld = WorldFormat.loadWorld(worldDirectory); + if (newWorld.isPresent()) { + loadedWorld = newWorld.get(); + loadedWorld.loadDimension(this.worldDimension); } else { Log.info("Could not load world: " + worldPath); + loadedWorld = EmptyWorld.INSTANCE; } } @@ -768,7 +772,7 @@ public synchronized void reloadChunks(TaskTracker taskTracker) { Log.warn("Can not reload chunks for scene - world directory not found!"); return; } - loadedWorld = World.loadWorld(loadedWorld.getWorldDirectory(), worldDimension, World.LoggedWarnings.NORMAL); + loadedWorld.loadDimension(worldDimension); loadChunks(taskTracker, loadedWorld, ChunkSelectionTracker.selectionByRegion(chunks)); refresh(); } @@ -861,8 +865,8 @@ public synchronized void loadChunks(TaskTracker taskTracker, World world, Map>>> createRegionDataFuture = (regionPosition, chunkDataArray) -> executor.submit(() -> { List chunkPositionsToLoad = chunksToLoadByRegion.get(regionPosition); diff --git a/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java b/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java index 68849c873e..409eba62bb 100644 --- a/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java +++ b/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java @@ -341,7 +341,7 @@ public void exportMapView() { "This scene shows a different world than the one that is currently loaded. Do you want to load the world of this scene?"); Dialogs.stayOnTop(loadWorldConfirm); if (loadWorldConfirm.showAndWait().orElse(ButtonType.CANCEL) == ButtonType.YES) { - mapLoader.loadWorld(newWorld.getWorldDirectory()); + mapLoader.loadWorld(newWorld); getChunkSelection().setSelection(chunky.getSceneManager().getScene().getChunks()); } } @@ -652,7 +652,7 @@ public File getSceneFile(String fileName) { mapOverlay.setOnKeyPressed(map::onKeyPressed); mapOverlay.setOnKeyReleased(map::onKeyReleased); - mapLoader.loadWorld(PersistentSettings.getLastWorld()); + mapLoader.loadWorldFromDirectory(PersistentSettings.getLastWorld()); IntIntPair heightRange = mapLoader.getWorld().currentDimension().heightRange(); mapView.setYMin(heightRange.firstInt()); mapView.setYMax(heightRange.secondInt()); diff --git a/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java b/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java index d01c4292be..184f14698c 100644 --- a/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java +++ b/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java @@ -42,7 +42,6 @@ import se.llbit.log.Log; import java.io.File; -import java.io.IOException; import java.net.URL; import java.text.DateFormat; import java.util.*; @@ -137,7 +136,7 @@ public void populate(WorldMapLoader mapLoader) { File directory = chooser.showDialog(stage); if (directory != null) { if (directory.isDirectory()) { - this.loadWorld(World.loadWorld(directory, mapLoader.getDimension(), World.LoggedWarnings.NORMAL), mapLoader); + this.loadWorld(WorldFormat.loadWorld(directory).orElse(EmptyWorld.INSTANCE), mapLoader); stage.close(); } else { Log.warn("Non-directory selected."); @@ -173,7 +172,7 @@ private void loadWorld(World world, WorldMapLoader mapLoader) { } } }); - mapLoader.loadWorld(world.getWorldDirectory()); + mapLoader.loadWorld(world); } /** @@ -198,18 +197,7 @@ protected List call() { File[] worldDirs = worldSavesDir.listFiles(); if (worldDirs != null) { for (File dir : worldDirs) { - for (WorldFormat worldFormat : WorldFormat.worldFormats) { - if (worldFormat.isValid(dir.toPath())) { - try { - World world = worldFormat.loadWorld(dir.toPath(), Dimension.Identifier.OVERWORLD); - if (world != EmptyWorld.INSTANCE) { - worlds.add(world); - } - } catch (IOException e) { - throw new RuntimeException(e); - } - } - } + WorldFormat.loadWorld(dir).ifPresent(worlds::add); } } } diff --git a/chunky/src/java/se/llbit/chunky/world/Dimension.java b/chunky/src/java/se/llbit/chunky/world/Dimension.java index 0142caedb5..9134720119 100644 --- a/chunky/src/java/se/llbit/chunky/world/Dimension.java +++ b/chunky/src/java/se/llbit/chunky/world/Dimension.java @@ -87,6 +87,11 @@ protected Dimension(Identifier dimensionId, File dimensionDirectory, Set getSpawnPosition() { return Optional.ofNullable(this.spawnPos); } diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java b/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java index d0de1b1dc9..91e31b8924 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java @@ -60,8 +60,7 @@ public IntIntPair heightRange() { return new IntIntImmutablePair(0, 0); } - @Override - public String toString() { + @Override public String getName() { return "[empty dimension]"; } diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java b/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java index 46824638e9..38731a0f17 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java @@ -17,6 +17,7 @@ package se.llbit.chunky.world; import java.util.Collections; +import java.util.Optional; import java.util.Set; /** @@ -35,10 +36,15 @@ private EmptyWorld() { } @Override - public Set listDimensions() { + public Set availableDimensions() { return Collections.emptySet(); } + @Override + public Optional defaultDimension() { + return Optional.empty(); + } + @Override public Dimension loadDimension(Dimension.Identifier dimensionId) { return this.currentDimension; @@ -48,5 +54,4 @@ public Dimension loadDimension(Dimension.Identifier dimensionId) { public String toString() { return "[empty world]"; } - } diff --git a/chunky/src/java/se/llbit/chunky/world/World.java b/chunky/src/java/se/llbit/chunky/world/World.java index 6ec34ed98c..6aea4a5cf5 100644 --- a/chunky/src/java/se/llbit/chunky/world/World.java +++ b/chunky/src/java/se/llbit/chunky/world/World.java @@ -72,26 +72,23 @@ public enum LoggedWarnings { } /** - * Parse player location and level name. + * The dimensions returned here are later provided to {@link #loadDimension(String)} when requesting a dimension be + * loaded. * - * @return {@code true} if the world data was loaded + * @return List the viewable dimensions within the world. */ - @Deprecated - public static World loadWorld(File worldDirectory, Dimension.Identifier dimensionId, LoggedWarnings warnings) { - if (worldDirectory == null) { - return EmptyWorld.INSTANCE; - } - return JavaWorld.loadWorld(worldDirectory, dimensionId, warnings); - } + public abstract Set availableDimensions(); /** - * The dimensions returned here are later provided to {@link #loadDimension(Dimension.Identifier)} when requesting a dimension be - * loaded. - * - * @return List the viewable dimensions within the world. + * MUST be one of {@link #availableDimensions()} + * @return The preferred default dimension of this world (typically the overworld) */ - public abstract Set listDimensions(); + public abstract Optional defaultDimension(); + /** + * @param dimension The dimension to load, guaranteed to be one of the dimensions previously returned by {@link #availableDimensions()} + * @return The loaded dimension + */ public abstract Dimension loadDimension(Dimension.Identifier dimensionId); /** diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java index 2cda0cfa68..211fd66cd7 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java @@ -130,4 +130,9 @@ public void regionDiscovered(RegionPosition pos) { public synchronized File getRegionDirectory() { return new File(getDimensionDirectory(), "region"); } + + @Override + public String getName() { + return dimensionDirectory.getName() ; + } } diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java index 59d77cdfde..7690c35cc5 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java @@ -60,7 +60,7 @@ protected JavaWorld(String levelName, File worldDirectory, long seed, long times * * @return {@code true} if the world data was loaded */ - public static World loadWorld(File worldDirectory, Dimension.Identifier dimensionId, LoggedWarnings warnings) { + public static World loadWorld(File worldDirectory, LoggedWarnings warnings) { if (worldDirectory == null) { return EmptyWorld.INSTANCE; } @@ -108,14 +108,13 @@ public static World loadWorld(File worldDirectory, Dimension.Identifier dimensio JavaWorld world = new JavaWorld(levelName, worldDirectory, seed, modtime, versionId.intValue(), playerEntities, spawnPos); world.gameMode = gameType.intValue(0); + if (singleplayerUuid.isIntArray(4)) { world.singleplayerPlayerUuid = UuidUtil.intsToUuid(singleplayerUuid.intArray()); } else if (!player.isError()) { world.singleplayerPlayerUuid = PlayerEntityData.getUuid(player); } - world.currentDimension = loadDimension(world, worldDirectory, dimensionId, playerEntities, spawnPos); - return world; } catch (FileNotFoundException e) { if (warnings == LoggedWarnings.NORMAL) { @@ -130,13 +129,18 @@ public static World loadWorld(File worldDirectory, Dimension.Identifier dimensio } @Override - public Set listDimensions() { + public Set availableDimensions() { return Set.of(Dimension.Identifier.OVERWORLD, Dimension.Identifier.THE_NETHER, Dimension.Identifier.THE_END ); } + @Override + public Optional defaultDimension() { + return Optional.empty(); + } + @Override public Dimension loadDimension(Dimension.Identifier dimensionId) { currentDimension = loadDimension( diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/JavaWorldFormat.java b/chunky/src/java/se/llbit/chunky/world/worldformat/JavaWorldFormat.java index 9424db9639..29fc0430e9 100644 --- a/chunky/src/java/se/llbit/chunky/world/worldformat/JavaWorldFormat.java +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/JavaWorldFormat.java @@ -8,13 +8,18 @@ import java.nio.file.Path; public class JavaWorldFormat implements WorldFormat { + @Override + public String name() { + return "Java (Anvil)"; + } + @Override public boolean isValid(Path path) { return JavaWorld.isWorldDir(path.toFile()); } @Override - public World loadWorld(Path path, Dimension.Identifier dimension) throws IOException { - return JavaWorld.loadWorld(path.toFile(), dimension, World.LoggedWarnings.SILENT); + public World loadWorld(Path path) throws IOException { + return JavaWorld.loadWorld(path.toFile(), World.LoggedWarnings.SILENT); } } diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java index c01904b90e..b79b71e177 100644 --- a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java @@ -1,19 +1,63 @@ package se.llbit.chunky.world.worldformat; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import se.llbit.chunky.world.EmptyWorld; import se.llbit.chunky.world.Dimension; import se.llbit.chunky.world.World; +import se.llbit.log.Log; import se.llbit.chunky.world.java.JavaWorld; +import java.io.File; import java.io.IOException; import java.nio.file.Path; -import java.util.Collection; -import java.util.List; +import java.util.*; public interface WorldFormat { // TODO: Registerable Collection worldFormats = List.of(new JavaWorldFormat()); + /** + * @return The user-recognisable name of the world format. Shown to the user if this format has issues or throws. + */ + String name(); + + /** + * This method will be called on every possible world directory (typically this is every directory in `.minecraft/saves`). + * + * @param path The path to the world. + * @return Whether this is a valid world under this world format. + */ boolean isValid(Path path); - World loadWorld(Path path, Dimension.Identifier dimension) throws IOException; -} + /** + * Load the world at the given path + * @param path The path to the world. + * @return The loaded world + * @throws IOException When something goes wrong when loading the world. + */ + World loadWorld(Path path) throws IOException; + + // Should this go somewhere else? + static Optional loadWorld(File dir) { + Map worldsByFormat = new Object2ObjectOpenHashMap<>(); + + for (WorldFormat worldFormat : WorldFormat.worldFormats) { + if (worldFormat.isValid(dir.toPath())) { + try { + World world = worldFormat.loadWorld(dir.toPath()); + if (world != EmptyWorld.INSTANCE) { + worldsByFormat.put(worldFormat.name(), world); + } + } catch (IOException e) { + Log.error(String.format("An error occurred when trying to load a world using format `%s` from %s", worldFormat.name(), dir.getAbsolutePath()), e); + } + } + } + if (worldsByFormat.size() > 1) { + // Maybe allow the user to select which? + // This method is called from a variety of different popup/menu situations, is this ^ possible? + Log.warn(String.format("The directory %s has multiple valid world formats: %s", dir.getAbsolutePath(), String.join(", ", worldsByFormat.keySet()))); + } + return worldsByFormat.values().stream().findFirst(); + } +} \ No newline at end of file From 2a58cab6dc16b3637bfef59b2a60bbb154b631ec Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sat, 27 Jun 2026 21:05:26 +0100 Subject: [PATCH 04/57] Make WorldFormats registerable --- .../se/llbit/chunky/map/WorldMapLoader.java | 5 +- .../se/llbit/chunky/renderer/scene/Scene.java | 9 ++- .../ui/controller/WorldChooserController.java | 6 +- .../src/java/se/llbit/chunky/world/World.java | 16 +----- .../JavaWorldFormat.java | 17 ++++-- .../chunky/world/worldformat/WorldFormat.java | 43 +-------------- .../world/worldformat/WorldFormats.java | 55 +++++++++++++++++++ 7 files changed, 82 insertions(+), 69 deletions(-) rename chunky/src/java/se/llbit/chunky/world/{worldformat => java}/JavaWorldFormat.java (58%) create mode 100644 chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java diff --git a/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java b/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java index ccbaa3f3d2..353da7fdc0 100644 --- a/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java +++ b/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java @@ -22,12 +22,11 @@ import se.llbit.chunky.renderer.ChunkViewListener; import se.llbit.chunky.ui.controller.ChunkyFxController; import se.llbit.chunky.world.*; -import se.llbit.chunky.world.java.JavaWorld; import se.llbit.chunky.world.region.RegionChangeWatcher; import se.llbit.chunky.world.region.RegionParser; import se.llbit.chunky.world.region.RegionQueue; import se.llbit.chunky.world.listeners.ChunkTopographyListener; -import se.llbit.chunky.world.worldformat.WorldFormat; +import se.llbit.chunky.world.worldformat.WorldFormats; import se.llbit.log.Log; import se.llbit.util.annotation.Nullable; @@ -73,7 +72,7 @@ public void loadWorldFromDirectory(@Nullable File worldLocation) { if (worldLocation == null) { return; } - this.loadWorld(WorldFormat.loadWorld(worldLocation).orElse(EmptyWorld.INSTANCE)); + this.loadWorld(WorldFormats.createWorld(worldLocation).orElse(EmptyWorld.INSTANCE)); } /** * This is called when a new world is loaded diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java index 0a3fa1c551..f70de4aa00 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java @@ -53,9 +53,8 @@ import se.llbit.chunky.world.biome.Biome; import se.llbit.chunky.world.biome.BiomePalette; import se.llbit.chunky.world.biome.Biomes; -import se.llbit.chunky.world.java.JavaWorld; +import se.llbit.chunky.world.worldformat.WorldFormats; import se.llbit.chunky.world.region.MCRegion; -import se.llbit.chunky.world.worldformat.WorldFormat; import se.llbit.json.*; import se.llbit.log.Log; import se.llbit.math.*; @@ -549,7 +548,7 @@ public synchronized void loadScene(RenderContext context, String sceneName, Task loadedWorld = EmptyWorld.INSTANCE; if (!worldPath.isEmpty()) { File worldDirectory = new File(worldPath); - Optional newWorld = WorldFormat.loadWorld(worldDirectory); + Optional newWorld = WorldFormats.createWorld(worldDirectory); if (newWorld.isPresent()) { loadedWorld = newWorld.get(); loadedWorld.loadDimension(this.worldDimension); @@ -865,8 +864,8 @@ public synchronized void loadChunks(TaskTracker taskTracker, World world, Map>>> createRegionDataFuture = (regionPosition, chunkDataArray) -> executor.submit(() -> { List chunkPositionsToLoad = chunksToLoadByRegion.get(regionPosition); diff --git a/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java b/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java index 184f14698c..da6858199a 100644 --- a/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java +++ b/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java @@ -36,7 +36,7 @@ import se.llbit.chunky.world.Dimension; import se.llbit.chunky.world.EmptyWorld; import se.llbit.chunky.world.World; -import se.llbit.chunky.world.worldformat.WorldFormat; +import se.llbit.chunky.world.worldformat.WorldFormats; import se.llbit.fxutil.Dialogs; import se.llbit.json.JsonArray; import se.llbit.log.Log; @@ -136,7 +136,7 @@ public void populate(WorldMapLoader mapLoader) { File directory = chooser.showDialog(stage); if (directory != null) { if (directory.isDirectory()) { - this.loadWorld(WorldFormat.loadWorld(directory).orElse(EmptyWorld.INSTANCE), mapLoader); + this.loadWorld(WorldFormats.createWorld(directory).orElse(EmptyWorld.INSTANCE), mapLoader); stage.close(); } else { Log.warn("Non-directory selected."); @@ -197,7 +197,7 @@ protected List call() { File[] worldDirs = worldSavesDir.listFiles(); if (worldDirs != null) { for (File dir : worldDirs) { - WorldFormat.loadWorld(dir).ifPresent(worlds::add); + WorldFormats.createWorld(dir).ifPresent(worlds::add); } } } diff --git a/chunky/src/java/se/llbit/chunky/world/World.java b/chunky/src/java/se/llbit/chunky/world/World.java index 6aea4a5cf5..141b910c3a 100644 --- a/chunky/src/java/se/llbit/chunky/world/World.java +++ b/chunky/src/java/se/llbit/chunky/world/World.java @@ -16,20 +16,8 @@ */ package se.llbit.chunky.world; -import se.llbit.chunky.world.java.JavaWorld; -import se.llbit.log.Log; -import se.llbit.math.Vector3i; -import se.llbit.nbt.NamedTag; -import se.llbit.nbt.Tag; -import se.llbit.util.MinecraftText; -import se.llbit.util.UuidUtil; -import se.llbit.util.annotation.NotNull; - import java.io.*; -import java.nio.file.Path; import java.util.*; -import java.util.stream.Collectors; -import java.util.zip.GZIPInputStream; /** * The World class contains information about the currently viewed world. @@ -72,7 +60,7 @@ public enum LoggedWarnings { } /** - * The dimensions returned here are later provided to {@link #loadDimension(String)} when requesting a dimension be + * The dimensions returned here are later provided to {@link #loadDimension(Dimension.Identifier)} when requesting a dimension be * loaded. * * @return List the viewable dimensions within the world. @@ -86,7 +74,7 @@ public enum LoggedWarnings { public abstract Optional defaultDimension(); /** - * @param dimension The dimension to load, guaranteed to be one of the dimensions previously returned by {@link #availableDimensions()} + * @param dimensionId The dimension to load, guaranteed to be one of the dimensions previously returned by {@link #availableDimensions()} * @return The loaded dimension */ public abstract Dimension loadDimension(Dimension.Identifier dimensionId); diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/JavaWorldFormat.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java similarity index 58% rename from chunky/src/java/se/llbit/chunky/world/worldformat/JavaWorldFormat.java rename to chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java index 29fc0430e9..0dee2c66f8 100644 --- a/chunky/src/java/se/llbit/chunky/world/worldformat/JavaWorldFormat.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java @@ -1,18 +1,27 @@ -package se.llbit.chunky.world.worldformat; +package se.llbit.chunky.world.java; -import se.llbit.chunky.world.Dimension; -import se.llbit.chunky.world.java.JavaWorld; import se.llbit.chunky.world.World; +import se.llbit.chunky.world.worldformat.WorldFormat; import java.io.IOException; import java.nio.file.Path; public class JavaWorldFormat implements WorldFormat { @Override - public String name() { + public String getName() { return "Java (Anvil)"; } + @Override + public String getDescription() { + return "The Minecraft world format for Java worlds since 1.2.1 (12w07a)"; + } + + @Override + public String getId() { + return "JAVA_ANVIL"; + } + @Override public boolean isValid(Path path) { return JavaWorld.isWorldDir(path.toFile()); diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java index b79b71e177..7eebf73e13 100644 --- a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java @@ -1,26 +1,13 @@ package se.llbit.chunky.world.worldformat; -import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; -import se.llbit.chunky.world.EmptyWorld; -import se.llbit.chunky.world.Dimension; import se.llbit.chunky.world.World; -import se.llbit.log.Log; -import se.llbit.chunky.world.java.JavaWorld; +import se.llbit.util.Registerable; -import java.io.File; import java.io.IOException; import java.nio.file.Path; -import java.util.*; - -public interface WorldFormat { - // TODO: Registerable - Collection worldFormats = List.of(new JavaWorldFormat()); - - /** - * @return The user-recognisable name of the world format. Shown to the user if this format has issues or throws. - */ - String name(); +/** For worlds that have multiple dimensions, and fully support the map view */ +public interface WorldFormat extends Registerable { /** * This method will be called on every possible world directory (typically this is every directory in `.minecraft/saves`). * @@ -36,28 +23,4 @@ public interface WorldFormat { * @throws IOException When something goes wrong when loading the world. */ World loadWorld(Path path) throws IOException; - - // Should this go somewhere else? - static Optional loadWorld(File dir) { - Map worldsByFormat = new Object2ObjectOpenHashMap<>(); - - for (WorldFormat worldFormat : WorldFormat.worldFormats) { - if (worldFormat.isValid(dir.toPath())) { - try { - World world = worldFormat.loadWorld(dir.toPath()); - if (world != EmptyWorld.INSTANCE) { - worldsByFormat.put(worldFormat.name(), world); - } - } catch (IOException e) { - Log.error(String.format("An error occurred when trying to load a world using format `%s` from %s", worldFormat.name(), dir.getAbsolutePath()), e); - } - } - } - if (worldsByFormat.size() > 1) { - // Maybe allow the user to select which? - // This method is called from a variety of different popup/menu situations, is this ^ possible? - Log.warn(String.format("The directory %s has multiple valid world formats: %s", dir.getAbsolutePath(), String.join(", ", worldsByFormat.keySet()))); - } - return worldsByFormat.values().stream().findFirst(); - } } \ No newline at end of file diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java new file mode 100644 index 0000000000..ef6bba3fdb --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java @@ -0,0 +1,55 @@ +package se.llbit.chunky.world.worldformat; + +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import se.llbit.chunky.world.EmptyWorld; +import se.llbit.chunky.world.World; +import se.llbit.chunky.world.java.JavaWorldFormat; +import se.llbit.log.Log; + +import java.io.File; +import java.io.IOException; +import java.util.*; + +public class WorldFormats { + private static final Map worldFormatsById = new Object2ObjectOpenHashMap<>(); + + public static void addWorldFormat(WorldFormat worldFormat) { + worldFormatsById.put(worldFormat.getId(), worldFormat); + } + + public static Map getWorldFormats() { + return Collections.unmodifiableMap(worldFormatsById); + } + + public static WorldFormat getWorldFormat(String id) { + return worldFormatsById.get(id); + } + + static { + addWorldFormat(new JavaWorldFormat()); + } + + public static Optional createWorld(File dir) { + Map providedWorlds = new Object2ObjectOpenHashMap<>(); + + getWorldFormats().forEach((id, format) -> { + if (format.isValid(dir.toPath())) { + try { + World world = format.loadWorld(dir.toPath()); + if (world != EmptyWorld.INSTANCE) { + providedWorlds.put(format.getId(), world); + } + } catch (IOException e) { + Log.error(String.format("An error occurred when trying to load a world using format `%s` from %s", format.getName(), dir.getAbsolutePath()), e); + } + } + }); + + if (providedWorlds.size() > 1) { + // Maybe allow the user to select which? + // This method is called from a variety of different popup/menu situations, is this ^ possible? + Log.warn(String.format("The directory %s has multiple valid world formats: %s", dir.getAbsolutePath(), String.join(", ", providedWorlds.keySet()))); + } + return providedWorlds.values().stream().findFirst(); + } +} From 84e3a01b93f3c6fa089a650dc9e3316e3a60d3cb Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sat, 27 Jun 2026 20:55:57 +0100 Subject: [PATCH 05/57] Rename MC_ classes to Java_ --- .../se/llbit/chunky/renderer/scene/Scene.java | 6 ++--- .../src/java/se/llbit/chunky/ui/ChunkMap.java | 14 +++-------- .../chunky/world/ChunkSelectionTracker.java | 22 ++++++++-------- .../se/llbit/chunky/world/java/JavaChunk.java | 6 ++--- .../chunky/world/java/JavaDimension.java | 6 +++-- .../region/JavaRegion.java} | 25 ++++++------------- .../region/JavaRegionChangeWatcher.java} | 10 ++++---- .../world/listeners/ChunkUpdateListener.java | 6 ++--- .../se/llbit/chunky/world/region/Region.java | 9 +++++++ 9 files changed, 49 insertions(+), 55 deletions(-) rename chunky/src/java/se/llbit/chunky/world/{region/MCRegion.java => java/region/JavaRegion.java} (96%) rename chunky/src/java/se/llbit/chunky/world/{region/MCRegionChangeWatcher.java => java/region/JavaRegionChangeWatcher.java} (90%) diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java index f70de4aa00..c0996af207 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java @@ -53,8 +53,8 @@ import se.llbit.chunky.world.biome.Biome; import se.llbit.chunky.world.biome.BiomePalette; import se.llbit.chunky.world.biome.Biomes; +import se.llbit.chunky.world.region.Region; import se.llbit.chunky.world.worldformat.WorldFormats; -import se.llbit.chunky.world.region.MCRegion; import se.llbit.json.*; import se.llbit.log.Log; import se.llbit.math.*; @@ -864,8 +864,8 @@ public synchronized void loadChunks(TaskTracker taskTracker, World world, Map>>> createRegionDataFuture = (regionPosition, chunkDataArray) -> executor.submit(() -> { List chunkPositionsToLoad = chunksToLoadByRegion.get(regionPosition); diff --git a/chunky/src/java/se/llbit/chunky/ui/ChunkMap.java b/chunky/src/java/se/llbit/chunky/ui/ChunkMap.java index 2384440e8e..7619c60f3f 100644 --- a/chunky/src/java/se/llbit/chunky/ui/ChunkMap.java +++ b/chunky/src/java/se/llbit/chunky/ui/ChunkMap.java @@ -19,22 +19,16 @@ import javafx.application.Platform; import javafx.geometry.Point2D; -import javafx.geometry.Pos; import javafx.scene.Scene; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.control.*; -import javafx.scene.control.Button; -import javafx.scene.control.Dialog; import javafx.scene.control.MenuItem; -import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.KeyEvent; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.input.ScrollEvent; -import javafx.scene.layout.Border; -import javafx.scene.layout.GridPane; import javafx.stage.PopupWindow; import se.llbit.chunky.map.MapBuffer; import se.llbit.chunky.map.MapView; @@ -45,11 +39,10 @@ import se.llbit.chunky.renderer.scene.SceneManager; import se.llbit.chunky.ui.controller.ChunkyFxController; import se.llbit.chunky.ui.dialogs.SelectChunksInRadiusDialog; -import se.llbit.chunky.ui.elements.TextFieldLabelWrapper; import se.llbit.chunky.world.*; import se.llbit.chunky.world.Dimension; import se.llbit.chunky.world.listeners.ChunkUpdateListener; -import se.llbit.chunky.world.region.MCRegion; +import se.llbit.chunky.world.region.Region; import se.llbit.log.Log; import se.llbit.math.*; @@ -57,7 +50,6 @@ import java.io.File; import java.io.IOException; import java.util.Collection; -import java.util.Optional; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -227,8 +219,8 @@ public ChunkMap(WorldMapLoader loader, ChunkyFxController controller, if (view.chunkScale >= 16) { int minChunkX = region.x << 5; int minChunkZ = region.z << 5; - for (int chunkX = minChunkX; chunkX < minChunkX + MCRegion.CHUNKS_X; chunkX++) { - for (int chunkZ = minChunkZ; chunkZ < minChunkZ + MCRegion.CHUNKS_Z; chunkZ++) { + for (int chunkX = minChunkX; chunkX < minChunkX + Region.CHUNKS_X; chunkX++) { + for (int chunkZ = minChunkZ; chunkZ < minChunkZ + Region.CHUNKS_Z; chunkZ++) { mapBuffer.drawTile(mapLoader, new ChunkPosition(chunkX, chunkZ), chunkSelection); } } diff --git a/chunky/src/java/se/llbit/chunky/world/ChunkSelectionTracker.java b/chunky/src/java/se/llbit/chunky/world/ChunkSelectionTracker.java index 2c1751c914..989931d74b 100644 --- a/chunky/src/java/se/llbit/chunky/world/ChunkSelectionTracker.java +++ b/chunky/src/java/se/llbit/chunky/world/ChunkSelectionTracker.java @@ -20,7 +20,7 @@ import it.unimi.dsi.fastutil.objects.Object2ReferenceOpenHashMap; import se.llbit.chunky.world.listeners.ChunkDeletionListener; import se.llbit.chunky.world.listeners.ChunkUpdateListener; -import se.llbit.chunky.world.region.MCRegion; +import se.llbit.chunky.world.region.Region; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -42,7 +42,7 @@ public class ChunkSelectionTracker implements ChunkDeletionListener { */ private boolean setChunk(ChunkPosition pos, boolean selected) { long regionPosLong = ChunkPosition.positionToLong(pos.x >> 5, pos.z >> 5); - BitSet selectedChunksForRegion = selectedChunksByRegion.computeIfAbsent(regionPosLong, p -> new BitSet(MCRegion.CHUNKS_X * MCRegion.CHUNKS_Z)); + BitSet selectedChunksForRegion = selectedChunksByRegion.computeIfAbsent(regionPosLong, p -> new BitSet(Region.CHUNKS_X * Region.CHUNKS_Z)); int bitIndex = (pos.x & 31) + ((pos.z & 31) << 5); boolean previousValue = selectedChunksForRegion.get(bitIndex); if(previousValue != selected) { @@ -75,7 +75,7 @@ private boolean setChunk(Dimension dimension, ChunkPosition pos, boolean selecte * @return Whether the selection changed */ private boolean setChunksWithinRegion(Dimension dimension, RegionPosition regionPos, int minX, int maxX, int minZ, int maxZ, boolean selected) { - BitSet selectedChunksForRegion = selectedChunksByRegion.computeIfAbsent(regionPos.getLong(), p -> new BitSet(MCRegion.CHUNKS_X * MCRegion.CHUNKS_Z)); + BitSet selectedChunksForRegion = selectedChunksByRegion.computeIfAbsent(regionPos.getLong(), p -> new BitSet(Region.CHUNKS_X * Region.CHUNKS_Z)); Collection changedChunks = new ArrayList<>(); boolean selectionChanged = false; @@ -249,15 +249,15 @@ public synchronized boolean setChunks(Dimension dimension, int minChunkX, int mi boolean selectionChanged = false; // If selection area must contain complete regions - if(maxChunkX - minChunkX >= MCRegion.CHUNKS_X*2 && maxChunkZ - minChunkZ >= MCRegion.CHUNKS_Z*2) { + if(maxChunkX - minChunkX >= Region.CHUNKS_X*2 && maxChunkZ - minChunkZ >= Region.CHUNKS_Z*2) { // All full regions are set first, then any chunks on the borders are set, top and bottom include corners, left and right don't // left, right, top, bottom are unrelated to the actual map view, and are just treating XZ as if they were XY on traditional cartesian coordinate axes - int leftBorder = MCRegion.CHUNKS_X - (minChunkX & (MCRegion.CHUNKS_X-1)); // want the border from minimum region corner to minimum chunk corner, so 32 - borderSize - int rightBorder = maxChunkX & (MCRegion.CHUNKS_X-1); + int leftBorder = Region.CHUNKS_X - (minChunkX & (Region.CHUNKS_X-1)); // want the border from minimum region corner to minimum chunk corner, so 32 - borderSize + int rightBorder = maxChunkX & (Region.CHUNKS_X-1); - int bottomBorder = MCRegion.CHUNKS_Z - (minChunkZ & (MCRegion.CHUNKS_Z-1)); - int topBorder = maxChunkZ & (MCRegion.CHUNKS_Z-1); + int bottomBorder = Region.CHUNKS_Z - (minChunkZ & (Region.CHUNKS_Z-1)); + int topBorder = maxChunkZ & (Region.CHUNKS_Z-1); int minInnerRegionX = (minChunkX + leftBorder) >> 5; int maxInnerRegionX = (maxChunkX - rightBorder) >> 5; @@ -397,9 +397,9 @@ public synchronized Map> getSelectionByRegio selectedChunksByRegion.forEach((regionPosition, selectedChunksBitSet) -> { RegionPosition regionPos = new RegionPosition(regionPosition); List positions = new ArrayList<>(); - for (int localX = 0; localX < MCRegion.CHUNKS_X; localX++) { - for (int localZ = 0; localZ < MCRegion.CHUNKS_Z; localZ++) { - int idx = localX + (localZ * MCRegion.CHUNKS_X); + for (int localX = 0; localX < Region.CHUNKS_X; localX++) { + for (int localZ = 0; localZ < Region.CHUNKS_Z; localZ++) { + int idx = localX + (localZ * Region.CHUNKS_X); if(selectedChunksBitSet.get(idx)) { positions.add(regionPos.asChunkPosition(localX, localZ)); } diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java b/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java index cf0e2726da..b1136ef667 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java @@ -13,7 +13,7 @@ import se.llbit.chunky.world.*; import se.llbit.chunky.world.biome.ArrayBiomePalette; import se.llbit.chunky.world.biome.BiomePalette; -import se.llbit.chunky.world.region.MCRegion; +import se.llbit.chunky.world.java.region.JavaRegion; import se.llbit.chunky.world.region.Region; import se.llbit.log.Log; import se.llbit.math.QuickMath; @@ -42,7 +42,7 @@ public JavaChunk(ChunkPosition pos, Dimension dimension) { * @return loaded data, or null if something went wrong */ private Map getChunkTags(Set request) throws ChunkLoadingException { - MCRegion region = (MCRegion) dimension.getRegion(position.getRegionPosition()); + JavaRegion region = (JavaRegion) dimension.getRegion(position.getRegionPosition()); Mutable timestamp = new Mutable<>(dataTimestamp); Map chunkTags = region.getChunkTags(this.position, request, timestamp); this.dataTimestamp = timestamp.get(); @@ -54,7 +54,7 @@ private Map getChunkTags(Set request) throws ChunkLoadingEx * @return loaded data, or null if something went wrong */ private Map getEntityTags(Set request) throws ChunkLoadingException { - MCRegion region = (MCRegion) dimension.getRegion(position.getRegionPosition()); + JavaRegion region = (JavaRegion) dimension.getRegion(position.getRegionPosition()); return region.getEntityTags(this.position, request); } diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java index 211fd66cd7..00a8e167d7 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java @@ -7,6 +7,8 @@ import se.llbit.chunky.map.MapView; import se.llbit.chunky.map.WorldMapLoader; import se.llbit.chunky.world.*; +import se.llbit.chunky.world.java.region.JavaRegion; +import se.llbit.chunky.world.java.region.JavaRegionChangeWatcher; import se.llbit.chunky.world.region.*; import se.llbit.math.Vector3; import se.llbit.math.Vector3i; @@ -34,12 +36,12 @@ protected JavaDimension(JavaWorld world, Identifier dimensionId, File dimensionD @Override public RegionChangeWatcher createRegionChangeWatcher(WorldMapLoader worldMapLoader, MapView mapView) { - return new MCRegionChangeWatcher(worldMapLoader, mapView); + return new JavaRegionChangeWatcher(worldMapLoader, mapView); } @Override public Region createRegion(RegionPosition pos) { - return new MCRegion(pos, this); + return new JavaRegion(pos, this); } /** diff --git a/chunky/src/java/se/llbit/chunky/world/region/MCRegion.java b/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegion.java similarity index 96% rename from chunky/src/java/se/llbit/chunky/world/region/MCRegion.java rename to chunky/src/java/se/llbit/chunky/world/java/region/JavaRegion.java index b9d36aaf42..606f8c539b 100644 --- a/chunky/src/java/se/llbit/chunky/world/region/MCRegion.java +++ b/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegion.java @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with Chunky. If not, see . */ -package se.llbit.chunky.world.region; +package se.llbit.chunky.world.java.region; import java.io.*; import java.util.Iterator; @@ -26,6 +26,8 @@ import se.llbit.chunky.world.*; import se.llbit.chunky.world.java.JavaChunk; import se.llbit.chunky.world.java.JavaDimension; +import se.llbit.chunky.world.region.ChunkReadException; +import se.llbit.chunky.world.region.Region; import se.llbit.log.Log; import se.llbit.nbt.ErrorTag; import se.llbit.nbt.NamedTag; @@ -44,19 +46,8 @@ * * @author Jesper Öqvist */ -public class MCRegion implements Region { - - /** - * Region X chunk width - */ - public static final int CHUNKS_X = 32; - - /** - * Region Z chunk width - */ - public static final int CHUNKS_Z = 32; - - private static final int NUM_CHUNKS = CHUNKS_X * CHUNKS_Z; +public class JavaRegion implements Region { + private static final int NUM_CHUNKS = Region.CHUNKS_X * Region.CHUNKS_Z; /** * Sector size in bytes. @@ -82,12 +73,12 @@ private static int getMCAChunkIndex(ChunkPosition chunkPos) { * * @param pos the region position */ - public MCRegion(RegionPosition pos, JavaDimension dimension) { + public JavaRegion(RegionPosition pos, JavaDimension dimension) { this.dimension = dimension; fileName = pos.getMcaName(); position = pos; - for (int z = 0; z < CHUNKS_Z; ++z) { - for (int x = 0; x < CHUNKS_X; ++x) { + for (int z = 0; z < Region.CHUNKS_Z; ++z) { + for (int x = 0; x < Region.CHUNKS_X; ++x) { chunks[getMCAChunkIndex(x, z)] = EmptyChunk.INSTANCE; } } diff --git a/chunky/src/java/se/llbit/chunky/world/region/MCRegionChangeWatcher.java b/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegionChangeWatcher.java similarity index 90% rename from chunky/src/java/se/llbit/chunky/world/region/MCRegionChangeWatcher.java rename to chunky/src/java/se/llbit/chunky/world/java/region/JavaRegionChangeWatcher.java index c989ee5c46..90791b8165 100644 --- a/chunky/src/java/se/llbit/chunky/world/region/MCRegionChangeWatcher.java +++ b/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegionChangeWatcher.java @@ -14,25 +14,25 @@ * You should have received a copy of the GNU General Public License * along with Chunky. If not, see . */ -package se.llbit.chunky.world.region; +package se.llbit.chunky.world.java.region; import javafx.application.Platform; import se.llbit.chunky.PersistentSettings; import se.llbit.chunky.map.MapView; import se.llbit.chunky.map.WorldMapLoader; -import se.llbit.chunky.world.ChunkPosition; import se.llbit.chunky.world.ChunkView; -import se.llbit.chunky.world.Dimension; import se.llbit.chunky.world.RegionPosition; import se.llbit.chunky.world.java.JavaDimension; +import se.llbit.chunky.world.region.Region; +import se.llbit.chunky.world.region.RegionChangeWatcher; /** * Monitors filesystem for changes to region files. * * @author Jesper Öqvist */ -public class MCRegionChangeWatcher extends RegionChangeWatcher { - public MCRegionChangeWatcher(WorldMapLoader loader, MapView mapView) { +public class JavaRegionChangeWatcher extends RegionChangeWatcher { + public JavaRegionChangeWatcher(WorldMapLoader loader, MapView mapView) { super(loader, mapView, "Region Refresher"); } diff --git a/chunky/src/java/se/llbit/chunky/world/listeners/ChunkUpdateListener.java b/chunky/src/java/se/llbit/chunky/world/listeners/ChunkUpdateListener.java index 436abd9507..84084a277c 100644 --- a/chunky/src/java/se/llbit/chunky/world/listeners/ChunkUpdateListener.java +++ b/chunky/src/java/se/llbit/chunky/world/listeners/ChunkUpdateListener.java @@ -18,7 +18,7 @@ import se.llbit.chunky.world.ChunkPosition; import se.llbit.chunky.world.RegionPosition; -import se.llbit.chunky.world.region.MCRegion; +import se.llbit.chunky.world.region.Region; import java.util.Collection; @@ -33,8 +33,8 @@ default void chunkUpdated(ChunkPosition chunkPosition) {} default void regionChunksUpdated(RegionPosition region) { int minChunkX = region.x << 5; int minChunkZ = region.z << 5; - for (int chunkX = minChunkX; chunkX < minChunkX + MCRegion.CHUNKS_X; chunkX++) { - for (int chunkZ = minChunkZ; chunkZ < minChunkZ + MCRegion.CHUNKS_Z; chunkZ++) { + for (int chunkX = minChunkX; chunkX < minChunkX + Region.CHUNKS_X; chunkX++) { + for (int chunkZ = minChunkZ; chunkZ < minChunkZ + Region.CHUNKS_Z; chunkZ++) { this.chunkUpdated(new ChunkPosition(chunkX, chunkZ)); } } diff --git a/chunky/src/java/se/llbit/chunky/world/region/Region.java b/chunky/src/java/se/llbit/chunky/world/region/Region.java index f12662ef1e..3cd29239d3 100644 --- a/chunky/src/java/se/llbit/chunky/world/region/Region.java +++ b/chunky/src/java/se/llbit/chunky/world/region/Region.java @@ -6,6 +6,15 @@ import se.llbit.chunky.world.RegionPosition; public interface Region extends Iterable { + /** + * Region X chunk width + */ + int CHUNKS_X = 32; + /** + * Region Z chunk width + */ + int CHUNKS_Z = 32; + /** * @return Chunk at (x, z) */ From 23fe79e7f4ae867ee22490032f0a230dc087f043 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sun, 28 Jun 2026 11:19:07 +0100 Subject: [PATCH 06/57] Create HeightRange and use it in place of the awkward IntIntPair --- chunky/src/java/se/llbit/chunky/map/MapTile.java | 5 +++-- .../chunky/ui/controller/ChunkyFxController.java | 12 ++++++------ .../llbit/chunky/ui/render/tabs/GeneralTab.java | 7 ++++--- .../se/llbit/chunky/world/CubicDimension.java | 12 ++++++------ .../java/se/llbit/chunky/world/Dimension.java | 14 +++++--------- .../se/llbit/chunky/world/EmptyDimension.java | 10 +++++----- .../java/se/llbit/chunky/world/HeightRange.java | 7 +++++++ .../se/llbit/chunky/world/java/JavaChunk.java | 13 ++++++------- .../llbit/chunky/world/java/JavaDimension.java | 16 +++++++--------- .../java/region/JavaRegionChangeWatcher.java | 6 ++++-- .../llbit/chunky/world/region/RegionParser.java | 2 +- 11 files changed, 54 insertions(+), 50 deletions(-) create mode 100644 chunky/src/java/se/llbit/chunky/world/HeightRange.java diff --git a/chunky/src/java/se/llbit/chunky/map/MapTile.java b/chunky/src/java/se/llbit/chunky/map/MapTile.java index 2f00a9991a..184536f8e5 100644 --- a/chunky/src/java/se/llbit/chunky/map/MapTile.java +++ b/chunky/src/java/se/llbit/chunky/map/MapTile.java @@ -71,8 +71,9 @@ public void draw(MapBuffer buffer, WorldMapLoader mapLoader, ChunkView view, } } else { RegionPosition regionPos = new RegionPosition(pos.x, pos.z); // intentionally don't convert, this position represented a region already. - boolean isValid = mapLoader.getWorld().currentDimension().regionExistsWithinRange(regionPos, view.yMin, view.yMax); - Region region = mapLoader.getWorld().currentDimension().getRegionWithinRange(regionPos, view.yMin, view.yMax); + HeightRange heightRange = new HeightRange(view.yMin, view.yMax); + boolean isValid = mapLoader.getWorld().currentDimension().hasRegionWithinRange(regionPos, heightRange); + Region region = mapLoader.getWorld().currentDimension().getRegionWithinRange(regionPos, heightRange); int pixelOffset = 0; for (int z = 0; z < 32; ++z) { for (int x = 0; x < 32; ++x) { diff --git a/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java b/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java index 409eba62bb..28b1620d3c 100644 --- a/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java +++ b/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java @@ -460,9 +460,9 @@ public File getSceneFile(String fileName) { } if (!reloaded) { ignoreYUpdate.set(true); - IntIntPair heightRange = mapLoader.getWorld().currentDimension().heightRange(); - int min = heightRange.firstInt(); - int max = heightRange.secondInt(); + HeightRange heightRange = mapLoader.getWorld().currentDimension().heightRange(); + int min = heightRange.min(); + int max = heightRange.max(); yMin.setRange(min, max); yMin.set(min); yMax.setRange(min, max); @@ -653,9 +653,9 @@ public File getSceneFile(String fileName) { mapOverlay.setOnKeyReleased(map::onKeyReleased); mapLoader.loadWorldFromDirectory(PersistentSettings.getLastWorld()); - IntIntPair heightRange = mapLoader.getWorld().currentDimension().heightRange(); - mapView.setYMin(heightRange.firstInt()); - mapView.setYMax(heightRange.secondInt()); + HeightRange heightRange = mapLoader.getWorld().currentDimension().heightRange(); + mapView.setYMin(heightRange.min()); + mapView.setYMax(heightRange.max()); canvas = new RenderCanvasFx(this, chunky.getSceneManager().getScene(), chunky.getRenderController().getRenderManager()); diff --git a/chunky/src/java/se/llbit/chunky/ui/render/tabs/GeneralTab.java b/chunky/src/java/se/llbit/chunky/ui/render/tabs/GeneralTab.java index d70afedbe1..056c2381b5 100644 --- a/chunky/src/java/se/llbit/chunky/ui/render/tabs/GeneralTab.java +++ b/chunky/src/java/se/llbit/chunky/ui/render/tabs/GeneralTab.java @@ -48,6 +48,7 @@ import se.llbit.chunky.ui.elements.SizeInput; import se.llbit.chunky.ui.render.RenderControlsTab; import se.llbit.chunky.world.EmptyWorld; +import se.llbit.chunky.world.HeightRange; import se.llbit.chunky.world.Icon; import se.llbit.chunky.world.World; import se.llbit.fxutil.Dialogs; @@ -594,9 +595,9 @@ private void updateCanvasCrop() { private void updateYClipSlidersRanges(World world) { if (world != null) { - IntIntPair heightRange = world.currentDimension().heightRange(); - int min = heightRange.firstInt(); - int max = heightRange.secondInt(); + HeightRange heightRange = world.currentDimension().heightRange(); + int min = heightRange.min(); + int max = heightRange.max(); yMin.setRange(min, max); yMin.set(min); yMax.setRange(min, max); diff --git a/chunky/src/java/se/llbit/chunky/world/CubicDimension.java b/chunky/src/java/se/llbit/chunky/world/CubicDimension.java index c08acc9770..5fc85bef76 100644 --- a/chunky/src/java/se/llbit/chunky/world/CubicDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/CubicDimension.java @@ -53,11 +53,11 @@ public Region createRegion(RegionPosition pos) { return new ImposterCubicRegion(pos, this); } - public synchronized Region getRegionWithinRange(RegionPosition pos, int minY, int maxY) { + public synchronized Region getRegionWithinRange(RegionPosition pos, HeightRange heightRange) { return regionMap.computeIfAbsent(pos.getLong(), p -> { // check if the region is present in the world directory Region region = EmptyRegion.instance; - if (regionExistsWithinRange(pos, minY, maxY)) { + if (this.hasRegionWithinRange(pos, heightRange)) { region = createRegion(pos); } return region; @@ -66,7 +66,7 @@ public synchronized Region getRegionWithinRange(RegionPosition pos, int minY, in /** no choice but to iterate over every file in the directory */ @Override - public boolean regionExists(RegionPosition pos) { + public boolean hasRegion(RegionPosition pos) { File regionDirectory = getRegionDirectory(); try (Stream list = Files.list(regionDirectory.toPath())) { return list.anyMatch(path -> { @@ -86,13 +86,13 @@ public boolean regionExists(RegionPosition pos) { } @Override - public boolean regionExistsWithinRange(RegionPosition pos, int minY, int maxY) { + public boolean hasRegionWithinRange(RegionPosition pos, HeightRange heightRange) { int cubicRegionX = pos.x << 1; int cubicRegionZ = pos.z << 1; File regionDirectory = getRegionDirectory(); - int minRegionY = cubeToCubicRegion(blockToCube(minY)); - int maxRegionY = cubeToCubicRegion(blockToCube(maxY - 1)); + int minRegionY = cubeToCubicRegion(blockToCube(heightRange().min())); + int maxRegionY = cubeToCubicRegion(blockToCube(heightRange.max() - 1)); for (int y = minRegionY; y <= maxRegionY; y++) { for (int localX = 0; localX < ImposterCubicRegion.DIAMETER_IN_CUBIC_REGIONS; localX++) { for (int localZ = 0; localZ < ImposterCubicRegion.DIAMETER_IN_CUBIC_REGIONS; localZ++) { diff --git a/chunky/src/java/se/llbit/chunky/world/Dimension.java b/chunky/src/java/se/llbit/chunky/world/Dimension.java index 9134720119..cfae48e89c 100644 --- a/chunky/src/java/se/llbit/chunky/world/Dimension.java +++ b/chunky/src/java/se/llbit/chunky/world/Dimension.java @@ -1,8 +1,5 @@ package se.llbit.chunky.world; -import it.unimi.dsi.fastutil.ints.IntIntPair; -import it.unimi.dsi.fastutil.longs.Long2ObjectMap; -import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import se.llbit.chunky.PersistentSettings; import se.llbit.chunky.chunk.ChunkData; import se.llbit.chunky.chunk.GenericChunkData; @@ -138,21 +135,20 @@ public ChunkData createChunkData(@Nullable ChunkData chunkData, int minY, int ma */ public abstract Region getRegion(RegionPosition pos); - public abstract Region getRegionWithinRange(RegionPosition pos, int yMin, int yMax); + public abstract Region getRegionWithinRange(RegionPosition pos, HeightRange heightRange); /** * @param pos region position * @return {@code true} if a region file exists for the given position */ - public abstract boolean regionExists(RegionPosition pos); + public abstract boolean hasRegion(RegionPosition pos); /** * @param pos Position of the region to load - * @param minY Minimum block Y (inclusive) - * @param maxY Maximum block Y (exclusive) + * @param heightRange The height range of the request * @return Whether the region exists */ - public abstract boolean regionExistsWithinRange(RegionPosition pos, int minY, int maxY); + public abstract boolean hasRegionWithinRange(RegionPosition pos, HeightRange heightRange); /** * WARNING: In some dimensions this could be from {@link Integer#MIN_VALUE} to {@link Integer#MAX_VALUE} @@ -161,7 +157,7 @@ public ChunkData createChunkData(@Nullable ChunkData chunkData, int minY, int ma * * @return The height range of the dimension. */ - public abstract IntIntPair heightRange(); + public abstract HeightRange heightRange(); /** * @return The chunk heightmap diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java b/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java index 91e31b8924..e627aebe74 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java @@ -41,23 +41,23 @@ public Region getRegion(RegionPosition pos) { } @Override - public Region getRegionWithinRange(RegionPosition pos, int yMin, int yMax) { + public Region getRegionWithinRange(RegionPosition pos, HeightRange heightRange) { return EmptyRegion.instance; } @Override - public boolean regionExists(RegionPosition pos) { + public boolean hasRegion(RegionPosition pos) { return false; } @Override - public boolean regionExistsWithinRange(RegionPosition pos, int minY, int maxY) { + public boolean hasRegionWithinRange(RegionPosition pos, HeightRange heightRange) { return false; } @Override - public IntIntPair heightRange() { - return new IntIntImmutablePair(0, 0); + public HeightRange heightRange() { + return new HeightRange(0, 0); } @Override public String getName() { diff --git a/chunky/src/java/se/llbit/chunky/world/HeightRange.java b/chunky/src/java/se/llbit/chunky/world/HeightRange.java new file mode 100644 index 0000000000..b1105efedf --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/HeightRange.java @@ -0,0 +1,7 @@ +package se.llbit.chunky.world; + +/** + * @param min Lower (INCLUSIVE) bound of the range + * @param max Upper (EXCLUSIVE) bound of the range + */ +public record HeightRange(int min, int max) { } diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java b/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java index b1136ef667..72a9abf130 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java @@ -1,6 +1,5 @@ package se.llbit.chunky.world.java; -import it.unimi.dsi.fastutil.ints.IntIntImmutablePair; import se.llbit.chunky.block.legacy.LegacyBlocks; import se.llbit.chunky.chunk.BlockPalette; import se.llbit.chunky.chunk.ChunkData; @@ -87,8 +86,8 @@ public synchronized boolean loadChunk(@NotNull Mutable chunkData, int surfaceTimestamp = dataTimestamp; version = chunkVersion(data); - IntIntImmutablePair chunkBounds = inclusiveChunkBounds(data); - chunkData.set(this.dimension.createChunkData(chunkData.get(), chunkBounds.leftInt(), chunkBounds.rightInt())); + HeightRange chunkBounds = inclusiveChunkBounds(data); + chunkData.set(this.dimension.createChunkData(chunkData.get(), chunkBounds.min(), chunkBounds.max())); loadSurface(data, chunkData.get(), yMin, yMax); biomesTimestamp = dataTimestamp; @@ -289,10 +288,10 @@ public synchronized void getChunkData(@NotNull Mutable reuseChunkData int dataVersion = data.get(DATAVERSION).intValue(); - IntIntImmutablePair chunkBounds = inclusiveChunkBounds(data); + HeightRange chunkBounds = inclusiveChunkBounds(data); if(reuseChunkData.get() == null || reuseChunkData.get() instanceof EmptyChunkData) { - reuseChunkData.set(dimension.createChunkData(reuseChunkData.get(), chunkBounds.leftInt(), chunkBounds.rightInt())); + reuseChunkData.set(dimension.createChunkData(reuseChunkData.get(), chunkBounds.min(), chunkBounds.max())); } else { reuseChunkData.get().clear(); } @@ -343,7 +342,7 @@ public synchronized void getChunkData(@NotNull Mutable reuseChunkData /** * @return The min and max blockY for a given section array */ - private IntIntImmutablePair inclusiveChunkBounds(Tag chunkData) { + private HeightRange inclusiveChunkBounds(Tag chunkData) { Tag sections = getTagFromNames(chunkData, LEVEL_SECTIONS, SECTIONS_POST_21W39A); int minSectionY = Integer.MAX_VALUE; int maxSectionY = Integer.MIN_VALUE; @@ -359,7 +358,7 @@ private IntIntImmutablePair inclusiveChunkBounds(Tag chunkData) { } } - return new IntIntImmutablePair(minSectionY << 4, (maxSectionY << 4) + 15); + return new HeightRange(minSectionY << 4, (maxSectionY << 4) + 15); } } diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java index 00a8e167d7..81ea9b91de 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java @@ -1,7 +1,5 @@ package se.llbit.chunky.world.java; -import it.unimi.dsi.fastutil.ints.IntIntImmutablePair; -import it.unimi.dsi.fastutil.ints.IntIntPair; import it.unimi.dsi.fastutil.longs.Long2ObjectMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import se.llbit.chunky.map.MapView; @@ -64,26 +62,26 @@ public synchronized Region getRegion(RegionPosition pos) { } @Override - public Region getRegionWithinRange(RegionPosition pos, int yMin, int yMax) { + public Region getRegionWithinRange(RegionPosition pos, HeightRange heightRange) { return getRegion(pos); } @Override - public boolean regionExists(RegionPosition pos) { + public boolean hasRegion(RegionPosition pos) { File regionFile = new File(getRegionDirectory(), pos.getMcaName()); return regionFile.exists(); } @Override - public boolean regionExistsWithinRange(RegionPosition pos, int minY, int maxY) { - return this.regionExists(pos); + public boolean hasRegionWithinRange(RegionPosition pos, HeightRange heightRange) { + return this.hasRegion(pos); } @Override - public IntIntPair heightRange() { + public HeightRange heightRange() { return this.world.versionId >= JavaWorld.VERSION_21W06A ? - new IntIntImmutablePair(-64, 320) : - new IntIntImmutablePair(0, 256); + new HeightRange(-64, 320) : + new HeightRange(0, 256); } diff --git a/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegionChangeWatcher.java b/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegionChangeWatcher.java index 90791b8165..9e469a3972 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegionChangeWatcher.java +++ b/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegionChangeWatcher.java @@ -21,6 +21,7 @@ import se.llbit.chunky.map.MapView; import se.llbit.chunky.map.WorldMapLoader; import se.llbit.chunky.world.ChunkView; +import se.llbit.chunky.world.HeightRange; import se.llbit.chunky.world.RegionPosition; import se.llbit.chunky.world.java.JavaDimension; import se.llbit.chunky.world.region.Region; @@ -51,9 +52,10 @@ public JavaRegionChangeWatcher(WorldMapLoader loader, MapView mapView) { for (int rx = theView.prx0; rx <= theView.prx1; ++rx) { for (int rz = theView.prz0; rz <= theView.prz1; ++rz) { RegionPosition pos = new RegionPosition(rx, rz); - Region region = dimension.getRegionWithinRange(pos, theView.yMin, theView.yMax); + HeightRange heightRange = new HeightRange(theView.yMin, theView.yMax); + Region region = dimension.getRegionWithinRange(pos, heightRange); if (region.isEmpty()) { - if (dimension.regionExistsWithinRange(pos, theView.yMin, theView.yMax)) { + if (dimension.hasRegionWithinRange(pos, heightRange)) { region = dimension.createRegion(pos); } dimension.setRegion(pos, region); diff --git a/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java b/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java index b25f0771c0..e47c641179 100644 --- a/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java +++ b/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java @@ -61,7 +61,7 @@ public RegionParser(WorldMapLoader loader, RegionQueue queue, MapView mapView) { ChunkView map = mapView.getMapView(); if (map.isRegionVisible(position)) { Dimension dimension = mapLoader.getWorld().currentDimension(); - Region region = dimension.getRegionWithinRange(position, mapView.getYMin(), mapView.getYMax()); + Region region = dimension.getRegionWithinRange(position, new HeightRange(mapView.getYMin(), mapView.getYMax())); region.parse(mapView.getYMin(), mapView.getYMax()); Mutable chunkData = new Mutable<>(null); for (Chunk chunk : region) { From 5bed5f47de52ad6c464fcba45a5ca3eefd043432 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sun, 28 Jun 2026 11:20:22 +0100 Subject: [PATCH 07/57] Various method renames --- .../src/java/se/llbit/chunky/map/WorldMapLoader.java | 8 ++++---- chunky/src/java/se/llbit/chunky/world/EmptyWorld.java | 4 ++-- .../java/se/llbit/chunky/world/ImposterCubicChunk.java | 2 +- chunky/src/java/se/llbit/chunky/world/World.java | 8 ++++---- .../src/java/se/llbit/chunky/world/java/JavaChunk.java | 10 +++++----- .../java/se/llbit/chunky/world/java/JavaDimension.java | 2 +- .../src/java/se/llbit/chunky/world/java/JavaWorld.java | 6 +++--- .../se/llbit/chunky/world/java/JavaWorldFormat.java | 2 +- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java b/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java index 353da7fdc0..f7509a0099 100644 --- a/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java +++ b/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java @@ -85,9 +85,9 @@ public void loadWorld(World newWorld) { Optional dimensionToLoad = Optional.of(world.currentDimension()) .map(Dimension::getDimensionId) - .filter(dimension -> newWorld.availableDimensions().contains(dimension)) - .or(newWorld::defaultDimension) - .or(() -> newWorld.availableDimensions().stream().findFirst()); + .filter(dimension -> newWorld.getAvailableDimensions().contains(dimension)) + .or(newWorld::getDefaultDimension) + .or(() -> newWorld.getAvailableDimensions().stream().findFirst()); if (dimensionToLoad.isEmpty()) { Log.infof("No dimension loaded for world %s", newWorld.toString()); @@ -192,7 +192,7 @@ private void updateRegionChangeWatcher(Dimension dimension) { /** * Set the current dimension. * - * @param value Must be a valid dimension see {@link World#availableDimensions()} + * @param value Must be a valid dimension see {@link World#getAvailableDimensions()} */ public void setDimension(Dimension.Identifier value) { if (value != currentDimensionId) { diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java b/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java index 38731a0f17..c75d8ddc9e 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java @@ -36,12 +36,12 @@ private EmptyWorld() { } @Override - public Set availableDimensions() { + public Set getAvailableDimensions() { return Collections.emptySet(); } @Override - public Optional defaultDimension() { + public Optional getDefaultDimension() { return Optional.empty(); } diff --git a/chunky/src/java/se/llbit/chunky/world/ImposterCubicChunk.java b/chunky/src/java/se/llbit/chunky/world/ImposterCubicChunk.java index 14c2e2f7cb..e1e5a1e18c 100644 --- a/chunky/src/java/se/llbit/chunky/world/ImposterCubicChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/ImposterCubicChunk.java @@ -53,7 +53,7 @@ private Map> getCubeTags(Set request) { */ @Override public synchronized boolean loadChunk(@NotNull Mutable mutableChunkData, int yMin, int yMax) { - if (!shouldReloadChunk()) { + if (!shouldReload()) { return false; } diff --git a/chunky/src/java/se/llbit/chunky/world/World.java b/chunky/src/java/se/llbit/chunky/world/World.java index 141b910c3a..d671031658 100644 --- a/chunky/src/java/se/llbit/chunky/world/World.java +++ b/chunky/src/java/se/llbit/chunky/world/World.java @@ -65,16 +65,16 @@ public enum LoggedWarnings { * * @return List the viewable dimensions within the world. */ - public abstract Set availableDimensions(); + public abstract Set getAvailableDimensions(); /** - * MUST be one of {@link #availableDimensions()} + * MUST be one of {@link #getAvailableDimensions()} * @return The preferred default dimension of this world (typically the overworld) */ - public abstract Optional defaultDimension(); + public abstract Optional getDefaultDimension(); /** - * @param dimensionId The dimension to load, guaranteed to be one of the dimensions previously returned by {@link #availableDimensions()} + * @param dimensionId The dimension to load, guaranteed to be one of the dimensions previously returned by {@link #getAvailableDimensions()} * @return The loaded dimension */ public abstract Dimension loadDimension(Dimension.Identifier dimensionId); diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java b/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java index 72a9abf130..c662f912e7 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java @@ -59,7 +59,7 @@ private Map getEntityTags(Set request) throws ChunkLoadingE @Override public synchronized boolean loadChunk(@NotNull Mutable chunkData, int yMin, int yMax) { - if (!shouldReloadChunk()) { + if (!shouldReload()) { return false; } @@ -85,7 +85,7 @@ public synchronized boolean loadChunk(@NotNull Mutable chunkData, int Tag data = tagFromMap(dataMap); surfaceTimestamp = dataTimestamp; - version = chunkVersion(data); + version = getChunkVersion(data); HeightRange chunkBounds = inclusiveChunkBounds(data); chunkData.set(this.dimension.createChunkData(chunkData.get(), chunkBounds.min(), chunkBounds.max())); loadSurface(data, chunkData.get(), yMin, yMax); @@ -137,7 +137,7 @@ private int[] extractHeightmapData(@NotNull Tag data, ChunkData chunkData) { } } - protected boolean shouldReloadChunk() { + protected boolean shouldReload() { int timestamp = Integer.MAX_VALUE; timestamp = Math.min(timestamp, surfaceTimestamp); timestamp = Math.min(timestamp, biomesTimestamp); @@ -149,7 +149,7 @@ protected boolean shouldReloadChunk() { } /** Detect Minecraft version that generated the chunk. */ - private static ChunkVersion chunkVersion(@NotNull Tag data) { + private static ChunkVersion getChunkVersion(@NotNull Tag data) { Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); if (sections.isList()) { for (SpecificTag section : sections.asList()) { @@ -297,7 +297,7 @@ public synchronized void getChunkData(@NotNull Mutable reuseChunkData } ChunkData chunkData = reuseChunkData.get(); //unwrap mutable, for ease of use - version = chunkVersion(data); + version = getChunkVersion(data); Tag sections = getTagFromNames(data, LEVEL_SECTIONS, SECTIONS_POST_21W39A); Tag entitiesTag = data.get(LEVEL_ENTITIES); Tag tileEntitiesTag = getTagFromNames(data, LEVEL_TILEENTITIES, BLOCK_ENTITIES_POST_21W43A); diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java index 81ea9b91de..41650e5ca3 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java @@ -54,7 +54,7 @@ public synchronized Region getRegion(RegionPosition pos) { return regionMap.computeIfAbsent(pos.getLong(), p -> { // check if the region is present in the world directory Region region = EmptyRegion.instance; - if (regionExists(pos)) { + if (hasRegion(pos)) { region = createRegion(pos); } return region; diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java index 7690c35cc5..c905cb1389 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java @@ -129,7 +129,7 @@ public static World loadWorld(File worldDirectory, LoggedWarnings warnings) { } @Override - public Set availableDimensions() { + public Set getAvailableDimensions() { return Set.of(Dimension.Identifier.OVERWORLD, Dimension.Identifier.THE_NETHER, Dimension.Identifier.THE_END @@ -137,7 +137,7 @@ public Set availableDimensions() { } @Override - public Optional defaultDimension() { + public Optional getDefaultDimension() { return Optional.empty(); } @@ -246,7 +246,7 @@ synchronized boolean reloadPlayerData() { return true; } - public static boolean isWorldDir(File worldDir) { + public static boolean isWorldDirectory(File worldDir) { if (worldDir != null && worldDir.isDirectory()) { File levelDat = new File(worldDir, "level.dat"); return levelDat.exists() && levelDat.isFile(); diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java index 0dee2c66f8..be115dd37e 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java @@ -24,7 +24,7 @@ public String getId() { @Override public boolean isValid(Path path) { - return JavaWorld.isWorldDir(path.toFile()); + return JavaWorld.isWorldDirectory(path.toFile()); } @Override From 9e1c958f06c30e3a49f20cfdd6db82a03fe6d48e Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sun, 26 Jul 2026 21:04:45 +0100 Subject: [PATCH 08/57] Move player data timestamp into JavaWorld # Conflicts: # chunky/src/java/se/llbit/chunky/world/EmptyWorld.java --- chunky/src/java/se/llbit/chunky/world/EmptyWorld.java | 3 ++- chunky/src/java/se/llbit/chunky/world/World.java | 7 +------ .../src/java/se/llbit/chunky/world/java/JavaWorld.java | 10 +++++++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java b/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java index c75d8ddc9e..149f18337c 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java @@ -31,7 +31,7 @@ public class EmptyWorld extends World { public static final EmptyWorld INSTANCE = new EmptyWorld(); private EmptyWorld() { - super("[empty world]", null, 0, -1); + super("[empty world]", null, 0); this.currentDimension = new EmptyDimension(); } @@ -47,6 +47,7 @@ public Optional getDefaultDimension() { @Override public Dimension loadDimension(Dimension.Identifier dimensionId) { + // no-op return this.currentDimension; } diff --git a/chunky/src/java/se/llbit/chunky/world/World.java b/chunky/src/java/se/llbit/chunky/world/World.java index d671031658..7f27fc4f39 100644 --- a/chunky/src/java/se/llbit/chunky/world/World.java +++ b/chunky/src/java/se/llbit/chunky/world/World.java @@ -38,20 +38,15 @@ public abstract class World implements Comparable { protected int gameMode = 0; protected final long seed; - /** Timestamp for level.dat when player data was last loaded. */ - protected long timestamp; - /** * @param levelName name of the world (not the world directory). * @param worldDirectory Minecraft world directory. * @param seed - * @param timestamp */ - protected World(String levelName, File worldDirectory, long seed, long timestamp) { + protected World(String levelName, File worldDirectory, long seed) { this.levelName = levelName; this.worldDirectory = worldDirectory; this.seed = seed; - this.timestamp = timestamp; } public enum LoggedWarnings { diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java index c905cb1389..5255370ec9 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java @@ -42,6 +42,9 @@ public Optional getSingleplayerPlayerUuid() { return Optional.ofNullable(singleplayerPlayerUuid); } + /** Timestamp of when player data was last loaded. */ + protected long playerDataTimestamp; + /** * @param levelName name of the world (not the world directory). * @param worldDirectory Minecraft world directory. @@ -49,10 +52,11 @@ public Optional getSingleplayerPlayerUuid() { * @param timestamp */ protected JavaWorld(String levelName, File worldDirectory, long seed, long timestamp, int versionId, Set playerEntities, Vector3i spawnPos) { - super(levelName, worldDirectory, seed, timestamp); + super(levelName, worldDirectory, seed); this.versionId = versionId; this.playerEntities = playerEntities; this.spawnPos = spawnPos; + this.playerDataTimestamp = timestamp; } /** @@ -216,11 +220,11 @@ synchronized boolean reloadPlayerData() { } File worldFile = new File(worldDirectory, "level.dat"); long lastModified = worldFile.lastModified(); - if (lastModified == timestamp) { + if (lastModified == playerDataTimestamp) { return false; } Log.infof("world %s: timestamp updated: reading player data", levelName); - timestamp = lastModified; + playerDataTimestamp = lastModified; try (FileInputStream fin = new FileInputStream(worldFile); InputStream gzin = new GZIPInputStream(fin); From 6a65f44bfbeb5969ef6f8b07d830885d9fd38e14 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sun, 28 Jun 2026 11:24:20 +0100 Subject: [PATCH 09/57] MC->Java rename misc. cleanup --- .../src/java/se/llbit/chunky/world/java/JavaWorld.java | 10 +++++----- .../world/java/region/JavaRegionChangeWatcher.java | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java index 5255370ec9..e51b8572be 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java @@ -24,7 +24,7 @@ public class JavaWorld extends World { public static final int VERSION_21W06A = 2694; public static final int VERSION_1_12_2 = 1343; - protected int versionId; + protected final int versionId; /** * In a java world spawn position is per-world and not per-dimension, so we store it here. @@ -38,10 +38,6 @@ public class JavaWorld extends World { protected UUID singleplayerPlayerUuid; - public Optional getSingleplayerPlayerUuid() { - return Optional.ofNullable(singleplayerPlayerUuid); - } - /** Timestamp of when player data was last loaded. */ protected long playerDataTimestamp; @@ -178,6 +174,10 @@ private static Dimension loadDimension(JavaWorld world, File worldDirectory, Dim } } + public Optional getSingleplayerPlayerUuid() { + return Optional.ofNullable(singleplayerPlayerUuid); + } + @NotNull private static Set getPlayerEntityData(File worldDirectory, Tag player) { Set playerEntities = new HashSet<>(); diff --git a/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegionChangeWatcher.java b/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegionChangeWatcher.java index 9e469a3972..887f9c1b1b 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegionChangeWatcher.java +++ b/chunky/src/java/se/llbit/chunky/world/java/region/JavaRegionChangeWatcher.java @@ -41,7 +41,7 @@ public JavaRegionChangeWatcher(WorldMapLoader loader, MapView mapView) { try { while (!isInterrupted()) { sleep(3000); - // MCRegionChangeWatcher is only created by JavaDimension, so this cast is always safe. + // RegionChangeWatcher is only created by JavaDimension, so this cast is always safe. JavaDimension dimension = (JavaDimension) mapLoader.getWorld().currentDimension(); if (dimension.reloadPlayerData()) { if (PersistentSettings.getFollowPlayer()) { From bf72013de65ef58ec19d799c8f1fd78db484962f Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sun, 28 Jun 2026 11:24:40 +0100 Subject: [PATCH 10/57] Add more detailed javadoc to WorldFormat apis --- .../chunky/world/worldformat/WorldFormat.java | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java index 7eebf73e13..27bfbe5427 100644 --- a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java @@ -6,10 +6,23 @@ import java.io.IOException; import java.nio.file.Path; -/** For worlds that have multiple dimensions, and fully support the map view */ +/** + * A {@link WorldFormat} represents a path on disk that can be loaded into chunky as a {@link World} + * + *

Implementations should be stateless, and never cache world validity.

+ * + *

Implementations will be queried for many different paths via {@link #isValid(Path)}. + * The same {@link Path} is likely to be checked more than once over the lifetime of the {@link WorldFormat}

+ * + *

Impls. are guaranteed that paths given to {@link #loadWorld(Path)} will already be validated + * through their {@link #isValid(Path)}

+ */ public interface WorldFormat extends Registerable { /** - * This method will be called on every possible world directory (typically this is every directory in `.minecraft/saves`). + * Determine whether a {@link Path} is valid for this {@link WorldFormat} + * + *

This method will be called on every possible world directory + * (typically this is every directory in `.minecraft/saves`).

* * @param path The path to the world. * @return Whether this is a valid world under this world format. @@ -17,7 +30,11 @@ public interface WorldFormat extends Registerable { boolean isValid(Path path); /** - * Load the world at the given path + * Load the world at the given path. + * + *

Calls to this method do not indicate that any blocks will be loaded from the world. As such implementations + * should do minimal work to load the metadata for a world.

+ * * @param path The path to the world. * @return The loaded world * @throws IOException When something goes wrong when loading the world. From aca6bb3120069ac687bb276e93cc37a040609c4a Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Tue, 30 Jun 2026 11:31:11 +0100 Subject: [PATCH 11/57] Move isWorldDirectory into JavaWorldFormat --- chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java | 8 -------- .../java/se/llbit/chunky/world/java/JavaWorldFormat.java | 7 ++++++- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java index e51b8572be..8041785e55 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java @@ -249,12 +249,4 @@ synchronized boolean reloadPlayerData() { } return true; } - - public static boolean isWorldDirectory(File worldDir) { - if (worldDir != null && worldDir.isDirectory()) { - File levelDat = new File(worldDir, "level.dat"); - return levelDat.exists() && levelDat.isFile(); - } - return false; - } } diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java index be115dd37e..7ece53c914 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java @@ -4,6 +4,7 @@ import se.llbit.chunky.world.worldformat.WorldFormat; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; public class JavaWorldFormat implements WorldFormat { @@ -24,7 +25,11 @@ public String getId() { @Override public boolean isValid(Path path) { - return JavaWorld.isWorldDirectory(path.toFile()); + if (Files.isDirectory(path)) { + Path levelDat = path.resolve("level.dat"); + return Files.exists(levelDat) && Files.isRegularFile(levelDat); + } + return false; } @Override From f7af0a2e6a8f710ff634d2aa0a64573e67de4099 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Tue, 30 Jun 2026 11:32:09 +0100 Subject: [PATCH 12/57] Make WorldFormat method arguments NotNull --- .../src/java/se/llbit/chunky/world/java/JavaWorldFormat.java | 5 +++-- .../java/se/llbit/chunky/world/worldformat/WorldFormat.java | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java index 7ece53c914..6890846da7 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java @@ -2,6 +2,7 @@ import se.llbit.chunky.world.World; import se.llbit.chunky.world.worldformat.WorldFormat; +import se.llbit.util.annotation.NotNull; import java.io.IOException; import java.nio.file.Files; @@ -24,7 +25,7 @@ public String getId() { } @Override - public boolean isValid(Path path) { + public boolean isValid(@NotNull Path path) { if (Files.isDirectory(path)) { Path levelDat = path.resolve("level.dat"); return Files.exists(levelDat) && Files.isRegularFile(levelDat); @@ -33,7 +34,7 @@ public boolean isValid(Path path) { } @Override - public World loadWorld(Path path) throws IOException { + public World loadWorld(@NotNull Path path) throws IOException { return JavaWorld.loadWorld(path.toFile(), World.LoggedWarnings.SILENT); } } diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java index 27bfbe5427..55afe02a5a 100644 --- a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java @@ -2,6 +2,7 @@ import se.llbit.chunky.world.World; import se.llbit.util.Registerable; +import se.llbit.util.annotation.NotNull; import java.io.IOException; import java.nio.file.Path; @@ -27,7 +28,7 @@ public interface WorldFormat extends Registerable { * @param path The path to the world. * @return Whether this is a valid world under this world format. */ - boolean isValid(Path path); + boolean isValid(@NotNull Path path); /** * Load the world at the given path. @@ -39,5 +40,5 @@ public interface WorldFormat extends Registerable { * @return The loaded world * @throws IOException When something goes wrong when loading the world. */ - World loadWorld(Path path) throws IOException; + World loadWorld(@NotNull Path path) throws IOException; } \ No newline at end of file From 73032d2de12545105fe6122e2754efafe328e7a4 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Wed, 8 Jul 2026 02:35:07 +0100 Subject: [PATCH 13/57] Adjust WorldFormat Javadoc --- .../java/se/llbit/chunky/world/worldformat/WorldFormat.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java index 55afe02a5a..517fff0250 100644 --- a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java @@ -8,15 +8,13 @@ import java.nio.file.Path; /** - * A {@link WorldFormat} represents a path on disk that can be loaded into chunky as a {@link World} + * A {@link WorldFormat} determines whether paths on disk are valid for its world type, and can then load that + * {@link World} into chunky. * *

Implementations should be stateless, and never cache world validity.

* *

Implementations will be queried for many different paths via {@link #isValid(Path)}. * The same {@link Path} is likely to be checked more than once over the lifetime of the {@link WorldFormat}

- * - *

Impls. are guaranteed that paths given to {@link #loadWorld(Path)} will already be validated - * through their {@link #isValid(Path)}

*/ public interface WorldFormat extends Registerable { /** From ea1dfd9f92c211cf6fb0288b0f2663446c9c9ff7 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sun, 26 Jul 2026 21:17:21 +0100 Subject: [PATCH 14/57] Refactor World metadata into World.Info to avoid World object creation. # Conflicts: # chunky/src/java/se/llbit/chunky/world/EmptyWorld.java --- .../se/llbit/chunky/map/WorldMapLoader.java | 60 ++++-- .../se/llbit/chunky/renderer/scene/Scene.java | 27 ++- .../ui/controller/ChunkyFxController.java | 18 +- .../ui/controller/WorldChooserController.java | 49 +++-- .../se/llbit/chunky/world/CubicDimension.java | 4 +- .../java/se/llbit/chunky/world/Dimension.java | 15 +- .../se/llbit/chunky/world/EmptyWorld.java | 42 +++- .../src/java/se/llbit/chunky/world/World.java | 94 ++++----- .../chunky/world/java/JavaDimension.java | 7 +- .../se/llbit/chunky/world/java/JavaWorld.java | 193 ++++++++++-------- .../chunky/world/java/JavaWorldFormat.java | 28 ++- .../chunky/world/worldformat/WorldFormat.java | 19 +- .../world/worldformat/WorldFormats.java | 53 ++--- .../se/llbit/chunky/PersistentSettings.java | 10 + 14 files changed, 364 insertions(+), 255 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java b/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java index f7509a0099..93b0c915cf 100644 --- a/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java +++ b/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java @@ -22,6 +22,7 @@ import se.llbit.chunky.renderer.ChunkViewListener; import se.llbit.chunky.ui.controller.ChunkyFxController; import se.llbit.chunky.world.*; +import se.llbit.chunky.world.java.JavaWorldFormat; import se.llbit.chunky.world.region.RegionChangeWatcher; import se.llbit.chunky.world.region.RegionParser; import se.llbit.chunky.world.region.RegionQueue; @@ -68,20 +69,27 @@ public WorldMapLoader(ChunkyFxController controller, MapView mapView) { topographyUpdater.start(); } - public void loadWorldFromDirectory(@Nullable File worldLocation) { - if (worldLocation == null) { + public void loadWorldFromDirectory(@Nullable File worldLocation, @Nullable String worldFormatId) { + if (worldLocation != null) { + if (worldFormatId == null || worldFormatId.isEmpty()) { + worldFormatId = JavaWorldFormat.ID; + } + Optional info = WorldFormats.getWorldFormat(worldFormatId) + .flatMap(format -> format.getWorldInfo(worldLocation.toPath())) // attempt to get the given format + .or(() -> WorldFormats.getInfos(worldLocation.toPath()).stream().findFirst()); // get any format + info.ifPresent(this::loadWorld); return; } - this.loadWorld(WorldFormats.createWorld(worldLocation).orElse(EmptyWorld.INSTANCE)); + setWorld(EmptyWorld.INSTANCE); } + /** - * This is called when a new world is loaded + * Load the world referred to by the {@link World.Info} + * + * @return The loaded world. May be {@link EmptyWorld} if loading failed. */ - public void loadWorld(World newWorld) { - if (this.world != null) { - this.world.currentDimension().removeChunkTopographyListener(this); - } - boolean isSameWorld = !(this.world instanceof EmptyWorld) && newWorld.getWorldDirectory().equals(this.world.getWorldDirectory()); + public World loadWorld(World.Info info) { + World newWorld = WorldFormats.createWorld(info); Optional dimensionToLoad = Optional.of(world.currentDimension()) .map(Dimension::getDimensionId) @@ -89,21 +97,45 @@ public void loadWorld(World newWorld) { .or(newWorld::getDefaultDimension) .or(() -> newWorld.getAvailableDimensions().stream().findFirst()); - if (dimensionToLoad.isEmpty()) { - Log.infof("No dimension loaded for world %s", newWorld.toString()); - return; + if (dimensionToLoad.isPresent()) { + newWorld.loadDimension(dimensionToLoad.get()); + } else { + Log.infof("No dimension loaded for world %s", info.toString()); + } + + setWorld(newWorld); + return this.world; + } + + /** + * Sets the map view world. + *

This is intended to be called with worlds with a dimension already loaded, as it will not trigger dimension + * loading.

+ * + * @param newWorld The world to set + */ + public void setWorld(World newWorld) { + if (this.world != null) { + this.world.currentDimension().removeChunkTopographyListener(this); + } + + boolean isSameWorld = !(this.world instanceof EmptyWorld) && newWorld.getInfo().path().equals(this.world.getInfo().path()); + + Dimension loadedDim = newWorld.currentDimension(); + if (loadedDim == EmptyWorld.INSTANCE.currentDimension()) { + Log.warn("Map view world was set but it has no dimension!"); } - Dimension loadedDim = newWorld.loadDimension(dimensionToLoad.get()); loadedDim.addChunkTopographyListener(this); synchronized (this) { this.world = newWorld; updateRegionChangeWatcher(loadedDim); - File newWorldDir = this.world.getWorldDirectory(); + File newWorldDir = this.world.getInfo().path().toFile(); if (!newWorldDir.equals(PersistentSettings.getLastWorld())) { PersistentSettings.setLastWorld(newWorldDir); } + PersistentSettings.setLastWorldFormat(newWorld.getInfo().worldFormat().getId()); } worldLoadListeners.forEach(listener -> listener.accept(newWorld, isSameWorld)); } diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java index c0996af207..c4b923dffc 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java @@ -53,6 +53,7 @@ import se.llbit.chunky.world.biome.Biome; import se.llbit.chunky.world.biome.BiomePalette; import se.llbit.chunky.world.biome.Biomes; +import se.llbit.chunky.world.java.JavaWorldFormat; import se.llbit.chunky.world.region.Region; import se.llbit.chunky.world.worldformat.WorldFormats; import se.llbit.json.*; @@ -68,6 +69,7 @@ import se.llbit.util.mojangapi.MinecraftProfile; import java.io.*; +import java.nio.file.Path; import java.text.SimpleDateFormat; import java.util.*; import java.util.concurrent.ExecutionException; @@ -193,6 +195,7 @@ public class Scene implements JsonSerializable { */ protected int rayDepth = PersistentSettings.getRayDepthDefault(); protected String worldPath = ""; + protected String worldFormat = JavaWorldFormat.ID; protected Dimension.Identifier worldDimension = Dimension.Identifier.OVERWORLD; protected RenderMode mode = RenderMode.PREVIEW; protected int dumpFrequency = DEFAULT_DUMP_FREQUENCY; @@ -405,6 +408,7 @@ public synchronized void copyState(Scene other, boolean copyChunks) { if (copyChunks) { loadedWorld = other.loadedWorld; worldPath = other.worldPath; + worldFormat = other.worldFormat; worldDimension = other.worldDimension; // The octree reference is overwritten to save time. @@ -547,14 +551,17 @@ public synchronized void loadScene(RenderContext context, String sceneName, Task loadedWorld = EmptyWorld.INSTANCE; if (!worldPath.isEmpty()) { - File worldDirectory = new File(worldPath); - Optional newWorld = WorldFormats.createWorld(worldDirectory); - if (newWorld.isPresent()) { - loadedWorld = newWorld.get(); + Path worldDirectory = Path.of(worldPath); + Optional info = WorldFormats.getWorldFormat(worldFormat) // only try to load the world as its known world format. + .flatMap(format -> format.getWorldInfo(worldDirectory)); + + if (info.isPresent()) { + World newWorld = WorldFormats.createWorld(info.get()); + loadedWorld = newWorld; loadedWorld.loadDimension(this.worldDimension); - } else { - Log.info("Could not load world: " + worldPath); - loadedWorld = EmptyWorld.INSTANCE; + if (newWorld == EmptyWorld.INSTANCE) { + Log.info("Could not load world: " + worldPath); + } } } @@ -807,7 +814,8 @@ public synchronized void loadChunks(TaskTracker taskTracker, World world, Map fileChooser.setInitialFileName(world.levelName() + ".png")); + mapLoader.withWorld(world -> fileChooser.setInitialFileName(world.getInfo().name() + ".png")); if (prevPngDir != null) { fileChooser.setInitialDirectory(prevPngDir.toFile()); } @@ -327,7 +321,7 @@ public void exportMapView() { World newWorld = scene.getWorld(); World currentWorld = mapLoader.getWorld(); boolean isSameWorld = currentWorld != EmptyWorld.INSTANCE && - currentWorld.getWorldDirectory().equals(newWorld.getWorldDirectory()); + currentWorld.getInfo().isSameWorld(newWorld.getInfo()); if (isSameWorld) { getChunkSelection().setSelection(chunky.getSceneManager().getScene().getChunks()); @@ -341,7 +335,7 @@ public void exportMapView() { "This scene shows a different world than the one that is currently loaded. Do you want to load the world of this scene?"); Dialogs.stayOnTop(loadWorldConfirm); if (loadWorldConfirm.showAndWait().orElse(ButtonType.CANCEL) == ButtonType.YES) { - mapLoader.loadWorld(newWorld); + mapLoader.setWorld(newWorld); getChunkSelection().setSelection(chunky.getSceneManager().getScene().getChunks()); } } @@ -473,7 +467,7 @@ public File getSceneFile(String fileName) { ignoreYUpdate.set(false); } map.redrawMap(); - mapName.setText(world.levelName()); + mapName.setText(world.getInfo().name()); showWorldMap(); }); }); @@ -652,7 +646,7 @@ public File getSceneFile(String fileName) { mapOverlay.setOnKeyPressed(map::onKeyPressed); mapOverlay.setOnKeyReleased(map::onKeyReleased); - mapLoader.loadWorldFromDirectory(PersistentSettings.getLastWorld()); + mapLoader.loadWorldFromDirectory(PersistentSettings.getLastWorld(), PersistentSettings.getLastWorldFormat()); HeightRange heightRange = mapLoader.getWorld().currentDimension().heightRange(); mapView.setYMin(heightRange.min()); mapView.setYMax(heightRange.max()); diff --git a/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java b/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java index da6858199a..6df657d0bb 100644 --- a/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java +++ b/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java @@ -49,17 +49,17 @@ public class WorldChooserController implements Initializable { @FXML private Label statusLabel; - @FXML private TableView worldTbl; + @FXML private TableView worldTbl; - @FXML private TableColumn worldNameCol; + @FXML private TableColumn worldNameCol; - @FXML private TableColumn worldDirCol; + @FXML private TableColumn worldDirCol; - @FXML private TableColumn gameModeCol; + @FXML private TableColumn gameModeCol; - @FXML private TableColumn seedCol; + @FXML private TableColumn seedCol; - @FXML public TableColumn modifiedCol; + @FXML public TableColumn modifiedCol; @FXML private Button changeWorldDirBtn; @@ -70,15 +70,15 @@ public class WorldChooserController implements Initializable { @Override public void initialize(URL location, ResourceBundle resources) { worldNameCol - .setCellValueFactory(data -> new ReadOnlyStringWrapper(data.getValue().levelName())); + .setCellValueFactory(data -> new ReadOnlyStringWrapper(data.getValue().name())); worldDirCol.setCellValueFactory( - data -> new ReadOnlyStringWrapper(data.getValue().getWorldDirectory().getName())); + data -> new ReadOnlyStringWrapper(data.getValue().path().getFileName().toString())); gameModeCol.setCellValueFactory(data -> new ReadOnlyStringWrapper(data.getValue().gameMode())); - seedCol.setCellValueFactory(data -> new ReadOnlyLongWrapper(data.getValue().getSeed())); + seedCol.setCellValueFactory(data -> new ReadOnlyLongWrapper(data.getValue().seed())); DateFormat localeFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT); - modifiedCol.setCellValueFactory(data -> new ReadOnlyObjectWrapper<>(data.getValue().getLastModified())); - modifiedCol.setCellFactory(col -> new TableCell() { + modifiedCol.setCellValueFactory(data -> new ReadOnlyObjectWrapper<>(new Date(data.getValue().lastModified()))); + modifiedCol.setCellFactory(col -> new TableCell<>() { public void updateItem(Date item, boolean empty) { if (item == this.getItem()) return; super.updateItem(item, empty); @@ -97,7 +97,7 @@ public void setStage(Stage stage) { */ public void populate(WorldMapLoader mapLoader) { worldTbl.setRowFactory(tbl -> { - TableRow row = new TableRow<>(); + TableRow row = new TableRow<>(); row.setOnMouseClicked(e -> { if (e.getClickCount() == 2 && !row.isEmpty()) { this.loadWorld(row.getItem(), mapLoader); @@ -136,7 +136,12 @@ public void populate(WorldMapLoader mapLoader) { File directory = chooser.showDialog(stage); if (directory != null) { if (directory.isDirectory()) { - this.loadWorld(WorldFormats.createWorld(directory).orElse(EmptyWorld.INSTANCE), mapLoader); + Optional info = WorldFormats.getInfos(directory.toPath()).stream().findFirst(); // TODO: could ask which world format to load as + if (info.isPresent()) { + this.loadWorld(info.get(), mapLoader); + } else { + mapLoader.setWorld(EmptyWorld.INSTANCE); + } stage.close(); } else { Log.warn("Non-directory selected."); @@ -151,8 +156,9 @@ public void populate(WorldMapLoader mapLoader) { }); } - private void loadWorld(World world, WorldMapLoader mapLoader) { - world.getResourcePack() + private void loadWorld(World.Info info, WorldMapLoader mapLoader) { + mapLoader.loadWorld(info) + .getResourcePack() .ifPresent(worldResourcePack -> { List currentlyLoadedPacks = new ArrayList<>(ResourcePackLoader.getLoadedResourcePacks()); @@ -162,7 +168,7 @@ private void loadWorld(World world, WorldMapLoader mapLoader) { loadTexturesConfirm.getButtonTypes().addAll(ButtonType.YES, ButtonType.NO); loadTexturesConfirm.setTitle("Bundled resource pack"); loadTexturesConfirm.setContentText( - "The world \"" + world.levelName() + "\" contains a resource pack. Do you want to load it now?"); + "The world \"" + info.name() + "\" contains a resource pack. Do you want to load it now?"); Dialogs.stayOnTop(loadTexturesConfirm); if (loadTexturesConfirm.showAndWait().orElse(ButtonType.CANCEL) == ButtonType.YES) { @@ -172,7 +178,6 @@ private void loadWorld(World world, WorldMapLoader mapLoader) { } } }); - mapLoader.loadWorld(world); } /** @@ -189,15 +194,15 @@ private void fillWorldList(final File worldSavesDir) { statusLabel.setText("Loading worlds list..."); disableControls(true); - Task> loadWorldsTask = new Task<>() { + Task> loadWorldsTask = new Task<>() { @Override - protected List call() { - List worlds = new ArrayList<>(); + protected List call() { + List worlds = new ArrayList<>(); if (worldSavesDir != null) { File[] worldDirs = worldSavesDir.listFiles(); if (worldDirs != null) { for (File dir : worldDirs) { - WorldFormats.createWorld(dir).ifPresent(worlds::add); + worlds.addAll(WorldFormats.getInfos(dir.toPath())); } } } @@ -206,7 +211,7 @@ protected List call() { }; loadWorldsTask.setOnSucceeded((WorkerStateEvent event) -> { - List worlds = loadWorldsTask.getValue(); + List worlds = loadWorldsTask.getValue(); worldTbl.setItems(FXCollections.observableArrayList(worlds)); if (!worlds.isEmpty()) { diff --git a/chunky/src/java/se/llbit/chunky/world/CubicDimension.java b/chunky/src/java/se/llbit/chunky/world/CubicDimension.java index 5fc85bef76..f2e17d6443 100644 --- a/chunky/src/java/se/llbit/chunky/world/CubicDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/CubicDimension.java @@ -26,7 +26,7 @@ public class CubicDimension extends JavaDimension { /** * @param dimensionDirectory Minecraft world directory. */ - public CubicDimension(JavaWorld world, Dimension.Identifier dimensionId, File dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { + public CubicDimension(JavaWorld world, Dimension.Identifier dimensionId, Path dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { super(world, dimensionId, dimensionDirectory, playerEntities, spawnPos); } @@ -35,7 +35,7 @@ public CubicDimension(JavaWorld world, Dimension.Identifier dimensionId, File di */ @Override public synchronized File getRegionDirectory() { - return new File(dimensionDirectory, "region3d"); + return dimensionDirectory.resolve("region3d").toFile(); } @Override diff --git a/chunky/src/java/se/llbit/chunky/world/Dimension.java b/chunky/src/java/se/llbit/chunky/world/Dimension.java index cfae48e89c..7a42e35d8b 100644 --- a/chunky/src/java/se/llbit/chunky/world/Dimension.java +++ b/chunky/src/java/se/llbit/chunky/world/Dimension.java @@ -15,7 +15,7 @@ import se.llbit.math.Vector3i; import se.llbit.util.annotation.Nullable; -import java.io.File; +import java.nio.file.Path; import java.util.*; /** @@ -61,7 +61,7 @@ public String toString() { } } - protected final File dimensionDirectory; + protected final Path dimensionDirectory; protected final Heightmap heightmap = new Heightmap(); @@ -77,7 +77,7 @@ public String toString() { /** * @param dimensionDirectory Minecraft world directory. */ - protected Dimension(Identifier dimensionId, File dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { + protected Dimension(Identifier dimensionId, Path dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { this.dimensionId = dimensionId; this.playerEntities = playerEntities; this.dimensionDirectory = dimensionDirectory; @@ -95,10 +95,11 @@ public Identifier getDimensionId() { /** * Get the data directory for the given dimension. + *

This may be the same as the world directory for some dimensions

* - * @return File object pointing to the data directory + * @return The path to the dimension */ - protected synchronized File getDimensionDirectory() { + protected synchronized Path getDimensionPath() { return dimensionDirectory; } @@ -170,10 +171,6 @@ public Optional getSpawnPosition() { return Optional.ofNullable(this.spawnPos); } - public Date getLastModified() { - return new Date(this.dimensionDirectory.lastModified()); - } - /** * Reload player data. * diff --git a/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java b/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java index 149f18337c..100993cc70 100644 --- a/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java +++ b/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java @@ -16,6 +16,10 @@ */ package se.llbit.chunky.world; +import se.llbit.chunky.world.worldformat.WorldFormat; +import se.llbit.util.annotation.NotNull; + +import java.nio.file.Path; import java.util.Collections; import java.util.Optional; import java.util.Set; @@ -31,8 +35,42 @@ public class EmptyWorld extends World { public static final EmptyWorld INSTANCE = new EmptyWorld(); private EmptyWorld() { - super("[empty world]", null, 0); - this.currentDimension = new EmptyDimension(); + super(new World.Info("[empty world]", Path.of(""), 0, 0, "Survival", + new WorldFormat() { + @Override + public boolean isValid(@NotNull Path path) { + return false; + } + + @NotNull + @Override + public Optional getWorldInfo(@NotNull Path path) { + return Optional.empty(); + } + + @NotNull + @Override + public World loadWorld(@NotNull Info info) { + return EmptyWorld.INSTANCE; + } + + @Override + public String getName() { + return "Empty World Format"; + } + + @Override + public String getDescription() { + return ""; + } + + @Override + public String getId() { + return ""; + } + }), + new EmptyDimension() + ); } @Override diff --git a/chunky/src/java/se/llbit/chunky/world/World.java b/chunky/src/java/se/llbit/chunky/world/World.java index 7f27fc4f39..54785870cc 100644 --- a/chunky/src/java/se/llbit/chunky/world/World.java +++ b/chunky/src/java/se/llbit/chunky/world/World.java @@ -16,37 +16,50 @@ */ package se.llbit.chunky.world; +import se.llbit.chunky.world.worldformat.WorldFormat; +import se.llbit.util.annotation.NotNull; + import java.io.*; +import java.nio.file.Path; import java.util.*; /** - * The World class contains information about the currently viewed world. - * It has a map of all chunks in the world and is responsible for parsing - * chunks when needed. All rendering is done through the WorldRenderer class. + * The World class contains {@link Info metadata} about itself, and methods for querying and loading its dimensions. + * It also contains the {@link #currentDimension() currently loaded dimension} if there is one. * * @author Jesper Öqvist */ -public abstract class World implements Comparable { +public abstract class World { + /** + * Metadata about a world. + */ + public record Info(String name, Path path, long lastModified, long seed, String gameMode, WorldFormat worldFormat) { + /** + * Chunky sees each valid {@link Info#path()} and {@link Info#worldFormat()} combination as a distinct world which + * can be selected by the user. + */ + public boolean isSameWorld(Info other) { + return this.worldFormat == other.worldFormat && this.path.equals(other.path); + } + } + /** Default sea water level. */ public static final int SEA_LEVEL = 63; - protected final File worldDirectory; + private final Info info; + @NotNull protected Dimension currentDimension; - protected final String levelName; - protected int gameMode = 0; - protected final long seed; + protected World(Info info) { + this.info = info; + this.currentDimension = EmptyWorld.INSTANCE.currentDimension(); + } - /** - * @param levelName name of the world (not the world directory). - * @param worldDirectory Minecraft world directory. - * @param seed - */ - protected World(String levelName, File worldDirectory, long seed) { - this.levelName = levelName; - this.worldDirectory = worldDirectory; - this.seed = seed; + /** Only for use by {@link EmptyWorld} */ + protected World(Info info, @NotNull EmptyDimension dimension) { + this.info = info; + this.currentDimension = dimension; } public enum LoggedWarnings { @@ -55,8 +68,8 @@ public enum LoggedWarnings { } /** - * The dimensions returned here are later provided to {@link #loadDimension(Dimension.Identifier)} when requesting a dimension be - * loaded. + * The dimensions returned here are later provided to {@link #loadDimension(Dimension.Identifier)} when requesting a + * dimension be loaded. * * @return List the viewable dimensions within the world. */ @@ -75,50 +88,19 @@ public enum LoggedWarnings { public abstract Dimension loadDimension(Dimension.Identifier dimensionId); /** - * @return The current dimension + * @return The current dimension or {@link EmptyDimension#INSTANCE} if there isn't one. */ + @NotNull public synchronized Dimension currentDimension() { return this.currentDimension; } - /** - * @return The world directory - */ - public File getWorldDirectory() { - return worldDirectory; + public Info getInfo() { + return this.info; } @Override public String toString() { - return levelName + " (" + worldDirectory.getName() + ")"; - } - - /** The name of this world (not the world directory name). */ - public String levelName() { - return levelName; - } - - /** - * @return String describing the game-mode of this world - */ - public String gameMode() { - return switch (gameMode) { - case 0 -> "Survival"; - case 1 -> "Creative"; - case 2 -> "Adventure"; - default -> "Unknown"; - }; - } - @Override public int compareTo(World o) { - // Compares world names and directories. - return toString().compareToIgnoreCase(o.toString()); - } - - public long getSeed() { - return seed; - } - - public Date getLastModified() { - return new Date(this.worldDirectory.lastModified()); + return info.name + " (" + info.path.getFileName() + ")"; } /** @@ -127,7 +109,7 @@ public Date getLastModified() { * @return Resource pack file/directory or empty optional if this world has no bundled resource pack */ public Optional getResourcePack() { - for (File resourcepacksDirectory : new File[]{getWorldDirectory(), new File(getWorldDirectory(), "resourcepacks")}) { + for (File resourcepacksDirectory : new File[]{ info.path.toFile(), new File(info.path.toFile(), "resourcepacks")}) { if (resourcepacksDirectory.isDirectory()) { File resourcePack = new File(resourcepacksDirectory, "resources.zip"); if (resourcePack.isFile()) { diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java index 41650e5ca3..dcfb177990 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java @@ -13,6 +13,7 @@ import se.llbit.util.annotation.Nullable; import java.io.File; +import java.nio.file.Path; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -27,7 +28,7 @@ public class JavaDimension extends Dimension { * @param dimensionDirectory Minecraft world directory. * @param playerEntities */ - protected JavaDimension(JavaWorld world, Identifier dimensionId, File dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { + protected JavaDimension(JavaWorld world, Identifier dimensionId, Path dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { super(dimensionId, dimensionDirectory, playerEntities, spawnPos); this.world = world; } @@ -128,11 +129,11 @@ public void regionDiscovered(RegionPosition pos) { * @return File object pointing to the region file directory */ public synchronized File getRegionDirectory() { - return new File(getDimensionDirectory(), "region"); + return dimensionDirectory.resolve("region").toFile(); } @Override public String getName() { - return dimensionDirectory.getName() ; + return dimensionDirectory.getFileName().toString(); } } diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java index 8041785e55..de0fe246fd 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java @@ -11,8 +11,11 @@ import se.llbit.util.annotation.Nullable; import java.io.*; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; import java.nio.file.Path; import java.util.*; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.zip.GZIPInputStream; @@ -41,96 +44,65 @@ public class JavaWorld extends World { /** Timestamp of when player data was last loaded. */ protected long playerDataTimestamp; - /** - * @param levelName name of the world (not the world directory). - * @param worldDirectory Minecraft world directory. - * @param seed - * @param timestamp - */ - protected JavaWorld(String levelName, File worldDirectory, long seed, long timestamp, int versionId, Set playerEntities, Vector3i spawnPos) { - super(levelName, worldDirectory, seed); + protected JavaWorld(Info info, long timestamp, int versionId, Set playerEntities, Vector3i spawnPos) { + super(info); this.versionId = versionId; this.playerEntities = playerEntities; this.spawnPos = spawnPos; this.playerDataTimestamp = timestamp; } - /** - * Parse player location and level name. - * - * @return {@code true} if the world data was loaded - */ - public static World loadWorld(File worldDirectory, LoggedWarnings warnings) { - if (worldDirectory == null) { - return EmptyWorld.INSTANCE; - } - String levelName = worldDirectory.getName(); // Default level name. - File worldFile = new File(worldDirectory, "level.dat"); - long modtime = worldFile.lastModified(); - try (FileInputStream fin = new FileInputStream(worldFile); - InputStream gzin = new GZIPInputStream(fin); - DataInputStream in = new DataInputStream(gzin)) { - Set request = new HashSet<>(); - request.add(".Data.version"); - request.add(".Data.Version.Id"); - request.add(".Data.RandomSeed"); - request.add(".Data.Player"); - request.add(".Data.singleplayer_uuid"); - request.add(".Data.LevelName"); - request.add(".Data.GameType"); - request.add(".Data.isCubicWorld"); - Map result = NamedTag.quickParse(in, request); + public static Optional loadWorldInfo(@NotNull Path worldDirectory, LoggedWarnings warnings, JavaWorldFormat format) { + return readWorldData(worldDirectory, warnings, data -> { + Tag gameType = data.tags.get(".Data.GameType"); + String gameMode = switch (gameType.intValue(0)) { + case 0 -> "Survival"; + case 1 -> "Creative"; + case 2 -> "Adventure"; + default -> "Unknown"; + }; + Tag randomSeed = data.tags.get(".Data.RandomSeed"); - Tag version = result.get(".Data.version"); - if (warnings == LoggedWarnings.NORMAL && version.intValue() != NBT_VERSION) { - Log.warnf("The world format for the world %s is not supported by Chunky.\n" + "Will attempt to load the world anyway.", - levelName); - } - Tag versionId = result.get(".Data.Version.Id"); - Tag player = result.get(".Data.Player"); - Tag spawnX = player.get("SpawnX"); - Tag spawnY = player.get("SpawnY"); - Tag spawnZ = player.get("SpawnZ"); - Tag singleplayerUuid = result.get(".Data.singleplayer_uuid"); - Tag gameType = result.get(".Data.GameType"); - Tag randomSeed = result.get(".Data.RandomSeed"); - levelName = MinecraftText.removeFormatChars(result.get(".Data.LevelName").stringValue(levelName)); + String levelName = MinecraftText.removeFormatChars(data.tags.get(".Data.LevelName").stringValue(data.levelName)); long seed = randomSeed.longValue(0); - Set playerEntities = getPlayerEntityData(worldDirectory, player); + return new Info(levelName, worldDirectory, data.modTime, seed, gameMode, format); + }); + } + + public static World loadWorld(Info info, LoggedWarnings warnings) { + return readWorldData(info.path(), warnings, data -> { + Tag versionId = data.tags.get(".Data.Version.Id"); - boolean haveSpawnPos = !(spawnX.isError() || spawnY.isError() || spawnZ.isError()); + Tag player = data.tags.get(".Data.Player"); + Set playerEntities = getPlayerEntityData(info.path(), player); + + Tag spawnX = player.get("SpawnX"); + Tag spawnY = player.get("SpawnY"); + Tag spawnZ = player.get("SpawnZ"); + boolean hasSpawnPos = !(spawnX.isError() || spawnY.isError() || spawnZ.isError()); Vector3i spawnPos = new Vector3i(); - if (haveSpawnPos) { + if (hasSpawnPos) { spawnPos = new Vector3i(spawnX.intValue(0), spawnY.intValue(0), spawnZ.intValue(0)); } - JavaWorld world = new JavaWorld(levelName, worldDirectory, seed, modtime, versionId.intValue(), playerEntities, spawnPos); - world.gameMode = gameType.intValue(0); + JavaWorld world = new JavaWorld(info, data.modTime, versionId.intValue(), playerEntities, spawnPos); + Tag singleplayerUuid = data.tags.get(".Data.singleplayer_uuid"); if (singleplayerUuid.isIntArray(4)) { world.singleplayerPlayerUuid = UuidUtil.intsToUuid(singleplayerUuid.intArray()); } else if (!player.isError()) { world.singleplayerPlayerUuid = PlayerEntityData.getUuid(player); } - return world; - } catch (FileNotFoundException e) { - if (warnings == LoggedWarnings.NORMAL) { - Log.infof("Could not find level.dat file for world %s!", levelName); - } - } catch (IOException e) { - if (warnings == LoggedWarnings.NORMAL) { - Log.infof("Could not read the level.dat file for world %s!", levelName); - } - } - return EmptyWorld.INSTANCE; + return (World) world; + }).orElse(EmptyWorld.INSTANCE); } @Override public Set getAvailableDimensions() { - return Set.of(Dimension.Identifier.OVERWORLD, + return Set.of(Dimension.Identifier.OVERWORLD, // TODO: return the actual set of dimensions on disk. Dimension.Identifier.THE_NETHER, Dimension.Identifier.THE_END ); @@ -145,7 +117,7 @@ public Optional getDefaultDimension() { public Dimension loadDimension(Dimension.Identifier dimensionId) { currentDimension = loadDimension( this, - this.worldDirectory, + getInfo().path(), dimensionId, this.playerEntities.stream().filter(player -> player.dimension.equals(dimensionId)).collect(Collectors.toSet()), this.spawnPos @@ -155,19 +127,19 @@ public Dimension loadDimension(Dimension.Identifier dimensionId) { } @NotNull - private static Dimension loadDimension(JavaWorld world, File worldDirectory, Dimension.Identifier dimensionId, Set playerEntities, @Nullable Vector3i spawnPos) { - File dimensionDirectory = Path.of(worldDirectory.getPath(), "dimensions", dimensionId.namespace(), dimensionId.name()).toFile(); - if (dimensionDirectory.exists()) { + private static Dimension loadDimension(JavaWorld world, Path worldDirectory, Dimension.Identifier dimensionId, Set playerEntities, @Nullable Vector3i spawnPos) { + Path dimensionDirectory = worldDirectory.resolve("dimensions").resolve(dimensionId.namespace()).resolve(dimensionId.name()); + if (Files.exists(dimensionDirectory)) { // 26.1-snapshot-6 or later return new JavaDimension(world, dimensionId, dimensionDirectory, playerEntities, spawnPos); } dimensionDirectory = switch (dimensionId.getNamespacedName()) { // TODO in Java 21+ we can use `switch (dimensionId)` here - case "minecraft:the_nether" -> new File(worldDirectory, "DIM-1"); - case "minecraft:the_end" -> new File(worldDirectory, "DIM1"); + case "minecraft:the_nether" -> worldDirectory.resolve("DIM-1"); + case "minecraft:the_end" -> worldDirectory.resolve("DIM1"); default -> worldDirectory; }; - if (new File(dimensionDirectory, "region3d").exists()) { + if (Files.isDirectory(dimensionDirectory.resolve("region3d"))) { return new CubicDimension(world, dimensionId, dimensionDirectory, playerEntities, spawnPos); } else { return new JavaDimension(world, dimensionId, dimensionDirectory, playerEntities, spawnPos); @@ -179,7 +151,7 @@ public Optional getSingleplayerPlayerUuid() { } @NotNull - private static Set getPlayerEntityData(File worldDirectory, Tag player) { + private static Set getPlayerEntityData(Path worldDirectory, Tag player) { Set playerEntities = new HashSet<>(); if (!player.isError()) { playerEntities.add(new PlayerEntityData(player)); @@ -188,24 +160,25 @@ private static Set getPlayerEntityData(File worldDirectory, Ta return playerEntities; } - private static void loadAdditionalPlayers(File worldDirectory, Set playerEntities) { - loadPlayerData(new File(worldDirectory, "players"), playerEntities); - loadPlayerData(new File(worldDirectory, "playerdata"), playerEntities); - loadPlayerData(new File(new File(worldDirectory, "players"), "data"), playerEntities); // 26.1-snapshot-6 or later + private static void loadAdditionalPlayers(Path worldDirectory, Set playerEntities) { + loadPlayerData(worldDirectory.resolve("players"), playerEntities); + loadPlayerData(worldDirectory.resolve("playerdata"), playerEntities); + loadPlayerData(worldDirectory.resolve("players").resolve("data"), playerEntities); // 26.1-snapshot-6 or later } - private static void loadPlayerData(File playerdata, Set playerEntities) { - if (playerdata.isDirectory()) { - File[] players = playerdata.listFiles(); - if (players != null) { - for (File player : players) { + private static void loadPlayerData(Path playerDataDirectory, Set playerEntities) { + if (Files.isDirectory(playerDataDirectory)) { + try (DirectoryStream paths = Files.newDirectoryStream(playerDataDirectory)) { + for (Path player : paths) { try (DataInputStream in = new DataInputStream( - new GZIPInputStream(new FileInputStream(player)))) { + new GZIPInputStream(new FileInputStream(player.toFile())))) { playerEntities.add(new PlayerEntityData(NamedTag.read(in).unpack())); } catch (IOException e) { - Log.infof("Could not read player data file '%s'", player.getAbsolutePath()); + Log.infof("Could not read player data file '%s'", player.toAbsolutePath()); } } + } catch (IOException e) { + Log.infof("Could not list player data directory '%s'", playerDataDirectory.toAbsolutePath()); } } } @@ -215,18 +188,20 @@ private static void loadPlayerData(File playerdata, Set player * @return {@code true} if player data was reloaded. */ synchronized boolean reloadPlayerData() { - if (worldDirectory == null) { + Path worldFile = getInfo().path().resolve("level.dat"); + long lastModified; + try { + lastModified = Files.getLastModifiedTime(worldFile).toMillis(); + } catch (IOException e) { return false; } - File worldFile = new File(worldDirectory, "level.dat"); - long lastModified = worldFile.lastModified(); if (lastModified == playerDataTimestamp) { return false; } - Log.infof("world %s: timestamp updated: reading player data", levelName); + Log.infof("world %s: timestamp updated: reading player data", getInfo().name()); playerDataTimestamp = lastModified; - try (FileInputStream fin = new FileInputStream(worldFile); + try (FileInputStream fin = new FileInputStream(worldFile.toFile()); InputStream gzin = new GZIPInputStream(fin); DataInputStream in = new DataInputStream(gzin)) { Set request = new HashSet<>(); @@ -242,11 +217,49 @@ synchronized boolean reloadPlayerData() { } this.playerEntities.clear(); - this.playerEntities.addAll(getPlayerEntityData(worldDirectory, player)); + this.playerEntities.addAll(getPlayerEntityData(getInfo().path(), player)); } catch (IOException e) { - Log.infof("Could not read the level.dat file for world %s while trying to reload player data!", levelName); + Log.infof("Could not read the level.dat file for world %s while trying to reload player data!", getInfo().name()); return false; } return true; } + + private record WorldData(String levelName, long modTime, Map tags) {} + private static Optional readWorldData(@NotNull Path worldDirectory, LoggedWarnings warnings, Function consumer) { + String levelName = worldDirectory.getFileName().toString(); + Path levelDat = worldDirectory.resolve("level.dat"); + try (FileInputStream fin = new FileInputStream(levelDat.toFile()); + InputStream gzin = new GZIPInputStream(fin); + DataInputStream in = new DataInputStream(gzin)) { + long modtime = Files.getLastModifiedTime(levelDat).toMillis(); + Set request = new HashSet<>(); + request.add(".Data.version"); + request.add(".Data.Version.Id"); + request.add(".Data.RandomSeed"); + request.add(".Data.Player"); + request.add(".Data.singleplayer_uuid"); + request.add(".Data.LevelName"); + request.add(".Data.GameType"); + + Map result = NamedTag.quickParse(in, request); + + Tag version = result.get(".Data.version"); + if (warnings == LoggedWarnings.NORMAL && version.intValue() != NBT_VERSION) { + Log.warnf("The world format for the world %s is not supported by Chunky.\n" + "Will attempt to load the world anyway.", + levelName); + } + + return Optional.of(consumer.apply(new WorldData(levelName, modtime, result))); + } catch (FileNotFoundException e) { + if (warnings == LoggedWarnings.NORMAL) { + Log.infof("Could not find level.dat file for world %s!", levelName); + } + } catch (IOException e) { + if (warnings == LoggedWarnings.NORMAL) { + Log.infof("Could not read the level.dat file for world %s!", levelName); + } + } + return Optional.empty(); + } } diff --git a/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java b/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java index 6890846da7..9de5a4fcf1 100644 --- a/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java +++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java @@ -4,14 +4,17 @@ import se.llbit.chunky.world.worldformat.WorldFormat; import se.llbit.util.annotation.NotNull; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Optional; public class JavaWorldFormat implements WorldFormat { + public static final String NAME = "Java (Anvil)"; + public static final String ID = "JAVA_ANVIL"; + @Override public String getName() { - return "Java (Anvil)"; + return NAME; } @Override @@ -21,7 +24,7 @@ public String getDescription() { @Override public String getId() { - return "JAVA_ANVIL"; + return ID; } @Override @@ -33,8 +36,23 @@ public boolean isValid(@NotNull Path path) { return false; } + @NotNull + @Override + public Optional getWorldInfo(@NotNull Path path) { + if (!Files.isDirectory(path)) { + return Optional.empty(); + } + Path levelDat = path.resolve("level.dat"); + if (!Files.exists(levelDat) || !Files.isRegularFile(levelDat)) { + return Optional.empty(); + } + + return JavaWorld.loadWorldInfo(path, World.LoggedWarnings.SILENT, this); + } + + @NotNull @Override - public World loadWorld(@NotNull Path path) throws IOException { - return JavaWorld.loadWorld(path.toFile(), World.LoggedWarnings.SILENT); + public World loadWorld(@NotNull World.Info info) { + return JavaWorld.loadWorld(info, World.LoggedWarnings.NORMAL); } } diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java index 517fff0250..3a4448fbff 100644 --- a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.nio.file.Path; +import java.util.Optional; /** * A {@link WorldFormat} determines whether paths on disk are valid for its world type, and can then load that @@ -29,14 +30,22 @@ public interface WorldFormat extends Registerable { boolean isValid(@NotNull Path path); /** - * Load the world at the given path. + * Load metadata about a world * *

Calls to this method do not indicate that any blocks will be loaded from the world. As such implementations * should do minimal work to load the metadata for a world.

* - * @param path The path to the world. - * @return The loaded world - * @throws IOException When something goes wrong when loading the world. + * @param path The path to the world + * @return The {@link World.Info}, if it was created successfully. + */ + @NotNull Optional getWorldInfo(@NotNull Path path); + + /** + * Load the world represented by the given world info. + * + * @param info The world info. + * @return The loaded world, or an empty world if the world was not loadable. + * @throws IOException When something goes wrong when loading the world. Will be reported to the user. */ - World loadWorld(@NotNull Path path) throws IOException; + @NotNull World loadWorld(@NotNull World.Info info) throws IOException; } \ No newline at end of file diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java index ef6bba3fdb..3df89315d6 100644 --- a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java @@ -1,17 +1,21 @@ package se.llbit.chunky.world.worldformat; -import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import se.llbit.chunky.world.EmptyWorld; import se.llbit.chunky.world.World; import se.llbit.chunky.world.java.JavaWorldFormat; import se.llbit.log.Log; +import se.llbit.util.annotation.NotNull; -import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.util.*; +import java.util.stream.Collectors; public class WorldFormats { - private static final Map worldFormatsById = new Object2ObjectOpenHashMap<>(); + /** + * Uses an ordered hashmap to guarantee a consistent encounter order (and that {@link JavaWorldFormat} is checked first) + */ + private static final LinkedHashMap worldFormatsById = new LinkedHashMap<>(); public static void addWorldFormat(WorldFormat worldFormat) { worldFormatsById.put(worldFormat.getId(), worldFormat); @@ -21,35 +25,32 @@ public static Map getWorldFormats() { return Collections.unmodifiableMap(worldFormatsById); } - public static WorldFormat getWorldFormat(String id) { - return worldFormatsById.get(id); + public static Optional getWorldFormat(String id) { + return Optional.ofNullable(worldFormatsById.get(id)); } static { addWorldFormat(new JavaWorldFormat()); } - public static Optional createWorld(File dir) { - Map providedWorlds = new Object2ObjectOpenHashMap<>(); - - getWorldFormats().forEach((id, format) -> { - if (format.isValid(dir.toPath())) { - try { - World world = format.loadWorld(dir.toPath()); - if (world != EmptyWorld.INSTANCE) { - providedWorlds.put(format.getId(), world); - } - } catch (IOException e) { - Log.error(String.format("An error occurred when trying to load a world using format `%s` from %s", format.getName(), dir.getAbsolutePath()), e); - } - } - }); - - if (providedWorlds.size() > 1) { - // Maybe allow the user to select which? - // This method is called from a variety of different popup/menu situations, is this ^ possible? - Log.warn(String.format("The directory %s has multiple valid world formats: %s", dir.getAbsolutePath(), String.join(", ", providedWorlds.keySet()))); + @NotNull + public static Collection getInfos(Path path) { + return getWorldFormats().values().stream() + .filter(format -> format.isValid(path)) + .map(format -> format.getWorldInfo(path)) + .flatMap(Optional::stream) + .collect(Collectors.toList()); + } + + @NotNull + public static World createWorld(@NotNull World.Info info) { + try { + return info.worldFormat().loadWorld(info); + } catch (IOException e) { + Log.error(String.format("An error occurred when trying to load a world using format `%s` from %s", + info.worldFormat().getName(), info.path().toAbsolutePath()), e + ); } - return providedWorlds.values().stream().findFirst(); + return EmptyWorld.INSTANCE; } } diff --git a/lib/src/se/llbit/chunky/PersistentSettings.java b/lib/src/se/llbit/chunky/PersistentSettings.java index b5758e0035..9f7e371285 100644 --- a/lib/src/se/llbit/chunky/PersistentSettings.java +++ b/lib/src/se/llbit/chunky/PersistentSettings.java @@ -190,6 +190,16 @@ public static File getLastWorld() { return lastWorld.isEmpty() ? null : new File(lastWorld); } + public static void setLastWorldFormat(String worldFormat) { + settings.setString("lastWorldFormat", worldFormat); + save(); + } + + /** @return the world format of the previously loaded world. */ + public static String getLastWorldFormat() { + return settings.getString("lastWorldFormat", ""); + } + public static void setSkinDirectory(File directory) { settings.setString("skinDirectory", directory.getAbsolutePath()); save(); From 133ee5224c272278b9a1398648bc82ac7fe5b3cd Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Wed, 8 Jul 2026 12:47:01 +0100 Subject: [PATCH 15/57] Create a single place for all chunky thread/executors to be registered Closes #1893 - Allows chunky to close them on shutdown without a System.exit() - Additionally provides an api to wait on all chunky threads to be joined. --- .../src/java/se/llbit/chunky/main/Chunky.java | 7 + .../chunky/renderer/DefaultRenderManager.java | 3 +- .../chunky/renderer/RenderWorkerPool.java | 3 +- .../scene/AsynchronousSceneManager.java | 3 +- .../se/llbit/chunky/renderer/scene/Scene.java | 3 +- .../src/java/se/llbit/chunky/ui/ChunkMap.java | 3 +- .../src/java/se/llbit/chunky/ui/ChunkyFx.java | 1 - .../ResourcePackChooserController.java | 3 +- .../ui/controller/SceneChooserController.java | 4 +- .../chunky/world/ChunkTopographyUpdater.java | 4 +- .../se/llbit/chunky/world/SkymapTexture.java | 3 +- .../world/region/RegionChangeWatcher.java | 3 +- .../chunky/world/region/RegionParser.java | 3 +- .../llbit/util/concurrent/ChunkyThread.java | 169 ++++++++++++++++++ 14 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java diff --git a/chunky/src/java/se/llbit/chunky/main/Chunky.java b/chunky/src/java/se/llbit/chunky/main/Chunky.java index 13a6965357..976a5038cd 100644 --- a/chunky/src/java/se/llbit/chunky/main/Chunky.java +++ b/chunky/src/java/se/llbit/chunky/main/Chunky.java @@ -48,6 +48,7 @@ import se.llbit.log.Log; import se.llbit.log.Receiver; import se.llbit.util.TaskTracker; +import se.llbit.util.concurrent.ChunkyThread; import java.io.File; import java.io.FileInputStream; @@ -241,6 +242,12 @@ public static void main(final String[] args) { exitCode = 2; } } + + ChunkyThread.interruptAndJoinAll(); + ForkJoinPool commonThreads = Chunky.getCommonThreads(); + commonThreads.shutdownNow(); // ForkJoinPool doesn't return any tasks that were awaiting execution (all canceled). + // We don't use the ForkJoinPool commonPool in chunky, but if we did there is no shutdown available so nothing changes. + if (exitCode != 0) { System.exit(exitCode); } diff --git a/chunky/src/java/se/llbit/chunky/renderer/DefaultRenderManager.java b/chunky/src/java/se/llbit/chunky/renderer/DefaultRenderManager.java index 9b091c2e43..6074c35775 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/DefaultRenderManager.java +++ b/chunky/src/java/se/llbit/chunky/renderer/DefaultRenderManager.java @@ -28,6 +28,7 @@ import se.llbit.chunky.resources.BitmapImage; import se.llbit.log.Log; import se.llbit.math.ColorUtil; +import se.llbit.util.concurrent.ChunkyThread; import se.llbit.util.TaskTracker; import java.time.Duration; @@ -52,7 +53,7 @@ *

All available final renderers are stored in {@code renderers} and preview renderers * are stored in {@code previewRenderers}. */ -public class DefaultRenderManager extends Thread implements RenderManager { +public class DefaultRenderManager extends ChunkyThread implements RenderManager { /** * Map containing all the final render {@code Renderer}s. The renderer corresponding to * {@code getRendererName()} is used when a render is requested. diff --git a/chunky/src/java/se/llbit/chunky/renderer/RenderWorkerPool.java b/chunky/src/java/se/llbit/chunky/renderer/RenderWorkerPool.java index 51b129d5d8..80cef5ea46 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/RenderWorkerPool.java +++ b/chunky/src/java/se/llbit/chunky/renderer/RenderWorkerPool.java @@ -18,6 +18,7 @@ package se.llbit.chunky.renderer; import se.llbit.log.Log; +import se.llbit.util.concurrent.ChunkyThread; import java.util.ArrayList; import java.util.Random; @@ -39,7 +40,7 @@ public interface Factory { RenderWorkerPool create(int threads, long seed); } - public static class RenderWorker extends Thread { + public static class RenderWorker extends ChunkyThread { private final RenderWorkerPool pool; public final Random random; diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/AsynchronousSceneManager.java b/chunky/src/java/se/llbit/chunky/renderer/scene/AsynchronousSceneManager.java index 919a4f4c2f..7d08780263 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/scene/AsynchronousSceneManager.java +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/AsynchronousSceneManager.java @@ -26,6 +26,7 @@ import se.llbit.chunky.world.RegionPosition; import se.llbit.chunky.world.World; import se.llbit.log.Log; +import se.llbit.util.concurrent.ChunkyThread; import se.llbit.util.TaskTracker; import java.io.File; @@ -41,7 +42,7 @@ * * @author Jesper Öqvist */ -public class AsynchronousSceneManager extends Thread implements SceneManager { +public class AsynchronousSceneManager extends ChunkyThread implements SceneManager { private final SynchronousSceneManager sceneManager; private final LinkedBlockingQueue taskQueue; diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java index bc4bc6bf61..82d0f3f21a 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java @@ -62,6 +62,7 @@ import se.llbit.nbt.Tag; import se.llbit.util.*; import se.llbit.util.annotation.NotNull; +import se.llbit.util.concurrent.ChunkyThread; import se.llbit.util.io.PositionalInputStream; import se.llbit.util.io.ZipExport; import se.llbit.util.mojangapi.MinecraftProfile; @@ -858,7 +859,7 @@ public synchronized void loadChunks(TaskTracker taskTracker, World world, Map {}; - private ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + private final ScheduledExecutorService executor = ChunkyThread.addExecutorService(Executors::newSingleThreadScheduledExecutor); private boolean shouldDrawPlayers = true; diff --git a/chunky/src/java/se/llbit/chunky/ui/ChunkyFx.java b/chunky/src/java/se/llbit/chunky/ui/ChunkyFx.java index 93f6532acc..44a552b5f4 100644 --- a/chunky/src/java/se/llbit/chunky/ui/ChunkyFx.java +++ b/chunky/src/java/se/llbit/chunky/ui/ChunkyFx.java @@ -82,7 +82,6 @@ public void stop() throws Exception { if(mainStage != null) { PersistentSettings.setWindowPosition(new WindowPosition(mainStage)); } - System.exit(0); } public static void startChunkyUI(Chunky chunkyInstance) { diff --git a/chunky/src/java/se/llbit/chunky/ui/controller/ResourcePackChooserController.java b/chunky/src/java/se/llbit/chunky/ui/controller/ResourcePackChooserController.java index dcac327038..251fdff9d5 100644 --- a/chunky/src/java/se/llbit/chunky/ui/controller/ResourcePackChooserController.java +++ b/chunky/src/java/se/llbit/chunky/ui/controller/ResourcePackChooserController.java @@ -49,6 +49,7 @@ import se.llbit.json.JsonParser; import se.llbit.log.Log; import se.llbit.util.MinecraftText; +import se.llbit.util.concurrent.ChunkyThread; import java.awt.*; import java.io.File; @@ -451,7 +452,7 @@ public void populate( private static class PackListItem { - private final static Executor PACK_PARSER_EXECUTOR = Executors.newSingleThreadExecutor(); + private final static Executor PACK_PARSER_EXECUTOR = ChunkyThread.addExecutorService(Executors::newSingleThreadExecutor); private static PackListItem DEFAULT = null; diff --git a/chunky/src/java/se/llbit/chunky/ui/controller/SceneChooserController.java b/chunky/src/java/se/llbit/chunky/ui/controller/SceneChooserController.java index 534926aeac..5c507b0568 100644 --- a/chunky/src/java/se/llbit/chunky/ui/controller/SceneChooserController.java +++ b/chunky/src/java/se/llbit/chunky/ui/controller/SceneChooserController.java @@ -36,6 +36,7 @@ import se.llbit.json.JsonObject; import se.llbit.json.JsonParser; import se.llbit.log.Log; +import se.llbit.util.concurrent.ChunkyThread; import java.io.File; import java.io.FileInputStream; @@ -64,6 +65,8 @@ public class SceneChooserController implements Initializable { private Stage stage; + private static final Executor loadExecutor = ChunkyThread.addExecutorService(Executors::newSingleThreadExecutor); + private ChunkyFxController controller; private static final HashMap sceneListCache = new HashMap<>(); @@ -219,7 +222,6 @@ public void setStage(Stage stage) { private void populateSceneTable(File sceneDir) { this.sceneTbl.setPlaceholder(new Label("Loading scenes…")); - Executor loadExecutor = Executors.newSingleThreadExecutor(); loadExecutor.execute(() -> { List scenes = new ArrayList<>(); diff --git a/chunky/src/java/se/llbit/chunky/world/ChunkTopographyUpdater.java b/chunky/src/java/se/llbit/chunky/world/ChunkTopographyUpdater.java index 8c61d5b591..58ef6be998 100644 --- a/chunky/src/java/se/llbit/chunky/world/ChunkTopographyUpdater.java +++ b/chunky/src/java/se/llbit/chunky/world/ChunkTopographyUpdater.java @@ -16,6 +16,8 @@ */ package se.llbit.chunky.world; +import se.llbit.util.concurrent.ChunkyThread; + import java.util.HashSet; import java.util.Iterator; import java.util.Set; @@ -25,7 +27,7 @@ * * @author Jesper Öqvist (jesper@llbit.se) */ -public class ChunkTopographyUpdater extends Thread { +public class ChunkTopographyUpdater extends ChunkyThread { private final Set queue = new HashSet<>(); diff --git a/chunky/src/java/se/llbit/chunky/world/SkymapTexture.java b/chunky/src/java/se/llbit/chunky/world/SkymapTexture.java index 4b2500a901..d879dc1576 100644 --- a/chunky/src/java/se/llbit/chunky/world/SkymapTexture.java +++ b/chunky/src/java/se/llbit/chunky/world/SkymapTexture.java @@ -25,6 +25,7 @@ import se.llbit.math.QuickMath; import se.llbit.math.Ray; import se.llbit.math.Vector4; +import se.llbit.util.concurrent.ChunkyThread; import se.llbit.util.ImageTools; /** @@ -35,7 +36,7 @@ */ public class SkymapTexture extends Texture { - class TexturePreprocessor extends Thread { + class TexturePreprocessor extends ChunkyThread { private final int x0; private final int x1; private final int y0; diff --git a/chunky/src/java/se/llbit/chunky/world/region/RegionChangeWatcher.java b/chunky/src/java/se/llbit/chunky/world/region/RegionChangeWatcher.java index 01ab9ed1b2..bdc35cd969 100644 --- a/chunky/src/java/se/llbit/chunky/world/region/RegionChangeWatcher.java +++ b/chunky/src/java/se/llbit/chunky/world/region/RegionChangeWatcher.java @@ -20,13 +20,14 @@ import se.llbit.chunky.map.WorldMapLoader; import se.llbit.chunky.renderer.ChunkViewListener; import se.llbit.chunky.world.ChunkView; +import se.llbit.util.concurrent.ChunkyThread; /** * Monitors filesystem for changes to region files. * * @author Jesper Öqvist */ -public abstract class RegionChangeWatcher extends Thread implements ChunkViewListener { +public abstract class RegionChangeWatcher extends ChunkyThread implements ChunkViewListener { protected final WorldMapLoader mapLoader; protected final MapView mapView; protected volatile ChunkView view = ChunkView.EMPTY; diff --git a/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java b/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java index b25f0771c0..841851a51e 100644 --- a/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java +++ b/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java @@ -23,6 +23,7 @@ import se.llbit.chunky.map.WorldMapLoader; import se.llbit.chunky.world.*; import se.llbit.log.Log; +import se.llbit.util.concurrent.ChunkyThread; import se.llbit.util.Mutable; /** @@ -34,7 +35,7 @@ * * @author Jesper Öqvist (jesper@llbit.se) */ -public class RegionParser extends Thread { +public class RegionParser extends ChunkyThread { private final WorldMapLoader mapLoader; private final RegionQueue queue; diff --git a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java new file mode 100644 index 0000000000..1ba27c88cb --- /dev/null +++ b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java @@ -0,0 +1,169 @@ +package se.llbit.util.concurrent; + +import se.llbit.chunky.main.Chunky; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; + +/** + * {@link Thread}/{@link ExecutorService} resource management. + *

The goal of this glass is to primarily:

+ *
    + *
  • Ensure all chunky threads (even daemons) are interrupted and given a chance clean up before getting killed by + * the runtime on termination.
  • + *
  • Within the non-termination {@link Runtime} shutdown sequence give guarantees to shut down hooks about the state + * of chunky.
  • + *
+ * + *

Usage

+ *

{@link Thread Threads} in chunky should extend this class, and {@link ExecutorService executor services} should + * be created with {@link #addExecutorService(Function)} to allow chunky to interrupt and join them before chunky closes.

+ * + */ +public class ChunkyThread extends Thread { + /* + * All operations lock. + * When interruptAndJoinAll is called, additional threads/executors can't be added preventing later joins from + * waiting on threads that have not been interrupted. + */ + private static final AtomicBoolean isShutdown = new AtomicBoolean(false); + private static final Collection threads = new ArrayList<>(); + private static final Collection executorServices = new ArrayList<>(); + + /** + * Add a {@link Thread} to be interrupted and joined by chunky on shutdown + * + * @throws IllegalStateException When calling after {@link #interruptAndJoinAll()} has been called. + */ + public synchronized static T addThread(T thread) { + if (isShutdown.get()) { + throw new IllegalStateException("Creating a thread as chunky is stopping."); + } + threads.add(thread); + return thread; + } + + /** + * Add an {@link ExecutorService} to be interrupted and joined by chunky on shutdown + * + @throws IllegalStateException When calling after {@link #interruptAndJoinAll()} has been called. + */ + public synchronized static E addExecutorService(Function executorServiceSupplier) { + if (isShutdown.get()) { + throw new IllegalStateException("Creating an executor service as chunky is stopping."); + } + E e = executorServiceSupplier.apply(ChunkyThread::new); + executorServices.add(e); + return e; + } + + /** + * Await the joining of all threads managed by chunky + * + *

This method is always safe to call.

+ * + *

WARNING: calling this from any thread registered with {@link #addThread(Thread)} may deadlock.

+ */ + public synchronized static void joinAll() { + for (Thread thread : threads) { + try { + thread.join(); + } catch (InterruptedException e) { + // ignored + } + } + for (ExecutorService executorService : executorServices) { + try { + executorService.awaitTermination(1, TimeUnit.MINUTES); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + /** + * Interrupt and then await joining of all threads managed by chunky. + * + *

This method is always safe to call.

+ * + *

WARNING: calling this from any thread registered with {@link #addThread(Thread)} may deadlock.

+ *

Only to be called by {@link Chunky}

+ */ + public synchronized static void interruptAndJoinAll() { + assert Thread.currentThread().getName().equals("main"); + isShutdown.set(true); + + // shut down executorServices BEFORE threads because they recreate their threads when they are interrupted and stop + // causing an infinite hang. + for (ExecutorService executorService : executorServices) { + executorService.shutdownNow(); + } + + for (Thread thread : ChunkyThread.threads) { + thread.interrupt(); + } + for (Thread thread : ChunkyThread.threads) { + try { + thread.join(); + } catch (InterruptedException e) { + // ignored + } + } + } + + private void setDefaults() { + this.setDaemon(true); + addThread(this); + } + + /* Constructors from super */ + public ChunkyThread() { + super(); + setDefaults(); + } + + public ChunkyThread(Runnable task) { + super(task); + setDefaults(); + } + + public ChunkyThread(ThreadGroup group, Runnable task) { + super(group, task); + setDefaults(); + } + + public ChunkyThread(String name) { + super(name); + setDefaults(); + } + + public ChunkyThread(ThreadGroup group, String name) { + super(group, name); + setDefaults(); + } + + public ChunkyThread(Runnable task, String name) { + super(task, name); + setDefaults(); + } + + public ChunkyThread(ThreadGroup group, Runnable task, String name) { + super(group, task, name); + setDefaults(); + } + + public ChunkyThread(ThreadGroup group, Runnable task, String name, long stackSize) { + super(group, task, name, stackSize); + setDefaults(); + } + + public ChunkyThread(ThreadGroup group, Runnable task, String name, long stackSize, boolean inheritInheritableThreadLocals) { + super(group, task, name, stackSize, inheritInheritableThreadLocals); + setDefaults(); + } +} From 30f3aa033ff9aba03ff7126350ecd8c47f56d17b Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sat, 11 Jul 2026 09:51:07 +0100 Subject: [PATCH 16/57] Fix race between joinAll and extremely lately added threads/executors --- .../llbit/util/concurrent/ChunkyThread.java | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java index 1ba27c88cb..066efc9aae 100644 --- a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java +++ b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java @@ -1,13 +1,14 @@ package se.llbit.util.concurrent; import se.llbit.chunky.main.Chunky; +import se.llbit.log.Log; import java.util.ArrayList; import java.util.Collection; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; /** @@ -31,7 +32,7 @@ public class ChunkyThread extends Thread { * When interruptAndJoinAll is called, additional threads/executors can't be added preventing later joins from * waiting on threads that have not been interrupted. */ - private static final AtomicBoolean isShutdown = new AtomicBoolean(false); + private static final CountDownLatch shutdownLatch = new CountDownLatch(1); private static final Collection threads = new ArrayList<>(); private static final Collection executorServices = new ArrayList<>(); @@ -41,7 +42,7 @@ public class ChunkyThread extends Thread { * @throws IllegalStateException When calling after {@link #interruptAndJoinAll()} has been called. */ public synchronized static T addThread(T thread) { - if (isShutdown.get()) { + if (shutdownLatch.getCount() == 0) { throw new IllegalStateException("Creating a thread as chunky is stopping."); } threads.add(thread); @@ -54,7 +55,7 @@ public synchronized static T addThread(T thread) { @throws IllegalStateException When calling after {@link #interruptAndJoinAll()} has been called. */ public synchronized static E addExecutorService(Function executorServiceSupplier) { - if (isShutdown.get()) { + if (shutdownLatch.getCount() == 0) { throw new IllegalStateException("Creating an executor service as chunky is stopping."); } E e = executorServiceSupplier.apply(ChunkyThread::new); @@ -70,20 +71,37 @@ public synchronized static E addExecutorService(Func *

WARNING: calling this from any thread registered with {@link #addThread(Thread)} may deadlock.

*/ public synchronized static void joinAll() { + boolean interrupted = false; + + while (true) { + try { + // must wait for the latch as hitting the for loop below first causes immediate evaluation of the + // for loop iterator, potentially missing new threads. + shutdownLatch.await(); + break; + } catch (InterruptedException e) { + interrupted = true; + } + } + for (Thread thread : threads) { try { thread.join(); } catch (InterruptedException e) { - // ignored + interrupted = true; } } for (ExecutorService executorService : executorServices) { try { executorService.awaitTermination(1, TimeUnit.MINUTES); } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + interrupted = true; } } + + if (interrupted) { + Thread.currentThread().interrupt(); + } } /** @@ -96,7 +114,7 @@ public synchronized static void joinAll() { */ public synchronized static void interruptAndJoinAll() { assert Thread.currentThread().getName().equals("main"); - isShutdown.set(true); + shutdownLatch.countDown(); // shut down executorServices BEFORE threads because they recreate their threads when they are interrupted and stop // causing an infinite hang. From c675acea49500b77020865b442653b0094471c09 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Mon, 13 Jul 2026 08:03:18 +0100 Subject: [PATCH 17/57] Make joinAll not synchronized --- .../src/java/se/llbit/util/concurrent/ChunkyThread.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java index 066efc9aae..d10300097a 100644 --- a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java +++ b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java @@ -70,7 +70,13 @@ public synchronized static E addExecutorService(Func * *

WARNING: calling this from any thread registered with {@link #addThread(Thread)} may deadlock.

*/ - public synchronized static void joinAll() { + public static void joinAll() { + /* + * This method should not be synchronized because: + * 1. Calls to this method that happen before interruptAndJoinAll will lock the latter interrupting thread, deadlocking. + * 2. shutdownLatch.await is at least acquire memory ordering, and modification is disabled after the latch is zero. + * As such we are guaranteed that no threads can modify the state. + */ boolean interrupted = false; while (true) { From 1d166b1c5ce9e05959ff2d768892f6842fc300c7 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Mon, 13 Jul 2026 08:49:26 +0100 Subject: [PATCH 18/57] Return whether all threads were joined from joinAll methods --- .../src/java/se/llbit/chunky/main/Chunky.java | 3 +- .../llbit/util/concurrent/ChunkyThread.java | 116 ++++++++++-------- 2 files changed, 68 insertions(+), 51 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/main/Chunky.java b/chunky/src/java/se/llbit/chunky/main/Chunky.java index 976a5038cd..4888adcb9a 100644 --- a/chunky/src/java/se/llbit/chunky/main/Chunky.java +++ b/chunky/src/java/se/llbit/chunky/main/Chunky.java @@ -57,6 +57,7 @@ import java.nio.file.Path; import java.util.*; import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** @@ -243,7 +244,7 @@ public static void main(final String[] args) { } } - ChunkyThread.interruptAndJoinAll(); + ChunkyThread.interruptAndJoinAll(5, TimeUnit.SECONDS); ForkJoinPool commonThreads = Chunky.getCommonThreads(); commonThreads.shutdownNow(); // ForkJoinPool doesn't return any tasks that were awaiting execution (all canceled). // We don't use the ForkJoinPool commonPool in chunky, but if we did there is no shutdown available so nothing changes. diff --git a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java index d10300097a..3284a7302d 100644 --- a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java +++ b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java @@ -1,19 +1,16 @@ package se.llbit.util.concurrent; import se.llbit.chunky.main.Chunky; -import se.llbit.log.Log; +import se.llbit.util.annotation.NotNull; import java.util.ArrayList; import java.util.Collection; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.*; import java.util.function.Function; /** * {@link Thread}/{@link ExecutorService} resource management. - *

The goal of this glass is to primarily:

+ *

The goal of this class is to primarily:

*
    *
  • Ensure all chunky threads (even daemons) are interrupted and given a chance clean up before getting killed by * the runtime on termination.
  • @@ -27,11 +24,6 @@ * */ public class ChunkyThread extends Thread { - /* - * All operations lock. - * When interruptAndJoinAll is called, additional threads/executors can't be added preventing later joins from - * waiting on threads that have not been interrupted. - */ private static final CountDownLatch shutdownLatch = new CountDownLatch(1); private static final Collection threads = new ArrayList<>(); private static final Collection executorServices = new ArrayList<>(); @@ -39,7 +31,7 @@ public class ChunkyThread extends Thread { /** * Add a {@link Thread} to be interrupted and joined by chunky on shutdown * - * @throws IllegalStateException When calling after {@link #interruptAndJoinAll()} has been called. + * @throws IllegalStateException When calling after {@link #interruptAndJoinAll(long, TimeUnit)} has been called. */ public synchronized static T addThread(T thread) { if (shutdownLatch.getCount() == 0) { @@ -52,7 +44,7 @@ public synchronized static T addThread(T thread) { /** * Add an {@link ExecutorService} to be interrupted and joined by chunky on shutdown * - @throws IllegalStateException When calling after {@link #interruptAndJoinAll()} has been called. + * @throws IllegalStateException When calling after {@link #interruptAndJoinAll(long, TimeUnit)} has been called. */ public synchronized static E addExecutorService(Function executorServiceSupplier) { if (shutdownLatch.getCount() == 0) { @@ -64,13 +56,18 @@ public synchronized static E addExecutorService(Func } /** - * Await the joining of all threads managed by chunky + * Await the joining of all threads managed by chunky. This method will wait indefinitely until a + * shutdown happens to begin its timeout. * *

    This method is always safe to call.

    * *

    WARNING: calling this from any thread registered with {@link #addThread(Thread)} may deadlock.

    + * + * @param timeout The maximum time to wait AFTER a shutdown is initiated + * @param unit the time unit of the timeout argument + * @return Whether all threads were joined before returning */ - public static void joinAll() { + public static boolean joinAll(long timeout, @NotNull TimeUnit unit) { /* * This method should not be synchronized because: * 1. Calls to this method that happen before interruptAndJoinAll will lock the latter interrupting thread, deadlocking. @@ -90,54 +87,73 @@ public static void joinAll() { } } - for (Thread thread : threads) { - try { - thread.join(); - } catch (InterruptedException e) { - interrupted = true; + long startTime = System.nanoTime(); + long endTime = startTime + unit.toNanos(timeout); + + boolean anyAlive = false; + + try { + for (ExecutorService executorService : executorServices) { + while (System.nanoTime() < endTime) { + try { + long waitTime = endTime - startTime; + if (waitTime > 0) { + executorService.awaitTermination(waitTime, TimeUnit.NANOSECONDS); + } + break; + } catch (InterruptedException e) { + interrupted = true; + } + } + anyAlive |= !executorService.isTerminated(); } - } - for (ExecutorService executorService : executorServices) { - try { - executorService.awaitTermination(1, TimeUnit.MINUTES); - } catch (InterruptedException e) { - interrupted = true; + for (Thread thread : ChunkyThread.threads) { + while (System.nanoTime() < endTime) { + try { + long waitTimeMillis = TimeUnit.NANOSECONDS.toMillis(endTime - startTime); + if (waitTimeMillis > 0) { + thread.join(waitTimeMillis); + } + break; + } catch (InterruptedException e) { + interrupted = true; + } + } + anyAlive |= thread.isAlive(); + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); } } - - if (interrupted) { - Thread.currentThread().interrupt(); - } + return !anyAlive; } /** * Interrupt and then await joining of all threads managed by chunky. * - *

    This method is always safe to call.

    - * *

    WARNING: calling this from any thread registered with {@link #addThread(Thread)} may deadlock.

    *

    Only to be called by {@link Chunky}

    + * + * @param timeout The maximum time to wait + * @param unit the time unit of the timeout argument + * @return Whether all threads were joined before the time limit was reached */ - public synchronized static void interruptAndJoinAll() { - assert Thread.currentThread().getName().equals("main"); - shutdownLatch.countDown(); - - // shut down executorServices BEFORE threads because they recreate their threads when they are interrupted and stop - // causing an infinite hang. - for (ExecutorService executorService : executorServices) { - executorService.shutdownNow(); - } - - for (Thread thread : ChunkyThread.threads) { - thread.interrupt(); - } - for (Thread thread : ChunkyThread.threads) { - try { - thread.join(); - } catch (InterruptedException e) { - // ignored + public static boolean interruptAndJoinAll(long timeout, @NotNull TimeUnit unit) { + synchronized (ChunkyThread.class) { + shutdownLatch.countDown(); + + // shut down executorServices BEFORE threads because they recreate their threads when they are interrupted and stop + // causing an infinite hang. + for (ExecutorService executorService : executorServices) { + executorService.shutdownNow(); + } + for (Thread thread : ChunkyThread.threads) { + thread.interrupt(); } } + + return joinAll(timeout, unit); } private void setDefaults() { From 63367bec09dda00d0f8714e41d012b35ad433447 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Mon, 13 Jul 2026 08:55:51 +0100 Subject: [PATCH 19/57] Add Chunky's ForkJoinPool to ChunkyThread --- .../src/java/se/llbit/chunky/main/Chunky.java | 7 ++----- .../llbit/util/concurrent/ChunkyThread.java | 20 +++++++++++++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/main/Chunky.java b/chunky/src/java/se/llbit/chunky/main/Chunky.java index 4888adcb9a..14e0925915 100644 --- a/chunky/src/java/se/llbit/chunky/main/Chunky.java +++ b/chunky/src/java/se/llbit/chunky/main/Chunky.java @@ -245,9 +245,6 @@ public static void main(final String[] args) { } ChunkyThread.interruptAndJoinAll(5, TimeUnit.SECONDS); - ForkJoinPool commonThreads = Chunky.getCommonThreads(); - commonThreads.shutdownNow(); // ForkJoinPool doesn't return any tasks that were awaiting execution (all canceled). - // We don't use the ForkJoinPool commonPool in chunky, but if we did there is no shutdown available so nothing changes. if (exitCode != 0) { System.exit(exitCode); @@ -350,7 +347,7 @@ public void update() { public static ForkJoinPool getCommonThreads() { if (commonThreads == null) { // use at least two threads to prevent deadlocks in some java versions (see #1631) - commonThreads = new ForkJoinPool(Math.max(PersistentSettings.getNumThreads(), 2)); + commonThreads = ChunkyThread.addForkJoinPool(new ForkJoinPool(Math.max(PersistentSettings.getNumThreads(), 2))); } return commonThreads; } @@ -361,7 +358,7 @@ public static ForkJoinPool getCommonThreads() { */ public static void setCommonThreadsCount(int threads) { ForkJoinPool t = getCommonThreads(); - commonThreads = new ForkJoinPool(threads); + commonThreads = ChunkyThread.addForkJoinPool(new ForkJoinPool(threads)); t.shutdown(); } diff --git a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java index 3284a7302d..ea3a544ff7 100644 --- a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java +++ b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java @@ -19,8 +19,11 @@ *
* *

Usage

- *

{@link Thread Threads} in chunky should extend this class, and {@link ExecutorService executor services} should - * be created with {@link #addExecutorService(Function)} to allow chunky to interrupt and join them before chunky closes.

+ *
    + *
  • {@link Thread Threads} in chunky should extend this class
  • + *
  • {@link ExecutorService Executor Services} should be created with {@link #addExecutorService(Function)}
  • + *
  • {@link ForkJoinPool Fork Join Pools} should be created with {@link #addForkJoinPool(ForkJoinPool)}
  • + *
* */ public class ChunkyThread extends Thread { @@ -55,6 +58,19 @@ public synchronized static E addExecutorService(Func return e; } + /** + * Add a {@link ForkJoinPool} to be interrupted and joined by chunky on shutdown + * + * @throws IllegalStateException When calling after {@link #interruptAndJoinAll(long, TimeUnit)} has been called. + */ + public synchronized static ForkJoinPool addForkJoinPool(ForkJoinPool pool) { + if (shutdownLatch.getCount() == 0) { + throw new IllegalStateException("Creating a fork join pool as chunky is stopping."); + } + executorServices.add(pool); + return pool; + } + /** * Await the joining of all threads managed by chunky. This method will wait indefinitely until a * shutdown happens to begin its timeout. From f5d4d139357ed04cbbe9c30284ac00754ab128ae Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Mon, 13 Jul 2026 08:57:10 +0100 Subject: [PATCH 20/57] Change ExecutorService thread factory --- .../src/java/se/llbit/util/concurrent/ChunkyThread.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java index ea3a544ff7..e86a080cf8 100644 --- a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java +++ b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java @@ -53,7 +53,11 @@ public synchronized static E addExecutorService(Func if (shutdownLatch.getCount() == 0) { throw new IllegalStateException("Creating an executor service as chunky is stopping."); } - E e = executorServiceSupplier.apply(ChunkyThread::new); + E e = executorServiceSupplier.apply(r -> { // executor shutdown interrupts its own threads, so they don't need to be ChunkyThreads + Thread t = new Thread(r); + t.setDaemon(true); + return t; + }); executorServices.add(e); return e; } @@ -159,8 +163,6 @@ public static boolean interruptAndJoinAll(long timeout, @NotNull TimeUnit unit) synchronized (ChunkyThread.class) { shutdownLatch.countDown(); - // shut down executorServices BEFORE threads because they recreate their threads when they are interrupted and stop - // causing an infinite hang. for (ExecutorService executorService : executorServices) { executorService.shutdownNow(); } From 7163545b093ea0fdc33281d10b2353c70e26083f Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Mon, 13 Jul 2026 09:29:45 +0100 Subject: [PATCH 21/57] Add PluginApi to relevant ChunkyThread methods --- chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java index e86a080cf8..07c9bd8b66 100644 --- a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java +++ b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java @@ -1,6 +1,7 @@ package se.llbit.util.concurrent; import se.llbit.chunky.main.Chunky; +import se.llbit.chunky.plugin.PluginApi; import se.llbit.util.annotation.NotNull; import java.util.ArrayList; @@ -36,6 +37,7 @@ public class ChunkyThread extends Thread { * * @throws IllegalStateException When calling after {@link #interruptAndJoinAll(long, TimeUnit)} has been called. */ + @PluginApi public synchronized static T addThread(T thread) { if (shutdownLatch.getCount() == 0) { throw new IllegalStateException("Creating a thread as chunky is stopping."); @@ -49,6 +51,7 @@ public synchronized static T addThread(T thread) { * * @throws IllegalStateException When calling after {@link #interruptAndJoinAll(long, TimeUnit)} has been called. */ + @PluginApi public synchronized static E addExecutorService(Function executorServiceSupplier) { if (shutdownLatch.getCount() == 0) { throw new IllegalStateException("Creating an executor service as chunky is stopping."); @@ -67,6 +70,7 @@ public synchronized static E addExecutorService(Func * * @throws IllegalStateException When calling after {@link #interruptAndJoinAll(long, TimeUnit)} has been called. */ + @PluginApi public synchronized static ForkJoinPool addForkJoinPool(ForkJoinPool pool) { if (shutdownLatch.getCount() == 0) { throw new IllegalStateException("Creating a fork join pool as chunky is stopping."); @@ -87,6 +91,7 @@ public synchronized static ForkJoinPool addForkJoinPool(ForkJoinPool pool) { * @param unit the time unit of the timeout argument * @return Whether all threads were joined before returning */ + @PluginApi public static boolean joinAll(long timeout, @NotNull TimeUnit unit) { /* * This method should not be synchronized because: From 297b535163413d0aabe671c4305d46a7dcacb22a Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Mon, 13 Jul 2026 09:32:28 +0100 Subject: [PATCH 22/57] Add shutdown hook to handle early System.exit() --- chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java index 07c9bd8b66..47a1161ed1 100644 --- a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java +++ b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java @@ -32,6 +32,13 @@ public class ChunkyThread extends Thread { private static final Collection threads = new ArrayList<>(); private static final Collection executorServices = new ArrayList<>(); + static { + // If anyone calls System.exit() we still want to attempt to stop all threads + Runtime.getRuntime().addShutdownHook( + new Thread(() -> ChunkyThread.interruptAndJoinAll(0, TimeUnit.SECONDS)) // intentionally not ChunkyThread + ); + } + /** * Add a {@link Thread} to be interrupted and joined by chunky on shutdown * From 7f0c6615338a470031dd33e34d3a42e912f0def8 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Mon, 13 Jul 2026 09:39:13 +0100 Subject: [PATCH 23/57] Put interruptAndJoinAll in a finally block --- .../src/java/se/llbit/chunky/main/Chunky.java | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/main/Chunky.java b/chunky/src/java/se/llbit/chunky/main/Chunky.java index 14e0925915..0e09e96a18 100644 --- a/chunky/src/java/se/llbit/chunky/main/Chunky.java +++ b/chunky/src/java/se/llbit/chunky/main/Chunky.java @@ -217,35 +217,39 @@ public static void main(final String[] args) { if (cmdline.mode == CommandLineOptions.Mode.CLI_OPERATION) { exitCode = cmdline.exitCode; } else { - // Initialize the common thread pool. - getCommonThreads(); + try { + // Initialize the common thread pool. + getCommonThreads(); - Chunky chunky = new Chunky(cmdline.options); - chunky.headless = cmdline.mode == Mode.HEADLESS_RENDER || cmdline.mode == Mode.CREATE_SNAPSHOT; - chunky.loadPlugins(); + Chunky chunky = new Chunky(cmdline.options); + chunky.headless = cmdline.mode == Mode.HEADLESS_RENDER || cmdline.mode == Mode.CREATE_SNAPSHOT; + chunky.loadPlugins(); - try { - switch (cmdline.mode) { - case HEADLESS_RENDER: - exitCode = chunky.doHeadlessRender(); - break; - case CREATE_SNAPSHOT: - exitCode = chunky.doSnapshot(); - break; - case START_GUI: - ChunkyFx.startChunkyUI(chunky); - break; + try { + switch (cmdline.mode) { + case HEADLESS_RENDER: + exitCode = chunky.doHeadlessRender(); + break; + case CREATE_SNAPSHOT: + exitCode = chunky.doSnapshot(); + break; + case START_GUI: + ChunkyFx.startChunkyUI(chunky); + break; + } + } catch (Throwable t) { + // set receiver in case an exception was thrown before it was set in one of the start modes. + Log.setReceiver(ConsoleReceiver.INSTANCE, Level.INFO, Level.WARNING, Level.ERROR); + Log.error("Unchecked exception caused Chunky to close.", t); + exitCode = 2; + } + } finally { + if (!ChunkyThread.interruptAndJoinAll(5, TimeUnit.SECONDS)) { + Log.warn("Not all Chunky threads stopped before exiting."); } - } catch (Throwable t) { - // set receiver in case an exception was thrown before it was set in one of the start modes. - Log.setReceiver(ConsoleReceiver.INSTANCE, Level.INFO, Level.WARNING, Level.ERROR); - Log.error("Unchecked exception caused Chunky to close.", t); - exitCode = 2; } } - ChunkyThread.interruptAndJoinAll(5, TimeUnit.SECONDS); - if (exitCode != 0) { System.exit(exitCode); } From 22330c69b37437271a140b306f2fdee1421cdbea Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Fri, 24 Jul 2026 20:39:34 +0100 Subject: [PATCH 24/57] Switch back to ConsoleReceiver on UI stop --- chunky/src/java/se/llbit/chunky/ui/ChunkyFx.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/chunky/src/java/se/llbit/chunky/ui/ChunkyFx.java b/chunky/src/java/se/llbit/chunky/ui/ChunkyFx.java index 44a552b5f4..6226d4c834 100644 --- a/chunky/src/java/se/llbit/chunky/ui/ChunkyFx.java +++ b/chunky/src/java/se/llbit/chunky/ui/ChunkyFx.java @@ -29,6 +29,8 @@ import se.llbit.chunky.resources.SettingsDirectory; import se.llbit.chunky.ui.controller.ChunkyFxController; import se.llbit.fxutil.WindowPosition; +import se.llbit.log.ConsoleReceiver; +import se.llbit.log.Level; import se.llbit.log.Log; import java.io.File; @@ -79,6 +81,8 @@ public class ChunkyFx extends Application { @Override public void stop() throws Exception { + // UI is closing so we need to replace the receivers + Log.setReceiver(ConsoleReceiver.INSTANCE, Level.INFO, Level.WARNING, Level.ERROR); if(mainStage != null) { PersistentSettings.setWindowPosition(new WindowPosition(mainStage)); } From afb094700438aaf1e89eb8a4e2957377badf2a53 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Fri, 24 Jul 2026 20:41:34 +0100 Subject: [PATCH 25/57] Move the shutdown hook out of ChunkyThread, now managed by Chunky itself --- .../src/java/se/llbit/chunky/main/Chunky.java | 69 ++++++++++--------- .../llbit/util/concurrent/ChunkyThread.java | 7 -- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/main/Chunky.java b/chunky/src/java/se/llbit/chunky/main/Chunky.java index 0e09e96a18..4b69de74d3 100644 --- a/chunky/src/java/se/llbit/chunky/main/Chunky.java +++ b/chunky/src/java/se/llbit/chunky/main/Chunky.java @@ -213,45 +213,52 @@ public static void main(final String[] args) { System.exit(1); } - int exitCode = 0; if (cmdline.mode == CommandLineOptions.Mode.CLI_OPERATION) { - exitCode = cmdline.exitCode; + System.exit(cmdline.exitCode); } else { - try { - // Initialize the common thread pool. - getCommonThreads(); + // Initialize the common thread pool. + getCommonThreads(); - Chunky chunky = new Chunky(cmdline.options); - chunky.headless = cmdline.mode == Mode.HEADLESS_RENDER || cmdline.mode == Mode.CREATE_SNAPSHOT; - chunky.loadPlugins(); + Chunky chunky = new Chunky(cmdline.options); + chunky.headless = cmdline.mode == Mode.HEADLESS_RENDER || cmdline.mode == Mode.CREATE_SNAPSHOT; + chunky.loadPlugins(); - try { - switch (cmdline.mode) { - case HEADLESS_RENDER: - exitCode = chunky.doHeadlessRender(); - break; - case CREATE_SNAPSHOT: - exitCode = chunky.doSnapshot(); - break; - case START_GUI: - ChunkyFx.startChunkyUI(chunky); - break; - } - } catch (Throwable t) { - // set receiver in case an exception was thrown before it was set in one of the start modes. - Log.setReceiver(ConsoleReceiver.INSTANCE, Level.INFO, Level.WARNING, Level.ERROR); - Log.error("Unchecked exception caused Chunky to close.", t); - exitCode = 2; - } - } finally { - if (!ChunkyThread.interruptAndJoinAll(5, TimeUnit.SECONDS)) { - Log.warn("Not all Chunky threads stopped before exiting."); + Runtime.getRuntime().addShutdownHook( + new Thread(() -> { // intentionally not a ChunkyThread + // Within a shutdown hook we need to close quickly, otherwise we risk the user/OS escalating to KILL + chunky.shutdown(1, TimeUnit.SECONDS); + }) + ); + + int exitCode = 0; + try { + switch (cmdline.mode) { + case HEADLESS_RENDER: + exitCode = chunky.doHeadlessRender(); + break; + case CREATE_SNAPSHOT: + exitCode = chunky.doSnapshot(); + break; + case START_GUI: + ChunkyFx.startChunkyUI(chunky); + break; } + } catch (Throwable t) { + // set receiver in case an exception was thrown before it was set in one of the start modes. + Log.setReceiver(ConsoleReceiver.INSTANCE, Level.INFO, Level.WARNING, Level.ERROR); + Log.error("Unchecked exception caused Chunky to close.", t); + exitCode = 2; } + chunky.shutdown(5, TimeUnit.SECONDS); + // Always exit, we've done all shutdown necessary and want to exit whether non-daemon threads exist or not. + // This should prevent hangs if threads aren't cooperating. + System.exit(exitCode); } + } - if (exitCode != 0) { - System.exit(exitCode); + private void shutdown(int timeout, TimeUnit unit) { + if (!ChunkyThread.interruptAndJoinAll(timeout, unit)) { + Log.error("Not all threads were joined before shutting down."); // FIXME: list all alive threads? ThreadGroups are annoying. } } diff --git a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java index 47a1161ed1..07c9bd8b66 100644 --- a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java +++ b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java @@ -32,13 +32,6 @@ public class ChunkyThread extends Thread { private static final Collection threads = new ArrayList<>(); private static final Collection executorServices = new ArrayList<>(); - static { - // If anyone calls System.exit() we still want to attempt to stop all threads - Runtime.getRuntime().addShutdownHook( - new Thread(() -> ChunkyThread.interruptAndJoinAll(0, TimeUnit.SECONDS)) // intentionally not ChunkyThread - ); - } - /** * Add a {@link Thread} to be interrupted and joined by chunky on shutdown * From f20b005e987f103f19d530a19085c048cf2b63b8 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Fri, 24 Jul 2026 20:41:47 +0100 Subject: [PATCH 26/57] Remove PluginApi from joinAll --- chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java | 1 - 1 file changed, 1 deletion(-) diff --git a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java index 07c9bd8b66..e7aeae8a9a 100644 --- a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java +++ b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java @@ -91,7 +91,6 @@ public synchronized static ForkJoinPool addForkJoinPool(ForkJoinPool pool) { * @param unit the time unit of the timeout argument * @return Whether all threads were joined before returning */ - @PluginApi public static boolean joinAll(long timeout, @NotNull TimeUnit unit) { /* * This method should not be synchronized because: From e69129b4fc27a09504edcf0136720300ecb39769 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Fri, 24 Jul 2026 20:43:50 +0100 Subject: [PATCH 27/57] Refactor ChunkyThread joinAll to give stronger guarantees And hopefully be more readable --- .../llbit/util/concurrent/ChunkyThread.java | 84 +++++++++++-------- 1 file changed, 51 insertions(+), 33 deletions(-) diff --git a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java index e7aeae8a9a..51d4631f35 100644 --- a/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java +++ b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java @@ -89,6 +89,7 @@ public synchronized static ForkJoinPool addForkJoinPool(ForkJoinPool pool) { * * @param timeout The maximum time to wait AFTER a shutdown is initiated * @param unit the time unit of the timeout argument + * * @return Whether all threads were joined before returning */ public static boolean joinAll(long timeout, @NotNull TimeUnit unit) { @@ -102,8 +103,9 @@ public static boolean joinAll(long timeout, @NotNull TimeUnit unit) { while (true) { try { - // must wait for the latch as hitting the for loop below first causes immediate evaluation of the - // for loop iterator, potentially missing new threads. + // Must wait for the latch as hitting the for loop below first causes immediate evaluation of: + // - The for loop iterator, potentially missing new threads. + // - The end time, meaning waiting starts before shutdown begins. shutdownLatch.await(); break; } catch (InterruptedException e) { @@ -111,46 +113,25 @@ public static boolean joinAll(long timeout, @NotNull TimeUnit unit) { } } - long startTime = System.nanoTime(); - long endTime = startTime + unit.toNanos(timeout); - - boolean anyAlive = false; - + long endTime = System.nanoTime() + unit.toNanos(timeout); try { - for (ExecutorService executorService : executorServices) { - while (System.nanoTime() < endTime) { - try { - long waitTime = endTime - startTime; - if (waitTime > 0) { - executorService.awaitTermination(waitTime, TimeUnit.NANOSECONDS); - } - break; - } catch (InterruptedException e) { - interrupted = true; - } - } - anyAlive |= !executorService.isTerminated(); - } - for (Thread thread : ChunkyThread.threads) { - while (System.nanoTime() < endTime) { - try { - long waitTimeMillis = TimeUnit.NANOSECONDS.toMillis(endTime - startTime); - if (waitTimeMillis > 0) { - thread.join(waitTimeMillis); - } - break; - } catch (InterruptedException e) { - interrupted = true; + while (System.nanoTime() < endTime) { + try { + if (joinAllInterruptable(endTime)) { + // All threads are joined, skip the rest of the wait time. + return true; } + } catch (InterruptedException e) { + interrupted = true; } - anyAlive |= thread.isAlive(); } + // Got to the end of the wait time without joining everything, can give no guarantees + return false; } finally { if (interrupted) { Thread.currentThread().interrupt(); } } - return !anyAlive; } /** @@ -178,6 +159,43 @@ public static boolean interruptAndJoinAll(long timeout, @NotNull TimeUnit unit) return joinAll(timeout, unit); } + /** + * Await the joining of all threads managed by chunky. + * + *

This method is only safe to call if the {@link ChunkyThread#shutdownLatch} has been set.

+ * + *

WARNING: calling this from any thread registered with {@link #addThread(Thread)} may deadlock.

+ * + * @param endTimeNanos The time at which to stop waiting. + * + * @return Whether all threads were joined before returning + * + * @throws InterruptedException Propagates up when interrupted. The caller has no guarantee that shutdown has begun, + * or that any of the inner threads have been joined. + */ + private static boolean joinAllInterruptable(long endTimeNanos) throws InterruptedException { + // The intention here whether we return true or false, is to give the caller the most complete acquire load possible. + // Even if we reach the timeout given by the caller, we still establish a happens-before with every dead thread. + + boolean anyAlive = false; + for (ExecutorService executorService : executorServices) { + long waitTime = endTimeNanos - System.nanoTime(); + anyAlive |= !executorService.awaitTermination(waitTime, TimeUnit.NANOSECONDS); + } + for (Thread thread : threads) { + long waitTime = endTimeNanos - System.nanoTime(); + if (waitTime > 0) { + thread.join(waitTime); // joining with 0 is infinite wait time, very intuitive. + } + // Thread.isAlive() establishes a happens-before with the thread. As such the following are non-issues: + // - Not joining the thread, if waitTime <= 0 + // - The thread stopping between Thread.join() and Thread.isAlive(). + anyAlive |= thread.isAlive(); + } + + return !anyAlive; + } + private void setDefaults() { this.setDaemon(true); addThread(this); From f6acc0b5efdb086bfb82d914c7c665b99a3b45fe Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Thu, 23 Jul 2026 22:04:39 +0100 Subject: [PATCH 28/57] Remove jank in PluginManager --- .../java/se/llbit/chunky/plugin/loader/PluginManager.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java b/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java index b9a903a995..d17461fe34 100644 --- a/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java +++ b/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java @@ -72,18 +72,18 @@ public void load(Set pluginManifests, BiConsumer(pluginsToLoad).forEach(plugin -> { + for (Iterator iterator = pluginsToLoad.iterator(); iterator.hasNext(); ) { + ResolvedPlugin plugin = iterator.next(); if (plugin.allDependenciesLoaded(loadedPlugins)) { loadedPlugins.add(plugin); - pluginsToLoad.remove(plugin); + iterator.remove(); Log.infof(" Loading plugin %s with deps { %s }, resolved { %s }%n", plugin, plugin.getManifest().getDependencies().stream().map(PluginDependency::toString).collect(Collectors.joining(", ")), plugin.getDependencies().stream().map(ResolvedPlugin::toString).collect(Collectors.joining(", ")) ); pluginLoader.load(onLoad, plugin.getManifest()); } - }); + } } // report if any unloaded plugins remain (their dependencies never got loaded) From 58125374d2758f3d6268b8aac324d96b45cb812c Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Thu, 23 Jul 2026 22:10:18 +0100 Subject: [PATCH 29/57] Remove unnecessary chunky.plugins.maxLoadCycles jvm arg. The comment already explains that the worst case cannot take more cycles than the number of plugins to load, so we use that. --- .../chunky/plugin/loader/PluginManager.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java b/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java index d17461fe34..cfc15e3524 100644 --- a/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java +++ b/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java @@ -35,8 +35,6 @@ import java.util.stream.Collectors; public class PluginManager { - private static final int MAX_CYCLES = Integer.parseInt(System.getProperty("chunky.plugins.maxLoadCycles", "100")); - private final PluginLoader pluginLoader; public PluginManager(PluginLoader pluginLoader) { @@ -64,12 +62,13 @@ public void load(Set pluginManifests, BiConsumer pluginsToLoad = pluginsByName.values().stream().flatMap(Collection::stream).collect(Collectors.toSet()); pluginsToLoad.forEach(plugin -> plugin.resolveDependencies(pluginsByName)); - // load plugins in dependency-first order, cyclic dependencies will never be loaded and will hit MAX_CYCLES cap. - // this was so trivial to implement using cycles that I decided against any kind of dependency tree structure, - // in the worst case this approach requires one cycle per plugin (if every plugin depended on the previous one in the list). + int maxCycles = pluginsToLoad.size(); + // Load plugins in dependency-first order, cyclic dependencies will never be loaded and will hit the maxCycles cap. + // This was so trivial to implement using cycles that I decided against any kind of dependency tree structure. + // In the worst case this approach requires one cycle per plugin (if every plugin depended on the previous one in the list). Set loadedPlugins = new HashSet<>(); int loadCycles = 0; - while (!pluginsToLoad.isEmpty() && loadCycles < MAX_CYCLES) { + while (!pluginsToLoad.isEmpty() && loadCycles < maxCycles) { Log.infof("Cycle %d", loadCycles); loadCycles++; for (Iterator iterator = pluginsToLoad.iterator(); iterator.hasNext(); ) { @@ -89,8 +88,9 @@ public void load(Set pluginManifests, BiConsumer Date: Fri, 24 Jul 2026 22:00:14 +0100 Subject: [PATCH 30/57] Implement plugin shutdown --- chunky/src/java/se/llbit/chunky/Plugin.java | 14 ++++ .../src/java/se/llbit/chunky/main/Chunky.java | 45 +++--------- .../chunky/plugin/loader/PluginManager.java | 69 ++++++++++++++++++- .../chunky/plugin/PluginManagerTest.java | 2 +- 4 files changed, 92 insertions(+), 38 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/Plugin.java b/chunky/src/java/se/llbit/chunky/Plugin.java index d8e244ee55..836e8c7624 100644 --- a/chunky/src/java/se/llbit/chunky/Plugin.java +++ b/chunky/src/java/se/llbit/chunky/Plugin.java @@ -18,6 +18,7 @@ package se.llbit.chunky; import se.llbit.chunky.main.Chunky; +import se.llbit.util.concurrent.ChunkyThread; /** * The plugin interface for Chunky plugins. @@ -33,4 +34,17 @@ public interface Plugin { * @param chunky Chunky instance which the plugin should attach to */ void attach(Chunky chunky); + + /** + * Called when chunky shuts down, allowing the plugin to close critical resources. Most plugins do not need to do + * anything here. + * + *

This function will be called after all {@link ChunkyThread}s have shut down. Chunky's UI thread may still + * be running.

+ *

This method will not be called if any {@link ChunkyThread} does not shut down within its given time limit

+ *

This method may not be called if chunky terminates in a non-normal way, such as through {@link Runtime#halt}, + * the user killing the process (SIGKILL & TerminateProcess), or an error within a native + * function.

+ */ + default void shutdown(Chunky chunky) { } } diff --git a/chunky/src/java/se/llbit/chunky/main/Chunky.java b/chunky/src/java/se/llbit/chunky/main/Chunky.java index 4b69de74d3..e444a0fa3c 100644 --- a/chunky/src/java/se/llbit/chunky/main/Chunky.java +++ b/chunky/src/java/se/llbit/chunky/main/Chunky.java @@ -18,6 +18,7 @@ package se.llbit.chunky.main; import se.llbit.chunky.PersistentSettings; +import se.llbit.chunky.Plugin; import se.llbit.chunky.block.BlockProvider; import se.llbit.chunky.block.BlockSpec; import se.llbit.chunky.block.MinecraftBlockProvider; @@ -87,6 +88,7 @@ public void logEvent(Level level, String message) { }; public final ChunkyOptions options; + private final PluginManager pluginManager; private RenderController renderController; private SceneFactory sceneFactory = SceneFactory.DEFAULT; private RenderContextFactory renderContextFactory = RenderContext::new; @@ -108,6 +110,7 @@ public static String getMainWindowTitle() { public Chunky(ChunkyOptions options) { this.options = options; + this.pluginManager = new PluginManager(new JarPluginLoader()); registerBlockProvider(new MinecraftBlockProvider()); registerBlockProvider(new LegacyMinecraftBlockProvider()); } @@ -257,7 +260,9 @@ public static void main(final String[] args) { } private void shutdown(int timeout, TimeUnit unit) { - if (!ChunkyThread.interruptAndJoinAll(timeout, unit)) { + if (ChunkyThread.interruptAndJoinAll(timeout, unit)) { + pluginManager.shutdownPlugins(plugin -> plugin.shutdown(this)); + } else { Log.error("Not all threads were joined before shutting down."); // FIXME: list all alive threads? ThreadGroups are annoying. } } @@ -271,40 +276,10 @@ public static void loadDefaultTextures() { } private void loadPlugins() { - File pluginsDirectory = SettingsDirectory.getPluginsDirectory(); - if (!pluginsDirectory.isDirectory()) { - Log.infof("Plugins directory does not exist: %s", pluginsDirectory.getAbsolutePath()); - return; - } - Path pluginsPath = pluginsDirectory.toPath(); - JsonArray plugins = PersistentSettings.getPlugins(); - // TODO: allow plugins to implement a custom plugin loader. - PluginManager pluginManager = new PluginManager(new JarPluginLoader()); - - // Parse plugin manifests - Set pluginManifests = plugins.elements.stream() - .map(value -> value.asString("")) - .filter(jarName -> !jarName.isEmpty()) - .map(jarName -> pluginsPath.resolve(jarName).toAbsolutePath().toFile()) - .map(PluginManager::parsePluginManifest) - .flatMap(Optional::stream) - .collect(Collectors.toSet()); - - // Load plugins - pluginManager.load(pluginManifests, (plugin, manifest) -> { - String jarName = manifest.pluginJar.getName(); - Log.infof("Loading plugin: %s", jarName); - if (!isHeadless()) { - CreditsController.addPlugin(manifest.name, manifest.version.toString(), manifest.author, manifest.description); - } - - try { - plugin.attach(this); - } catch (Throwable t) { - Log.error("Plugin " + jarName + " failed to load.", t); - } - Log.infof("Plugin loaded: %s %s", manifest.name, manifest.version); - }); + this.pluginManager.loadPluginsFromDirectory( + SettingsDirectory.getPluginsDirectory(), + (plugin, manifest) -> plugin.attach(this) + ); } /** diff --git a/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java b/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java index cfc15e3524..6ccaf6eb27 100644 --- a/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java +++ b/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java @@ -16,9 +16,11 @@ */ package se.llbit.chunky.plugin.loader; +import se.llbit.chunky.PersistentSettings; import se.llbit.chunky.Plugin; import se.llbit.chunky.plugin.manifest.PluginDependency; import se.llbit.chunky.plugin.manifest.PluginManifest; +import se.llbit.json.JsonArray; import se.llbit.json.JsonParser; import se.llbit.log.Log; @@ -31,17 +33,75 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.stream.Collectors; public class PluginManager { private final PluginLoader pluginLoader; + private final LinkedList plugins = new LinkedList<>(); + private final AtomicBoolean isShutdown = new AtomicBoolean(false); public PluginManager(PluginLoader pluginLoader) { this.pluginLoader = pluginLoader; } - public void load(Set pluginManifests, BiConsumer onLoad) { + public void loadPluginsFromDirectory(File pluginsDirectory, BiConsumer onLoad) { + if (!pluginsDirectory.isDirectory()) { + Log.infof("Plugins directory does not exist: %s", pluginsDirectory.getAbsolutePath()); + return; + } + Path pluginsPath = pluginsDirectory.toPath(); + JsonArray plugins = PersistentSettings.getPlugins(); + // TODO: allow plugins to implement a custom plugin loader?. + + // Parse plugin manifests + Set pluginManifests = plugins.elements.stream() + .map(value -> value.asString("")) + .filter(jarName -> !jarName.isEmpty()) + .map(jarName -> pluginsPath.resolve(jarName).toAbsolutePath().toFile()) + .map(PluginManager::parsePluginManifest) + .flatMap(Optional::stream) + .collect(Collectors.toSet()); + + loadPlugins(pluginManifests, onLoad); + } + + public void loadPlugins(Set pluginManifests, BiConsumer onLoad) { + // Load plugins + this.load(pluginManifests, (plugin, manifest) -> { + String jarName = manifest.pluginJar.getName(); + Log.infof("Loading plugin: %s", jarName); + try { + onLoad.accept(plugin, manifest); + } catch (Throwable t) { + Log.error("Plugin " + jarName + " failed to load.", t); + } + Log.infof("Plugin loaded: %s %s", manifest.name, manifest.version); + }); + } + + /** + * Calls shutdown on every plugin. + *

Has no effect if already shutdown.

+ */ + public void shutdownPlugins(Consumer onShutdown) { + if (this.isShutdown.getAndSet(true)) { + return; + } + + // Iterates backwards to shut down plugins in the reverse of the order they were attached + this.plugins.descendingIterator().forEachRemaining(plugin -> { + try { + onShutdown.accept(plugin.plugin); + } catch (Throwable t) { + Log.error(String.format("The plugin %s threw when shutting down", plugin.manifest.name), t); + } + }); + } + + private void load(Set pluginManifests, BiConsumer onLoad) { // create plugin objects Map> pluginsByName = new HashMap<>(); pluginManifests.forEach(manifest -> { @@ -80,7 +140,10 @@ public void load(Set pluginManifests, BiConsumer { + this.plugins.add(new PluginEntry(manifest, loadedPlugin)); + onLoad.accept(loadedPlugin, manifest); + }, plugin.getManifest()); } } } @@ -118,4 +181,6 @@ public static Optional parsePluginManifest(File pluginJar) { } return Optional.empty(); } + + private record PluginEntry(PluginManifest manifest, Plugin plugin) {} } diff --git a/chunky/src/test/se/llbit/chunky/plugin/PluginManagerTest.java b/chunky/src/test/se/llbit/chunky/plugin/PluginManagerTest.java index 8b6d85fd20..54e62e3126 100644 --- a/chunky/src/test/se/llbit/chunky/plugin/PluginManagerTest.java +++ b/chunky/src/test/se/llbit/chunky/plugin/PluginManagerTest.java @@ -164,7 +164,7 @@ private static void assertLoadOrder(Set manifests, Set e loadedPlugins.add(pluginManifest.name); }); - pluginLoader.load(manifests, (plugin, manifest) -> {}); + pluginLoader.loadPlugins(manifests, (plugin, manifest) -> {}); assertEquals(expectedPlugins, loadedPlugins); } } From c5b971d5ae01c6d5329659ffb5006e2ea70fefa3 Mon Sep 17 00:00:00 2001 From: Maik Marschner Date: Sat, 25 Jul 2026 18:22:12 +0200 Subject: [PATCH 31/57] Update github actions. --- .github/workflows/gradle.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 8e361ebffe..0659ccd799 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -14,16 +14,16 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/master' && github.ref != 'refs/heads/chunky-2.4.x' }} - name: Grant execute permission for gradlew @@ -46,12 +46,12 @@ jobs: ;; esac - name: Upload build - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Chunky Build path: build/installer - name: Upload build - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Chunky Core path: build/chunky-core-*.jar From d3eb7f6fce256500e4f0180f3dfef0ccf8cd9b6a Mon Sep 17 00:00:00 2001 From: Maik Marschner Date: Sat, 25 Jul 2026 18:51:22 +0200 Subject: [PATCH 32/57] Update to java 25. --- .github/workflows/gradle.yml | 2 +- build.gradle | 6 +++--- chunky/build.gradle | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 0659ccd799..1aaecce463 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -21,7 +21,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: 'temurin' - java-version: '17' + java-version: '25' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: diff --git a/build.gradle b/build.gradle index cffaaf3285..29a76bb2aa 100644 --- a/build.gradle +++ b/build.gradle @@ -33,19 +33,19 @@ subprojects { java { toolchain { // default java version for the project - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(25) } } compileTestJava { // compile tests using more recent features javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(27) } } test { // run tests using more recent features javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(27) } useJUnitPlatform() diff --git a/chunky/build.gradle b/chunky/build.gradle index a71bd7026b..9504a82c89 100644 --- a/chunky/build.gradle +++ b/chunky/build.gradle @@ -29,7 +29,7 @@ dependencies { } javafx { - version = '17.0.11' + version = '25' configuration = 'implementation' modules = ['javafx.base', 'javafx.controls', 'javafx.fxml'] } From 69689c050ca53dd898715f65e3591c89d2a3fa5f Mon Sep 17 00:00:00 2001 From: Maik Marschner Date: Sat, 25 Jul 2026 20:24:37 +0200 Subject: [PATCH 33/57] Update gradle for java 25, use java 25 for testing too. --- build.gradle | 38 ++++++------- chunky/build.gradle | 68 ++++++++++++++---------- gradle/wrapper/gradle-wrapper.properties | 2 +- launcher/build.gradle | 6 ++- settings.gradle | 2 +- 5 files changed, 64 insertions(+), 52 deletions(-) diff --git a/build.gradle b/build.gradle index 29a76bb2aa..a48b5a62d8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ -project.version = getVersion() -println "Building version ${project.version}" +def projectVersion = getVersion().toString() +println "Building version ${projectVersion}" allprojects { repositories { @@ -15,7 +15,7 @@ buildscript { } } dependencies { - classpath 'org.openjfx:javafx-plugin:0.0.13' + classpath 'org.openjfx:javafx-plugin:0.1.0' classpath 'com.github.ben-manes:gradle-versions-plugin:0.46.0' } } @@ -36,17 +36,7 @@ subprojects { languageVersion = JavaLanguageVersion.of(25) } } - compileTestJava { - // compile tests using more recent features - javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(27) - } - } test { - // run tests using more recent features - javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(27) - } useJUnitPlatform() // Always run tests, even when nothing changed. @@ -113,7 +103,7 @@ defaultTasks 'release' task releaseVersion { doLast { - tryCommand([ 'git', 'tag', '-a', "${project.version}", '-m', "Version ${project.version}" ], true) + tryCommand([ 'git', 'tag', '-a', "${projectVersion}", '-m', "Version ${projectVersion}" ], true) } } @@ -121,11 +111,14 @@ task versionInfo(type: JavaExec) { dependsOn 'copyArtifacts' outputs.upToDateWhen { false } - outputs.files file("build/chunky-${project.version}.jar") - description 'Writes build/chunky-VERSION.jar, latest.json and updates chunky-core-VERSION.jar/version.json' + outputs.file(layout.buildDirectory.file("chunky-${projectVersion}.jar")) + + description = 'Writes build/chunky-VERSION.jar, latest.json and updates chunky-core-VERSION.jar/version.json' + classpath = project(':releasetools').sourceSets.main.runtimeClasspath mainClass.set('releasetools.ReleaseBuilder') - args "${project.version}", "release_notes-${project.version}.txt" + + args projectVersion, "release_notes-${projectVersion}.txt" } task release { @@ -138,30 +131,33 @@ task release { destinationDirectory.mkdirs() file("./latest.json").renameTo("${destinationDirectory}/latest.json") fileTree(buildDir).matching { - include "*-${project.version}.*" + include "*-${projectVersion}.*" }.each { it.renameTo("${destinationDirectory}/${it.name}") } } } +def launcherArchives = project(':launcher').configurations.archives + task buildReleaseJar(type: Jar) { dependsOn ':launcher:assembleDist' dependsOn 'versionInfo' - archiveFileName = "chunky-${project.version}.jar" + archiveFileName = "chunky-${projectVersion}.jar" destinationDirectory = file('build/installer') manifest { attributes('Main-Class': 'se.llbit.chunky.launcher.ChunkyLauncher') + duplicatesStrategy = DuplicatesStrategy.EXCLUDE } into('lib') { from fileTree('chunky/lib').include('*.jar') - from file("build/chunky-core-${project.version}.jar") + from file("build/chunky-core-${projectVersion}.jar") } from { - project(':launcher').configurations.archives.allArtifacts.files.collect { + launcherArchives.allArtifacts.files.collect { zipTree(it) } } diff --git a/chunky/build.gradle b/chunky/build.gradle index 9504a82c89..4e04a04dd2 100644 --- a/chunky/build.gradle +++ b/chunky/build.gradle @@ -2,8 +2,13 @@ apply plugin: 'application' apply plugin: 'maven-publish' apply plugin: 'org.openjfx.javafxplugin' -mainClassName = 'se.llbit.chunky.main.Chunky' -archivesBaseName = 'chunky-core' +application { + mainClass = 'se.llbit.chunky.main.Chunky' +} + +base { + archivesName = 'chunky-core' +} configurations { implementation.extendsFrom configurations.jsonlib @@ -52,7 +57,7 @@ jar { zipTree(it) } manifest { - attributes('Main-Class': mainClassName) + attributes('Main-Class': application.mainClass) } into('se/llbit/chunky/main') { from file("src/gen-res/Version.properties") @@ -78,25 +83,35 @@ sourceSets { processResources.dependsOn 'updateVersionString' +def projectVersion = providers.provider { + version.toString() +} + task updateVersionString { - description 'Store the current version string in src/gen-res/Version.properties' + description = 'Store the current version string in src/gen-res/Version.properties' + + def versionFile = layout.projectDirectory.file('src/gen-res/Version.properties') outputs.upToDateWhen { def props = new Properties() - def output = file('src/gen-res/Version.properties') - if (output.isFile()) { - output.withInputStream { stream -> props.load(stream) } + if (versionFile.asFile.isFile()) { + versionFile.asFile.withInputStream { stream -> + props.load(stream) + } } - props['version'] == project.version + props['version'] == projectVersion.get() } doLast { - file('src/gen-res').mkdirs() - def date = new Date() - def versionFile = file('src/gen-res/Version.properties') - ant.propertyfile(file: versionFile) { - entry(key: 'version', value: project.version) - entry(key: 'gitSha', value: tryCommand(['git', 'rev-parse', 'HEAD']).trim()) + versionFile.asFile.parentFile.mkdirs() + + def gitSha = rootProject.tryCommand( + ['git', 'rev-parse', 'HEAD'] + ).trim() + + ant.propertyfile(file: versionFile.asFile) { + entry(key: 'version', value: projectVersion.get()) + entry(key: 'gitSha', value: gitSha) } } } @@ -112,12 +127,13 @@ task sourcesJar(type: Jar) { } task copyExternalDependencies(type: Copy) { - into "${buildDir}/${libsDirName}" + into layout.buildDirectory.dir("libs") from configurations.externalDependencies } -artifacts { - archives javadocJar, sourcesJar +tasks.named("assemble") { + dependsOn(javadocJar) + dependsOn(sourcesJar) } publishing { @@ -129,7 +145,7 @@ publishing { packaging = "jar" description = "Minecraft mapping and rendering tool" url = "http://chunky.llbit.se" - artifactId = archivesBaseName + artifactId = base.archivesName version = "2.5.0-SNAPSHOT" licenses { @@ -156,22 +172,20 @@ publishing { // fix dependency scopes (see https://discuss.gradle.org/t/maven-publish-plugin-generated-pom-making-dependency-scope-runtime/7494/9) // and filter dependencies + def implementationDependencies = configurations.implementation.allDependencies publishing.publications.all { pom.withXml { - asNode().dependencies.'*'.findAll() { - it.scope.text() == 'runtime' && project.configurations.implementation.allDependencies.find { dep -> + asNode().dependencies.'*'.findAll { + it.scope.text() == 'runtime' && + implementationDependencies.find { dep -> dep.name == it.artifactId.text() } - }.each() { + }.each { if (it.groupId.text() == "org.openjfx") { - // remove javafx dependencies for now (maybe make them optional in the future?) it.parent().remove(it) - // it.appendNode('optional', 'true') } else if (it.groupId.text() == "se.llbit") { - // se.llbit dependencies are included in chunky-core it.parent().remove(it) } else { - // everything else is a dependency as usual it.scope*.value = 'compile' } } @@ -181,8 +195,8 @@ publishing { repositories { maven { - name 'Build' - url layout.buildDirectory.dir('maven') + name = 'Build' + url = layout.buildDirectory.dir('maven') } } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index bdc9a83b1e..0feaf0e65b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/launcher/build.gradle b/launcher/build.gradle index 26b483e1f5..ec5db95a09 100644 --- a/launcher/build.gradle +++ b/launcher/build.gradle @@ -1,7 +1,9 @@ apply plugin: 'application' apply plugin: 'org.openjfx.javafxplugin' -mainClassName = 'se.llbit.chunky.launcher.ChunkyLauncher' +application { + mainClass = 'se.llbit.chunky.launcher.ChunkyLauncher' +} configurations { bundled @@ -51,7 +53,7 @@ sourceSets { } jar { - manifest.attributes 'Main-Class': mainClassName + manifest.attributes 'Main-Class': application.mainClass // Include classes from the common library. from project(':lib').configurations.archives.allArtifacts.files.collect { diff --git a/settings.gradle b/settings.gradle index 8863763fd0..aa69042eb0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' // java toolchain resolver + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' // java toolchain resolver } rootProject.name = 'chunky' From 6e26c9c97486c929be3d40260c8571965679c1de Mon Sep 17 00:00:00 2001 From: Maik Marschner Date: Sat, 25 Jul 2026 20:25:20 +0200 Subject: [PATCH 34/57] Remove obsolete names from setup-java and setup-gradle actions. --- .github/workflows/gradle.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 1aaecce463..0d5f90d580 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -17,13 +17,11 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - - name: Set up JDK 17 - uses: actions/setup-java@v5 + - uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '25' - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + - uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/master' && github.ref != 'refs/heads/chunky-2.4.x' }} - name: Grant execute permission for gradlew From 091fe65071ecaddcbd04e67fca29a2fd45886e82 Mon Sep 17 00:00:00 2001 From: Maik Marschner Date: Sat, 25 Jul 2026 20:28:58 +0200 Subject: [PATCH 35/57] Update JaCoCo for Java 25 support. --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index a48b5a62d8..418a81bdeb 100644 --- a/build.gradle +++ b/build.gradle @@ -45,7 +45,7 @@ subprojects { finalizedBy jacocoTestReport // report is always generated after tests run (it's very cheap) } jacoco { - toolVersion = "0.8.11" + toolVersion = "0.8.15" } jacocoTestReport { dependsOn test // tests are required to run before generating the report From 559a6b792c106bc86d75464a08623843703a84c8 Mon Sep 17 00:00:00 2001 From: Maik Marschner Date: Sat, 25 Jul 2026 20:46:11 +0200 Subject: [PATCH 36/57] Use JUnit 5 for the launcher, too. --- launcher/build.gradle | 1 - .../se/llbit/chunky/launcher/ChunkyLauncherCliParseTest.java | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/launcher/build.gradle b/launcher/build.gradle index ec5db95a09..2500b63bba 100644 --- a/launcher/build.gradle +++ b/launcher/build.gradle @@ -18,7 +18,6 @@ dependencies { implementation project(':lib') testImplementation 'com.google.truth:truth:1.1.3' - testImplementation 'junit:junit:4.13.2' } java { diff --git a/launcher/test/se/llbit/chunky/launcher/ChunkyLauncherCliParseTest.java b/launcher/test/se/llbit/chunky/launcher/ChunkyLauncherCliParseTest.java index a0cde57503..1480866384 100644 --- a/launcher/test/se/llbit/chunky/launcher/ChunkyLauncherCliParseTest.java +++ b/launcher/test/se/llbit/chunky/launcher/ChunkyLauncherCliParseTest.java @@ -20,10 +20,9 @@ import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.ParseException; -import org.junit.Test; +import org.junit.jupiter.api.Test; import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.assertThrows; public class ChunkyLauncherCliParseTest { @Test From eaf263cdc9d6a27d0cbd5b54999339c2ef3219e4 Mon Sep 17 00:00:00 2001 From: Maik Marschner Date: Wed, 29 Jul 2026 01:23:30 +0200 Subject: [PATCH 37/57] Copy versioned chunky-core.jar into build directory and fix generating latest.json file. --- build.gradle | 32 +++++++++++++++++++++++++++++--- chunky/.gitignore | 1 + 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 418a81bdeb..18d232e418 100644 --- a/build.gradle +++ b/build.gradle @@ -107,8 +107,23 @@ task releaseVersion { } } +task prepareReleaseLibraries { + dependsOn ':chunky:copyExternalDependencies' + outputs.upToDateWhen { false } + + doLast { + def libDir = file('chunky/lib') + libDir.mkdirs() + copy { + from project(':chunky').tasks.named('copyExternalDependencies').get().outputs.files + into libDir + } + } +} + task versionInfo(type: JavaExec) { dependsOn 'copyArtifacts' + dependsOn 'prepareReleaseLibraries' outputs.upToDateWhen { false } outputs.file(layout.buildDirectory.file("chunky-${projectVersion}.jar")) @@ -142,6 +157,7 @@ def launcherArchives = project(':launcher').configurations.archives task buildReleaseJar(type: Jar) { dependsOn ':launcher:assembleDist' + dependsOn 'prepareReleaseLibraries' dependsOn 'versionInfo' archiveFileName = "chunky-${projectVersion}.jar" @@ -166,10 +182,20 @@ task buildReleaseJar(type: Jar) { rename "latest.json", "version.json" } -task copyArtifacts(type: Copy) { +task copyArtifacts { dependsOn subprojects.jar - from subprojects.jar - into buildDir + dependsOn ':chunky:jar' + outputs.upToDateWhen { false } + + doLast { + def sourceJar = project(':chunky').tasks.named('jar').get().archiveFile.get().asFile + def rootJar = file("build/chunky-core-${projectVersion}.jar") + copy { + from sourceJar + into rootJar.parentFile + rename { rootJar.name } + } + } } /** Helper function to run a command. Returns the command output if the command succeeded. */ diff --git a/chunky/.gitignore b/chunky/.gitignore index 0cdf654876..7e3963237f 100644 --- a/chunky/.gitignore +++ b/chunky/.gitignore @@ -1,3 +1,4 @@ /bin/ /build/ +/lib/ /src/gen-res/ From 665f37455968119a275494a47cd6f25e1eb5af23 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sun, 26 Jul 2026 21:49:02 +0100 Subject: [PATCH 38/57] bedrock: Loading worlds --- build.gradle | 6 +- chunky/build.gradle | 2 + .../se/llbit/chunky/renderer/scene/Scene.java | 7 +- .../chunky/world/bedrock/BedrockChunk.java | 154 ++++++++++++++++++ .../world/bedrock/BedrockDimension.java | 139 ++++++++++++++++ .../chunky/world/bedrock/BedrockWorld.java | 53 ++++++ .../world/bedrock/BedrockWorldFormat.java | 56 +++++++ .../chunky/world/region/RegionParser.java | 2 - .../world/worldformat/WorldFormats.java | 2 + 9 files changed, 416 insertions(+), 5 deletions(-) create mode 100644 chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java create mode 100644 chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java create mode 100644 chunky/src/java/se/llbit/chunky/world/bedrock/BedrockWorld.java create mode 100644 chunky/src/java/se/llbit/chunky/world/bedrock/BedrockWorldFormat.java diff --git a/build.gradle b/build.gradle index 18d232e418..0859e955ba 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,11 @@ println "Building version ${projectVersion}" allprojects { repositories { mavenCentral() - mavenLocal() + // FIXME @NotStirred: DO NOT RELY ON SNAPSHOT LEVELDB-FFI + maven { + name = "Maven Central SNAPSHOT" + url = "https://central.sonatype.com/repository/maven-snapshots/" + } } } diff --git a/chunky/build.gradle b/chunky/build.gradle index 4e04a04dd2..3eeb2ab075 100644 --- a/chunky/build.gradle +++ b/chunky/build.gradle @@ -30,6 +30,8 @@ dependencies { implementation 'com.google.code.gson:gson:2.9.0' implementation 'org.lz4:lz4-java:1.8.0' implementation 'org.apache.maven:maven-artifact:3.9.9' + implementation 'io.github.notstirred:leveldb-ffi:0.1.0-SNAPSHOT' + implementation 'org.cloudburstmc:nbt:3.0.0.Final' implementation project(':lib') } diff --git a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java index 6d687d47bc..aa03fcea38 100644 --- a/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java +++ b/chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java @@ -53,6 +53,7 @@ import se.llbit.chunky.world.biome.Biome; import se.llbit.chunky.world.biome.BiomePalette; import se.llbit.chunky.world.biome.Biomes; +import se.llbit.chunky.world.java.JavaDimension; import se.llbit.chunky.world.java.JavaWorldFormat; import se.llbit.chunky.world.region.Region; import se.llbit.chunky.world.worldformat.WorldFormats; @@ -838,8 +839,10 @@ public synchronized void loadChunks(TaskTracker taskTracker, World world, Map chunkData, int yMin, int yMax) { + return false; + } + + public static int ceilDiv(int x, int y) { + final int q = x / y; + // if the signs are the same and modulo not zero, round up + if ((x ^ y) >= 0 && (q * y != x)) { + return q + 1; + } + return q; + } + + @Override + public void getChunkData(@NotNull Mutable reuseChunkData, BlockPalette palette, BiomePalette biomePalette, int minY, int maxY) throws ChunkLoadingException { + if (reuseChunkData.get() == null) { + reuseChunkData.set(new GenericChunkData()); + } else { + reuseChunkData.get().clear(); + } + + // A great resource on bedrock's binary formats: https://github.com/Team-Lodestone/Documentation/tree/main/Bedrock/LevelDB_Output_Array_Formats + + for (byte subchunkIdx = 0; subchunkIdx < 16; subchunkIdx++) { + // Create subchunk key + boolean dimensionIsOverworld = this.dimension.getDimensionId().equals(Dimension.Identifier.OVERWORLD); + int subChunkKeySize = dimensionIsOverworld ? 10 : 14; + ByteBuffer byteBuffer = ByteBuffer.allocate(subChunkKeySize).order(ByteOrder.LITTLE_ENDIAN) + .putInt(this.position.x).putInt(this.position.z); + if (!dimensionIsOverworld) { + byteBuffer.putInt(switch (this.dimension.getDimensionId().getNamespacedName()) { // TODO in Java 21+ we can use `switch (dimensionId)` here + case "minecraft:the_nether" -> 1; + case "minecraft:the_end" -> 2; + default -> throw new RuntimeException("Unsupported dimension in Bedrock world"); // TODO: should this throw? + }); + } + byteBuffer.put((byte) 0x2f); + byteBuffer.put(subchunkIdx); + + try { + BedrockDimension dim = (BedrockDimension) this.dimension; + Optional dbValue = dim.getDbValue(byteBuffer.array()); + if (dbValue.isEmpty()) { + return; + } + ByteBuffer value = ByteBuffer.wrap(dbValue.get()).order(ByteOrder.LITTLE_ENDIAN); + + // Parse subchunk + int version = value.get(); + int numStorages = value.get(); + int yIndex = value.get(); + + for (int storage = 0; storage < numStorages; storage++) { + int packed = value.get(); + boolean isRuntime = (packed & 1) != 0; + assert !isRuntime : "Runtime state on disk?!"; + int bitsPerBlock = packed >> 1; + int mask = (1 << bitsPerBlock)-1; + + int blocksPerWord = 32 / bitsPerBlock; + int wordCount = ceilDiv(4096, blocksPerWord); + + ByteBuffer blockData = value.slice().order(ByteOrder.LITTLE_ENDIAN); + value.position(value.position() + wordCount * 4); + ChunkData chunkData = reuseChunkData.get(); + + int b = value.getInt(); + + Tag[] subpalette = new Tag[b]; +// int bufPos = value.position(); +// ByteBuffer allocate = ByteBuffer.allocate(value.capacity()).order(ByteOrder.LITTLE_ENDIAN); +// allocate.put(value); +// allocate.position(bufPos); +// value.position(bufPos); +// Tag tag = NamedTag.read(new LittleEndianDataInputStream(new DataInputStream(new BedrockDimension.ByteBufferBackedInputStream(value)))); + NBTInputStream tags = NbtUtils.createReaderLE(new BedrockDimension.ByteBufferBackedInputStream(value)); + + for (int i = 0; i < b; i++) { + NbtMap compound = (NbtMap) tags.readTag(); + String name = compound.getString("name"); + subpalette[i] = new CompoundTag(List.of(new NamedTag("Name", new StringTag(name)))); + } + + int u = 0; + for (int j = 0; j < wordCount; j++) { + int temp = blockData.getInt(); + + for (int k = 0; k < blocksPerWord && u < 4096; k++) { + int x = (u >> 8) & 0xf; + int y = u & 0xf; + int z = (u >> 4) & 0xf; + int pos = x + 16 * y + 256 * z; + + int subpaletteIdx = (temp & mask); + chunkData.setBlockAt(x, 16 * yIndex + y, z, palette.put(subpalette[subpaletteIdx])); + + temp >>= bitsPerBlock; + u++; + } + } + + yIndex += 1; + } + + } catch (LevelDBException | IOException e) { + throw new ChunkLoadingException("Exception thrown when loading chunk " + this.position, e); + } + } + } + + public static Tag read(DataInputStream in) { + try { + byte type = in.readByte(); + if (type == 0) { + return Tag.END; + } else { + SpecificTag name = StringTag.read(in); + SpecificTag payload = SpecificTag.read(type, in); + return new NamedTag(name.stringValue(), payload); + } + } catch (IOException e) { + return new ErrorTag("IOException while reading tag type:\n" + e.getMessage()); + } + } +} diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java new file mode 100644 index 0000000000..da6b243629 --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java @@ -0,0 +1,139 @@ +package se.llbit.chunky.world.bedrock; + +import io.github.notstirred.leveldb_ffi.*; +import se.llbit.chunky.map.MapView; +import se.llbit.chunky.map.WorldMapLoader; +import se.llbit.chunky.world.*; +import se.llbit.chunky.world.region.Region; +import se.llbit.chunky.world.region.RegionChangeWatcher; +import se.llbit.math.Vector3; +import se.llbit.math.Vector3i; +import se.llbit.util.annotation.Nullable; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.Cleaner; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.*; + +public class BedrockDimension extends Dimension implements Closeable { + private static final Cleaner cleaner = Cleaner.create(); // TODO: move this to Chunky class or something usable by all. + + private final LevelDB db; + private final Map chunks = new HashMap<>(); + + protected BedrockDimension(BedrockWorld world, Identifier dimensionId, Path dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { + super(dimensionId, dimensionDirectory, playerEntities, null); + Options options = Options.create(); + options.setCompression(Compressor.ZLIB_RAW); + options.setCreateIfMissing(false); + try { + this.db = LevelDB.open(options, dimensionDirectory.resolve("db").toAbsolutePath()); + cleaner.register(this, this.db::close); + } catch (LevelDBException e) { + throw new RuntimeException(e); + } + } + + public Optional getDbValue(byte[] key) throws LevelDBException { + return this.db.get(ReadOptions.create(), key); + } + + @Override + public boolean reloadPlayerData() { + return false; + } + + @Override + public Optional getPlayerPos() { + return Optional.empty(); + } + + @Override + public void close() throws IOException { + this.db.close(); + } + + static class ByteBufferBackedInputStream extends InputStream { + private final ByteBuffer buf; + + public ByteBufferBackedInputStream(ByteBuffer buf) { + this.buf = buf; + } + + public int read() throws IOException { + if (!buf.hasRemaining()) { + return -1; + } + return buf.get() & 0xFF; + } + + public int read(byte[] bytes, int off, int len) + throws IOException { + if (!buf.hasRemaining()) { + return -1; + } + + len = Math.min(len, buf.remaining()); + buf.get(bytes, off, len); + return len; + } + } + + @Override + public String getName() { + return ""; + } + + @Override + public Chunk getChunk(ChunkPosition pos) { + return this.chunks.computeIfAbsent(pos, p -> new BedrockChunk(pos, this)); + } + + @Override + public Region createRegion(RegionPosition pos) { + return null; + } + + @Override + public HeightRange heightRange() { + return new HeightRange(-64, 320); + } + + @Override + public RegionChangeWatcher createRegionChangeWatcher(WorldMapLoader worldMapLoader, MapView mapView) { + return new RegionChangeWatcher(worldMapLoader, mapView, "the thread name") { + @Override + public void run() { + + } + }; + } + + @Override + public Region getRegion(RegionPosition pos) { + return null; + } + + @Override + public Region getRegionWithinRange(RegionPosition pos, HeightRange heightRange) { + return null; + } + + @Override + public boolean hasRegion(RegionPosition pos) { + return false; + } + + @Override + public String toString() { + return "A Bedrock dimension"; // FIXME + } + + @Override + public boolean hasRegionWithinRange(RegionPosition regionPos, HeightRange heightRange) { + return false; + } +} diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockWorld.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockWorld.java new file mode 100644 index 0000000000..693bb82e3a --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockWorld.java @@ -0,0 +1,53 @@ +package se.llbit.chunky.world.bedrock; + +import io.github.notstirred.leveldb_ffi.LevelDBLib; +import se.llbit.chunky.world.Dimension; +import se.llbit.chunky.world.EmptyWorld; +import se.llbit.chunky.world.World; +import se.llbit.log.Log; +import se.llbit.math.Vector3i; + +import java.util.Collections; +import java.util.Optional; +import java.util.Set; + +public class BedrockWorld extends World { + public static final boolean IS_BEDROCK_SUPPORTED = LevelDBLib.init(); + private static boolean warnedUserIfNotSupported; + + public BedrockWorld(Info info) { + super(info); + + if (!IS_BEDROCK_SUPPORTED && !warnedUserIfNotSupported) { + Log.warn("A bedrock world was loaded but bedrock is not supported on this OS/ARCH.\n" + + "If you believe your platform should be supported or this is an error please report a bug on the chunky bug tracker."); + warnedUserIfNotSupported = true; + } + } + + @Override + public Set getAvailableDimensions() { + return Set.of(Dimension.Identifier.OVERWORLD); + } + + @Override + public Optional getDefaultDimension() { + return Optional.of(Dimension.Identifier.OVERWORLD); + } + + @Override + public Dimension loadDimension(Dimension.Identifier dimensionId) { + try { + if (this.currentDimension != EmptyWorld.INSTANCE.currentDimension()) { + ((BedrockDimension) this.currentDimension).close(); // close early to avoid cleaner + } + } catch (Exception e) { + throw new RuntimeException(e); + } + BedrockDimension dimension = new BedrockDimension(this, dimensionId, this.getInfo().path(), Collections.emptySet(), new Vector3i(0, 0, 0)); + + this.currentDimension = dimension; + + return dimension; + } +} diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockWorldFormat.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockWorldFormat.java new file mode 100644 index 0000000000..d83045cd15 --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockWorldFormat.java @@ -0,0 +1,56 @@ +package se.llbit.chunky.world.bedrock; + +import se.llbit.chunky.world.World; +import se.llbit.chunky.world.worldformat.WorldFormat; +import se.llbit.util.annotation.NotNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +public class BedrockWorldFormat implements WorldFormat { + @Override + public String getName() { + return "Bedrock"; + } + + @Override + public String getDescription() { + return "The Minecraft world format for Bedrock worlds"; + } + + @Override + public String getId() { + return "BEDROCK_LEVELDB"; + } + + @Override + public boolean isValid(Path path) { + return Files.isRegularFile(path.resolve("level.dat")) + && Files.isDirectory(path.resolve("db")); + } + + @NotNull + @Override + public Optional getWorldInfo(@NotNull Path path) { + if (!isValid(path)) { + return Optional.empty(); + } + + String name = path.getFileName().toString(); + if (Files.exists(path.resolve("levelname.txt"))) { + try { + name = Files.readAllLines(path.resolve("levelname.txt")).stream().findFirst().orElse(name); + } catch (IOException ignored) { + } + } + return Optional.of(new World.Info(name, path, 0, 0, "Survival", this)); + } + + @NotNull + @Override + public World loadWorld(@NotNull World.Info info) { + return new BedrockWorld(info); + } +} diff --git a/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java b/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java index 052b277d6e..5fe3ea1f58 100644 --- a/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java +++ b/chunky/src/java/se/llbit/chunky/world/region/RegionParser.java @@ -17,8 +17,6 @@ package se.llbit.chunky.world.region; import se.llbit.chunky.chunk.ChunkData; -import se.llbit.chunky.chunk.GenericChunkData; -import se.llbit.chunky.chunk.SimpleChunkData; import se.llbit.chunky.map.MapView; import se.llbit.chunky.map.WorldMapLoader; import se.llbit.chunky.world.*; diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java index 3df89315d6..2a25cc8d7c 100644 --- a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java @@ -2,6 +2,7 @@ import se.llbit.chunky.world.EmptyWorld; import se.llbit.chunky.world.World; +import se.llbit.chunky.world.bedrock.BedrockWorldFormat; import se.llbit.chunky.world.java.JavaWorldFormat; import se.llbit.log.Log; import se.llbit.util.annotation.NotNull; @@ -31,6 +32,7 @@ public static Optional getWorldFormat(String id) { static { addWorldFormat(new JavaWorldFormat()); + addWorldFormat(new BedrockWorldFormat()); } @NotNull From e07dd69a3c42fa941fa7f48b53d12533d0de81d1 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Wed, 24 Jun 2026 19:50:08 +0100 Subject: [PATCH 39/57] bedrock: Implement map surface layer map view --- .../chunky/world/bedrock/BedrockChunk.java | 102 +++++++++++++----- .../world/bedrock/BedrockDimension.java | 18 ++-- .../world/bedrock/VirtualBedrockRegion.java | 68 ++++++++++++ 3 files changed, 153 insertions(+), 35 deletions(-) create mode 100644 chunky/src/java/se/llbit/chunky/world/bedrock/VirtualBedrockRegion.java diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java index 37cd8f4486..888333080d 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java @@ -5,10 +5,14 @@ import org.cloudburstmc.nbt.NbtMap; import org.cloudburstmc.nbt.NbtUtils; import se.llbit.chunky.chunk.*; +import se.llbit.chunky.map.BiomeLayer; +import se.llbit.chunky.map.SurfaceLayer; import se.llbit.chunky.world.Chunk; import se.llbit.chunky.world.ChunkPosition; import se.llbit.chunky.world.Dimension; +import se.llbit.chunky.world.biome.ArrayBiomePalette; import se.llbit.chunky.world.biome.BiomePalette; +import se.llbit.log.Log; import se.llbit.nbt.*; import se.llbit.util.Mutable; import se.llbit.util.annotation.NotNull; @@ -17,18 +21,54 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; -import java.util.List; -import java.util.Optional; +import java.util.*; public class BedrockChunk extends Chunk { + private boolean renderedToMap = false; public BedrockChunk(ChunkPosition pos, BedrockDimension dimension) { super(pos, dimension); } @Override - public boolean loadChunk(@NotNull Mutable chunkData, int yMin, int yMax) { - return false; + public boolean loadChunk(@NotNull Mutable chunkDataMutable, int yMin, int yMax) { + if (renderedToMap) { + return false; + } + renderedToMap = true; + + BlockPalette palette = new BlockPalette(); + palette.unsynchronize(); + BiomePalette biomePalette = new ArrayBiomePalette(); + + chunkDataMutable.set(this.dimension.createChunkData(chunkDataMutable.get(), 0, 256)); + try { + ChunkData chunkData = chunkDataMutable.get(); + boolean readData = readChunkData(chunkData, palette, biomePalette, yMin, yMax); + if (!readData) { + return false; + } + readBiomeData(chunkData, biomePalette, yMin, yMax); + + int[] heightmapData = new int[Chunk.X_MAX * Chunk.Z_MAX]; + Arrays.fill(heightmapData, 256); + + biomes = new BiomeLayer(chunkData, biomePalette); + surface = new SurfaceLayer(dimension.getDimensionId(), chunkData, palette, biomePalette, yMin, yMax, heightmapData); + updateHeightmap(dimension.getHeightmap(), this.position, chunkData, heightmapData, palette, yMax); + queueTopography(); + } catch (ChunkLoadingException e) { + Log.warn(String.format("Failed to load chunk %s", position), e); + } + + return true; + } + + private void readBiomeData(ChunkData chunkData, BiomePalette biomePalette, int yMin, int yMax) { + byte Data3D = 0x2B; + + // TODO: biome data + } public static int ceilDiv(int x, int y) { @@ -48,30 +88,40 @@ public void getChunkData(@NotNull Mutable reuseChunkData, BlockPalett reuseChunkData.get().clear(); } + readChunkData(reuseChunkData.get(), palette, biomePalette, minY, maxY); + } + + public Optional readSubChunk(ChunkPosition pos, byte subChunkIdx) throws LevelDBException { + // Create subchunk key + boolean dimensionIsOverworld = dimension.getDimensionId().equals(Dimension.Identifier.OVERWORLD); + int subChunkKeySize = dimensionIsOverworld ? 10 : 14; + ByteBuffer byteBuffer = ByteBuffer.allocate(subChunkKeySize).order(ByteOrder.LITTLE_ENDIAN) + .putInt(pos.x) + .putInt(pos.z); + + if (!dimensionIsOverworld) { + byteBuffer.putInt(switch (dimension.getDimensionId().getNamespacedName()) { + case "minecraft:the_nether" -> 1; + case "minecraft:the_end" -> 2; + default -> throw new RuntimeException("Unsupported dimension in Bedrock world"); // TODO: should this throw? + }); + } + byteBuffer.put((byte) 0x2f); + byteBuffer.put(subChunkIdx); + return ((BedrockDimension) dimension).getDbValue(byteBuffer.array()); + } + + private boolean readChunkData(ChunkData chunkData, BlockPalette palette, BiomePalette biomePalette, int minY, int maxY) throws ChunkLoadingException { // A great resource on bedrock's binary formats: https://github.com/Team-Lodestone/Documentation/tree/main/Bedrock/LevelDB_Output_Array_Formats + boolean dataPresent = false; for (byte subchunkIdx = 0; subchunkIdx < 16; subchunkIdx++) { - // Create subchunk key - boolean dimensionIsOverworld = this.dimension.getDimensionId().equals(Dimension.Identifier.OVERWORLD); - int subChunkKeySize = dimensionIsOverworld ? 10 : 14; - ByteBuffer byteBuffer = ByteBuffer.allocate(subChunkKeySize).order(ByteOrder.LITTLE_ENDIAN) - .putInt(this.position.x).putInt(this.position.z); - if (!dimensionIsOverworld) { - byteBuffer.putInt(switch (this.dimension.getDimensionId().getNamespacedName()) { // TODO in Java 21+ we can use `switch (dimensionId)` here - case "minecraft:the_nether" -> 1; - case "minecraft:the_end" -> 2; - default -> throw new RuntimeException("Unsupported dimension in Bedrock world"); // TODO: should this throw? - }); - } - byteBuffer.put((byte) 0x2f); - byteBuffer.put(subchunkIdx); - try { - BedrockDimension dim = (BedrockDimension) this.dimension; - Optional dbValue = dim.getDbValue(byteBuffer.array()); + Optional dbValue = readSubChunk(this.position, subchunkIdx); if (dbValue.isEmpty()) { - return; + continue; } + dataPresent = true; ByteBuffer value = ByteBuffer.wrap(dbValue.get()).order(ByteOrder.LITTLE_ENDIAN); // Parse subchunk @@ -91,17 +141,10 @@ public void getChunkData(@NotNull Mutable reuseChunkData, BlockPalett ByteBuffer blockData = value.slice().order(ByteOrder.LITTLE_ENDIAN); value.position(value.position() + wordCount * 4); - ChunkData chunkData = reuseChunkData.get(); int b = value.getInt(); Tag[] subpalette = new Tag[b]; -// int bufPos = value.position(); -// ByteBuffer allocate = ByteBuffer.allocate(value.capacity()).order(ByteOrder.LITTLE_ENDIAN); -// allocate.put(value); -// allocate.position(bufPos); -// value.position(bufPos); -// Tag tag = NamedTag.read(new LittleEndianDataInputStream(new DataInputStream(new BedrockDimension.ByteBufferBackedInputStream(value)))); NBTInputStream tags = NbtUtils.createReaderLE(new BedrockDimension.ByteBufferBackedInputStream(value)); for (int i = 0; i < b; i++) { @@ -135,6 +178,7 @@ public void getChunkData(@NotNull Mutable reuseChunkData, BlockPalett throw new ChunkLoadingException("Exception thrown when loading chunk " + this.position, e); } } + return dataPresent; } public static Tag read(DataInputStream in) { diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java index da6b243629..41022b9e26 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java @@ -4,6 +4,7 @@ import se.llbit.chunky.map.MapView; import se.llbit.chunky.map.WorldMapLoader; import se.llbit.chunky.world.*; +import se.llbit.chunky.world.region.EmptyRegion; import se.llbit.chunky.world.region.Region; import se.llbit.chunky.world.region.RegionChangeWatcher; import se.llbit.math.Vector3; @@ -17,12 +18,16 @@ import java.nio.ByteBuffer; import java.nio.file.Path; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; public class BedrockDimension extends Dimension implements Closeable { private static final Cleaner cleaner = Cleaner.create(); // TODO: move this to Chunky class or something usable by all. + protected final ConcurrentHashMap regionMap = new ConcurrentHashMap<>(); + private final LevelDB db; - private final Map chunks = new HashMap<>(); + + private final Map chunks = new ConcurrentHashMap<>(); protected BedrockDimension(BedrockWorld world, Identifier dimensionId, Path dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { super(dimensionId, dimensionDirectory, playerEntities, null); @@ -94,7 +99,7 @@ public Chunk getChunk(ChunkPosition pos) { @Override public Region createRegion(RegionPosition pos) { - return null; + return new VirtualBedrockRegion(pos, this); } @Override @@ -113,18 +118,19 @@ public void run() { } @Override - public Region getRegion(RegionPosition pos) { - return null; + public synchronized Region getRegion(RegionPosition pos) { + // Unconditionally create virtual regions when requested, as bedrock has no concept of a region + return regionMap.computeIfAbsent(pos, this::createRegion); } @Override public Region getRegionWithinRange(RegionPosition pos, HeightRange heightRange) { - return null; + return getRegion(pos); } @Override public boolean hasRegion(RegionPosition pos) { - return false; + return !(regionMap.get(pos) instanceof EmptyRegion); } @Override diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/VirtualBedrockRegion.java b/chunky/src/java/se/llbit/chunky/world/bedrock/VirtualBedrockRegion.java new file mode 100644 index 0000000000..8371093a2f --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/VirtualBedrockRegion.java @@ -0,0 +1,68 @@ +package se.llbit.chunky.world.bedrock; + +import se.llbit.chunky.world.Chunk; +import se.llbit.chunky.world.ChunkPosition; +import se.llbit.chunky.world.RegionPosition; +import se.llbit.chunky.world.region.Region; + +import java.util.Iterator; + +/** + * Bedrock doesn't have regions, this class redirects region calls to the chunk/dimension as is appropriate + */ +public class VirtualBedrockRegion implements Region { + private final RegionPosition position; + private final BedrockDimension dimension; + + public VirtualBedrockRegion(RegionPosition pos, BedrockDimension dimension) { + this.position = pos; + this.dimension = dimension; + } + + @Override + public Chunk getChunk(int x, int z) { + return this.dimension.getChunk(new ChunkPosition(x, z)); + } + + @Override + public void parse(int minY, int maxY) { } + + @Override + public RegionPosition getPosition() { + return this.position; + } + + @Override + public boolean hasChanged() { + return false; // Not supported by Bedrock implementation + } + + @Override + public boolean chunkChangedSince(ChunkPosition chunkPos, int timestamp) { + return false; + } + + @Override public Iterator iterator() { + return new Iterator<>() { + private int index = 0; + + @Override + public boolean hasNext() { + return index < Region.CHUNKS_X * Region.CHUNKS_Z; // virtual bedrock regions are the same size as java ones (dictated by the map view) + } + + @Override + public Chunk next() { + int localX = index & 0x1f; + int localZ = index >> 5; + index++; + return dimension.getChunk(position.asChunkPosition(localX, localZ)); + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + }; + } +} From a57450242b54e3e5040ed0788e9f49faeedb0c6d Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Wed, 24 Jun 2026 19:52:36 +0100 Subject: [PATCH 40/57] bedrock: Set chunks to empty if they don't exist. This means the map view shows empty white stripes, not a black void. --- .../src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java | 2 ++ .../java/se/llbit/chunky/world/bedrock/BedrockDimension.java | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java index 888333080d..e26bbe4723 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java @@ -10,6 +10,7 @@ import se.llbit.chunky.world.Chunk; import se.llbit.chunky.world.ChunkPosition; import se.llbit.chunky.world.Dimension; +import se.llbit.chunky.world.EmptyChunk; import se.llbit.chunky.world.biome.ArrayBiomePalette; import se.llbit.chunky.world.biome.BiomePalette; import se.llbit.log.Log; @@ -46,6 +47,7 @@ public boolean loadChunk(@NotNull Mutable chunkDataMutable, int yMin, ChunkData chunkData = chunkDataMutable.get(); boolean readData = readChunkData(chunkData, palette, biomePalette, yMin, yMax); if (!readData) { + ((BedrockDimension) dimension).setChunk(position, EmptyChunk.INSTANCE); return false; } readBiomeData(chunkData, biomePalette, yMin, yMax); diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java index 41022b9e26..db5ceeec7c 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java @@ -61,6 +61,11 @@ public void close() throws IOException { this.db.close(); } + public void setChunk(ChunkPosition position, Chunk chunk) { + this.chunks.put(position, chunk); + chunkUpdated(position); + } + static class ByteBufferBackedInputStream extends InputStream { private final ByteBuffer buf; From 9a53a547d25cd7b4affd975c938c5de1bfb637a1 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Wed, 24 Jun 2026 19:53:40 +0100 Subject: [PATCH 41/57] bedrock: Optimise reads for chunky's general case --- .../java/se/llbit/chunky/world/bedrock/BedrockDimension.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java index db5ceeec7c..4ce24e508d 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java @@ -26,6 +26,7 @@ public class BedrockDimension extends Dimension implements Closeable { protected final ConcurrentHashMap regionMap = new ConcurrentHashMap<>(); private final LevelDB db; + private final ReadOptions readOptions; private final Map chunks = new ConcurrentHashMap<>(); @@ -40,10 +41,12 @@ protected BedrockDimension(BedrockWorld world, Identifier dimensionId, Path dime } catch (LevelDBException e) { throw new RuntimeException(e); } + readOptions = ReadOptions.create(); + readOptions.setFillCache(false); // almost all reads happen only once } public Optional getDbValue(byte[] key) throws LevelDBException { - return this.db.get(ReadOptions.create(), key); + return this.db.get(readOptions, key); // leveldb is thread safe for N readers so no synchronization required } @Override From ad52d6a35b523f027772ae7e23892550fff20a60 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Fri, 26 Jun 2026 17:42:25 +0100 Subject: [PATCH 42/57] bedrock: Fix incorrect coordinates for chunks in the map view --- .../se/llbit/chunky/world/bedrock/VirtualBedrockRegion.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/VirtualBedrockRegion.java b/chunky/src/java/se/llbit/chunky/world/bedrock/VirtualBedrockRegion.java index 8371093a2f..08937c5021 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/VirtualBedrockRegion.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/VirtualBedrockRegion.java @@ -21,7 +21,7 @@ public VirtualBedrockRegion(RegionPosition pos, BedrockDimension dimension) { @Override public Chunk getChunk(int x, int z) { - return this.dimension.getChunk(new ChunkPosition(x, z)); + return this.dimension.getChunk(this.position.asChunkPosition(x, z)); } @Override From 77fd1d970c24d5054e91435d8b4c3a0a0c84ea30 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sat, 27 Jun 2026 12:46:53 +0100 Subject: [PATCH 43/57] bedrock: Parse biomes and heightmap data (Data3D) --- .../chunk/biome/GenericBiomeData3d.java | 47 ++++ .../chunky/world/bedrock/BedrockChunk.java | 230 ++++++++++++++++-- 2 files changed, 262 insertions(+), 15 deletions(-) create mode 100644 chunky/src/java/se/llbit/chunky/chunk/biome/GenericBiomeData3d.java diff --git a/chunky/src/java/se/llbit/chunky/chunk/biome/GenericBiomeData3d.java b/chunky/src/java/se/llbit/chunky/chunk/biome/GenericBiomeData3d.java new file mode 100644 index 0000000000..4f5ada3321 --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/chunk/biome/GenericBiomeData3d.java @@ -0,0 +1,47 @@ +package se.llbit.chunky.chunk.biome; + +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; + +import static se.llbit.chunky.world.Chunk.*; +import static se.llbit.chunky.world.Chunk.SECTION_Y_MAX; + +/** + * Implementation of a 3D biome grid where every block has a biome + * Supports any Y values + * + * Minecraft versions: Bedrock & CubicChunks + */ +public class GenericBiomeData3d implements BiomeData { + private final Int2ObjectOpenHashMap sections = new Int2ObjectOpenHashMap<>(); + + @Override + public int getBiome(int chunkLocalX, int chunkLocalY, int chunkLocalZ) { + int sectionY = chunkLocalY >> 4; + int[] sectionData = sections.get(sectionY); + + if(sectionData == null) { + return 0; + } + + return sectionData[getIdx(chunkLocalX, chunkLocalY, chunkLocalZ)]; + } + + @Override + public void setBiomeAt(int chunkLocalX, int chunkLocalY, int chunkLocalZ, int biome) { + if(biome == 0) + return; + + int sectionY = chunkLocalY >> 4; + int[] sectionData = sections.computeIfAbsent(sectionY, _ -> new int[X_MAX * SECTION_Y_MAX * Z_MAX]); + sectionData[getIdx(chunkLocalX, chunkLocalY, chunkLocalZ)] = biome; + } + + public static int getIdx(int localX, int localY, int localZ) { + return (localX & 0xf) + SECTION_Y_MAX * ((localY & 0xf) + (localZ & 0xf) * X_MAX); + } + + @Override + public void clear() { + sections.clear(); + } +} diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java index e26bbe4723..2bb59569a0 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java @@ -1,10 +1,13 @@ package se.llbit.chunky.world.bedrock; import io.github.notstirred.leveldb_ffi.LevelDBException; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import org.cloudburstmc.nbt.NBTInputStream; import org.cloudburstmc.nbt.NbtMap; import org.cloudburstmc.nbt.NbtUtils; import se.llbit.chunky.chunk.*; +import se.llbit.chunky.chunk.biome.BiomeData; +import se.llbit.chunky.chunk.biome.GenericBiomeData3d; import se.llbit.chunky.map.BiomeLayer; import se.llbit.chunky.map.SurfaceLayer; import se.llbit.chunky.world.Chunk; @@ -12,7 +15,9 @@ import se.llbit.chunky.world.Dimension; import se.llbit.chunky.world.EmptyChunk; import se.llbit.chunky.world.biome.ArrayBiomePalette; +import se.llbit.chunky.world.biome.Biome; import se.llbit.chunky.world.biome.BiomePalette; +import se.llbit.chunky.world.biome.Biomes; import se.llbit.log.Log; import se.llbit.nbt.*; import se.llbit.util.Mutable; @@ -25,6 +30,20 @@ import java.util.*; public class BedrockChunk extends Chunk { + private final byte Data3D_KEY = 0x2b; + private final byte Version_KEY = 0x2c; + private final byte SubChunkPrefix_KEY = 0x2f; + private final byte BlockEntity_KEY = 0x31; + private final byte Entity_KEY = 0x32; + + private static final Int2ObjectOpenHashMap bedrockBiomesById = new Int2ObjectOpenHashMap<>(); + + /** + * Bedrock has no chunk timestamps like java, so we render to the map once. + * + * Additionally chunky should NEVER support having a bedrock world open in MC and itself, + * leveldb doesn't support this. + */ private boolean renderedToMap = false; public BedrockChunk(ChunkPosition pos, BedrockDimension dimension) { @@ -50,7 +69,7 @@ public boolean loadChunk(@NotNull Mutable chunkDataMutable, int yMin, ((BedrockDimension) dimension).setChunk(position, EmptyChunk.INSTANCE); return false; } - readBiomeData(chunkData, biomePalette, yMin, yMax); + readData3D(chunkData, palette, biomePalette, yMax); int[] heightmapData = new int[Chunk.X_MAX * Chunk.Z_MAX]; Arrays.fill(heightmapData, 256); @@ -66,13 +85,6 @@ public boolean loadChunk(@NotNull Mutable chunkDataMutable, int yMin, return true; } - private void readBiomeData(ChunkData chunkData, BiomePalette biomePalette, int yMin, int yMax) { - byte Data3D = 0x2B; - - // TODO: biome data - - } - public static int ceilDiv(int x, int y) { final int q = x / y; // if the signs are the same and modulo not zero, round up @@ -91,13 +103,30 @@ public void getChunkData(@NotNull Mutable reuseChunkData, BlockPalett } readChunkData(reuseChunkData.get(), palette, biomePalette, minY, maxY); + readData3D(reuseChunkData.get(), palette, biomePalette, maxY); } public Optional readSubChunk(ChunkPosition pos, byte subChunkIdx) throws LevelDBException { - // Create subchunk key - boolean dimensionIsOverworld = dimension.getDimensionId().equals(Dimension.Identifier.OVERWORLD); - int subChunkKeySize = dimensionIsOverworld ? 10 : 14; - ByteBuffer byteBuffer = ByteBuffer.allocate(subChunkKeySize).order(ByteOrder.LITTLE_ENDIAN) + ByteBuffer byteBuffer = createDBKey(pos, SubChunkPrefix_KEY); + byteBuffer.put(subChunkIdx); + return ((BedrockDimension) dimension).getDbValue(byteBuffer.array()); + } + + private Optional readDBValue(ChunkPosition pos, byte key) throws LevelDBException { + return ((BedrockDimension) dimension).getDbValue(createDBKey(pos, key).array()); + } + + @NotNull + private ByteBuffer createDBKey(ChunkPosition pos, byte keyType) { + boolean dimensionIsOverworld = dimension.getDimensionId() == Dimension.Identifier.OVERWORLD; + int keySize = 9; // minimum key size, XZ+KEY + if (!dimensionIsOverworld) + keySize += 4; // +4 bytes for dimension ID + if (keyType == SubChunkPrefix_KEY) { + keySize++; // +1 byte for subchunk index + } + + ByteBuffer byteBuffer = ByteBuffer.allocate(keySize).order(ByteOrder.LITTLE_ENDIAN) .putInt(pos.x) .putInt(pos.z); @@ -108,9 +137,8 @@ public Optional readSubChunk(ChunkPosition pos, byte subChunkIdx) throws default -> throw new RuntimeException("Unsupported dimension in Bedrock world"); // TODO: should this throw? }); } - byteBuffer.put((byte) 0x2f); - byteBuffer.put(subChunkIdx); - return ((BedrockDimension) dimension).getDbValue(byteBuffer.array()); + byteBuffer.put(keyType); + return byteBuffer; } private boolean readChunkData(ChunkData chunkData, BlockPalette palette, BiomePalette biomePalette, int minY, int maxY) throws ChunkLoadingException { @@ -183,6 +211,87 @@ private boolean readChunkData(ChunkData chunkData, BlockPalette palette, BiomePa return dataPresent; } + private void readData3D(ChunkData chunkData, BlockPalette palette, BiomePalette biomePalette, int maxY) { + try { + Optional bytes = readDBValue(position, Data3D_KEY); + if (bytes.isEmpty()) { + return; + } + + ByteBuffer data3d = ByteBuffer.wrap(bytes.get()).order(ByteOrder.LITTLE_ENDIAN); + // HEIGHTMAP: + int[] heightmapData = new int[16*16]; + for (int x = 0; x < Chunk.X_MAX; x++) { + for (int z = 0; z < Chunk.Z_MAX; z++) { + heightmapData[x * Chunk.Z_MAX + z] = data3d.getShort(); + } + } + updateHeightmap(this.dimension.getHeightmap(), position, chunkData, heightmapData, palette, maxY); + + // BIOMES: + BiomeData biomeDataStorage = new GenericBiomeData3d(); + chunkData.setBiomeData(biomeDataStorage); + for (int paletteIdx = 0; paletteIdx < 24; paletteIdx++) { + int packed = data3d.get() & 0xff; // & because java and unsigned is dumb. + int isRuntime = (packed & 1); + assert isRuntime == 1 : "Biomes are currently only stored as runtime IDs"; + int bitsPerBlock = packed >> 1; + int mask = (1 << bitsPerBlock)-1; + + if (bitsPerBlock == 127) { // null subchunk + continue; + } + if (bitsPerBlock == 0) { // all-same subchunk + int singleBiome = data3d.getInt(); + + Biome biome = bedrockBiomesById.get(singleBiome); + for (int x = 0; x < Chunk.X_MAX; x++) { + for (int z = 0; z < Chunk.Z_MAX; z++) { + for (int y = 0; y < Chunk.SECTION_Y_MAX; y++) { + biomeDataStorage.setBiomeAt(x, 16 * paletteIdx + y, z, biomePalette.put(biome)); + } + } + } + break; + } + + int blocksPerWord = 32 / bitsPerBlock; + int wordCount = ceilDiv(4096, blocksPerWord); + + ByteBuffer biomeData = data3d.slice().order(ByteOrder.LITTLE_ENDIAN); + data3d.position(data3d.position() + wordCount * 4); + + int paletteSize = data3d.getInt(); + int[] subpalette = new int[paletteSize]; + for (int i = 0; i < paletteSize; i++) { + subpalette[i] = data3d.getInt(); + } + + int u = 0; + for (int j = 0; j < wordCount; j++) { + int temp = biomeData.getInt(); + + for (int k = 0; k < blocksPerWord && u < 4096; k++) { + int x = (u >> 8) & 0xf; + int y = u & 0xf; + int z = (u >> 4) & 0xf; + int pos = x + 16 * y + 256 * z; + + int subpaletteIdx = (temp & mask); + + Biome biome = bedrockBiomesById.get(subpalette[subpaletteIdx]); + biomeDataStorage.setBiomeAt(x, 16 * paletteIdx + y, z, biomePalette.put(biome)); + + temp >>= bitsPerBlock; + u++; + } + } + } + } catch (LevelDBException e) { + Log.error("Exception thrown when loading chunk DATA3D " + this.position, e); + } + } + public static Tag read(DataInputStream in) { try { byte type = in.readByte(); @@ -197,4 +306,95 @@ public static Tag read(DataInputStream in) { return new ErrorTag("IOException while reading tag type:\n" + e.getMessage()); } } + + static { + // These IDs can and do differ between bedrock versions, we may need to parse the behaviour pack(?) from the game files to do this properly + // but jank for now! + bedrockBiomesById.put(0, Biomes.biomesByResourceLocation.getOrDefault("minecraft:ocean", Biomes.unknown)); + bedrockBiomesById.put(1, Biomes.biomesByResourceLocation.getOrDefault("minecraft:plains", Biomes.unknown)); + bedrockBiomesById.put(2, Biomes.biomesByResourceLocation.getOrDefault("minecraft:desert", Biomes.unknown)); + bedrockBiomesById.put(3, Biomes.biomesByResourceLocation.getOrDefault("minecraft:extreme_hills", Biomes.unknown)); + bedrockBiomesById.put(4, Biomes.biomesByResourceLocation.getOrDefault("minecraft:forest", Biomes.unknown)); + bedrockBiomesById.put(5, Biomes.biomesByResourceLocation.getOrDefault("minecraft:taiga", Biomes.unknown)); + bedrockBiomesById.put(6, Biomes.biomesByResourceLocation.getOrDefault("minecraft:swampland", Biomes.unknown)); + bedrockBiomesById.put(7, Biomes.biomesByResourceLocation.getOrDefault("minecraft:river", Biomes.unknown)); + bedrockBiomesById.put(8, Biomes.biomesByResourceLocation.getOrDefault("minecraft:hell", Biomes.unknown)); + bedrockBiomesById.put(9, Biomes.biomesByResourceLocation.getOrDefault("minecraft:the_end", Biomes.unknown)); + bedrockBiomesById.put(10, Biomes.biomesByResourceLocation.getOrDefault("minecraft:frozen_ocean", Biomes.unknown)); + bedrockBiomesById.put(11, Biomes.biomesByResourceLocation.getOrDefault("minecraft:frozen_river", Biomes.unknown)); + bedrockBiomesById.put(12, Biomes.biomesByResourceLocation.getOrDefault("minecraft:ice_plains", Biomes.unknown)); + bedrockBiomesById.put(13, Biomes.biomesByResourceLocation.getOrDefault("minecraft:ice_mountains", Biomes.unknown)); + bedrockBiomesById.put(14, Biomes.biomesByResourceLocation.getOrDefault("minecraft:mushroom_island", Biomes.unknown)); + bedrockBiomesById.put(15, Biomes.biomesByResourceLocation.getOrDefault("minecraft:mushroom_island_shore", Biomes.unknown)); + bedrockBiomesById.put(16, Biomes.biomesByResourceLocation.getOrDefault("minecraft:beach", Biomes.unknown)); + bedrockBiomesById.put(17, Biomes.biomesByResourceLocation.getOrDefault("minecraft:desert_hills", Biomes.unknown)); + bedrockBiomesById.put(18, Biomes.biomesByResourceLocation.getOrDefault("minecraft:forest_hills", Biomes.unknown)); + bedrockBiomesById.put(19, Biomes.biomesByResourceLocation.getOrDefault("minecraft:taiga_hills", Biomes.unknown)); + bedrockBiomesById.put(20, Biomes.biomesByResourceLocation.getOrDefault("minecraft:extreme_hills_edge", Biomes.unknown)); + bedrockBiomesById.put(21, Biomes.biomesByResourceLocation.getOrDefault("minecraft:jungle", Biomes.unknown)); + bedrockBiomesById.put(22, Biomes.biomesByResourceLocation.getOrDefault("minecraft:jungle_hills", Biomes.unknown)); + bedrockBiomesById.put(23, Biomes.biomesByResourceLocation.getOrDefault("minecraft:jungle_edge", Biomes.unknown)); + bedrockBiomesById.put(24, Biomes.biomesByResourceLocation.getOrDefault("minecraft:deep_ocean", Biomes.unknown)); + bedrockBiomesById.put(25, Biomes.biomesByResourceLocation.getOrDefault("minecraft:stone_beach", Biomes.unknown)); + bedrockBiomesById.put(26, Biomes.biomesByResourceLocation.getOrDefault("minecraft:cold_beach", Biomes.unknown)); + bedrockBiomesById.put(27, Biomes.biomesByResourceLocation.getOrDefault("minecraft:birch_forest", Biomes.unknown)); + bedrockBiomesById.put(28, Biomes.biomesByResourceLocation.getOrDefault("minecraft:birch_forest_hills", Biomes.unknown)); + bedrockBiomesById.put(29, Biomes.biomesByResourceLocation.getOrDefault("minecraft:roofed_forest", Biomes.unknown)); + bedrockBiomesById.put(30, Biomes.biomesByResourceLocation.getOrDefault("minecraft:cold_taiga", Biomes.unknown)); + bedrockBiomesById.put(31, Biomes.biomesByResourceLocation.getOrDefault("minecraft:cold_taiga_hills", Biomes.unknown)); + bedrockBiomesById.put(32, Biomes.biomesByResourceLocation.getOrDefault("minecraft:mega_taiga", Biomes.unknown)); + bedrockBiomesById.put(33, Biomes.biomesByResourceLocation.getOrDefault("minecraft:mega_taiga_hills", Biomes.unknown)); + bedrockBiomesById.put(34, Biomes.biomesByResourceLocation.getOrDefault("minecraft:extreme_hills_plus_trees", Biomes.unknown)); + bedrockBiomesById.put(35, Biomes.biomesByResourceLocation.getOrDefault("minecraft:savanna", Biomes.unknown)); + bedrockBiomesById.put(36, Biomes.biomesByResourceLocation.getOrDefault("minecraft:savanna_plateau", Biomes.unknown)); + bedrockBiomesById.put(37, Biomes.biomesByResourceLocation.getOrDefault("minecraft:mesa", Biomes.unknown)); + bedrockBiomesById.put(38, Biomes.biomesByResourceLocation.getOrDefault("minecraft:mesa_plateau", Biomes.unknown)); + bedrockBiomesById.put(39, Biomes.biomesByResourceLocation.getOrDefault("minecraft:mesa_plateau_stone", Biomes.unknown)); + bedrockBiomesById.put(40, Biomes.biomesByResourceLocation.getOrDefault("minecraft:warm_ocean", Biomes.unknown)); + bedrockBiomesById.put(41, Biomes.biomesByResourceLocation.getOrDefault("minecraft:deep_warm_ocean", Biomes.unknown)); + bedrockBiomesById.put(42, Biomes.biomesByResourceLocation.getOrDefault("minecraft:lukewarm_ocean", Biomes.unknown)); + bedrockBiomesById.put(43, Biomes.biomesByResourceLocation.getOrDefault("minecraft:deep_lukewarm_ocean", Biomes.unknown)); + bedrockBiomesById.put(44, Biomes.biomesByResourceLocation.getOrDefault("minecraft:cold_ocean", Biomes.unknown)); + bedrockBiomesById.put(45, Biomes.biomesByResourceLocation.getOrDefault("minecraft:deep_cold_ocean", Biomes.unknown)); + bedrockBiomesById.put(46, Biomes.biomesByResourceLocation.getOrDefault("minecraft:deep_frozen_ocean", Biomes.unknown)); + bedrockBiomesById.put(47, Biomes.biomesByResourceLocation.getOrDefault("minecraft:legacy_frozen_ocean", Biomes.unknown)); + bedrockBiomesById.put(48, Biomes.biomesByResourceLocation.getOrDefault("minecraft:bamboo_jungle", Biomes.unknown)); + bedrockBiomesById.put(49, Biomes.biomesByResourceLocation.getOrDefault("minecraft:bamboo_jungle_hills", Biomes.unknown)); + bedrockBiomesById.put(129, Biomes.biomesByResourceLocation.getOrDefault("minecraft:sunflower_plains", Biomes.unknown)); + bedrockBiomesById.put(130, Biomes.biomesByResourceLocation.getOrDefault("minecraft:desert_mutated", Biomes.unknown)); + bedrockBiomesById.put(131, Biomes.biomesByResourceLocation.getOrDefault("minecraft:extreme_hills_mutated", Biomes.unknown)); + bedrockBiomesById.put(132, Biomes.biomesByResourceLocation.getOrDefault("minecraft:flower_forest", Biomes.unknown)); + bedrockBiomesById.put(133, Biomes.biomesByResourceLocation.getOrDefault("minecraft:taiga_mutated", Biomes.unknown)); + bedrockBiomesById.put(134, Biomes.biomesByResourceLocation.getOrDefault("minecraft:swampland_mutated", Biomes.unknown)); + bedrockBiomesById.put(140, Biomes.biomesByResourceLocation.getOrDefault("minecraft:ice_plains_spikes", Biomes.unknown)); + bedrockBiomesById.put(149, Biomes.biomesByResourceLocation.getOrDefault("minecraft:jungle_mutated", Biomes.unknown)); + bedrockBiomesById.put(151, Biomes.biomesByResourceLocation.getOrDefault("minecraft:jungle_edge_mutated", Biomes.unknown)); + bedrockBiomesById.put(155, Biomes.biomesByResourceLocation.getOrDefault("minecraft:birch_forest_mutated", Biomes.unknown)); + bedrockBiomesById.put(156, Biomes.biomesByResourceLocation.getOrDefault("minecraft:birch_forest_hills_mutated", Biomes.unknown)); + bedrockBiomesById.put(157, Biomes.biomesByResourceLocation.getOrDefault("minecraft:roofed_forest_mutated", Biomes.unknown)); + bedrockBiomesById.put(158, Biomes.biomesByResourceLocation.getOrDefault("minecraft:cold_taiga_mutated", Biomes.unknown)); + bedrockBiomesById.put(160, Biomes.biomesByResourceLocation.getOrDefault("minecraft:redwood_taiga_mutated", Biomes.unknown)); + bedrockBiomesById.put(161, Biomes.biomesByResourceLocation.getOrDefault("minecraft:redwood_taiga_hills_mutated", Biomes.unknown)); + bedrockBiomesById.put(162, Biomes.biomesByResourceLocation.getOrDefault("minecraft:extreme_hills_plus_trees_mutated", Biomes.unknown)); + bedrockBiomesById.put(163, Biomes.biomesByResourceLocation.getOrDefault("minecraft:savanna_mutated", Biomes.unknown)); + bedrockBiomesById.put(164, Biomes.biomesByResourceLocation.getOrDefault("minecraft:savanna_plateau_mutated", Biomes.unknown)); + bedrockBiomesById.put(165, Biomes.biomesByResourceLocation.getOrDefault("minecraft:mesa_bryce", Biomes.unknown)); + bedrockBiomesById.put(166, Biomes.biomesByResourceLocation.getOrDefault("minecraft:mesa_plateau_mutated", Biomes.unknown)); + bedrockBiomesById.put(167, Biomes.biomesByResourceLocation.getOrDefault("minecraft:mesa_plateau_stone_mutated", Biomes.unknown)); + bedrockBiomesById.put(178, Biomes.biomesByResourceLocation.getOrDefault("minecraft:soulsand_valley", Biomes.unknown)); + bedrockBiomesById.put(179, Biomes.biomesByResourceLocation.getOrDefault("minecraft:crimson_forest", Biomes.unknown)); + bedrockBiomesById.put(180, Biomes.biomesByResourceLocation.getOrDefault("minecraft:warped_forest", Biomes.unknown)); + bedrockBiomesById.put(181, Biomes.biomesByResourceLocation.getOrDefault("minecraft:basalt_deltas", Biomes.unknown)); + bedrockBiomesById.put(182, Biomes.biomesByResourceLocation.getOrDefault("minecraft:jagged_peaks", Biomes.unknown)); + bedrockBiomesById.put(183, Biomes.biomesByResourceLocation.getOrDefault("minecraft:frozen_peaks", Biomes.unknown)); + bedrockBiomesById.put(184, Biomes.biomesByResourceLocation.getOrDefault("minecraft:snowy_slopes", Biomes.unknown)); + bedrockBiomesById.put(185, Biomes.biomesByResourceLocation.getOrDefault("minecraft:grove", Biomes.unknown)); + bedrockBiomesById.put(186, Biomes.biomesByResourceLocation.getOrDefault("minecraft:meadow", Biomes.unknown)); + bedrockBiomesById.put(187, Biomes.biomesByResourceLocation.getOrDefault("minecraft:lush_caves", Biomes.unknown)); + bedrockBiomesById.put(188, Biomes.biomesByResourceLocation.getOrDefault("minecraft:dripstone_caves", Biomes.unknown)); + bedrockBiomesById.put(189, Biomes.biomesByResourceLocation.getOrDefault("minecraft:stony_peaks", Biomes.unknown)); + bedrockBiomesById.put(190, Biomes.biomesByResourceLocation.getOrDefault("minecraft:deep_dark", Biomes.unknown)); + bedrockBiomesById.put(191, Biomes.biomesByResourceLocation.getOrDefault("minecraft:mangrove_swamp", Biomes.unknown)); + bedrockBiomesById.put(192, Biomes.biomesByResourceLocation.getOrDefault("minecraft:cherry_groves", Biomes.unknown)); + } } From 5be40fcc1ac22e4d828a917d64acb7a3720d312d Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sat, 27 Jun 2026 12:47:14 +0100 Subject: [PATCH 44/57] bedrock: Fix being able to select empty chunks in the map --- .../java/se/llbit/chunky/world/bedrock/BedrockChunk.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java index 2bb59569a0..8ee00446c0 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java @@ -10,10 +10,7 @@ import se.llbit.chunky.chunk.biome.GenericBiomeData3d; import se.llbit.chunky.map.BiomeLayer; import se.llbit.chunky.map.SurfaceLayer; -import se.llbit.chunky.world.Chunk; -import se.llbit.chunky.world.ChunkPosition; -import se.llbit.chunky.world.Dimension; -import se.llbit.chunky.world.EmptyChunk; +import se.llbit.chunky.world.*; import se.llbit.chunky.world.biome.ArrayBiomePalette; import se.llbit.chunky.world.biome.Biome; import se.llbit.chunky.world.biome.BiomePalette; @@ -66,7 +63,7 @@ public boolean loadChunk(@NotNull Mutable chunkDataMutable, int yMin, ChunkData chunkData = chunkDataMutable.get(); boolean readData = readChunkData(chunkData, palette, biomePalette, yMin, yMax); if (!readData) { - ((BedrockDimension) dimension).setChunk(position, EmptyChunk.INSTANCE); + ((BedrockDimension) dimension).setChunk(position, EmptyRegionChunk.INSTANCE); return false; } readData3D(chunkData, palette, biomePalette, yMax); From dcbe84a4d3a6d5b0e063a79c42240ef0eaf97420 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sat, 27 Jun 2026 12:48:01 +0100 Subject: [PATCH 45/57] bedrock: Fix incorrectly parsing empty subchunks --- .../src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java index 8ee00446c0..070028b04b 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java @@ -163,6 +163,11 @@ private boolean readChunkData(ChunkData chunkData, BlockPalette palette, BiomePa int bitsPerBlock = packed >> 1; int mask = (1 << bitsPerBlock)-1; + if (bitsPerBlock == 0) { // all-same subchunk + value.position(value.position() + 4); // no palette or other data exists, guessing this means an all-air chunk + continue; + } + int blocksPerWord = 32 / bitsPerBlock; int wordCount = ceilDiv(4096, blocksPerWord); From ae637f67f60f5764b15304b7f4447fb80af480c3 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sat, 27 Jun 2026 12:48:54 +0100 Subject: [PATCH 46/57] bedrock: Prevent opening the same DB in two places at once --- .../llbit/chunky/world/bedrock/BedrockDB.java | 117 ++++++++++++++++++ .../world/bedrock/BedrockDimension.java | 15 +-- 2 files changed, 119 insertions(+), 13 deletions(-) create mode 100644 chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java new file mode 100644 index 0000000000..0de91d5b86 --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java @@ -0,0 +1,117 @@ +package se.llbit.chunky.world.bedrock; + +import io.github.notstirred.leveldb_ffi.*; + +import java.io.Closeable; +import java.io.IOException; +import java.lang.ref.Cleaner; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +public class BedrockDB implements Closeable { + /* + * This class uses reference counting and cleaners to prevent the same DB from being opened by two different bedrock dimensions. + * + * Minecraft doesn't use lock files so we can't either. + */ + + private static final Cleaner cleaner = Cleaner.create(); // TODO: move this to Chunky class or something usable by all. + + private static final Lock lock = new ReentrantLock(); + private static final Map openDBs = new HashMap<>(); + + private final DBRef ref; + private Cleaner.Cleanable cleanable; + + private BedrockDB(DBRef ref) { + ref.acquire(); + this.ref = ref; + } + + /** + * leveldb is thread safe for N readers so no synchronization required + */ + public Optional get(ReadOptions options, byte[] key) throws LevelDBException { + // It's always safe to call this without locking. This BedrockDB exists so the db can't be closed. + return this.ref.db.get(options, key); + } + + public static BedrockDB getOrOpen(Path dbPath) { + Lock lock = BedrockDB.lock; + try { + lock.lock(); + + Options options = Options.create(); + options.setCompression(Compressor.ZLIB_RAW); + options.setCreateIfMissing(false); + try { + DBRef dbRef = openDBs.get(dbPath); + if (dbRef != null) { + return new BedrockDB(dbRef); + } + + DBRef ref = new DBRef(dbPath, + LevelDB.open(options, dbPath.toAbsolutePath().toString()) + ); + openDBs.put(dbPath, ref); + + BedrockDB bedrockDB = new BedrockDB(ref); + bedrockDB.cleanable = cleaner.register(bedrockDB, ref::release); + + return bedrockDB; + } catch (LevelDBException e) { + throw new RuntimeException(e); + } + } finally { + lock.unlock(); + } + } + + /** + * Optional, triggers db close faster. + */ + @Override + public void close() throws IOException { + this.cleanable.clean(); + } + + private static class DBRef { + private final Path path; + private final LevelDB db; + private int references; + + private DBRef(Path path, LevelDB db) { + this.path = path; + this.db = db; + this.references = 0; + } + + private void acquire() { + Lock lock = BedrockDB.lock; + try { + lock.lock(); + this.references++; + } finally { + lock.unlock(); + } + } + + private void release() { + Lock lock = BedrockDB.lock; + try { + lock.lock(); + references--; + if (references == 0) { + DBRef removed = openDBs.remove(this.path); + removed.db.close(); + } + } finally { + lock.unlock(); + } + } + } +} diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java index 4ce24e508d..bab107e7f2 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java @@ -14,33 +14,22 @@ import java.io.Closeable; import java.io.IOException; import java.io.InputStream; -import java.lang.ref.Cleaner; import java.nio.ByteBuffer; import java.nio.file.Path; import java.util.*; import java.util.concurrent.ConcurrentHashMap; public class BedrockDimension extends Dimension implements Closeable { - private static final Cleaner cleaner = Cleaner.create(); // TODO: move this to Chunky class or something usable by all. - protected final ConcurrentHashMap regionMap = new ConcurrentHashMap<>(); - private final LevelDB db; + private final BedrockDB db; private final ReadOptions readOptions; private final Map chunks = new ConcurrentHashMap<>(); protected BedrockDimension(BedrockWorld world, Identifier dimensionId, Path dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { super(dimensionId, dimensionDirectory, playerEntities, null); - Options options = Options.create(); - options.setCompression(Compressor.ZLIB_RAW); - options.setCreateIfMissing(false); - try { - this.db = LevelDB.open(options, dimensionDirectory.resolve("db").toAbsolutePath()); - cleaner.register(this, this.db::close); - } catch (LevelDBException e) { - throw new RuntimeException(e); - } + this.db = BedrockDB.getOrOpen(dimensionDirectory.resolve("db").toAbsolutePath()); readOptions = ReadOptions.create(); readOptions.setFillCache(false); // almost all reads happen only once } From 5bc88f6a76c05ce2ed0a56bfc8ce8554a3cbe06b Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sun, 28 Jun 2026 08:45:39 +0100 Subject: [PATCH 47/57] bedrock: Always set BiomeData when loading chunks --- .../src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java index 070028b04b..f1ad94f33b 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java @@ -215,6 +215,9 @@ private boolean readChunkData(ChunkData chunkData, BlockPalette palette, BiomePa private void readData3D(ChunkData chunkData, BlockPalette palette, BiomePalette biomePalette, int maxY) { try { + BiomeData biomeDataStorage = new GenericBiomeData3d(); + chunkData.setBiomeData(biomeDataStorage); + Optional bytes = readDBValue(position, Data3D_KEY); if (bytes.isEmpty()) { return; @@ -231,8 +234,6 @@ private void readData3D(ChunkData chunkData, BlockPalette palette, BiomePalette updateHeightmap(this.dimension.getHeightmap(), position, chunkData, heightmapData, palette, maxY); // BIOMES: - BiomeData biomeDataStorage = new GenericBiomeData3d(); - chunkData.setBiomeData(biomeDataStorage); for (int paletteIdx = 0; paletteIdx < 24; paletteIdx++) { int packed = data3d.get() & 0xff; // & because java and unsigned is dumb. int isRuntime = (packed & 1); From e52ad65c143892d73dfb2f6122a63f1ac3d4163f Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Thu, 2 Jul 2026 20:33:33 +0100 Subject: [PATCH 48/57] bedrock: Switch to a Plugin, properly close DBs on plugin shutdown. --- .../src/java/se/llbit/chunky/main/Chunky.java | 6 ++ .../llbit/chunky/world/bedrock/BedrockDB.java | 56 ++++++++++++++++--- .../chunky/world/bedrock/BedrockPlugin.java | 31 ++++++++++ .../world/worldformat/WorldFormats.java | 2 - 4 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 chunky/src/java/se/llbit/chunky/world/bedrock/BedrockPlugin.java diff --git a/chunky/src/java/se/llbit/chunky/main/Chunky.java b/chunky/src/java/se/llbit/chunky/main/Chunky.java index e444a0fa3c..bb9e5e8282 100644 --- a/chunky/src/java/se/llbit/chunky/main/Chunky.java +++ b/chunky/src/java/se/llbit/chunky/main/Chunky.java @@ -43,6 +43,7 @@ import se.llbit.chunky.ui.controller.CreditsController; import se.llbit.chunky.ui.render.RenderControlsTabTransformer; import se.llbit.chunky.world.MaterialStore; +import se.llbit.chunky.world.bedrock.BedrockPlugin; import se.llbit.json.JsonArray; import se.llbit.log.ConsoleReceiver; import se.llbit.log.Level; @@ -205,6 +206,9 @@ public void close() { } } + // FIXME: inbuilt plugins + BedrockPlugin bedrockPlugin = new BedrockPlugin(); + /** * Main entry point for Chunky. Chunky should normally be started via the launcher which sets up * the classpath with all dependencies. @@ -262,6 +266,7 @@ public static void main(final String[] args) { private void shutdown(int timeout, TimeUnit unit) { if (ChunkyThread.interruptAndJoinAll(timeout, unit)) { pluginManager.shutdownPlugins(plugin -> plugin.shutdown(this)); + bedrockPlugin.shutdown(this); } else { Log.error("Not all threads were joined before shutting down."); // FIXME: list all alive threads? ThreadGroups are annoying. } @@ -280,6 +285,7 @@ private void loadPlugins() { SettingsDirectory.getPluginsDirectory(), (plugin, manifest) -> plugin.attach(this) ); + bedrockPlugin.attach(this); } /** diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java index 0de91d5b86..d140dc0e72 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java @@ -1,6 +1,7 @@ package se.llbit.chunky.world.bedrock; import io.github.notstirred.leveldb_ffi.*; +import se.llbit.log.Log; import java.io.Closeable; import java.io.IOException; @@ -12,21 +13,63 @@ import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +/** + * Manages {@link LevelDB} instances and prevents the same location from being opened in more than one place. + * + *

Callers are guaranteed that if they hold a {@link BedrockDB} it is not closed.

TODO: this isn't necessarily true during a shutdown hook + * + *

Closure

+ *

Callers can call {@link #close()} to release their hold on a DB. If this DB is being used elsewhere it will not be closed immediately. + * + *

Automatic closure

+ *

If a DB is not closed by the caller it will be cleaned up automatically at an unknown time later.

+ *

A DB is automatically closed on a non-termination stop of the JVM as according to the {@link Runtime Runtime's Shutdown Sequence}

+ */ public class BedrockDB implements Closeable { /* - * This class uses reference counting and cleaners to prevent the same DB from being opened by two different bedrock dimensions. + * This class keeps track of currently open LevelDB objects + * + * All operations (other than reading from a LevelDB) lock before starting. * - * Minecraft doesn't use lock files so we can't either. */ private static final Cleaner cleaner = Cleaner.create(); // TODO: move this to Chunky class or something usable by all. - private static final Lock lock = new ReentrantLock(); + /* + * A ReentrantLock is needed as getOrOpen locks, and acquire locks in the constructor which simplifies the impl a bit. + */ + private static final ReentrantLock lock = new ReentrantLock(); private static final Map openDBs = new HashMap<>(); private final DBRef ref; private Cleaner.Cleanable cleanable; + /** + * Only safe to call after ALL threads interacting with ALL dbs are stopped. + */ + public static void closeAllDBs() { + /* + * In a situation where chunky is killed and this never runs the db state /should/ be fine. + * - We never write to the DB (WAL will be empty). + * - Crashes mid-compaction will be recovered by bedrock when opening the world. + */ + Lock lock = BedrockDB.lock; + try { + lock.lock(); + openDBs.values().forEach(dbRef -> { + try { + dbRef.db.close(); // Don't need to free the arena as we're shutting down anyway. + Log.info("Shutdown hook closed Bedrock DB " + dbRef.path.toString()); + } catch (Throwable t) { + // Nothing we can do in the middle of closing. + } + }); + } finally { + openDBs.clear(); + lock.unlock(); + } + } + private BedrockDB(DBRef ref) { ref.acquire(); this.ref = ref; @@ -36,7 +79,7 @@ private BedrockDB(DBRef ref) { * leveldb is thread safe for N readers so no synchronization required */ public Optional get(ReadOptions options, byte[] key) throws LevelDBException { - // It's always safe to call this without locking. This BedrockDB exists so the db can't be closed. + // It's always safe to call this without locking. This BedrockDB exists so the db shouldn't be closed. return this.ref.db.get(options, key); } @@ -72,7 +115,7 @@ public static BedrockDB getOrOpen(Path dbPath) { } /** - * Optional, triggers db close faster. + * Triggers db close immediately. If the db is already closed then invoking this method has no effect. */ @Override public void close() throws IOException { @@ -82,12 +125,11 @@ public void close() throws IOException { private static class DBRef { private final Path path; private final LevelDB db; - private int references; + private int references = 0; private DBRef(Path path, LevelDB db) { this.path = path; this.db = db; - this.references = 0; } private void acquire() { diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockPlugin.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockPlugin.java new file mode 100644 index 0000000000..035083da39 --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockPlugin.java @@ -0,0 +1,31 @@ +package se.llbit.chunky.world.bedrock; + +import io.github.notstirred.leveldb_ffi.LevelDBLib; +import se.llbit.chunky.Plugin; +import se.llbit.chunky.main.Chunky; +import se.llbit.chunky.world.worldformat.WorldFormats; +import se.llbit.log.Log; + +public class BedrockPlugin implements Plugin { + public static final boolean IS_BEDROCK_SUPPORTED = LevelDBLib.init(); + private static boolean warnedUserIfNotSupported; + + @Override + public void attach(Chunky chunky) { + if (!IS_BEDROCK_SUPPORTED && !warnedUserIfNotSupported) { + Log.warn("A bedrock world was loaded but bedrock is not supported on this OS/ARCH.\n" + + "If you believe your platform should be supported or this is an error please report a bug on the chunky bug tracker."); + warnedUserIfNotSupported = true; + } + if (IS_BEDROCK_SUPPORTED) { + WorldFormats.addWorldFormat(new BedrockWorldFormat()); + } + } + + @Override + public void shutdown(Chunky chunky) { + if (IS_BEDROCK_SUPPORTED) { + BedrockDB.closeAllDBs(); + } + } +} diff --git a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java index 2a25cc8d7c..3df89315d6 100644 --- a/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java +++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java @@ -2,7 +2,6 @@ import se.llbit.chunky.world.EmptyWorld; import se.llbit.chunky.world.World; -import se.llbit.chunky.world.bedrock.BedrockWorldFormat; import se.llbit.chunky.world.java.JavaWorldFormat; import se.llbit.log.Log; import se.llbit.util.annotation.NotNull; @@ -32,7 +31,6 @@ public static Optional getWorldFormat(String id) { static { addWorldFormat(new JavaWorldFormat()); - addWorldFormat(new BedrockWorldFormat()); } @NotNull From 501f7ead08b474708fd45e7164542210298a2c91 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sat, 11 Jul 2026 10:03:20 +0100 Subject: [PATCH 49/57] bedrock: Use per-DBRef arenas instead of incorrect auto usage --- .../llbit/chunky/world/bedrock/BedrockDB.java | 56 +++++++++++++------ .../world/bedrock/BedrockDimension.java | 4 +- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java index d140dc0e72..5c64dc37e7 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java @@ -5,6 +5,7 @@ import java.io.Closeable; import java.io.IOException; +import java.lang.foreign.Arena; import java.lang.ref.Cleaner; import java.nio.file.Path; import java.util.HashMap; @@ -88,32 +89,45 @@ public static BedrockDB getOrOpen(Path dbPath) { try { lock.lock(); - Options options = Options.create(); + Arena arena = Arena.ofShared(); + + Options options = Options.create(arena); options.setCompression(Compressor.ZLIB_RAW); options.setCreateIfMissing(false); - try { - DBRef dbRef = openDBs.get(dbPath); - if (dbRef != null) { - return new BedrockDB(dbRef); - } - - DBRef ref = new DBRef(dbPath, - LevelDB.open(options, dbPath.toAbsolutePath().toString()) - ); - openDBs.put(dbPath, ref); - BedrockDB bedrockDB = new BedrockDB(ref); - bedrockDB.cleanable = cleaner.register(bedrockDB, ref::release); + DBRef dbRef = openDBs.get(dbPath); + if (dbRef != null) { + return new BedrockDB(dbRef); + } - return bedrockDB; + LevelDB db; + try { + db = LevelDB.open(arena, options, dbPath.toAbsolutePath().toString()); } catch (LevelDBException e) { + arena.close(); // something threw, we are responsible for the arena throw new RuntimeException(e); } + + DBRef ref = new DBRef(dbPath, db, arena); // DBRef is responsible for the arena + DBRef existing = openDBs.put(dbPath, ref); + assert existing == null; + + BedrockDB bedrockDB = new BedrockDB(ref); + bedrockDB.cleanable = cleaner.register(bedrockDB, ref::release); + + return bedrockDB; } finally { lock.unlock(); } } + /** + * @return An arena whose lifetime is at least as long as the underlying {@link LevelDB} + */ + public Arena getArena() { + return this.ref.arena; + } + /** * Triggers db close immediately. If the db is already closed then invoking this method has no effect. */ @@ -124,12 +138,14 @@ public void close() throws IOException { private static class DBRef { private final Path path; - private final LevelDB db; + private LevelDB db; + private Arena arena; private int references = 0; - private DBRef(Path path, LevelDB db) { + private DBRef(Path path, LevelDB db, Arena arena) { this.path = path; this.db = db; + this.arena = arena; } private void acquire() { @@ -146,10 +162,16 @@ private void release() { Lock lock = BedrockDB.lock; try { lock.lock(); + assert references > 0; references--; if (references == 0) { DBRef removed = openDBs.remove(this.path); - removed.db.close(); + assert this == removed; + // We are not concerned with what the chunky threads are doing here (different to the shutdown hook) + // Here we are guaranteed that no reference to this DBRef exist, so we instantly close. + removed.arena.close(); + removed.db = null; // db lifetime is tied to arena. + removed.arena = null; } } finally { lock.unlock(); diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java index bab107e7f2..f6ca97a1b8 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java @@ -28,9 +28,9 @@ public class BedrockDimension extends Dimension implements Closeable { private final Map chunks = new ConcurrentHashMap<>(); protected BedrockDimension(BedrockWorld world, Identifier dimensionId, Path dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) { - super(dimensionId, dimensionDirectory, playerEntities, null); + super(dimensionId, dimensionDirectory, playerEntities, spawnPos); this.db = BedrockDB.getOrOpen(dimensionDirectory.resolve("db").toAbsolutePath()); - readOptions = ReadOptions.create(); + readOptions = ReadOptions.create(this.db.getArena()); readOptions.setFillCache(false); // almost all reads happen only once } From ad02e31c68c1218dce7a203adf5086165924f4b5 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sat, 11 Jul 2026 08:53:30 +0100 Subject: [PATCH 50/57] bedrock: Remove manual db closure It had the potential to allow RegionParser threads to call getChunk on a dimension with a closed DB. --- .../llbit/chunky/world/bedrock/BedrockDB.java | 26 +++++++------------ .../world/bedrock/BedrockDimension.java | 8 +----- .../chunky/world/bedrock/BedrockWorld.java | 7 ----- 3 files changed, 10 insertions(+), 31 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java index 5c64dc37e7..150be5b454 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java @@ -3,8 +3,6 @@ import io.github.notstirred.leveldb_ffi.*; import se.llbit.log.Log; -import java.io.Closeable; -import java.io.IOException; import java.lang.foreign.Arena; import java.lang.ref.Cleaner; import java.nio.file.Path; @@ -19,14 +17,11 @@ * *

Callers are guaranteed that if they hold a {@link BedrockDB} it is not closed.

TODO: this isn't necessarily true during a shutdown hook * - *

Closure

- *

Callers can call {@link #close()} to release their hold on a DB. If this DB is being used elsewhere it will not be closed immediately. - * - *

Automatic closure

- *

If a DB is not closed by the caller it will be cleaned up automatically at an unknown time later.

- *

A DB is automatically closed on a non-termination stop of the JVM as according to the {@link Runtime Runtime's Shutdown Sequence}

+ *

Automatic closure

+ *

DBs are cleaned up automatically at an unknown time after all references are dropped.

+ *

All DBs are automatically closed on a non-termination stop of the JVM as according to the {@link Runtime Runtime's Shutdown Sequence}

*/ -public class BedrockDB implements Closeable { +public class BedrockDB { /* * This class keeps track of currently open LevelDB objects * @@ -43,7 +38,6 @@ public class BedrockDB implements Closeable { private static final Map openDBs = new HashMap<>(); private final DBRef ref; - private Cleaner.Cleanable cleanable; /** * Only safe to call after ALL threads interacting with ALL dbs are stopped. @@ -113,7 +107,7 @@ public static BedrockDB getOrOpen(Path dbPath) { assert existing == null; BedrockDB bedrockDB = new BedrockDB(ref); - bedrockDB.cleanable = cleaner.register(bedrockDB, ref::release); + cleaner.register(bedrockDB, ref::release); return bedrockDB; } finally { @@ -129,13 +123,11 @@ public Arena getArena() { } /** - * Triggers db close immediately. If the db is already closed then invoking this method has no effect. + * Holds leveldb references and frees them when appropriate. + * + *

It is only safe for {@link DBRef#references} to reach zero when no more references to this {@link DBRef} exist.

+ *

As such callers must only call {@link DBRef#release()} from a {@link Cleaner}'s cleanup action

*/ - @Override - public void close() throws IOException { - this.cleanable.clean(); - } - private static class DBRef { private final Path path; private LevelDB db; diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java index f6ca97a1b8..5a2e2d5908 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java @@ -11,7 +11,6 @@ import se.llbit.math.Vector3i; import se.llbit.util.annotation.Nullable; -import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; @@ -19,7 +18,7 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; -public class BedrockDimension extends Dimension implements Closeable { +public class BedrockDimension extends Dimension { protected final ConcurrentHashMap regionMap = new ConcurrentHashMap<>(); private final BedrockDB db; @@ -48,11 +47,6 @@ public Optional getPlayerPos() { return Optional.empty(); } - @Override - public void close() throws IOException { - this.db.close(); - } - public void setChunk(ChunkPosition position, Chunk chunk) { this.chunks.put(position, chunk); chunkUpdated(position); diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockWorld.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockWorld.java index 693bb82e3a..20ad105e58 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockWorld.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockWorld.java @@ -37,13 +37,6 @@ public Optional getDefaultDimension() { @Override public Dimension loadDimension(Dimension.Identifier dimensionId) { - try { - if (this.currentDimension != EmptyWorld.INSTANCE.currentDimension()) { - ((BedrockDimension) this.currentDimension).close(); // close early to avoid cleaner - } - } catch (Exception e) { - throw new RuntimeException(e); - } BedrockDimension dimension = new BedrockDimension(this, dimensionId, this.getInfo().path(), Collections.emptySet(), new Vector3i(0, 0, 0)); this.currentDimension = dimension; From b98bc385cf4d9ff34f9171d02a1f0e989ff073c9 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sat, 11 Jul 2026 09:11:35 +0100 Subject: [PATCH 51/57] bedrock: Null dbRef fields to avoid extremely rare double free --- chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java index 150be5b454..ef787ae6be 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java @@ -54,7 +54,8 @@ public static void closeAllDBs() { openDBs.values().forEach(dbRef -> { try { dbRef.db.close(); // Don't need to free the arena as we're shutting down anyway. - Log.info("Shutdown hook closed Bedrock DB " + dbRef.path.toString()); + dbRef.arena = null; // null to prevent cleaner double free through leveldb_ffi_close + dbRef.db = null; } catch (Throwable t) { // Nothing we can do in the middle of closing. } From 438ad37e194cf71018c6ed8fb61de0759158b04f Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sat, 11 Jul 2026 09:13:10 +0100 Subject: [PATCH 52/57] bedrock: Log DB opens and closes --- chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java index ef787ae6be..4e6b378296 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java @@ -56,6 +56,7 @@ public static void closeAllDBs() { dbRef.db.close(); // Don't need to free the arena as we're shutting down anyway. dbRef.arena = null; // null to prevent cleaner double free through leveldb_ffi_close dbRef.db = null; + Log.info("Closed Bedrock DB on shutdown " + dbRef.path.toString()); } catch (Throwable t) { // Nothing we can do in the middle of closing. } @@ -92,6 +93,7 @@ public static BedrockDB getOrOpen(Path dbPath) { DBRef dbRef = openDBs.get(dbPath); if (dbRef != null) { + Log.info("Reused open Bedrock DB " + dbRef.path.toString()); return new BedrockDB(dbRef); } @@ -110,6 +112,7 @@ public static BedrockDB getOrOpen(Path dbPath) { BedrockDB bedrockDB = new BedrockDB(ref); cleaner.register(bedrockDB, ref::release); + Log.info("Opened Bedrock DB " + ref.path.toString()); return bedrockDB; } finally { lock.unlock(); @@ -165,6 +168,7 @@ private void release() { removed.arena.close(); removed.db = null; // db lifetime is tied to arena. removed.arena = null; + Log.info("Closed Bedrock DB " + this.path.toString()); } } finally { lock.unlock(); From 9cc28e650596e8e4581a83e138401aaeb57ef1e2 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sat, 11 Jul 2026 09:26:11 +0100 Subject: [PATCH 53/57] bedrock: Add DBRef javadoc --- .../src/java/se/llbit/chunky/world/bedrock/BedrockDB.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java index 4e6b378296..5f4e8c0a9e 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java @@ -138,6 +138,12 @@ private static class DBRef { private Arena arena; private int references = 0; + /** + * @param path The path to the db + * @param db The db + * @param arena Must be an arena that supports {@link Arena#close()}. + * The lifetime of the {@link LevelDB db} must be tied to the arena. + */ private DBRef(Path path, LevelDB db, Arena arena) { this.path = path; this.db = db; From 9f4de5fd375ef1f99fe50e8a565114d82d5978ef Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Sun, 26 Jul 2026 20:57:39 +0100 Subject: [PATCH 54/57] REMOVE ME BEFORE MERGE: don't cache snapshot jars --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index 0859e955ba..2d63da90fd 100644 --- a/build.gradle +++ b/build.gradle @@ -10,6 +10,9 @@ allprojects { url = "https://central.sonatype.com/repository/maven-snapshots/" } } + configurations.configureEach { // FIXME: Remove, this makes gradle never cache snapshot jars (leveldb-ffi snapshots are changing throughout dev) + it.resolutionStrategy.cacheChangingModulesFor 0, 'seconds' + } } buildscript { From 1e9b5cb0dba3bdc44b0500dda6d90869b212113e Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Thu, 30 Jul 2026 15:56:09 +0100 Subject: [PATCH 55/57] bedrock: Fix incorrect handling of overlapping subchunk storages --- .../llbit/chunky/world/bedrock/BedrockChunk.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java index f1ad94f33b..6f953c8aef 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java @@ -156,6 +156,8 @@ private boolean readChunkData(ChunkData chunkData, BlockPalette palette, BiomePa int numStorages = value.get(); int yIndex = value.get(); + // Each yIndex can have many overlapping storages. Typically the first storage is the "main" storage with most blocks. + // Other storages are for things like waterlogged state, which in bedrock can apply to any block due to this "layering" system. for (int storage = 0; storage < numStorages; storage++) { int packed = value.get(); boolean isRuntime = (packed & 1) != 0; @@ -179,10 +181,16 @@ private boolean readChunkData(ChunkData chunkData, BlockPalette palette, BiomePa Tag[] subpalette = new Tag[b]; NBTInputStream tags = NbtUtils.createReaderLE(new BedrockDimension.ByteBufferBackedInputStream(value)); + int airSubpaletteIdx = -1; for (int i = 0; i < b; i++) { NbtMap compound = (NbtMap) tags.readTag(); String name = compound.getString("name"); subpalette[i] = new CompoundTag(List.of(new NamedTag("Name", new StringTag(name)))); + + if (name.equals("minecraft:air")) { + assert airSubpaletteIdx == -1 : "There is more than one air block in the palette?"; // I assume this isn't possible + airSubpaletteIdx = i; + } } int u = 0; @@ -193,17 +201,19 @@ private boolean readChunkData(ChunkData chunkData, BlockPalette palette, BiomePa int x = (u >> 8) & 0xf; int y = u & 0xf; int z = (u >> 4) & 0xf; - int pos = x + 16 * y + 256 * z; int subpaletteIdx = (temp & mask); chunkData.setBlockAt(x, 16 * yIndex + y, z, palette.put(subpalette[subpaletteIdx])); + // For non-main storages we don't want to overwrite an existing block with an air block. + if (subpaletteIdx != airSubpaletteIdx) { + chunkData.setBlockAt(x, 16 * yIndex + y, z, palette.put(subpalette[subpaletteIdx])); + } + temp >>= bitsPerBlock; u++; } } - - yIndex += 1; } } catch (LevelDBException | IOException e) { From 5ab4b1c6e248bc2dc79a9fbfd824750a7ec031fc Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Thu, 30 Jul 2026 15:56:41 +0100 Subject: [PATCH 56/57] bedrock: Support increased world height range -64/320 --- chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java index 6f953c8aef..02978dadac 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java @@ -142,7 +142,7 @@ private boolean readChunkData(ChunkData chunkData, BlockPalette palette, BiomePa // A great resource on bedrock's binary formats: https://github.com/Team-Lodestone/Documentation/tree/main/Bedrock/LevelDB_Output_Array_Formats boolean dataPresent = false; - for (byte subchunkIdx = 0; subchunkIdx < 16; subchunkIdx++) { + for (byte subchunkIdx = -4; subchunkIdx < 20; subchunkIdx++) { // FIXME: subchunk indices range try { Optional dbValue = readSubChunk(this.position, subchunkIdx); if (dbValue.isEmpty()) { From 5eb4f7e5333248780be9a7df8152ab6e5506e9f4 Mon Sep 17 00:00:00 2001 From: Tom Martin Date: Thu, 30 Jul 2026 15:57:00 +0100 Subject: [PATCH 57/57] bedrock: Support subchunk version 8 --- .../src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java index 02978dadac..1780fa8dd6 100644 --- a/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java +++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java @@ -153,8 +153,9 @@ private boolean readChunkData(ChunkData chunkData, BlockPalette palette, BiomePa // Parse subchunk int version = value.get(); + assert version >= 8 && version <= 9 : "Currently only bedrock subchunk versions 8 & 9 are supported"; int numStorages = value.get(); - int yIndex = value.get(); + int yIndex = version == 9 ? value.get() : subchunkIdx; // Each yIndex can have many overlapping storages. Typically the first storage is the "main" storage with most blocks. // Other storages are for things like waterlogged state, which in bedrock can apply to any block due to this "layering" system. @@ -203,8 +204,6 @@ private boolean readChunkData(ChunkData chunkData, BlockPalette palette, BiomePa int z = (u >> 4) & 0xf; int subpaletteIdx = (temp & mask); - chunkData.setBlockAt(x, 16 * yIndex + y, z, palette.put(subpalette[subpaletteIdx])); - // For non-main storages we don't want to overwrite an existing block with an air block. if (subpaletteIdx != airSubpaletteIdx) { chunkData.setBlockAt(x, 16 * yIndex + y, z, palette.put(subpalette[subpaletteIdx]));