diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 8e361ebffe..0d5f90d580 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -14,16 +14,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: distribution: 'temurin' - java-version: '17' - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + java-version: '25' + - uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/master' && github.ref != 'refs/heads/chunky-2.4.x' }} - name: Grant execute permission for gradlew @@ -46,12 +44,12 @@ jobs: ;; esac - name: Upload build - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Chunky Build path: build/installer - name: Upload build - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Chunky Core path: build/chunky-core-*.jar diff --git a/build.gradle b/build.gradle index cffaaf3285..2d63da90fd 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,17 @@ -project.version = getVersion() -println "Building version ${project.version}" +def projectVersion = getVersion().toString() +println "Building version ${projectVersion}" allprojects { repositories { mavenCentral() - mavenLocal() + // FIXME @NotStirred: DO NOT RELY ON SNAPSHOT LEVELDB-FFI + maven { + name = "Maven Central SNAPSHOT" + url = "https://central.sonatype.com/repository/maven-snapshots/" + } + } + configurations.configureEach { // FIXME: Remove, this makes gradle never cache snapshot jars (leveldb-ffi snapshots are changing throughout dev) + it.resolutionStrategy.cacheChangingModulesFor 0, 'seconds' } } @@ -15,7 +22,7 @@ buildscript { } } dependencies { - classpath 'org.openjfx:javafx-plugin:0.0.13' + classpath 'org.openjfx:javafx-plugin:0.1.0' classpath 'com.github.ben-manes:gradle-versions-plugin:0.46.0' } } @@ -33,20 +40,10 @@ subprojects { java { toolchain { // default java version for the project - languageVersion = JavaLanguageVersion.of(17) - } - } - compileTestJava { - // compile tests using more recent features - javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(25) } } test { - // run tests using more recent features - javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(21) - } useJUnitPlatform() // Always run tests, even when nothing changed. @@ -55,7 +52,7 @@ subprojects { finalizedBy jacocoTestReport // report is always generated after tests run (it's very cheap) } jacoco { - toolVersion = "0.8.11" + toolVersion = "0.8.15" } jacocoTestReport { dependsOn test // tests are required to run before generating the report @@ -113,19 +110,37 @@ defaultTasks 'release' task releaseVersion { doLast { - tryCommand([ 'git', 'tag', '-a', "${project.version}", '-m', "Version ${project.version}" ], true) + tryCommand([ 'git', 'tag', '-a', "${projectVersion}", '-m', "Version ${projectVersion}" ], true) } } +task prepareReleaseLibraries { + dependsOn ':chunky:copyExternalDependencies' + outputs.upToDateWhen { false } + + doLast { + def libDir = file('chunky/lib') + libDir.mkdirs() + copy { + from project(':chunky').tasks.named('copyExternalDependencies').get().outputs.files + into libDir + } + } +} + task versionInfo(type: JavaExec) { dependsOn 'copyArtifacts' + dependsOn 'prepareReleaseLibraries' outputs.upToDateWhen { false } - outputs.files file("build/chunky-${project.version}.jar") - description 'Writes build/chunky-VERSION.jar, latest.json and updates chunky-core-VERSION.jar/version.json' + outputs.file(layout.buildDirectory.file("chunky-${projectVersion}.jar")) + + description = 'Writes build/chunky-VERSION.jar, latest.json and updates chunky-core-VERSION.jar/version.json' + classpath = project(':releasetools').sourceSets.main.runtimeClasspath mainClass.set('releasetools.ReleaseBuilder') - args "${project.version}", "release_notes-${project.version}.txt" + + args projectVersion, "release_notes-${projectVersion}.txt" } task release { @@ -138,30 +153,34 @@ task release { destinationDirectory.mkdirs() file("./latest.json").renameTo("${destinationDirectory}/latest.json") fileTree(buildDir).matching { - include "*-${project.version}.*" + include "*-${projectVersion}.*" }.each { it.renameTo("${destinationDirectory}/${it.name}") } } } +def launcherArchives = project(':launcher').configurations.archives + task buildReleaseJar(type: Jar) { dependsOn ':launcher:assembleDist' + dependsOn 'prepareReleaseLibraries' dependsOn 'versionInfo' - archiveFileName = "chunky-${project.version}.jar" + archiveFileName = "chunky-${projectVersion}.jar" destinationDirectory = file('build/installer') manifest { attributes('Main-Class': 'se.llbit.chunky.launcher.ChunkyLauncher') + duplicatesStrategy = DuplicatesStrategy.EXCLUDE } into('lib') { from fileTree('chunky/lib').include('*.jar') - from file("build/chunky-core-${project.version}.jar") + from file("build/chunky-core-${projectVersion}.jar") } from { - project(':launcher').configurations.archives.allArtifacts.files.collect { + launcherArchives.allArtifacts.files.collect { zipTree(it) } } @@ -170,10 +189,20 @@ task buildReleaseJar(type: Jar) { rename "latest.json", "version.json" } -task copyArtifacts(type: Copy) { +task copyArtifacts { dependsOn subprojects.jar - from subprojects.jar - into buildDir + dependsOn ':chunky:jar' + outputs.upToDateWhen { false } + + doLast { + def sourceJar = project(':chunky').tasks.named('jar').get().archiveFile.get().asFile + def rootJar = file("build/chunky-core-${projectVersion}.jar") + copy { + from sourceJar + into rootJar.parentFile + rename { rootJar.name } + } + } } /** Helper function to run a command. Returns the command output if the command succeeded. */ diff --git a/chunky/.gitignore b/chunky/.gitignore index 0cdf654876..7e3963237f 100644 --- a/chunky/.gitignore +++ b/chunky/.gitignore @@ -1,3 +1,4 @@ /bin/ /build/ +/lib/ /src/gen-res/ diff --git a/chunky/build.gradle b/chunky/build.gradle index a71bd7026b..3eeb2ab075 100644 --- a/chunky/build.gradle +++ b/chunky/build.gradle @@ -2,8 +2,13 @@ apply plugin: 'application' apply plugin: 'maven-publish' apply plugin: 'org.openjfx.javafxplugin' -mainClassName = 'se.llbit.chunky.main.Chunky' -archivesBaseName = 'chunky-core' +application { + mainClass = 'se.llbit.chunky.main.Chunky' +} + +base { + archivesName = 'chunky-core' +} configurations { implementation.extendsFrom configurations.jsonlib @@ -25,11 +30,13 @@ dependencies { implementation 'com.google.code.gson:gson:2.9.0' implementation 'org.lz4:lz4-java:1.8.0' implementation 'org.apache.maven:maven-artifact:3.9.9' + implementation 'io.github.notstirred:leveldb-ffi:0.1.0-SNAPSHOT' + implementation 'org.cloudburstmc:nbt:3.0.0.Final' implementation project(':lib') } javafx { - version = '17.0.11' + version = '25' configuration = 'implementation' modules = ['javafx.base', 'javafx.controls', 'javafx.fxml'] } @@ -52,7 +59,7 @@ jar { zipTree(it) } manifest { - attributes('Main-Class': mainClassName) + attributes('Main-Class': application.mainClass) } into('se/llbit/chunky/main') { from file("src/gen-res/Version.properties") @@ -78,25 +85,35 @@ sourceSets { processResources.dependsOn 'updateVersionString' +def projectVersion = providers.provider { + version.toString() +} + task updateVersionString { - description 'Store the current version string in src/gen-res/Version.properties' + description = 'Store the current version string in src/gen-res/Version.properties' + + def versionFile = layout.projectDirectory.file('src/gen-res/Version.properties') outputs.upToDateWhen { def props = new Properties() - def output = file('src/gen-res/Version.properties') - if (output.isFile()) { - output.withInputStream { stream -> props.load(stream) } + if (versionFile.asFile.isFile()) { + versionFile.asFile.withInputStream { stream -> + props.load(stream) + } } - props['version'] == project.version + props['version'] == projectVersion.get() } doLast { - file('src/gen-res').mkdirs() - def date = new Date() - def versionFile = file('src/gen-res/Version.properties') - ant.propertyfile(file: versionFile) { - entry(key: 'version', value: project.version) - entry(key: 'gitSha', value: tryCommand(['git', 'rev-parse', 'HEAD']).trim()) + versionFile.asFile.parentFile.mkdirs() + + def gitSha = rootProject.tryCommand( + ['git', 'rev-parse', 'HEAD'] + ).trim() + + ant.propertyfile(file: versionFile.asFile) { + entry(key: 'version', value: projectVersion.get()) + entry(key: 'gitSha', value: gitSha) } } } @@ -112,12 +129,13 @@ task sourcesJar(type: Jar) { } task copyExternalDependencies(type: Copy) { - into "${buildDir}/${libsDirName}" + into layout.buildDirectory.dir("libs") from configurations.externalDependencies } -artifacts { - archives javadocJar, sourcesJar +tasks.named("assemble") { + dependsOn(javadocJar) + dependsOn(sourcesJar) } publishing { @@ -129,7 +147,7 @@ publishing { packaging = "jar" description = "Minecraft mapping and rendering tool" url = "http://chunky.llbit.se" - artifactId = archivesBaseName + artifactId = base.archivesName version = "2.5.0-SNAPSHOT" licenses { @@ -156,22 +174,20 @@ publishing { // fix dependency scopes (see https://discuss.gradle.org/t/maven-publish-plugin-generated-pom-making-dependency-scope-runtime/7494/9) // and filter dependencies + def implementationDependencies = configurations.implementation.allDependencies publishing.publications.all { pom.withXml { - asNode().dependencies.'*'.findAll() { - it.scope.text() == 'runtime' && project.configurations.implementation.allDependencies.find { dep -> + asNode().dependencies.'*'.findAll { + it.scope.text() == 'runtime' && + implementationDependencies.find { dep -> dep.name == it.artifactId.text() } - }.each() { + }.each { if (it.groupId.text() == "org.openjfx") { - // remove javafx dependencies for now (maybe make them optional in the future?) it.parent().remove(it) - // it.appendNode('optional', 'true') } else if (it.groupId.text() == "se.llbit") { - // se.llbit dependencies are included in chunky-core it.parent().remove(it) } else { - // everything else is a dependency as usual it.scope*.value = 'compile' } } @@ -181,8 +197,8 @@ publishing { repositories { maven { - name 'Build' - url layout.buildDirectory.dir('maven') + name = 'Build' + url = layout.buildDirectory.dir('maven') } } } diff --git a/chunky/src/java/se/llbit/chunky/Plugin.java b/chunky/src/java/se/llbit/chunky/Plugin.java index d8e244ee55..836e8c7624 100644 --- a/chunky/src/java/se/llbit/chunky/Plugin.java +++ b/chunky/src/java/se/llbit/chunky/Plugin.java @@ -18,6 +18,7 @@ package se.llbit.chunky; import se.llbit.chunky.main.Chunky; +import se.llbit.util.concurrent.ChunkyThread; /** * The plugin interface for Chunky plugins. @@ -33,4 +34,17 @@ public interface Plugin { * @param chunky Chunky instance which the plugin should attach to */ void attach(Chunky chunky); + + /** + * Called when chunky shuts down, allowing the plugin to close critical resources. Most plugins do not need to do + * anything here. + * + *

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

+ *

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

+ *

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

+ */ + default void shutdown(Chunky chunky) { } } diff --git a/chunky/src/java/se/llbit/chunky/chunk/biome/GenericBiomeData3d.java b/chunky/src/java/se/llbit/chunky/chunk/biome/GenericBiomeData3d.java new file mode 100644 index 0000000000..4f5ada3321 --- /dev/null +++ b/chunky/src/java/se/llbit/chunky/chunk/biome/GenericBiomeData3d.java @@ -0,0 +1,47 @@ +package se.llbit.chunky.chunk.biome; + +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; + +import static se.llbit.chunky.world.Chunk.*; +import static se.llbit.chunky.world.Chunk.SECTION_Y_MAX; + +/** + * Implementation of a 3D biome grid where every block has a biome + * Supports any Y values + * + * Minecraft versions: Bedrock & CubicChunks + */ +public class GenericBiomeData3d implements BiomeData { + private final Int2ObjectOpenHashMap sections = new Int2ObjectOpenHashMap<>(); + + @Override + public int getBiome(int chunkLocalX, int chunkLocalY, int chunkLocalZ) { + int sectionY = chunkLocalY >> 4; + int[] sectionData = sections.get(sectionY); + + if(sectionData == null) { + return 0; + } + + return sectionData[getIdx(chunkLocalX, chunkLocalY, chunkLocalZ)]; + } + + @Override + public void setBiomeAt(int chunkLocalX, int chunkLocalY, int chunkLocalZ, int biome) { + if(biome == 0) + return; + + int sectionY = chunkLocalY >> 4; + int[] sectionData = sections.computeIfAbsent(sectionY, _ -> new int[X_MAX * SECTION_Y_MAX * Z_MAX]); + sectionData[getIdx(chunkLocalX, chunkLocalY, chunkLocalZ)] = biome; + } + + public static int getIdx(int localX, int localY, int localZ) { + return (localX & 0xf) + SECTION_Y_MAX * ((localY & 0xf) + (localZ & 0xf) * X_MAX); + } + + @Override + public void clear() { + sections.clear(); + } +} diff --git a/chunky/src/java/se/llbit/chunky/main/Chunky.java b/chunky/src/java/se/llbit/chunky/main/Chunky.java index 13a6965357..bb9e5e8282 100644 --- a/chunky/src/java/se/llbit/chunky/main/Chunky.java +++ b/chunky/src/java/se/llbit/chunky/main/Chunky.java @@ -18,6 +18,7 @@ package se.llbit.chunky.main; import se.llbit.chunky.PersistentSettings; +import se.llbit.chunky.Plugin; import se.llbit.chunky.block.BlockProvider; import se.llbit.chunky.block.BlockSpec; import se.llbit.chunky.block.MinecraftBlockProvider; @@ -42,12 +43,14 @@ import se.llbit.chunky.ui.controller.CreditsController; import se.llbit.chunky.ui.render.RenderControlsTabTransformer; import se.llbit.chunky.world.MaterialStore; +import se.llbit.chunky.world.bedrock.BedrockPlugin; import se.llbit.json.JsonArray; import se.llbit.log.ConsoleReceiver; import se.llbit.log.Level; import se.llbit.log.Log; import se.llbit.log.Receiver; import se.llbit.util.TaskTracker; +import se.llbit.util.concurrent.ChunkyThread; import java.io.File; import java.io.FileInputStream; @@ -56,6 +59,7 @@ import java.nio.file.Path; import java.util.*; import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** @@ -85,6 +89,7 @@ public void logEvent(Level level, String message) { }; public final ChunkyOptions options; + private final PluginManager pluginManager; private RenderController renderController; private SceneFactory sceneFactory = SceneFactory.DEFAULT; private RenderContextFactory renderContextFactory = RenderContext::new; @@ -106,6 +111,7 @@ public static String getMainWindowTitle() { public Chunky(ChunkyOptions options) { this.options = options; + this.pluginManager = new PluginManager(new JarPluginLoader()); registerBlockProvider(new MinecraftBlockProvider()); registerBlockProvider(new LegacyMinecraftBlockProvider()); } @@ -200,6 +206,9 @@ public void close() { } } + // FIXME: inbuilt plugins + BedrockPlugin bedrockPlugin = new BedrockPlugin(); + /** * Main entry point for Chunky. Chunky should normally be started via the launcher which sets up * the classpath with all dependencies. @@ -211,9 +220,8 @@ public static void main(final String[] args) { System.exit(1); } - int exitCode = 0; if (cmdline.mode == CommandLineOptions.Mode.CLI_OPERATION) { - exitCode = cmdline.exitCode; + System.exit(cmdline.exitCode); } else { // Initialize the common thread pool. getCommonThreads(); @@ -222,6 +230,14 @@ public static void main(final String[] args) { chunky.headless = cmdline.mode == Mode.HEADLESS_RENDER || cmdline.mode == Mode.CREATE_SNAPSHOT; chunky.loadPlugins(); + Runtime.getRuntime().addShutdownHook( + new Thread(() -> { // intentionally not a ChunkyThread + // Within a shutdown hook we need to close quickly, otherwise we risk the user/OS escalating to KILL + chunky.shutdown(1, TimeUnit.SECONDS); + }) + ); + + int exitCode = 0; try { switch (cmdline.mode) { case HEADLESS_RENDER: @@ -240,12 +256,22 @@ public static void main(final String[] args) { Log.error("Unchecked exception caused Chunky to close.", t); exitCode = 2; } - } - if (exitCode != 0) { + chunky.shutdown(5, TimeUnit.SECONDS); + // Always exit, we've done all shutdown necessary and want to exit whether non-daemon threads exist or not. + // This should prevent hangs if threads aren't cooperating. System.exit(exitCode); } } + private void shutdown(int timeout, TimeUnit unit) { + if (ChunkyThread.interruptAndJoinAll(timeout, unit)) { + pluginManager.shutdownPlugins(plugin -> plugin.shutdown(this)); + bedrockPlugin.shutdown(this); + } else { + Log.error("Not all threads were joined before shutting down."); // FIXME: list all alive threads? ThreadGroups are annoying. + } + } + /** * This can be used by plugins to load the default Minecraft textures. */ @@ -255,40 +281,11 @@ public static void loadDefaultTextures() { } private void loadPlugins() { - File pluginsDirectory = SettingsDirectory.getPluginsDirectory(); - if (!pluginsDirectory.isDirectory()) { - Log.infof("Plugins directory does not exist: %s", pluginsDirectory.getAbsolutePath()); - return; - } - Path pluginsPath = pluginsDirectory.toPath(); - JsonArray plugins = PersistentSettings.getPlugins(); - // TODO: allow plugins to implement a custom plugin loader. - PluginManager pluginManager = new PluginManager(new JarPluginLoader()); - - // Parse plugin manifests - Set pluginManifests = plugins.elements.stream() - .map(value -> value.asString("")) - .filter(jarName -> !jarName.isEmpty()) - .map(jarName -> pluginsPath.resolve(jarName).toAbsolutePath().toFile()) - .map(PluginManager::parsePluginManifest) - .flatMap(Optional::stream) - .collect(Collectors.toSet()); - - // Load plugins - pluginManager.load(pluginManifests, (plugin, manifest) -> { - String jarName = manifest.pluginJar.getName(); - Log.infof("Loading plugin: %s", jarName); - if (!isHeadless()) { - CreditsController.addPlugin(manifest.name, manifest.version.toString(), manifest.author, manifest.description); - } - - try { - plugin.attach(this); - } catch (Throwable t) { - Log.error("Plugin " + jarName + " failed to load.", t); - } - Log.infof("Plugin loaded: %s %s", manifest.name, manifest.version); - }); + this.pluginManager.loadPluginsFromDirectory( + SettingsDirectory.getPluginsDirectory(), + (plugin, manifest) -> plugin.attach(this) + ); + bedrockPlugin.attach(this); } /** @@ -342,7 +339,7 @@ public void update() { public static ForkJoinPool getCommonThreads() { if (commonThreads == null) { // use at least two threads to prevent deadlocks in some java versions (see #1631) - commonThreads = new ForkJoinPool(Math.max(PersistentSettings.getNumThreads(), 2)); + commonThreads = ChunkyThread.addForkJoinPool(new ForkJoinPool(Math.max(PersistentSettings.getNumThreads(), 2))); } return commonThreads; } @@ -353,7 +350,7 @@ public static ForkJoinPool getCommonThreads() { */ public static void setCommonThreadsCount(int threads) { ForkJoinPool t = getCommonThreads(); - commonThreads = new ForkJoinPool(threads); + commonThreads = ChunkyThread.addForkJoinPool(new ForkJoinPool(threads)); t.shutdown(); } diff --git a/chunky/src/java/se/llbit/chunky/map/MapTile.java b/chunky/src/java/se/llbit/chunky/map/MapTile.java index 2f00a9991a..184536f8e5 100644 --- a/chunky/src/java/se/llbit/chunky/map/MapTile.java +++ b/chunky/src/java/se/llbit/chunky/map/MapTile.java @@ -71,8 +71,9 @@ public void draw(MapBuffer buffer, WorldMapLoader mapLoader, ChunkView view, } } else { RegionPosition regionPos = new RegionPosition(pos.x, pos.z); // intentionally don't convert, this position represented a region already. - boolean isValid = mapLoader.getWorld().currentDimension().regionExistsWithinRange(regionPos, view.yMin, view.yMax); - Region region = mapLoader.getWorld().currentDimension().getRegionWithinRange(regionPos, view.yMin, view.yMax); + HeightRange heightRange = new HeightRange(view.yMin, view.yMax); + boolean isValid = mapLoader.getWorld().currentDimension().hasRegionWithinRange(regionPos, heightRange); + Region region = mapLoader.getWorld().currentDimension().getRegionWithinRange(regionPos, heightRange); int pixelOffset = 0; for (int z = 0; z < 32; ++z) { for (int x = 0; x < 32; ++x) { diff --git a/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java b/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java index 1119340ea5..93b0c915cf 100644 --- a/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java +++ b/chunky/src/java/se/llbit/chunky/map/WorldMapLoader.java @@ -16,15 +16,20 @@ */ package se.llbit.chunky.map; +import java.util.Optional; import java.util.function.BiConsumer; import se.llbit.chunky.PersistentSettings; import se.llbit.chunky.renderer.ChunkViewListener; import se.llbit.chunky.ui.controller.ChunkyFxController; import se.llbit.chunky.world.*; +import se.llbit.chunky.world.java.JavaWorldFormat; import se.llbit.chunky.world.region.RegionChangeWatcher; import se.llbit.chunky.world.region.RegionParser; import se.llbit.chunky.world.region.RegionQueue; import se.llbit.chunky.world.listeners.ChunkTopographyListener; +import se.llbit.chunky.world.worldformat.WorldFormats; +import se.llbit.log.Log; +import se.llbit.util.annotation.Nullable; import java.io.File; import java.util.ArrayList; @@ -64,28 +69,75 @@ public WorldMapLoader(ChunkyFxController controller, MapView mapView) { topographyUpdater.start(); } + public void loadWorldFromDirectory(@Nullable File worldLocation, @Nullable String worldFormatId) { + if (worldLocation != null) { + if (worldFormatId == null || worldFormatId.isEmpty()) { + worldFormatId = JavaWorldFormat.ID; + } + Optional info = WorldFormats.getWorldFormat(worldFormatId) + .flatMap(format -> format.getWorldInfo(worldLocation.toPath())) // attempt to get the given format + .or(() -> WorldFormats.getInfos(worldLocation.toPath()).stream().findFirst()); // get any format + info.ifPresent(this::loadWorld); + return; + } + setWorld(EmptyWorld.INSTANCE); + } + /** - * This is called when a new world is loaded + * Load the world referred to by the {@link World.Info} + * + * @return The loaded world. May be {@link EmptyWorld} if loading failed. */ - public void loadWorld(File worldDir) { - if (World.isWorldDir(worldDir)) { - if (world != null) { - world.currentDimension().removeChunkTopographyListener(this); - } - boolean isSameWorld = !(world instanceof EmptyWorld) && worldDir.equals(world.getWorldDirectory()); - World newWorld = World.loadWorld(worldDir, currentDimensionId, World.LoggedWarnings.NORMAL); - newWorld.currentDimension().addChunkTopographyListener(this); - synchronized (this) { - world = newWorld; - updateRegionChangeWatcher(newWorld.currentDimension()); - - File newWorldDir = world.getWorldDirectory(); - if (newWorldDir != null && !newWorldDir.equals(PersistentSettings.getLastWorld())) { - PersistentSettings.setLastWorld(newWorldDir); - } + public World loadWorld(World.Info info) { + World newWorld = WorldFormats.createWorld(info); + + Optional dimensionToLoad = Optional.of(world.currentDimension()) + .map(Dimension::getDimensionId) + .filter(dimension -> newWorld.getAvailableDimensions().contains(dimension)) + .or(newWorld::getDefaultDimension) + .or(() -> newWorld.getAvailableDimensions().stream().findFirst()); + + if (dimensionToLoad.isPresent()) { + newWorld.loadDimension(dimensionToLoad.get()); + } else { + Log.infof("No dimension loaded for world %s", info.toString()); + } + + setWorld(newWorld); + return this.world; + } + + /** + * Sets the map view world. + *

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

