parsePluginManifest(File pluginJar) {
}
return Optional.empty();
}
+
+ private record PluginEntry(PluginManifest manifest, Plugin plugin) {}
}
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..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,7 +53,10 @@
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.MCRegion;
+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;
import se.llbit.json.*;
import se.llbit.log.Log;
import se.llbit.math.*;
@@ -62,11 +65,13 @@
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;
import java.io.*;
+import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.ExecutionException;
@@ -192,6 +197,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;
@@ -404,6 +410,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.
@@ -546,11 +553,17 @@ public synchronized void loadScene(RenderContext context, String sceneName, Task
loadedWorld = EmptyWorld.INSTANCE;
if (!worldPath.isEmpty()) {
- File worldDirectory = new File(worldPath);
- if (World.isWorldDir(worldDirectory)) {
- loadedWorld = World.loadWorld(worldDirectory, worldDimension, World.LoggedWarnings.NORMAL);
- } else {
- Log.info("Could not load world: " + worldPath);
+ 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);
+ if (newWorld == EmptyWorld.INSTANCE) {
+ Log.info("Could not load world: " + worldPath);
+ }
}
}
@@ -767,7 +780,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();
}
@@ -803,7 +816,8 @@ public synchronized void loadChunks(TaskTracker taskTracker, World world, Map>>> createRegionDataFuture = (regionPosition, chunkDataArray) -> executor.submit(() -> {
List chunkPositionsToLoad = chunksToLoadByRegion.get(regionPosition);
@@ -2670,6 +2686,7 @@ public void setUseCustomWaterColor(boolean value) {
// Save world info.
JsonObject world = new JsonObject();
world.add("path", worldPath);
+ world.add("worldType", loadedWorld.getInfo().worldFormat().getId());
world.add("dimension", worldDimension.getNamespacedName());
json.add("world", world);
}
@@ -2992,7 +3009,7 @@ else if(waterShader.equals("SIMPLEX"))
if (json.get("world").isObject()) {
JsonObject world = json.get("world").object();
worldPath = world.get("path").stringValue(worldPath);
-
+ worldFormat = world.get("worldFormat").stringValue(JavaWorldFormat.ID);
if (world.get("dimension") instanceof JsonString) {
// dimension already is a string (or undefined)
worldDimension = Dimension.Identifier.fromNamespacedName(world.get("dimension").stringValue("minecraft:overworld"));
diff --git a/chunky/src/java/se/llbit/chunky/ui/ChunkMap.java b/chunky/src/java/se/llbit/chunky/ui/ChunkMap.java
index 270374e554..edac760101 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,19 +39,18 @@
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.*;
+import se.llbit.util.concurrent.ChunkyThread;
import java.awt.*;
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;
@@ -115,7 +108,7 @@ public class ChunkMap implements ChunkUpdateListener, ChunkViewListener, CameraV
private volatile boolean scheduledUpdate = false;
private volatile long lastRedraw = 0;
private Runnable onViewDragged = () -> {};
- private ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
+ private final ScheduledExecutorService executor = ChunkyThread.addExecutorService(Executors::newSingleThreadScheduledExecutor);
private boolean shouldDrawPlayers = true;
@@ -227,8 +220,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);
}
}
@@ -596,18 +589,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/ChunkyFx.java b/chunky/src/java/se/llbit/chunky/ui/ChunkyFx.java
index 93f6532acc..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,10 +81,11 @@ 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));
}
- System.exit(0);
}
public static void startChunkyUI(Chunky chunkyInstance) {
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..cb5cc071bb 100644
--- a/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java
+++ b/chunky/src/java/se/llbit/chunky/ui/controller/ChunkyFxController.java
@@ -23,12 +23,7 @@
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.time.Duration;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.IdentityHashMap;
-import java.util.Map;
-import java.util.Optional;
-import java.util.ResourceBundle;
+import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -286,7 +281,7 @@ public void exportMapView() {
fileChooser.setTitle("Export PNG");
fileChooser
.getExtensionFilters().add(new FileChooser.ExtensionFilter("PNG image", "*.png"));
- mapLoader.withWorld(world -> fileChooser.setInitialFileName(world.levelName() + ".png"));
+ mapLoader.withWorld(world -> fileChooser.setInitialFileName(world.getInfo().name() + ".png"));
if (prevPngDir != null) {
fileChooser.setInitialDirectory(prevPngDir.toFile());
}
@@ -326,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());
@@ -340,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.getWorldDirectory());
+ mapLoader.setWorld(newWorld);
getChunkSelection().setSelection(chunky.getSceneManager().getScene().getChunks());
}
}
@@ -459,25 +454,20 @@ 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);
- }
+ 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);
+ yMax.set(max);
+ mapView.setYMinMax(min, max);
yMin.getStyleClass().removeAll("invalid");
yMax.getStyleClass().removeAll("invalid");
ignoreYUpdate.set(false);
}
map.redrawMap();
- mapName.setText(world.levelName());
+ mapName.setText(world.getInfo().name());
showWorldMap();
});
});
@@ -656,14 +646,10 @@ public File getSceneFile(String fileName) {
mapOverlay.setOnKeyPressed(map::onKeyPressed);
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);
- }
+ mapLoader.loadWorldFromDirectory(PersistentSettings.getLastWorld(), PersistentSettings.getLastWorldFormat());
+ 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/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/ui/controller/WorldChooserController.java b/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java
index 5705e9e625..6df657d0bb 100644
--- a/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java
+++ b/chunky/src/java/se/llbit/chunky/ui/controller/WorldChooserController.java
@@ -31,11 +31,12 @@
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.WorldFormats;
import se.llbit.fxutil.Dialogs;
import se.llbit.json.JsonArray;
import se.llbit.log.Log;
@@ -48,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;
@@ -69,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);
@@ -96,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);
@@ -135,7 +136,12 @@ 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);
+ 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.");
@@ -150,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());
@@ -161,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) {
@@ -171,7 +178,6 @@ private void loadWorld(World world, WorldMapLoader mapLoader) {
}
}
});
- mapLoader.loadWorld(world.getWorldDirectory());
}
/**
@@ -188,20 +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) {
- if (World.isWorldDir(dir)) {
- World world = World.loadWorld(dir, Dimension.Identifier.OVERWORLD, World.LoggedWarnings.SILENT);
- if (world != EmptyWorld.INSTANCE) {
- worlds.add(world);
- }
- }
+ worlds.addAll(WorldFormats.getInfos(dir.toPath()));
}
}
}
@@ -210,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/ui/render/tabs/GeneralTab.java b/chunky/src/java/se/llbit/chunky/ui/render/tabs/GeneralTab.java
index 095e056f55..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
@@ -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;
@@ -47,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;
@@ -592,12 +594,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) {
+ HeightRange heightRange = world.currentDimension().heightRange();
+ int min = heightRange.min();
+ int max = heightRange.max();
+ 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/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/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/CubicDimension.java b/chunky/src/java/se/llbit/chunky/world/CubicDimension.java
index 778f3a69d1..f2e17d6443 100644
--- a/chunky/src/java/se/llbit/chunky/world/CubicDimension.java
+++ b/chunky/src/java/se/llbit/chunky/world/CubicDimension.java
@@ -3,9 +3,13 @@
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;
+import se.llbit.math.Vector3i;
+import se.llbit.util.annotation.Nullable;
import java.io.File;
import java.io.IOException;
@@ -17,13 +21,13 @@
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) {
- super(world, dimensionId, dimensionDirectory, playerEntities);
+ public CubicDimension(JavaWorld world, Dimension.Identifier dimensionId, Path dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) {
+ super(world, dimensionId, dimensionDirectory, playerEntities, spawnPos);
}
/**
@@ -31,7 +35,7 @@ protected CubicDimension(World world, Dimension.Identifier dimensionId, File dim
*/
@Override
public synchronized File getRegionDirectory() {
- return new File(dimensionDirectory, "region3d");
+ return dimensionDirectory.resolve("region3d").toFile();
}
@Override
@@ -49,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;
@@ -62,10 +66,9 @@ 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());
+ try (Stream list = Files.list(regionDirectory.toPath())) {
return list.anyMatch(path -> {
String[] split = path.getFileName().toString().split("[.]");
if(split.length == 4) {
@@ -83,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 34c0b2b8ee..7a42e35d8b 100644
--- a/chunky/src/java/se/llbit/chunky/world/Dimension.java
+++ b/chunky/src/java/se/llbit/chunky/world/Dimension.java
@@ -1,7 +1,5 @@
package se.llbit.chunky.world;
-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;
@@ -17,13 +15,13 @@
import se.llbit.math.Vector3i;
import se.llbit.util.annotation.Nullable;
-import java.io.File;
+import java.nio.file.Path;
import java.util.*;
/**
*
*/
-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,77 +61,52 @@ public String toString() {
}
}
- private final World world;
+ protected final Path dimensionDirectory;
- protected final Long2ObjectMap regionMap = new Long2ObjectOpenHashMap<>();
+ protected final Heightmap heightmap = new Heightmap();
- protected final File dimensionDirectory;
- private Set playerEntities;
+ protected final Identifier dimensionId;
- private final Heightmap heightmap = new Heightmap();
+ @Nullable protected final Vector3i spawnPos;
+ protected final Set playerEntities;
- private final Identifier dimensionId;
-
- private final Collection chunkDeletionListeners = new LinkedList<>();
- private final Collection chunkTopographyListeners = new LinkedList<>();
- private final Collection chunkUpdateListeners = new LinkedList<>();
-
- private Vector3i spawnPos = null;
+ protected final Collection chunkDeletionListeners = new LinkedList<>();
+ protected final Collection chunkTopographyListeners = new LinkedList<>();
+ protected final Collection chunkUpdateListeners = new LinkedList<>();
/**
* @param dimensionDirectory Minecraft world directory.
*/
- protected Dimension(World world, Identifier dimensionId, File dimensionDirectory, Set playerEntities) {
- this.world = world;
+ protected Dimension(Identifier dimensionId, Path dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) {
this.dimensionId = dimensionId;
- this.dimensionDirectory = dimensionDirectory;
this.playerEntities = playerEntities;
- }
-
- public Identifier getDimensionId() {
- return dimensionId;
+ this.dimensionDirectory = dimensionDirectory;
+ this.spawnPos = spawnPos;
}
/**
- * Reload player data.
- *
- * @return {@code true} if player data was reloaded.
+ * @return A user presentable name of the dimension
*/
- public synchronized boolean reloadPlayerData() {
- return this.world.reloadPlayerData();
- }
+ public abstract String getName();
- /**
- * Add a chunk deletion listener.
- */
- public void addChunkDeletionListener(ChunkDeletionListener listener) {
- synchronized (chunkDeletionListeners) {
- chunkDeletionListeners.add(listener);
- }
+ public Identifier getDimensionId() {
+ return dimensionId;
}
/**
- * Add a region discovery listener.
+ * Get the data directory for the given dimension.
+ * This may be the same as the world directory for some dimensions
+ *
+ * @return The path to the dimension
*/
- public void addChunkUpdateListener(ChunkUpdateListener listener) {
- synchronized (chunkUpdateListeners) {
- chunkUpdateListeners.add(listener);
- }
- }
-
- private void fireChunkDeleted(ChunkPosition chunk) {
- synchronized (chunkDeletionListeners) {
- for (ChunkDeletionListener listener : chunkDeletionListeners)
- listener.chunkDeleted(chunk);
- }
+ protected synchronized Path getDimensionPath() {
+ return dimensionDirectory;
}
/**
* @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,147 +126,134 @@ 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 Region getRegionWithinRange(RegionPosition pos, int yMin, int yMax) {
- return getRegion(pos);
- }
+ public abstract Region getRegion(RegionPosition 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, HeightRange heightRange);
/**
* @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 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 boolean regionExistsWithinRange(RegionPosition pos, int minY, int maxY) {
- return this.regionExists(pos);
- }
+ public abstract boolean hasRegionWithinRange(RegionPosition pos, HeightRange heightRange);
/**
- * 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 HeightRange 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;
}
+ public Optional getSpawnPosition() {
+ return Optional.ofNullable(this.spawnPos);
+ }
+
+ /**
+ * 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 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 +284,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..e627aebe74 100644
--- a/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java
+++ b/chunky/src/java/se/llbit/chunky/world/EmptyDimension.java
@@ -1,14 +1,76 @@
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(), null);
+ }
+
+ @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, HeightRange heightRange) {
+ return EmptyRegion.instance;
+ }
+
+ @Override
+ public boolean hasRegion(RegionPosition pos) {
+ return false;
}
@Override
- public String toString() {
+ public boolean hasRegionWithinRange(RegionPosition pos, HeightRange heightRange) {
+ return false;
+ }
+
+ @Override
+ public HeightRange heightRange() {
+ return new HeightRange(0, 0);
+ }
+
+ @Override public String getName() {
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..c4b2f533ca 100644
--- a/chunky/src/java/se/llbit/chunky/world/EmptyRegionChunk.java
+++ b/chunky/src/java/se/llbit/chunky/world/EmptyRegionChunk.java
@@ -23,10 +23,11 @@
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.
- * 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
*/
@@ -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/EmptyWorld.java b/chunky/src/java/se/llbit/chunky/world/EmptyWorld.java
index d302150b49..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,14 @@
*/
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;
+
/**
* Represents an empty or non-existent world.
*
@@ -27,18 +35,62 @@ public class EmptyWorld extends World {
public static final EmptyWorld INSTANCE = new EmptyWorld();
private EmptyWorld() {
- super("[empty world]", null, 0, -1);
- 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
- public void loadDimension(Dimension.Identifier dimensionId) {
+ public Set getAvailableDimensions() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public Optional getDefaultDimension() {
+ return Optional.empty();
+ }
+
+ @Override
+ public Dimension loadDimension(Dimension.Identifier dimensionId) {
// no-op
+ return this.currentDimension;
}
@Override
public String toString() {
return "[empty world]";
}
-
}
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/ImposterCubicChunk.java b/chunky/src/java/se/llbit/chunky/world/ImposterCubicChunk.java
index 1ea4ed8460..e1e5a1e18c 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) {
@@ -52,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/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/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/World.java b/chunky/src/java/se/llbit/chunky/world/World.java
index 51653becde..54785870cc 100644
--- a/chunky/src/java/se/llbit/chunky/world/World.java
+++ b/chunky/src/java/se/llbit/chunky/world/World.java
@@ -16,65 +16,50 @@
*/
package 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.chunky.world.worldformat.WorldFormat;
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.
- * 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 class World implements Comparable {
-
- /** The currently supported NBT version of level.dat files. */
- public static final int NBT_VERSION = 19133;
+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;
- /** 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;
+ private final Info info;
+ @NotNull
protected Dimension currentDimension;
- private final String levelName;
- private int gameMode = 0;
- private final long seed;
-
- private int versionId;
-
- /** Timestamp for level.dat when player data was last loaded. */
- private long timestamp;
-
- private UUID singleplayerPlayerUuid;
+ 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
- * @param timestamp
- */
- protected World(String levelName, File worldDirectory, long seed, long timestamp) {
- this.levelName = levelName;
- this.worldDirectory = worldDirectory;
- this.seed = seed;
- this.timestamp = timestamp;
+ /** Only for use by {@link EmptyWorld} */
+ protected World(Info info, @NotNull EmptyDimension dimension) {
+ this.info = info;
+ this.currentDimension = dimension;
}
public enum LoggedWarnings {
@@ -82,246 +67,40 @@ 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.
+ * The dimensions returned here are later provided to {@link #loadDimension(Dimension.Identifier)} when requesting a
+ * dimension be loaded.
*
- * @return {@code true} if the world data was loaded
+ * @return List the viewable dimensions within the world.
*/
- 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);
-
- 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;
- }
-
- @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);
- }
-
- 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;
- }
+ public abstract Set getAvailableDimensions();
/**
- * 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.
+ * MUST be one of {@link #getAvailableDimensions()}
+ * @return The preferred default dimension of this world (typically the overworld)
*/
- 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
- }
+ public abstract Optional getDefaultDimension();
- 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());
- }
- }
- }
- }
- }
+ /**
+ * @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);
/**
- * @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;
}
- public Optional getSingleplayerPlayerUuid() {
- return Optional.ofNullable(singleplayerPlayerUuid);
- }
-
- /**
- * @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;
- }
-
- public int getVersionId() {
- return versionId;
- }
-
- /**
- * @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
- */
- 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() + ")";
}
/**
@@ -330,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/bedrock/BedrockChunk.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java
new file mode 100644
index 0000000000..1780fa8dd6
--- /dev/null
+++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockChunk.java
@@ -0,0 +1,412 @@
+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.*;
+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;
+import se.llbit.util.annotation.NotNull;
+
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+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) {
+ super(pos, dimension);
+ }
+
+ @Override
+ 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) {
+ ((BedrockDimension) dimension).setChunk(position, EmptyRegionChunk.INSTANCE);
+ return false;
+ }
+ readData3D(chunkData, palette, biomePalette, 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;
+ }
+
+ 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();
+ }
+
+ readChunkData(reuseChunkData.get(), palette, biomePalette, minY, maxY);
+ readData3D(reuseChunkData.get(), palette, biomePalette, maxY);
+ }
+
+ public Optional readSubChunk(ChunkPosition pos, byte subChunkIdx) throws LevelDBException {
+ 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);
+
+ 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(keyType);
+ return byteBuffer;
+ }
+
+ 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 = -4; subchunkIdx < 20; subchunkIdx++) { // FIXME: subchunk indices range
+ try {
+ Optional dbValue = readSubChunk(this.position, subchunkIdx);
+ if (dbValue.isEmpty()) {
+ continue;
+ }
+ dataPresent = true;
+ ByteBuffer value = ByteBuffer.wrap(dbValue.get()).order(ByteOrder.LITTLE_ENDIAN);
+
+ // 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 = 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.
+ 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;
+
+ 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);
+
+ ByteBuffer blockData = value.slice().order(ByteOrder.LITTLE_ENDIAN);
+ value.position(value.position() + wordCount * 4);
+
+ int b = value.getInt();
+
+ 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;
+ 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 subpaletteIdx = (temp & mask);
+ // 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++;
+ }
+ }
+ }
+
+ } catch (LevelDBException | IOException e) {
+ throw new ChunkLoadingException("Exception thrown when loading chunk " + this.position, e);
+ }
+ }
+ return dataPresent;
+ }
+
+ 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;
+ }
+
+ 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:
+ 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();
+ 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());
+ }
+ }
+
+ 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));
+ }
+}
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..5f4e8c0a9e
--- /dev/null
+++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDB.java
@@ -0,0 +1,184 @@
+package se.llbit.chunky.world.bedrock;
+
+import io.github.notstirred.leveldb_ffi.*;
+import se.llbit.log.Log;
+
+import java.lang.foreign.Arena;
+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;
+
+/**
+ * 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
+ *
+ * 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 {
+ /*
+ * This class keeps track of currently open LevelDB objects
+ *
+ * All operations (other than reading from a LevelDB) lock before starting.
+ *
+ */
+
+ private static final Cleaner cleaner = Cleaner.create(); // TODO: move this to Chunky class or something usable by all.
+
+ /*
+ * 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;
+
+ /**
+ * 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.
+ 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.
+ }
+ });
+ } finally {
+ openDBs.clear();
+ lock.unlock();
+ }
+ }
+
+ 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 shouldn't be closed.
+ return this.ref.db.get(options, key);
+ }
+
+ public static BedrockDB getOrOpen(Path dbPath) {
+ Lock lock = BedrockDB.lock;
+ try {
+ lock.lock();
+
+ Arena arena = Arena.ofShared();
+
+ Options options = Options.create(arena);
+ options.setCompression(Compressor.ZLIB_RAW);
+ options.setCreateIfMissing(false);
+
+ DBRef dbRef = openDBs.get(dbPath);
+ if (dbRef != null) {
+ Log.info("Reused open Bedrock DB " + dbRef.path.toString());
+ return new BedrockDB(dbRef);
+ }
+
+ 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);
+ cleaner.register(bedrockDB, ref::release);
+
+ Log.info("Opened Bedrock DB " + ref.path.toString());
+ 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;
+ }
+
+ /**
+ * 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
+ */
+ private static class DBRef {
+ private final Path path;
+ private LevelDB db;
+ 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;
+ this.arena = arena;
+ }
+
+ 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();
+ assert references > 0;
+ references--;
+ if (references == 0) {
+ DBRef removed = openDBs.remove(this.path);
+ 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;
+ Log.info("Closed Bedrock DB " + this.path.toString());
+ }
+ } 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
new file mode 100644
index 0000000000..5a2e2d5908
--- /dev/null
+++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockDimension.java
@@ -0,0 +1,136 @@
+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.EmptyRegion;
+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.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class BedrockDimension extends Dimension {
+ protected final ConcurrentHashMap regionMap = new ConcurrentHashMap<>();
+
+ 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, spawnPos);
+ this.db = BedrockDB.getOrOpen(dimensionDirectory.resolve("db").toAbsolutePath());
+ readOptions = ReadOptions.create(this.db.getArena());
+ readOptions.setFillCache(false); // almost all reads happen only once
+ }
+
+ public Optional getDbValue(byte[] key) throws LevelDBException {
+ return this.db.get(readOptions, key); // leveldb is thread safe for N readers so no synchronization required
+ }
+
+ @Override
+ public boolean reloadPlayerData() {
+ return false;
+ }
+
+ @Override
+ public Optional getPlayerPos() {
+ return Optional.empty();
+ }
+
+ public void setChunk(ChunkPosition position, Chunk chunk) {
+ this.chunks.put(position, chunk);
+ chunkUpdated(position);
+ }
+
+ 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 new VirtualBedrockRegion(pos, this);
+ }
+
+ @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 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 getRegion(pos);
+ }
+
+ @Override
+ public boolean hasRegion(RegionPosition pos) {
+ return !(regionMap.get(pos) instanceof EmptyRegion);
+ }
+
+ @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/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/bedrock/BedrockWorld.java b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockWorld.java
new file mode 100644
index 0000000000..20ad105e58
--- /dev/null
+++ b/chunky/src/java/se/llbit/chunky/world/bedrock/BedrockWorld.java
@@ -0,0 +1,46 @@
+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) {
+ 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/bedrock/VirtualBedrockRegion.java b/chunky/src/java/se/llbit/chunky/world/bedrock/VirtualBedrockRegion.java
new file mode 100644
index 0000000000..08937c5021
--- /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(this.position.asChunkPosition(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();
+ }
+ };
+ }
+}
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..c662f912e7
--- /dev/null
+++ b/chunky/src/java/se/llbit/chunky/world/java/JavaChunk.java
@@ -0,0 +1,364 @@
+package se.llbit.chunky.world.java;
+
+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.java.region.JavaRegion;
+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 {
+ JavaRegion region = (JavaRegion) 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 {
+ JavaRegion region = (JavaRegion) dimension.getRegion(position.getRegionPosition());
+ return region.getEntityTags(this.position, request);
+ }
+
+ @Override
+ public synchronized boolean loadChunk(@NotNull Mutable chunkData, int yMin, int yMax) {
+ if (!shouldReload()) {
+ 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 = getChunkVersion(data);
+ HeightRange chunkBounds = inclusiveChunkBounds(data);
+ chunkData.set(this.dimension.createChunkData(chunkData.get(), chunkBounds.min(), chunkBounds.max()));
+ 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 shouldReload() {
+ 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 getChunkVersion(@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();
+
+ HeightRange chunkBounds = inclusiveChunkBounds(data);
+
+ if(reuseChunkData.get() == null || reuseChunkData.get() instanceof EmptyChunkData) {
+ reuseChunkData.set(dimension.createChunkData(reuseChunkData.get(), chunkBounds.min(), chunkBounds.max()));
+ } else {
+ reuseChunkData.get().clear();
+ }
+ ChunkData chunkData = reuseChunkData.get(); //unwrap mutable, for ease of use
+
+ 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);
+
+ 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 HeightRange 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 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
new file mode 100644
index 0000000000..dcfb177990
--- /dev/null
+++ b/chunky/src/java/se/llbit/chunky/world/java/JavaDimension.java
@@ -0,0 +1,139 @@
+package se.llbit.chunky.world.java;
+
+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.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;
+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;
+
+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, Path dimensionDirectory, Set playerEntities, @Nullable Vector3i spawnPos) {
+ super(dimensionId, dimensionDirectory, playerEntities, spawnPos);
+ this.world = world;
+ }
+
+ @Override
+ public RegionChangeWatcher createRegionChangeWatcher(WorldMapLoader worldMapLoader, MapView mapView) {
+ return new JavaRegionChangeWatcher(worldMapLoader, mapView);
+ }
+
+ @Override
+ public Region createRegion(RegionPosition pos) {
+ return new JavaRegion(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 (hasRegion(pos)) {
+ region = createRegion(pos);
+ }
+ return region;
+ });
+ }
+
+ @Override
+ public Region getRegionWithinRange(RegionPosition pos, HeightRange heightRange) {
+ return getRegion(pos);
+ }
+
+ @Override
+ public boolean hasRegion(RegionPosition pos) {
+ File regionFile = new File(getRegionDirectory(), pos.getMcaName());
+ return regionFile.exists();
+ }
+
+ @Override
+ public boolean hasRegionWithinRange(RegionPosition pos, HeightRange heightRange) {
+ return this.hasRegion(pos);
+ }
+
+ @Override
+ public HeightRange heightRange() {
+ return this.world.versionId >= JavaWorld.VERSION_21W06A ?
+ new HeightRange(-64, 320) :
+ new HeightRange(0, 256);
+ }
+
+
+ @Override
+ public synchronized Chunk getChunk(ChunkPosition pos) {
+ return getRegion(pos.getRegionPosition()).getChunk(pos);
+ }
+
+ @Override
+ public synchronized boolean 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 (!this.playerEntities.isEmpty()) {
+ return world.getSingleplayerPlayerUuid()
+ .flatMap(uuid -> this.playerEntities.stream()
+ .filter(player -> player.uuid.equals(uuid))
+ .map(pos -> new Vector3(pos.x, pos.y, pos.z))
+ .findFirst());
+ } else {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * 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));
+ }
+ }
+
+ /**
+ * @return File object pointing to the region file directory
+ */
+ public synchronized File getRegionDirectory() {
+ return dimensionDirectory.resolve("region").toFile();
+ }
+
+ @Override
+ public String 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
new file mode 100644
index 0000000000..de0fe246fd
--- /dev/null
+++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorld.java
@@ -0,0 +1,265 @@
+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 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;
+
+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;
+
+ /**
+ * 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;
+
+ /** Timestamp of when player data was last loaded. */
+ protected long playerDataTimestamp;
+
+ 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;
+ }
+
+ 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");
+
+ String levelName = MinecraftText.removeFormatChars(data.tags.get(".Data.LevelName").stringValue(data.levelName));
+
+ long seed = randomSeed.longValue(0);
+
+ 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");
+
+ 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 (hasSpawnPos) {
+ spawnPos = new Vector3i(spawnX.intValue(0), spawnY.intValue(0), spawnZ.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) world;
+ }).orElse(EmptyWorld.INSTANCE);
+ }
+
+ @Override
+ public Set getAvailableDimensions() {
+ return Set.of(Dimension.Identifier.OVERWORLD, // TODO: return the actual set of dimensions on disk.
+ Dimension.Identifier.THE_NETHER,
+ Dimension.Identifier.THE_END
+ );
+ }
+
+ @Override
+ public Optional getDefaultDimension() {
+ return Optional.empty();
+ }
+
+ @Override
+ public Dimension loadDimension(Dimension.Identifier dimensionId) {
+ currentDimension = loadDimension(
+ this,
+ getInfo().path(),
+ 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, 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" -> worldDirectory.resolve("DIM-1");
+ case "minecraft:the_end" -> worldDirectory.resolve("DIM1");
+ default -> worldDirectory;
+ };
+ if (Files.isDirectory(dimensionDirectory.resolve("region3d"))) {
+ return new CubicDimension(world, dimensionId, dimensionDirectory, playerEntities, spawnPos);
+ } else {
+ return new JavaDimension(world, dimensionId, dimensionDirectory, playerEntities, spawnPos);
+ }
+ }
+
+ public Optional getSingleplayerPlayerUuid() {
+ return Optional.ofNullable(singleplayerPlayerUuid);
+ }
+
+ @NotNull
+ private static Set getPlayerEntityData(Path worldDirectory, Tag player) {
+ Set playerEntities = new HashSet<>();
+ if (!player.isError()) {
+ playerEntities.add(new PlayerEntityData(player));
+ }
+ loadAdditionalPlayers(worldDirectory, playerEntities);
+ return playerEntities;
+ }
+
+ 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(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.toFile())))) {
+ playerEntities.add(new PlayerEntityData(NamedTag.read(in).unpack()));
+ } catch (IOException e) {
+ 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());
+ }
+ }
+ }
+
+ /**
+ * 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() {
+ Path worldFile = getInfo().path().resolve("level.dat");
+ long lastModified;
+ try {
+ lastModified = Files.getLastModifiedTime(worldFile).toMillis();
+ } catch (IOException e) {
+ return false;
+ }
+ if (lastModified == playerDataTimestamp) {
+ return false;
+ }
+ Log.infof("world %s: timestamp updated: reading player data", getInfo().name());
+ playerDataTimestamp = lastModified;
+
+ try (FileInputStream fin = new FileInputStream(worldFile.toFile());
+ 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);
+ }
+
+ this.playerEntities.clear();
+ 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!", 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
new file mode 100644
index 0000000000..9de5a4fcf1
--- /dev/null
+++ b/chunky/src/java/se/llbit/chunky/world/java/JavaWorldFormat.java
@@ -0,0 +1,58 @@
+package se.llbit.chunky.world.java;
+
+import se.llbit.chunky.world.World;
+import se.llbit.chunky.world.worldformat.WorldFormat;
+import se.llbit.util.annotation.NotNull;
+
+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 NAME;
+ }
+
+ @Override
+ public String getDescription() {
+ return "The Minecraft world format for Java worlds since 1.2.1 (12w07a)";
+ }
+
+ @Override
+ public String getId() {
+ return ID;
+ }
+
+ @Override
+ public boolean isValid(@NotNull Path path) {
+ if (Files.isDirectory(path)) {
+ Path levelDat = path.resolve("level.dat");
+ return Files.exists(levelDat) && Files.isRegularFile(levelDat);
+ }
+ 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 World.Info info) {
+ return JavaWorld.loadWorld(info, World.LoggedWarnings.NORMAL);
+ }
+}
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 95%
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 e6cee2dda4..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;
@@ -24,6 +24,10 @@
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.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;
@@ -42,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.
@@ -63,7 +56,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,12 +73,12 @@ private static int getMCAChunkIndex(ChunkPosition chunkPos) {
*
* @param pos the region position
*/
- public MCRegion(RegionPosition pos, Dimension 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;
}
}
@@ -156,7 +149,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/java/region/JavaRegionChangeWatcher.java
similarity index 75%
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 b3e9e3bcc0..887f9c1b1b 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,24 +14,26 @@
* 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.HeightRange;
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");
}
@@ -39,7 +41,8 @@ public MCRegionChangeWatcher(WorldMapLoader loader, MapView mapView) {
try {
while (!isInterrupted()) {
sleep(3000);
- Dimension dimension = mapLoader.getWorld().currentDimension();
+ // RegionChangeWatcher 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));
@@ -49,9 +52,10 @@ public MCRegionChangeWatcher(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/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)
*/
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..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,12 +17,11 @@
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.*;
import se.llbit.log.Log;
+import se.llbit.util.concurrent.ChunkyThread;
import se.llbit.util.Mutable;
/**
@@ -34,7 +33,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;
@@ -61,7 +60,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) {
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..3a4448fbff
--- /dev/null
+++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormat.java
@@ -0,0 +1,51 @@
+package se.llbit.chunky.world.worldformat;
+
+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;
+import java.util.Optional;
+
+/**
+ * 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}
+ */
+public interface WorldFormat extends Registerable {
+ /**
+ * 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.
+ */
+ boolean isValid(@NotNull Path 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 {@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.
+ */
+ @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
new file mode 100644
index 0000000000..3df89315d6
--- /dev/null
+++ b/chunky/src/java/se/llbit/chunky/world/worldformat/WorldFormats.java
@@ -0,0 +1,56 @@
+package se.llbit.chunky.world.worldformat;
+
+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.IOException;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.stream.Collectors;
+
+public class WorldFormats {
+ /**
+ * 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);
+ }
+
+ public static Map getWorldFormats() {
+ return Collections.unmodifiableMap(worldFormatsById);
+ }
+
+ public static Optional getWorldFormat(String id) {
+ return Optional.ofNullable(worldFormatsById.get(id));
+ }
+
+ static {
+ addWorldFormat(new JavaWorldFormat());
+ }
+
+ @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 EmptyWorld.INSTANCE;
+ }
+}
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..51d4631f35
--- /dev/null
+++ b/chunky/src/java/se/llbit/util/concurrent/ChunkyThread.java
@@ -0,0 +1,249 @@
+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;
+import java.util.Collection;
+import java.util.concurrent.*;
+import java.util.function.Function;
+
+/**
+ * {@link Thread}/{@link ExecutorService} resource management.
+ * 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.
+ * - 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
+ * - {@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 {
+ private static final CountDownLatch shutdownLatch = new CountDownLatch(1);
+ 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(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.");
+ }
+ 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(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.");
+ }
+ 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;
+ }
+
+ /**
+ * 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.
+ */
+ @PluginApi
+ 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.
+ *
+ * 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 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.
+ * 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) {
+ 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.
+ // - The end time, meaning waiting starts before shutdown begins.
+ shutdownLatch.await();
+ break;
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+
+ long endTime = System.nanoTime() + unit.toNanos(timeout);
+ try {
+ 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;
+ }
+ }
+ // Got to the end of the wait time without joining everything, can give no guarantees
+ return false;
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ /**
+ * Interrupt and then await joining of all threads managed by chunky.
+ *
+ * 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 static boolean interruptAndJoinAll(long timeout, @NotNull TimeUnit unit) {
+ synchronized (ChunkyThread.class) {
+ shutdownLatch.countDown();
+
+ for (ExecutorService executorService : executorServices) {
+ executorService.shutdownNow();
+ }
+ for (Thread thread : ChunkyThread.threads) {
+ thread.interrupt();
+ }
+ }
+
+ 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);
+ }
+
+ /* 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();
+ }
+}
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);
}
}
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..2500b63bba 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
@@ -16,7 +18,6 @@ dependencies {
implementation project(':lib')
testImplementation 'com.google.truth:truth:1.1.3'
- testImplementation 'junit:junit:4.13.2'
}
java {
@@ -51,7 +52,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/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
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();
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'