+ * + * @param newWorld The world to set + */ + public void setWorld(World newWorld) { + if (this.world != null) { + this.world.currentDimension().removeChunkTopographyListener(this); + } + + boolean isSameWorld = !(this.world instanceof EmptyWorld) && newWorld.getInfo().path().equals(this.world.getInfo().path()); + + Dimension loadedDim = newWorld.currentDimension(); + if (loadedDim == EmptyWorld.INSTANCE.currentDimension()) { + Log.warn("Map view world was set but it has no dimension!"); + } + + loadedDim.addChunkTopographyListener(this); + synchronized (this) { + this.world = newWorld; + updateRegionChangeWatcher(loadedDim); + + File newWorldDir = this.world.getInfo().path().toFile(); + if (!newWorldDir.equals(PersistentSettings.getLastWorld())) { + PersistentSettings.setLastWorld(newWorldDir); } - worldLoadListeners.forEach(listener -> listener.accept(newWorld, isSameWorld)); + PersistentSettings.setLastWorldFormat(newWorld.getInfo().worldFormat().getId()); } + worldLoadListeners.forEach(listener -> listener.accept(newWorld, isSameWorld)); } /** @@ -151,14 +203,12 @@ public void regionUpdated(RegionPosition region) { public void reloadWorld() { topographyUpdater.clearQueue(); world.currentDimension().removeChunkTopographyListener(this); - World newWorld = World.loadWorld(world.getWorldDirectory(), currentDimensionId, - World.LoggedWarnings.NORMAL); - newWorld.currentDimension().addChunkTopographyListener(this); + world.loadDimension(currentDimensionId); + world.currentDimension().addChunkTopographyListener(this); synchronized (this) { - world = newWorld; - updateRegionChangeWatcher(newWorld.currentDimension()); + updateRegionChangeWatcher(world.currentDimension()); } - worldLoadListeners.forEach(listener -> listener.accept(newWorld, true)); + worldLoadListeners.forEach(listener -> listener.accept(world, true)); viewUpdated(mapView.getMapView()); // update visible chunks immediately } @@ -173,6 +223,8 @@ private void updateRegionChangeWatcher(Dimension dimension) { /** * Set the current dimension. + * + * @param value Must be a valid dimension see {@link World#getAvailableDimensions()} */ public void setDimension(Dimension.Identifier value) { if (value != currentDimensionId) { diff --git a/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java b/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java index b9a903a995..6ccaf6eb27 100644 --- a/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java +++ b/chunky/src/java/se/llbit/chunky/plugin/loader/PluginManager.java @@ -16,9 +16,11 @@ */ package se.llbit.chunky.plugin.loader; +import se.llbit.chunky.PersistentSettings; import se.llbit.chunky.Plugin; import se.llbit.chunky.plugin.manifest.PluginDependency; import se.llbit.chunky.plugin.manifest.PluginManifest; +import se.llbit.json.JsonArray; import se.llbit.json.JsonParser; import se.llbit.log.Log; @@ -31,19 +33,75 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.stream.Collectors; public class PluginManager { - private static final int MAX_CYCLES = Integer.parseInt(System.getProperty("chunky.plugins.maxLoadCycles", "100")); - private final PluginLoader pluginLoader; + private final LinkedList plugins = new LinkedList<>(); + private final AtomicBoolean isShutdown = new AtomicBoolean(false); public PluginManager(PluginLoader pluginLoader) { this.pluginLoader = pluginLoader; } - public void load(Set pluginManifests, BiConsumer onLoad) { + public void loadPluginsFromDirectory(File pluginsDirectory, BiConsumer onLoad) { + if (!pluginsDirectory.isDirectory()) { + Log.infof("Plugins directory does not exist: %s", pluginsDirectory.getAbsolutePath()); + return; + } + Path pluginsPath = pluginsDirectory.toPath(); + JsonArray plugins = PersistentSettings.getPlugins(); + // TODO: allow plugins to implement a custom plugin loader?. + + // Parse plugin manifests + Set pluginManifests = plugins.elements.stream() + .map(value -> value.asString("")) + .filter(jarName -> !jarName.isEmpty()) + .map(jarName -> pluginsPath.resolve(jarName).toAbsolutePath().toFile()) + .map(PluginManager::parsePluginManifest) + .flatMap(Optional::stream) + .collect(Collectors.toSet()); + + loadPlugins(pluginManifests, onLoad); + } + + public void loadPlugins(Set pluginManifests, BiConsumer onLoad) { + // Load plugins + this.load(pluginManifests, (plugin, manifest) -> { + String jarName = manifest.pluginJar.getName(); + Log.infof("Loading plugin: %s", jarName); + try { + onLoad.accept(plugin, manifest); + } catch (Throwable t) { + Log.error("Plugin " + jarName + " failed to load.", t); + } + Log.infof("Plugin loaded: %s %s", manifest.name, manifest.version); + }); + } + + /** + * Calls shutdown on every plugin. + *

Has no effect if already shutdown.

+ */ + public void shutdownPlugins(Consumer onShutdown) { + if (this.isShutdown.getAndSet(true)) { + return; + } + + // Iterates backwards to shut down plugins in the reverse of the order they were attached + this.plugins.descendingIterator().forEachRemaining(plugin -> { + try { + onShutdown.accept(plugin.plugin); + } catch (Throwable t) { + Log.error(String.format("The plugin %s threw when shutting down", plugin.manifest.name), t); + } + }); + } + + private void load(Set pluginManifests, BiConsumer onLoad) { // create plugin objects Map> pluginsByName = new HashMap<>(); pluginManifests.forEach(manifest -> { @@ -64,33 +122,38 @@ public void load(Set pluginManifests, BiConsumer pluginsToLoad = pluginsByName.values().stream().flatMap(Collection::stream).collect(Collectors.toSet()); pluginsToLoad.forEach(plugin -> plugin.resolveDependencies(pluginsByName)); - // load plugins in dependency-first order, cyclic dependencies will never be loaded and will hit MAX_CYCLES cap. - // this was so trivial to implement using cycles that I decided against any kind of dependency tree structure, - // in the worst case this approach requires one cycle per plugin (if every plugin depended on the previous one in the list). + int maxCycles = pluginsToLoad.size(); + // Load plugins in dependency-first order, cyclic dependencies will never be loaded and will hit the maxCycles cap. + // This was so trivial to implement using cycles that I decided against any kind of dependency tree structure. + // In the worst case this approach requires one cycle per plugin (if every plugin depended on the previous one in the list). Set loadedPlugins = new HashSet<>(); int loadCycles = 0; - while (!pluginsToLoad.isEmpty() && loadCycles < MAX_CYCLES) { + while (!pluginsToLoad.isEmpty() && loadCycles < maxCycles) { Log.infof("Cycle %d", loadCycles); loadCycles++; - // new list to avoid CME due to removing inside iteration. - new ArrayList<>(pluginsToLoad).forEach(plugin -> { + for (Iterator iterator = pluginsToLoad.iterator(); iterator.hasNext(); ) { + ResolvedPlugin plugin = iterator.next(); if (plugin.allDependenciesLoaded(loadedPlugins)) { loadedPlugins.add(plugin); - pluginsToLoad.remove(plugin); + iterator.remove(); Log.infof(" Loading plugin %s with deps { %s }, resolved { %s }%n", plugin, plugin.getManifest().getDependencies().stream().map(PluginDependency::toString).collect(Collectors.joining(", ")), plugin.getDependencies().stream().map(ResolvedPlugin::toString).collect(Collectors.joining(", ")) ); - pluginLoader.load(onLoad, plugin.getManifest()); + pluginLoader.load((loadedPlugin, manifest) -> { + this.plugins.add(new PluginEntry(manifest, loadedPlugin)); + onLoad.accept(loadedPlugin, manifest); + }, plugin.getManifest()); } - }); + } } // report if any unloaded plugins remain (their dependencies never got loaded) if (!pluginsToLoad.isEmpty()) { Log.errorf( - "Reached maximum cycles (%d) when loading plugins. Failed to load plugins: (%s)", - MAX_CYCLES, + "Reached maximum cycles %d when loading %d plugins. Failed to load plugins: (%s)", + maxCycles, + pluginManifests.size(), pluginsToLoad.stream().map(ResolvedPlugin::toString).collect(Collectors.joining(", ")) ); } @@ -118,4 +181,6 @@ public static Optional parsePluginManifest(File pluginJar) { } return Optional.empty(); } + + private record PluginEntry(PluginManifest manifest, Plugin plugin) {} } diff --git a/chunky/src/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'