, BasicMessageType> CLASS_TO_MESSAGE = new Object2ObjectOpenHashMap<>();
diff --git a/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/request/CancelPreviewTilesRequest.java b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/request/CancelPreviewTilesRequest.java
new file mode 100644
index 00000000..b9c5c7d9
--- /dev/null
+++ b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/request/CancelPreviewTilesRequest.java
@@ -0,0 +1,72 @@
+package com.hivemc.chunker.cli.messenger.messaging.request;
+
+import com.hivemc.chunker.cli.messenger.messaging.BasicMessage;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.UUID;
+
+/**
+ * Request to cancel preview tile generation for a world (optionally filtered by LOD).
+ */
+public class CancelPreviewTilesRequest extends BasicMessage {
+ private final UUID anonymousId;
+ private final String world;
+ @Nullable
+ private final Integer lod;
+
+ /**
+ * Create a new request to cancel preview tiles.
+ *
+ * @param anonymousId the session ID for the user.
+ * @param world the name of the world.
+ * @param lod the level of detail, or null to cancel all LODs.
+ */
+ public CancelPreviewTilesRequest(UUID anonymousId, String world, @Nullable Integer lod) {
+ this.anonymousId = anonymousId;
+ this.world = world;
+ this.lod = lod;
+ }
+
+ /**
+ * Create a new request to cancel preview tiles.
+ *
+ * @param requestId the request ID to use for this request.
+ * @param anonymousId the session ID for the user.
+ * @param world the name of the world.
+ * @param lod the level of detail, or null to cancel all LODs.
+ */
+ public CancelPreviewTilesRequest(UUID requestId, UUID anonymousId, String world, @Nullable Integer lod) {
+ super(requestId);
+ this.anonymousId = anonymousId;
+ this.world = world;
+ this.lod = lod;
+ }
+
+ /**
+ * The session ID used by the user.
+ *
+ * @return a random UUID used by the user.
+ */
+ public UUID getAnonymousId() {
+ return anonymousId;
+ }
+
+ /**
+ * The name of the world.
+ *
+ * @return the world name.
+ */
+ public String getWorld() {
+ return world;
+ }
+
+ /**
+ * The level of detail.
+ *
+ * @return the LOD, or null to cancel all LODs.
+ */
+ @Nullable
+ public Integer getLod() {
+ return lod;
+ }
+}
diff --git a/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/request/RequestPreviewTilesRequest.java b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/request/RequestPreviewTilesRequest.java
new file mode 100644
index 00000000..6d485465
--- /dev/null
+++ b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/request/RequestPreviewTilesRequest.java
@@ -0,0 +1,126 @@
+package com.hivemc.chunker.cli.messenger.messaging.request;
+
+import com.hivemc.chunker.cli.messenger.messaging.BasicMessage;
+
+import java.util.UUID;
+
+/**
+ * Request indicating that the client wants a range of preview tiles to be generated
+ * for the given world at the given LOD.
+ */
+public class RequestPreviewTilesRequest extends BasicMessage {
+ private final UUID anonymousId;
+ private final String world;
+ private final int lod;
+ private final int minTx;
+ private final int minTz;
+ private final int maxTx;
+ private final int maxTz;
+
+ /**
+ * Create a new request for preview tiles.
+ *
+ * @param anonymousId the session ID for the user.
+ * @param world the name of the world.
+ * @param lod the level of detail.
+ * @param minTx the minimum tile X coordinate.
+ * @param minTz the minimum tile Z coordinate.
+ * @param maxTx the maximum tile X coordinate.
+ * @param maxTz the maximum tile Z coordinate.
+ */
+ public RequestPreviewTilesRequest(UUID anonymousId, String world, int lod, int minTx, int minTz, int maxTx, int maxTz) {
+ this.anonymousId = anonymousId;
+ this.world = world;
+ this.lod = lod;
+ this.minTx = minTx;
+ this.minTz = minTz;
+ this.maxTx = maxTx;
+ this.maxTz = maxTz;
+ }
+
+ /**
+ * Create a new request for preview tiles.
+ *
+ * @param requestId the request ID to use for this request.
+ * @param anonymousId the session ID for the user.
+ * @param world the name of the world.
+ * @param lod the level of detail.
+ * @param minTx the minimum tile X coordinate.
+ * @param minTz the minimum tile Z coordinate.
+ * @param maxTx the maximum tile X coordinate.
+ * @param maxTz the maximum tile Z coordinate.
+ */
+ public RequestPreviewTilesRequest(UUID requestId, UUID anonymousId, String world, int lod, int minTx, int minTz, int maxTx, int maxTz) {
+ super(requestId);
+ this.anonymousId = anonymousId;
+ this.world = world;
+ this.lod = lod;
+ this.minTx = minTx;
+ this.minTz = minTz;
+ this.maxTx = maxTx;
+ this.maxTz = maxTz;
+ }
+
+ /**
+ * The session ID used by the user.
+ *
+ * @return a random UUID used by the user.
+ */
+ public UUID getAnonymousId() {
+ return anonymousId;
+ }
+
+ /**
+ * The name of the world.
+ *
+ * @return the world name.
+ */
+ public String getWorld() {
+ return world;
+ }
+
+ /**
+ * The level of detail.
+ *
+ * @return the LOD.
+ */
+ public int getLod() {
+ return lod;
+ }
+
+ /**
+ * The minimum tile X coordinate.
+ *
+ * @return the minimum tile X.
+ */
+ public int getMinTx() {
+ return minTx;
+ }
+
+ /**
+ * The minimum tile Z coordinate.
+ *
+ * @return the minimum tile Z.
+ */
+ public int getMinTz() {
+ return minTz;
+ }
+
+ /**
+ * The maximum tile X coordinate.
+ *
+ * @return the maximum tile X.
+ */
+ public int getMaxTx() {
+ return maxTx;
+ }
+
+ /**
+ * The maximum tile Z coordinate.
+ *
+ * @return the maximum tile Z.
+ */
+ public int getMaxTz() {
+ return maxTz;
+ }
+}
diff --git a/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/response/TileErrorResponse.java b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/response/TileErrorResponse.java
new file mode 100644
index 00000000..5627910a
--- /dev/null
+++ b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/response/TileErrorResponse.java
@@ -0,0 +1,32 @@
+package com.hivemc.chunker.cli.messenger.messaging.response;
+
+import com.hivemc.chunker.cli.messenger.messaging.BasicMessage;
+
+import java.util.UUID;
+
+/**
+ * Push event indicating that a preview tile failed to generate; the client should treat
+ * the tile as gray-placeholder and not retry.
+ */
+public final class TileErrorResponse extends BasicMessage {
+ private final String world;
+ private final int lod;
+ private final int tx;
+ private final int tz;
+ private final String reason;
+
+ public TileErrorResponse(UUID requestId, String world, int lod, int tx, int tz, String reason) {
+ super(requestId);
+ this.world = world;
+ this.lod = lod;
+ this.tx = tx;
+ this.tz = tz;
+ this.reason = reason;
+ }
+
+ public String world() { return world; }
+ public int lod() { return lod; }
+ public int tx() { return tx; }
+ public int tz() { return tz; }
+ public String reason() { return reason; }
+}
diff --git a/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/response/TileReadyResponse.java b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/response/TileReadyResponse.java
new file mode 100644
index 00000000..f3a1254f
--- /dev/null
+++ b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/response/TileReadyResponse.java
@@ -0,0 +1,31 @@
+package com.hivemc.chunker.cli.messenger.messaging.response;
+
+import com.hivemc.chunker.cli.messenger.messaging.BasicMessage;
+
+import java.util.UUID;
+
+/**
+ * Push event indicating that a preview tile PNG is on disk and ready to be loaded by the client.
+ */
+public final class TileReadyResponse extends BasicMessage {
+ private final String world;
+ private final int lod;
+ private final int tx;
+ private final int tz;
+ private final String path;
+
+ public TileReadyResponse(UUID requestId, String world, int lod, int tx, int tz, String path) {
+ super(requestId);
+ this.world = world;
+ this.lod = lod;
+ this.tx = tx;
+ this.tz = tz;
+ this.path = path;
+ }
+
+ public String world() { return world; }
+ public int lod() { return lod; }
+ public int tx() { return tx; }
+ public int tz() { return tz; }
+ public String path() { return path; }
+}
diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/EncodingType.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/EncodingType.java
index 74fc2df8..97dd6365 100644
--- a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/EncodingType.java
+++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/EncodingType.java
@@ -6,7 +6,6 @@
import com.hivemc.chunker.conversion.encoding.base.writer.LevelWriter;
import com.hivemc.chunker.conversion.encoding.bedrock.BedrockEncoders;
import com.hivemc.chunker.conversion.encoding.java.JavaEncoders;
-import com.hivemc.chunker.conversion.encoding.preview.PreviewLevelWriter;
import com.hivemc.chunker.conversion.encoding.settings.SettingsLevelWriter;
import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet;
@@ -54,7 +53,7 @@ public class EncodingType {
"Preview",
true,
null,
- (directory, version, settings) -> Optional.of(new PreviewLevelWriter(directory)),
+ (directory, version, settings) -> Optional.empty(),
Collections.emptyList()
);
diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSource.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSource.java
new file mode 100644
index 00000000..bd33575e
--- /dev/null
+++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSource.java
@@ -0,0 +1,136 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import com.hivemc.chunker.conversion.WorldConverter;
+import com.hivemc.chunker.conversion.encoding.EncodingType;
+import com.hivemc.chunker.conversion.encoding.base.reader.LevelReader;
+import com.hivemc.chunker.conversion.intermediate.world.Dimension;
+import com.hivemc.chunker.pruning.PruningConfig;
+import com.hivemc.chunker.pruning.PruningRegion;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Production {@link RegionRgbaSource} that drives a fresh {@link WorldConverter} for each region
+ * request, constraining conversion to a single Minecraft region via {@link PruningConfig}.
+ *
+ * All non-block-data processing is disabled so each call is as fast as possible. The result is
+ * captured by {@link RegionRgbaCapturingWriter} and returned as an {@code int[262144]} ARGB array
+ * (row-major, 512 stride), or {@code null} when no chunks are present in the region.
+ */
+public class ChunkerWorldRegionRgbaSource implements RegionRgbaSource {
+
+ /** Maximum time to wait for a single region conversion before giving up. */
+ private static final long TIMEOUT_SECONDS = 30L;
+
+ private final File worldDir;
+
+ /**
+ * Guards concurrent {@link #loadRegion} calls. Bedrock worlds back onto a single LevelDB
+ * instance whose file lock (db/LOCK) the JVM refuses to acquire twice via
+ * {@link java.nio.channels.OverlappingFileLockException}; without this mutex two preview
+ * workers entering loadRegion concurrently both spin up a fresh BedrockLevelReader and the
+ * second one fails immediately. Anvil worlds are unaffected by the lock contention but the
+ * serialization cost there is negligible because the heavy work (downsampling, PNG encoding)
+ * runs on the calling worker outside this method.
+ */
+ private final Object regionLoadLock = new Object();
+
+ /**
+ * Create a new source backed by the given world directory.
+ *
+ * @param worldDir the root folder of the Minecraft world.
+ */
+ public ChunkerWorldRegionRgbaSource(File worldDir) {
+ this.worldDir = worldDir;
+ }
+
+ @Override
+ public int[] loadRegion(String world, int rx, int rz) throws IOException {
+ synchronized (regionLoadLock) {
+ return loadRegionLocked(world, rx, rz);
+ }
+ }
+
+ private int[] loadRegionLocked(String world, int rx, int rz) throws IOException {
+ File dbDir = new File(worldDir, "db");
+ if (dbDir.isDirectory()) {
+ // Bedrock: every region opens the world's LevelDB afresh (the converter frees — and so
+ // closes — the reader after each region). That open can transiently fail to acquire
+ // db/LOCK, e.g. when the previous region's handle hasn't been released yet, so retry
+ // briefly rather than dropping the tile.
+ return PreviewLevelDb.withLockRetry(dbDir, () -> readRegion(world, rx, rz));
+ }
+ return readRegion(world, rx, rz);
+ }
+
+ private int[] readRegion(String world, int rx, int rz) throws IOException {
+ // Compute the chunk range covered by this region
+ int minCx = rx * 32;
+ int minCz = rz * 32;
+ int maxCx = minCx + 31;
+ int maxCz = minCz + 31;
+
+ // Fresh converter with a random session id — no persistent state needed
+ WorldConverter worldConverter = new WorldConverter(UUID.randomUUID());
+
+ // Resolve the requested dimension
+ Dimension targetDimension = worldConverter.getDimensionRegistry().getByIdentifier(world);
+ if (targetDimension == null) {
+ return null;
+ }
+
+ // Constrain conversion to the single region
+ worldConverter.setPruningConfigs(Map.of(
+ targetDimension,
+ new PruningConfig(true, List.of(new PruningRegion(minCx, minCz, maxCx, maxCz)))
+ ));
+
+ // Disable all processing that isn't needed for block-colour extraction
+ worldConverter.setProcessMaps(false);
+ worldConverter.setProcessLighting(false);
+ worldConverter.setProcessHeightMap(false);
+ worldConverter.setProcessBlockEntities(false);
+ worldConverter.setProcessColumnPreTransform(false);
+ worldConverter.setProcessEntities(false);
+ worldConverter.setProcessBiomes(false);
+ worldConverter.setProcessItems(false);
+
+ // Destination pixel buffer
+ int[] dst = new int[262144];
+ RegionRgbaCapturingWriter writer = new RegionRgbaCapturingWriter(rx, rz, dst);
+
+ // Attempt to detect a reader for this world directory
+ Optional extends LevelReader> readerOpt = EncodingType.findReader(worldDir, worldConverter);
+ if (readerOpt.isEmpty()) {
+ return null;
+ }
+
+ // Drive the conversion synchronously with a timeout
+ try {
+ worldConverter.convert(readerOpt.get(), writer)
+ .future()
+ .get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ } catch (TimeoutException e) {
+ throw new IOException("Timed out reading region (" + rx + ", " + rz + ") from " + worldDir, e);
+ } catch (Exception e) {
+ // Unwrap and re-throw as IOException so callers have a stable exception contract
+ Throwable cause = e.getCause() != null ? e.getCause() : e;
+ throw new IOException("Failed to read region (" + rx + ", " + rz + ") from " + worldDir + ": " + cause.getMessage(), cause);
+ }
+
+ // Return null when no block data was written (all pixels are transparent/zero)
+ for (int pixel : dst) {
+ if (pixel != 0) {
+ return dst;
+ }
+ }
+ return null;
+ }
+}
diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewColumnWriter.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewColumnWriter.java
deleted file mode 100644
index b54dda8f..00000000
--- a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewColumnWriter.java
+++ /dev/null
@@ -1,131 +0,0 @@
-package com.hivemc.chunker.conversion.encoding.preview;
-
-import com.google.common.collect.Sets;
-import com.hivemc.chunker.conversion.encoding.base.writer.ColumnWriter;
-import com.hivemc.chunker.conversion.intermediate.column.ChunkerColumn;
-import com.hivemc.chunker.conversion.intermediate.column.chunk.ChunkCoordPair;
-import com.hivemc.chunker.conversion.intermediate.column.chunk.RegionCoordPair;
-import com.hivemc.chunker.conversion.intermediate.column.chunk.identifier.ChunkerBlockIdentifier;
-import it.unimi.dsi.fastutil.Pair;
-
-import javax.imageio.ImageIO;
-import java.awt.image.BufferedImage;
-import java.io.File;
-import java.io.IOException;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentMap;
-
-/**
- * Write all the regions as images based on the block colors.
- */
-public class PreviewColumnWriter implements ColumnWriter {
- private final File outputFolder;
- private final ConcurrentMap> chunkRGBA = new ConcurrentHashMap<>();
- private final PreviewWorldWriter.WorldData worldData;
-
- /**
- * Create a new preview column writer.
- *
- * @param outputFolder the folder where the images should be written.
- * @param worldData the world data to add present chunks to.
- */
- public PreviewColumnWriter(File outputFolder, PreviewWorldWriter.WorldData worldData) {
- this.outputFolder = outputFolder;
- this.worldData = worldData;
- }
-
- @Override
- public void writeColumn(ChunkerColumn chunkerColumn) {
- int[] argb = new int[256];
- boolean present = false;
-
- // Loop through each column to calculate color
- for (int x = 0; x < 16; x++) {
- for (int z = 0; z < 16; z++) {
- // Find the highest block that has RGB color
- Pair block = chunkerColumn.getHighestBlock(x, z, ChunkerBlockIdentifier::hasRGBColor);
- if (block != null) {
- // Mark the chunk as present
- present = true;
-
- // Grab the color
- int rgb = block.value().getRGBColor();
-
- // Convert to ARGB
- argb[(z << 4) | x] = rgb == 0 ? 0 : 0xFF000000 | rgb;
- }
- }
- }
-
- RegionCoordPair regionCoordPair = chunkerColumn.getPosition().getRegion();
- ConcurrentMap regionRGBA = chunkRGBA.computeIfAbsent(regionCoordPair, (ignored) -> new ConcurrentHashMap<>());
- if (present) {
- // Add the RGB
- regionRGBA.put(chunkerColumn.getPosition(), argb);
-
- // Record the chunk being present in this region
- Set chunks = worldData.regionToPresentChunks.computeIfAbsent(regionCoordPair, (ignored) -> Sets.newConcurrentHashSet());
- chunks.add(chunkerColumn.getPosition());
- }
- }
-
- @Override
- public void flushColumns() {
- // Calculate min & max for the world
- for (Set regionChunks : worldData.regionToPresentChunks.values()) {
- for (ChunkCoordPair chunk : regionChunks) {
- if (chunk.chunkX() < worldData.minX) {
- worldData.minX = chunk.chunkX();
- }
-
- if (chunk.chunkX() > worldData.maxX) {
- worldData.maxX = chunk.chunkX();
- }
-
- if (chunk.chunkZ() < worldData.minZ) {
- worldData.minZ = chunk.chunkZ();
- }
-
- if (chunk.chunkZ() > worldData.maxZ) {
- worldData.maxZ = chunk.chunkZ();
- }
- }
- }
-
- // Ensure output is a directory
- outputFolder.mkdirs();
-
- // Create the images for each region and write them
- for (Map.Entry> entry : chunkRGBA.entrySet()) {
- RegionCoordPair region = entry.getKey();
-
- BufferedImage image = new BufferedImage(512, 512, BufferedImage.TYPE_INT_ARGB);
- for (Map.Entry chunk : entry.getValue().entrySet()) {
- // If the chunk isn't present, we don't need to write any data (as it should be transparent)
- if (chunk.getValue().length == 0) continue;
-
- // Copy pixels
- image.setRGB(
- ((chunk.getKey().chunkX() & 31) << 4), // Place our chunk inside the region (512x512)
- ((chunk.getKey().chunkZ() & 31) << 4),
- 16, // Each chunk is 16x16
- 16,
- chunk.getValue(), // ARGB array
- 0, // Starts from the initial value
- 16 // Size of each Y
- );
- }
-
- String name = worldData.dimension.getIdentifier().replace(":", "_");
- // Write the region PNG
- File outputFile = new File(outputFolder, name + "." + region.regionX() + "." + region.regionZ() + ".png");
- try {
- ImageIO.write(image, "png", outputFile);
- } catch (IOException e) {
- throw new RuntimeException(e);
- }
- }
- }
-}
diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelDb.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelDb.java
new file mode 100644
index 00000000..f108579d
--- /dev/null
+++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelDb.java
@@ -0,0 +1,76 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.channels.OverlappingFileLockException;
+import java.util.Locale;
+import java.util.concurrent.Callable;
+
+/**
+ * Helpers for opening a Bedrock world's LevelDB defensively while generating preview data.
+ *
+ * iq80 LevelDB guards a database directory with a single {@code db/LOCK} file and refuses to
+ * open it twice ({@link OverlappingFileLockException} / "Unable to acquire lock"). During preview
+ * generation the database is opened repeatedly — once for the metadata pass and again for every
+ * region tile — so a stale LOCK left by a crashed run, or the brief window where the OS has not yet
+ * released the handle from the previously closed reader, can make an open fail. Clearing the stale
+ * LOCK and retrying lets the preview survive that transient contention instead of failing the pass.
+ */
+final class PreviewLevelDb {
+ /** Number of times to attempt the action before giving up. */
+ private static final int MAX_ATTEMPTS = 4;
+ /** Delay between attempts; long enough for the OS to release a just-closed file handle. */
+ private static final long RETRY_DELAY_MS = 120L;
+
+ private PreviewLevelDb() {
+ }
+
+ /**
+ * Run an action that opens / reads the LevelDB at {@code dbDir}, clearing a stale {@code LOCK}
+ * before each attempt and retrying when the failure is a lock-acquisition problem.
+ *
+ * @param dbDir the {@code db} directory of the world.
+ * @param action the work to perform (typically opening the database).
+ * @param the result type.
+ * @return the action's result.
+ * @throws IOException if the action keeps failing or fails for a non-lock reason.
+ */
+ static T withLockRetry(File dbDir, Callable action) throws IOException {
+ Throwable last = null;
+ for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+ // Clear a LOCK left behind by a crashed reader (or one still being released) before trying.
+ new File(dbDir, "LOCK").delete();
+ try {
+ return action.call();
+ } catch (Throwable t) {
+ last = t;
+
+ // Only a lock-acquisition failure is worth retrying; anything else (corrupt data, a
+ // read timeout, ...) won't be fixed by waiting, so fail fast.
+ if (attempt == MAX_ATTEMPTS || !isLockFailure(t)) break;
+
+ try {
+ Thread.sleep(RETRY_DELAY_MS);
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ }
+
+ if (last instanceof IOException io) throw io;
+ throw new IOException("Failed to access LevelDB at " + dbDir, last);
+ }
+
+ /**
+ * @return true if the throwable (or one of its causes) is an iq80 LevelDB lock-acquisition error.
+ */
+ static boolean isLockFailure(Throwable throwable) {
+ for (Throwable cause = throwable; cause != null; cause = cause.getCause()) {
+ if (cause instanceof OverlappingFileLockException) return true;
+ String message = cause.getMessage();
+ if (message != null && message.toLowerCase(Locale.ROOT).contains("acquire lock")) return true;
+ }
+ return false;
+ }
+}
diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelWriter.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelWriter.java
deleted file mode 100644
index d19dd444..00000000
--- a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelWriter.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.hivemc.chunker.conversion.encoding.preview;
-
-import com.hivemc.chunker.conversion.encoding.EncodingType;
-import com.hivemc.chunker.conversion.encoding.base.Version;
-import com.hivemc.chunker.conversion.encoding.base.writer.LevelWriter;
-import com.hivemc.chunker.conversion.encoding.base.writer.WorldWriter;
-import com.hivemc.chunker.conversion.intermediate.column.biome.ChunkerBiome;
-import com.hivemc.chunker.conversion.intermediate.level.ChunkerLevel;
-
-import java.io.File;
-import java.util.Set;
-
-/**
- * PreviewLevelWriter is a level writer that outputs a folder with preview images / a binary of present chunks.
- */
-public class PreviewLevelWriter implements LevelWriter {
- private final File outputFolder;
-
- /**
- * Create a new preview level writer.
- *
- * @param outputFolder the output folder to write the data to.
- */
- public PreviewLevelWriter(File outputFolder) {
- this.outputFolder = outputFolder;
- }
-
- @Override
- public WorldWriter writeLevel(ChunkerLevel chunkerLevel) {
- // Create a new world writer with the created worldData
- return new PreviewWorldWriter(outputFolder);
- }
-
- @Override
- public EncodingType getEncodingType() {
- return EncodingType.PREVIEW;
- }
-
- @Override
- public Version getVersion() {
- return new Version(1, 0, 0);
- }
-
- @Override
- public Set getSupportedBiomes() {
- return Set.of(); // Biomes aren't used in the PreviewLevelWriter
- }
-}
diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBin.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBin.java
new file mode 100644
index 00000000..7e8e4222
--- /dev/null
+++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBin.java
@@ -0,0 +1,172 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import com.hivemc.chunker.nbt.io.Writer;
+
+import java.io.*;
+import java.util.*;
+
+/**
+ * Reads and writes the map.bin metadata blob in the format produced by the legacy
+ * PreviewWorldWriter, and answers empty-tile queries used by the lazy tile service
+ * to skip generation of tiles that cannot contain any block data.
+ */
+public final class PreviewMapBin {
+ private final List worlds;
+
+ private PreviewMapBin(List worlds) {
+ this.worlds = worlds;
+ }
+
+ public WorldData findByIdentifier(String identifier) {
+ for (WorldData w : worlds) {
+ if (w.identifier.equals(identifier)) return w;
+ }
+ return null;
+ }
+
+ /**
+ * A tile is empty when every region it covers at LOD 0 has zero present chunks.
+ * For lod == 0 each tile maps to one region. For lod < 0 a tile covers 2^|lod| x 2^|lod| regions.
+ * Positive lod is undefined here (the native pyramid bottoms out at LOD 0); always returns
+ * false in that case so the caller does not skip a tile the client may legitimately request.
+ */
+ public boolean isTileEmpty(String world, int lod, int tx, int tz) {
+ WorldData w = findByIdentifier(world);
+ if (w == null) return true;
+ // Guard against positive lod: Java bit-shift uses the low 5 bits of the right operand,
+ // so `1 << -lod` wraps to a huge value and the loop below would never terminate.
+ if (lod > 0) return false;
+ int scale = 1 << (-lod);
+ int minRx = tx * scale;
+ int minRz = tz * scale;
+ int maxRx = minRx + scale; // exclusive
+ int maxRz = minRz + scale;
+ for (int rx = minRx; rx < maxRx; rx++) {
+ for (int rz = minRz; rz < maxRz; rz++) {
+ if (w.regionHasAnyChunk(rx, rz)) return false;
+ }
+ }
+ return true;
+ }
+
+ public static PreviewMapBin read(File file) throws IOException {
+ try (FileInputStream fis = new FileInputStream(file);
+ BufferedInputStream bis = new BufferedInputStream(fis);
+ DataInputStream dis = new DataInputStream(bis)) {
+ int worldCount = Integer.reverseBytes(dis.readInt());
+ List worlds = new ArrayList<>(worldCount);
+ for (int i = 0; i < worldCount; i++) {
+ int bedrockId = Integer.reverseBytes(dis.readInt());
+ int minX = Integer.reverseBytes(dis.readInt());
+ int minZ = Integer.reverseBytes(dis.readInt());
+ int maxX = Integer.reverseBytes(dis.readInt());
+ int maxZ = Integer.reverseBytes(dis.readInt());
+ int regionCount = Integer.reverseBytes(dis.readInt());
+ Map regionToChunks = new HashMap<>(regionCount * 2);
+ for (int r = 0; r < regionCount; r++) {
+ int rx = Integer.reverseBytes(dis.readInt());
+ int rz = Integer.reverseBytes(dis.readInt());
+ byte[] bitset = dis.readNBytes(128);
+ regionToChunks.put(packRegion(rx, rz), BitSet.valueOf(bitset));
+ }
+ worlds.add(new WorldData(bedrockId, identifierForBedrockId(bedrockId), minX, minZ, maxX, maxZ, regionToChunks));
+ }
+ return new PreviewMapBin(worlds);
+ }
+ }
+
+ private static String identifierForBedrockId(int id) {
+ return switch (id) {
+ case 0 -> "minecraft:overworld";
+ case 1 -> "minecraft:the_nether";
+ case 2 -> "minecraft:the_end";
+ default -> "custom:" + id;
+ };
+ }
+
+ private static long packRegion(int rx, int rz) {
+ return ((long) rx << 32) | (rz & 0xFFFFFFFFL);
+ }
+
+ /**
+ * Builder mirrors the layout of the legacy PreviewWorldWriter for byte-for-byte compatibility.
+ */
+ public static final class Builder {
+ private final List worlds = new ArrayList<>();
+
+ public Builder addWorld(int bedrockId, String identifier, int minX, int minZ, int maxX, int maxZ) {
+ worlds.add(new WorldData(bedrockId, identifier, minX, minZ, maxX, maxZ, new HashMap<>()));
+ return this;
+ }
+
+ public Builder addRegion(int worldIndex, int rx, int rz, BitSet present) {
+ worlds.get(worldIndex).regionToChunks.put(packRegion(rx, rz), present);
+ return this;
+ }
+
+ public int size() {
+ return worlds.size();
+ }
+
+ public Builder setBoundsForWorld(int worldIndex, int minX, int minZ, int maxX, int maxZ) {
+ WorldData old = worlds.get(worldIndex);
+ WorldData updated = new WorldData(old.bedrockId, old.identifier, minX, minZ, maxX, maxZ, old.regionToChunks);
+ worlds.set(worldIndex, updated);
+ return this;
+ }
+
+ public void writeTo(File file) throws IOException {
+ try (FileOutputStream fos = new FileOutputStream(file);
+ BufferedOutputStream bos = new BufferedOutputStream(fos);
+ DataOutputStream dos = new DataOutputStream(bos)) {
+ Writer writer = Writer.toLittleEndianWriter(dos);
+ writer.writeInt(worlds.size());
+ for (WorldData w : worlds) {
+ writer.writeInt(w.bedrockId);
+ writer.writeInt(w.minX);
+ writer.writeInt(w.minZ);
+ writer.writeInt(w.maxX);
+ writer.writeInt(w.maxZ);
+ writer.writeInt(w.regionToChunks.size());
+ for (Map.Entry e : w.regionToChunks.entrySet()) {
+ long packed = e.getKey();
+ int rx = (int) (packed >> 32);
+ int rz = (int) packed;
+ writer.writeInt(rx);
+ writer.writeInt(rz);
+ byte[] bits = e.getValue().toByteArray();
+ writer.writeBytes(bits);
+ for (int i = bits.length; i < 128; i++) writer.writeByte(0);
+ }
+ }
+ }
+ }
+ }
+
+ public static final class WorldData {
+ public final int bedrockId;
+ public final String identifier;
+ private final int minX, minZ, maxX, maxZ;
+ private final Map regionToChunks;
+
+ WorldData(int bedrockId, String identifier, int minX, int minZ, int maxX, int maxZ, Map regionToChunks) {
+ this.bedrockId = bedrockId;
+ this.identifier = identifier;
+ this.minX = minX;
+ this.minZ = minZ;
+ this.maxX = maxX;
+ this.maxZ = maxZ;
+ this.regionToChunks = regionToChunks;
+ }
+
+ public int minX() { return minX; }
+ public int minZ() { return minZ; }
+ public int maxX() { return maxX; }
+ public int maxZ() { return maxZ; }
+
+ public boolean regionHasAnyChunk(int rx, int rz) {
+ BitSet b = regionToChunks.get(packRegion(rx, rz));
+ return b != null && !b.isEmpty();
+ }
+ }
+}
diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReader.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReader.java
new file mode 100644
index 00000000..3817a547
--- /dev/null
+++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReader.java
@@ -0,0 +1,230 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import org.iq80.leveldb.*;
+import org.iq80.leveldb.impl.Iq80DBFactory;
+import org.iq80.leveldb.table.BloomFilterPolicy;
+
+import java.io.*;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Reads only the chunk-location headers of a Minecraft world's region files to determine
+ * which chunks exist, without decompressing any chunk data. Output matches the byte layout
+ * produced by the legacy PreviewWorldWriter.
+ */
+public final class PreviewMetadataReader {
+ private static final Pattern REGION_FILE = Pattern.compile("^r\\.(-?\\d+)\\.(-?\\d+)\\.mca$");
+
+ public void readJavaWorld(File worldDir, File outputFolder) throws IOException {
+ if (!outputFolder.exists() && !outputFolder.mkdirs()) {
+ throw new IOException("Could not create preview output folder: " + outputFolder);
+ }
+
+ PreviewMapBin.Builder builder = new PreviewMapBin.Builder();
+
+ // Overworld: /region
+ scanDimensionInto(builder, 0, "minecraft:overworld", new File(worldDir, "region"));
+
+ // Nether: /DIM-1/region
+ File nether = new File(worldDir, "DIM-1/region");
+ if (nether.isDirectory()) {
+ scanDimensionInto(builder, 1, "minecraft:the_nether", nether);
+ }
+
+ // End: /DIM1/region
+ File end = new File(worldDir, "DIM1/region");
+ if (end.isDirectory()) {
+ scanDimensionInto(builder, 2, "minecraft:the_end", end);
+ }
+
+ builder.writeTo(new File(outputFolder, "map.bin"));
+ }
+
+ private void scanDimensionInto(PreviewMapBin.Builder builder, int bedrockId, String identifier, File regionDir) {
+ int worldIndex = builder.size();
+ builder.addWorld(bedrockId, identifier, 0, 0, 0, 0);
+ if (!regionDir.isDirectory()) return;
+
+ File[] files = regionDir.listFiles();
+ if (files == null) return;
+
+ int minX = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE;
+ int maxX = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE;
+
+ for (File f : files) {
+ Matcher m = REGION_FILE.matcher(f.getName());
+ if (!m.matches()) continue;
+ int rx, rz;
+ try {
+ rx = Integer.parseInt(m.group(1));
+ rz = Integer.parseInt(m.group(2));
+ } catch (NumberFormatException ignored) {
+ continue;
+ }
+ BitSet present = readRegionPresence(f);
+ if (present == null) continue; // corrupt or unreadable; skip
+ if (present.isEmpty()) continue;
+ builder.addRegion(worldIndex, rx, rz, present);
+
+ // Update bounds in chunk coordinates from the present chunks of this region.
+ for (int bit = present.nextSetBit(0); bit >= 0; bit = present.nextSetBit(bit + 1)) {
+ int localX = bit & 31;
+ int localZ = (bit >> 5) & 31;
+ int cx = (rx << 5) | localX;
+ int cz = (rz << 5) | localZ;
+ if (cx < minX) minX = cx;
+ if (cz < minZ) minZ = cz;
+ if (cx > maxX) maxX = cx;
+ if (cz > maxZ) maxZ = cz;
+ }
+ }
+
+ if (minX != Integer.MAX_VALUE) {
+ builder.setBoundsForWorld(worldIndex, minX, minZ, maxX, maxZ);
+ }
+ }
+
+ private BitSet readRegionPresence(File regionFile) {
+ try (FileInputStream fis = new FileInputStream(regionFile);
+ DataInputStream dis = new DataInputStream(new BufferedInputStream(fis, 4096))) {
+ byte[] header = dis.readNBytes(4096);
+ if (header.length < 4096) return null;
+ BitSet present = new BitSet(1024);
+ for (int i = 0; i < 1024; i++) {
+ int off = i * 4;
+ int entry = ((header[off] & 0xFF) << 24)
+ | ((header[off + 1] & 0xFF) << 16)
+ | ((header[off + 2] & 0xFF) << 8)
+ | (header[off + 3] & 0xFF);
+ if (entry != 0) present.set(i);
+ }
+ return present;
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ public void readBedrockWorld(File worldDir, File outputFolder) throws IOException {
+ if (!outputFolder.exists() && !outputFolder.mkdirs()) {
+ throw new IOException("Could not create preview output folder: " + outputFolder);
+ }
+
+ File dbDir = new File(worldDir, "db");
+
+ Options options = new Options();
+ options.compressionType(CompressionType.ZLIB_RAW);
+ options.blockSize(160 * 1024);
+ options.filterPolicy(new BloomFilterPolicy(10));
+ options.createIfMissing(true);
+
+ // Per-dimension state: bounds and region presence.
+ // dimensionID -> (minX, minZ, maxX, maxZ)
+ Map dimensionBounds = new HashMap<>();
+ // dimensionID -> (regionKey -> BitSet)
+ Map> dimensionRegions = new HashMap<>();
+
+ // LevelDBChunkType byte values that count as a chunk being present:
+ // DATA_3D=43, DATA_2D=45, SUB_CHUNK_PREFIX=47, BLOCK_ENTITY=49, ENTITY=50
+ final byte TYPE_DATA_3D = 43;
+ final byte TYPE_DATA_2D = 45;
+ final byte TYPE_SUB_CHUNK_PREFIX = 47;
+ final byte TYPE_BLOCK_ENTITY = 49;
+ final byte TYPE_ENTITY = 50;
+
+ // Open defensively: a stale db/LOCK (or one not yet released) would otherwise fail the
+ // whole metadata pass, leaving the preview with no map.bin and nothing to display.
+ DB db = PreviewLevelDb.withLockRetry(dbDir, () -> new Iq80DBFactory().open(dbDir, options));
+ try (db; DBIterator iterator = db.iterator()) {
+ while (iterator.hasNext()) {
+ byte[] key = iterator.next().getKey();
+ int keyLength = key.length;
+
+ boolean containsSubChunk = keyLength == 14 || keyLength == 10;
+ boolean containsDimension = keyLength == 14 || keyLength == 13;
+
+ if (keyLength != 9 && !containsSubChunk && !containsDimension) continue;
+
+ ByteBuffer buffer = ByteBuffer.wrap(key).order(ByteOrder.LITTLE_ENDIAN);
+ int x = buffer.getInt();
+ int z = buffer.getInt();
+ int dimensionID = containsDimension ? buffer.getInt() : 0;
+ // The type byte precedes the optional sub-chunk Y in the key (see LevelDBKey), so
+ // read the type directly; the trailing Y isn't needed to detect chunk presence.
+ byte type = buffer.get();
+
+ if (type != TYPE_DATA_3D && type != TYPE_DATA_2D && type != TYPE_SUB_CHUNK_PREFIX
+ && type != TYPE_ENTITY && type != TYPE_BLOCK_ENTITY) {
+ continue;
+ }
+
+ // Compute region coordinates and bit index within the region.
+ int rx = x >> 5;
+ int rz = z >> 5;
+ int bit = ((z & 31) << 5) | (x & 31);
+
+ // Record the chunk in the region presence map.
+ Map regions = dimensionRegions.computeIfAbsent(dimensionID, id -> new HashMap<>());
+ long regionKey = ((long) rx << 32) | (rz & 0xFFFFFFFFL);
+ BitSet regionBits = regions.computeIfAbsent(regionKey, k -> new BitSet(1024));
+ regionBits.set(bit);
+
+ // Update bounds.
+ int[] bounds = dimensionBounds.computeIfAbsent(dimensionID,
+ id -> new int[]{Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE});
+ if (x < bounds[0]) bounds[0] = x;
+ if (z < bounds[1]) bounds[1] = z;
+ if (x > bounds[2]) bounds[2] = x;
+ if (z > bounds[3]) bounds[3] = z;
+ }
+ }
+
+ PreviewMapBin.Builder builder = new PreviewMapBin.Builder();
+
+ // Always include the three vanilla dimensions.
+ int[] vanillaDims = {0, 1, 2};
+ String[] vanillaIdentifiers = {"minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"};
+ for (int i = 0; i < vanillaDims.length; i++) {
+ int dimId = vanillaDims[i];
+ int worldIndex = builder.size();
+ builder.addWorld(dimId, vanillaIdentifiers[i], 0, 0, 0, 0);
+ populateDimension(builder, worldIndex, dimId, dimensionBounds, dimensionRegions);
+ }
+
+ // Custom dimensions (id > 2) — only add if they have chunks.
+ for (Map.Entry> entry : dimensionRegions.entrySet()) {
+ int dimId = entry.getKey();
+ if (dimId <= 2) continue;
+ int worldIndex = builder.size();
+ builder.addWorld(dimId, "custom:" + dimId, 0, 0, 0, 0);
+ populateDimension(builder, worldIndex, dimId, dimensionBounds, dimensionRegions);
+ }
+
+ builder.writeTo(new File(outputFolder, "map.bin"));
+ }
+
+ private void populateDimension(PreviewMapBin.Builder builder, int worldIndex, int dimId,
+ Map dimensionBounds,
+ Map> dimensionRegions) {
+ Map regions = dimensionRegions.get(dimId);
+ if (regions == null) return;
+
+ for (Map.Entry entry : regions.entrySet()) {
+ long packed = entry.getKey();
+ int rx = (int) (packed >> 32);
+ int rz = (int) packed;
+ BitSet bits = entry.getValue();
+ if (!bits.isEmpty()) {
+ builder.addRegion(worldIndex, rx, rz, bits);
+ }
+ }
+
+ int[] bounds = dimensionBounds.get(dimId);
+ if (bounds != null && bounds[0] != Integer.MAX_VALUE) {
+ builder.setBoundsForWorld(worldIndex, bounds[0], bounds[1], bounds[2], bounds[3]);
+ }
+ }
+}
diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileCache.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileCache.java
new file mode 100644
index 00000000..f7d84b07
--- /dev/null
+++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileCache.java
@@ -0,0 +1,50 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Bounded LRU cache of decoded preview tile ARGB arrays (one entry per tile, 1 MB each).
+ * Thread-safe via synchronized methods; the access pattern is moderate and not on the hot path
+ * for the renderer.
+ */
+public final class PreviewTileCache {
+ public static final int BYTES_PER_ENTRY = 262144 * 4; // int[262144] of ARGB
+ private static final long MIN_CAPACITY_BYTES = 64L * 1024 * 1024;
+ private static final long MAX_CAPACITY_BYTES = 512L * 1024 * 1024;
+
+ private final int capacityEntries;
+ private final LinkedHashMap map;
+
+ public PreviewTileCache(int capacityEntries) {
+ this.capacityEntries = capacityEntries;
+ this.map = new LinkedHashMap<>(16, 0.75f, true) {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry eldest) {
+ return size() > PreviewTileCache.this.capacityEntries;
+ }
+ };
+ }
+
+ /**
+ * Compute the number of cache entries given the JVM max heap in bytes.
+ * Clamped to [64MB / 1MB-per-entry, 512MB / 1MB-per-entry].
+ */
+ public static int computeCapacityEntries(long maxHeapBytes) {
+ long tenPercent = maxHeapBytes / 10;
+ long clampedBytes = Math.max(MIN_CAPACITY_BYTES, Math.min(MAX_CAPACITY_BYTES, tenPercent));
+ return (int) (clampedBytes / BYTES_PER_ENTRY);
+ }
+
+ public synchronized int[] get(PreviewTileKey key) {
+ return map.get(key);
+ }
+
+ public synchronized void put(PreviewTileKey key, int[] data) {
+ map.put(key, data);
+ }
+
+ public synchronized int size() {
+ return map.size();
+ }
+}
diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileDownsampler.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileDownsampler.java
new file mode 100644
index 00000000..aba1d200
--- /dev/null
+++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileDownsampler.java
@@ -0,0 +1,62 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+/**
+ * Stateless 2x downsampling for ARGB preview tiles, alpha-aware so that empty chunks
+ * (alpha == 0) do not contaminate the colour of their neighbours when building higher LOD tiles.
+ */
+public final class PreviewTileDownsampler {
+ public static final int TILE_SIZE = 512;
+ private static final int TILE_PIXELS = TILE_SIZE * TILE_SIZE;
+ private static final int HALF = TILE_SIZE / 2;
+
+ private PreviewTileDownsampler() {
+ }
+
+ /**
+ * Combine four source tiles laid out as a 2x2 grid into one tile.
+ * Each source quadrant is downsampled 2x and copied to the matching quadrant of the output.
+ *
+ * @param topLeft source for the upper-left output quadrant
+ * @param topRight source for the upper-right output quadrant
+ * @param bottomLeft source for the lower-left output quadrant
+ * @param bottomRight source for the lower-right output quadrant
+ * @return a new int[TILE_PIXELS] ARGB tile
+ */
+ public static int[] aggregate(int[] topLeft, int[] topRight, int[] bottomLeft, int[] bottomRight) {
+ int[] out = new int[TILE_PIXELS];
+ downsampleInto(topLeft, out, 0, 0);
+ downsampleInto(topRight, out, HALF, 0);
+ downsampleInto(bottomLeft, out, 0, HALF);
+ downsampleInto(bottomRight, out, HALF, HALF);
+ return out;
+ }
+
+ private static void downsampleInto(int[] src, int[] dst, int destX, int destY) {
+ for (int sy = 0; sy < TILE_SIZE; sy += 2) {
+ int oy = destY + (sy >> 1);
+ int srcRow0 = sy << 9; // sy * TILE_SIZE
+ int srcRow1 = (sy + 1) << 9;
+ int dstRow = oy << 9;
+ for (int sx = 0; sx < TILE_SIZE; sx += 2) {
+ int p00 = src[srcRow0 | sx];
+ int p01 = src[srcRow0 | (sx + 1)];
+ int p10 = src[srcRow1 | sx];
+ int p11 = src[srcRow1 | (sx + 1)];
+
+ int r = 0, g = 0, b = 0, count = 0;
+ if ((p00 >>> 24) != 0) { r += (p00 >>> 16) & 0xFF; g += (p00 >>> 8) & 0xFF; b += p00 & 0xFF; count++; }
+ if ((p01 >>> 24) != 0) { r += (p01 >>> 16) & 0xFF; g += (p01 >>> 8) & 0xFF; b += p01 & 0xFF; count++; }
+ if ((p10 >>> 24) != 0) { r += (p10 >>> 16) & 0xFF; g += (p10 >>> 8) & 0xFF; b += p10 & 0xFF; count++; }
+ if ((p11 >>> 24) != 0) { r += (p11 >>> 16) & 0xFF; g += (p11 >>> 8) & 0xFF; b += p11 & 0xFF; count++; }
+
+ int outArgb;
+ if (count == 0) {
+ outArgb = 0;
+ } else {
+ outArgb = 0xFF000000 | ((r / count) << 16) | ((g / count) << 8) | (b / count);
+ }
+ dst[dstRow | (destX + (sx >> 1))] = outArgb;
+ }
+ }
+ }
+}
diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileKey.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileKey.java
new file mode 100644
index 00000000..5b68cb7e
--- /dev/null
+++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileKey.java
@@ -0,0 +1,24 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import java.util.Objects;
+
+/**
+ * Identifies a single tile in the preview tile pyramid.
+ *
+ * @param world the dimension identifier (e.g. "minecraft:overworld")
+ * @param lod the level-of-detail (0 = native, negative = downsampled)
+ * @param tx tile x at this LOD
+ * @param tz tile z at this LOD
+ */
+public record PreviewTileKey(String world, int lod, int tx, int tz) {
+ public PreviewTileKey {
+ Objects.requireNonNull(world, "world");
+ }
+
+ /**
+ * @return the file name used when persisting this tile under the preview folder.
+ */
+ public String toFileName() {
+ return world.replace(':', '_') + "." + lod + "." + tx + "." + tz + ".png";
+ }
+}
diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileService.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileService.java
new file mode 100644
index 00000000..8e172799
--- /dev/null
+++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileService.java
@@ -0,0 +1,176 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import com.hivemc.chunker.cli.messenger.messaging.response.TileErrorResponse;
+import com.hivemc.chunker.cli.messenger.messaging.response.TileReadyResponse;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import java.util.Set;
+import java.util.concurrent.*;
+
+/**
+ * Long-lived per-session service that lazily generates preview tile PNGs on demand.
+ * Owns a worker pool, a bounded LRU of decoded region ARGB arrays, and a dedup set so
+ * repeated client requests for the same tile do not cause duplicate work.
+ */
+public final class PreviewTileService {
+ public interface EventListener {
+ void onTileReady(TileReadyResponse r);
+ void onTileError(TileErrorResponse r);
+ }
+
+ private final File outputFolder;
+ private final PreviewMapBin mapBin;
+ private final RegionRgbaSource source;
+ private final PreviewTileCache cache;
+ private final ExecutorService workers;
+ private final Set inFlight = ConcurrentHashMap.newKeySet();
+ private final Set writtenTiles = ConcurrentHashMap.newKeySet();
+ private volatile EventListener listener = NULL_LISTENER;
+
+ public PreviewTileService(File outputFolder, PreviewMapBin mapBin, RegionRgbaSource source,
+ PreviewTileCache cache, int workerCount) {
+ this.outputFolder = outputFolder;
+ this.mapBin = mapBin;
+ this.source = source;
+ this.cache = cache;
+ this.workers = Executors.newFixedThreadPool(workerCount, runnable -> {
+ Thread t = new Thread(runnable, "preview-tile-worker");
+ t.setDaemon(true);
+ return t;
+ });
+ }
+
+ public void setEventListener(EventListener listener) {
+ this.listener = listener == null ? NULL_LISTENER : listener;
+ }
+
+ public void enqueueRange(String world, int lod, int minTx, int minTz, int maxTx, int maxTz) {
+ for (int tx = minTx; tx <= maxTx; tx++) {
+ for (int tz = minTz; tz <= maxTz; tz++) {
+ if (mapBin.isTileEmpty(world, lod, tx, tz)) continue;
+ PreviewTileKey key = new PreviewTileKey(world, lod, tx, tz);
+ if (writtenTiles.contains(key)) {
+ emitReady(key);
+ continue;
+ }
+ if (!inFlight.add(key)) continue; // dedup
+ workers.submit(() -> processTile(key));
+ }
+ }
+ }
+
+ public void cancel(String world, Integer lod) {
+ inFlight.removeIf(k -> k.world().equals(world) && (lod == null || k.lod() == lod));
+ }
+
+ public void shutdown() {
+ workers.shutdownNow();
+ }
+
+ private void processTile(PreviewTileKey key) {
+ try {
+ if (!inFlight.contains(key)) return; // cancelled before start
+ int[] rgba = loadOrAggregate(key);
+ if (rgba == null) {
+ emitError(key, "missing-data");
+ return;
+ }
+ File outFile = new File(outputFolder, key.toFileName());
+ writePng(rgba, outFile);
+ writtenTiles.add(key);
+ cache.put(key, rgba);
+ emitReady(key);
+ } catch (Throwable t) {
+ emitError(key, t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage());
+ } finally {
+ inFlight.remove(key);
+ }
+ }
+
+ private int[] loadOrAggregate(PreviewTileKey key) throws IOException {
+ int[] cached = cache.get(key);
+ if (cached != null) return cached;
+ if (key.lod() == 0) {
+ return loadLodZero(key.world(), key.tx(), key.tz());
+ }
+ int childLod = key.lod() + 1;
+ int cx = key.tx() << 1;
+ int cz = key.tz() << 1;
+ int[] tl = ensureChild(key.world(), childLod, cx, cz);
+ int[] tr = ensureChild(key.world(), childLod, cx + 1, cz);
+ int[] bl = ensureChild(key.world(), childLod, cx, cz + 1);
+ int[] br = ensureChild(key.world(), childLod, cx + 1, cz + 1);
+ if (tl == null && tr == null && bl == null && br == null) return null;
+ int[] empty = new int[PreviewTileDownsampler.TILE_SIZE * PreviewTileDownsampler.TILE_SIZE];
+ return PreviewTileDownsampler.aggregate(
+ tl == null ? empty : tl,
+ tr == null ? empty : tr,
+ bl == null ? empty : bl,
+ br == null ? empty : br
+ );
+ }
+
+ private int[] ensureChild(String world, int lod, int tx, int tz) throws IOException {
+ if (mapBin.isTileEmpty(world, lod, tx, tz)) return null;
+ PreviewTileKey ck = new PreviewTileKey(world, lod, tx, tz);
+ int[] cached = cache.get(ck);
+ if (cached != null) return cached;
+ int[] data = loadOrAggregate(ck);
+ if (data != null) cache.put(ck, data);
+ return data;
+ }
+
+ private int[] loadLodZero(String world, int rx, int rz) throws IOException {
+ PreviewTileKey key = new PreviewTileKey(world, 0, rx, rz);
+ int[] cached = cache.get(key);
+ if (cached != null) return cached;
+ int[] data = source.loadRegion(world, rx, rz);
+ if (data == null) return null;
+ cache.put(key, data);
+ return data;
+ }
+
+ private void writePng(int[] argb, File outFile) throws IOException {
+ int size = PreviewTileDownsampler.TILE_SIZE;
+ BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
+ image.setRGB(0, 0, size, size, argb, 0, size);
+ // Write to a temp file alongside the target then rename. Without this, a tile_ready
+ // can race a still-streaming write: the client opens the file mid-encode and the
+ // browser renders a truncated PNG (typically a single flat colour) until a later
+ // fetch picks up the full bytes. The rename is observed atomically by the protocol
+ // handler so the file never appears partially written.
+ File tmpFile = new File(outFile.getParent(), outFile.getName() + ".tmp");
+ ImageIO.write(image, "png", tmpFile);
+ try {
+ Files.move(tmpFile.toPath(), outFile.toPath(),
+ StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+ } catch (AtomicMoveNotSupportedException atomicNotSupported) {
+ // Some filesystems (rare on local disk) don't support atomic moves — fall back to a
+ // regular replace. Still safer than letting ImageIO write straight into the target.
+ Files.move(tmpFile.toPath(), outFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ private void emitReady(PreviewTileKey key) {
+ listener.onTileReady(new TileReadyResponse(
+ null, key.world(), key.lod(), key.tx(), key.tz(), key.toFileName()
+ ));
+ }
+
+ private void emitError(PreviewTileKey key, String reason) {
+ listener.onTileError(new TileErrorResponse(
+ null, key.world(), key.lod(), key.tx(), key.tz(), reason
+ ));
+ }
+
+ private static final EventListener NULL_LISTENER = new EventListener() {
+ @Override public void onTileReady(TileReadyResponse r) {}
+ @Override public void onTileError(TileErrorResponse r) {}
+ };
+}
diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewWorldWriter.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewWorldWriter.java
deleted file mode 100644
index 7b68824c..00000000
--- a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewWorldWriter.java
+++ /dev/null
@@ -1,99 +0,0 @@
-package com.hivemc.chunker.conversion.encoding.preview;
-
-import com.hivemc.chunker.conversion.encoding.base.writer.ColumnWriter;
-import com.hivemc.chunker.conversion.encoding.base.writer.WorldWriter;
-import com.hivemc.chunker.conversion.intermediate.column.chunk.ChunkCoordPair;
-import com.hivemc.chunker.conversion.intermediate.column.chunk.RegionCoordPair;
-import com.hivemc.chunker.conversion.intermediate.world.ChunkerWorld;
-import com.hivemc.chunker.conversion.intermediate.world.Dimension;
-import com.hivemc.chunker.nbt.io.Writer;
-
-import java.io.*;
-import java.util.*;
-import java.util.concurrent.ConcurrentHashMap;
-
-/**
- * PreviewWorldWriter writes binary data on present regions in the various dimensions.
- */
-public class PreviewWorldWriter implements WorldWriter {
- private final List worldDataList = Collections.synchronizedList(new ArrayList<>(4));
- private final File outputFolder;
-
- /**
- * Create a new preview world writer.
- *
- * @param outputFolder the output folder which the preview / binary data should be written to.
- */
- public PreviewWorldWriter(File outputFolder) {
- this.outputFolder = outputFolder;
- }
-
- @Override
- public ColumnWriter writeWorld(ChunkerWorld chunkerWorld) {
- // Create the world entry
- WorldData worldData = new WorldData();
- worldData.dimension = chunkerWorld.getDimension();
-
- // Add it to the list
- worldDataList.add(worldData);
-
- // Return a new column writer
- return new PreviewColumnWriter(outputFolder, worldData);
- }
-
- @Override
- public void flushWorlds() throws IOException {
- // Save a binary file with worlds, min/max and present chunks
- File outputFile = new File(outputFolder, "map.bin");
- try (FileOutputStream fileOutputStream = new FileOutputStream(outputFile);
- BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(fileOutputStream);
- DataOutputStream writerStream = new DataOutputStream(bufferedOutputStream)) {
- Writer writer = Writer.toLittleEndianWriter(writerStream);
-
- // Encode bytes
- writer.writeInt(worldDataList.size());
- for (WorldData worldData : worldDataList) {
- writer.writeInt(worldData.dimension.getBedrockID());
- writer.writeInt(worldData.minX);
- writer.writeInt(worldData.minZ);
- writer.writeInt(worldData.maxX);
- writer.writeInt(worldData.maxZ);
-
- // Write each region
- writer.writeInt(worldData.regionToPresentChunks.size());
- for (Map.Entry> entry : worldData.regionToPresentChunks.entrySet()) {
- // Write region position
- writer.writeInt(entry.getKey().regionX());
- writer.writeInt(entry.getKey().regionZ());
-
- // Write a bitset of present chunks
- BitSet presentChunks = new BitSet(1024);
- for (ChunkCoordPair pos : entry.getValue()) {
- presentChunks.set(pos.to10BitIndex(), true);
- }
-
- // Write the byte array
- byte[] bitSet = presentChunks.toByteArray();
- writer.writeBytes(bitSet);
-
- // Pad to 128 bytes
- for (int i = bitSet.length; i < 128; i++) {
- writer.writeByte(0);
- }
- }
- }
- }
- }
-
- /**
- * World data to record the region the world covers.
- */
- public static class WorldData {
- public final Map> regionToPresentChunks = new ConcurrentHashMap<>();
- public int minX = Integer.MAX_VALUE;
- public int minZ = Integer.MAX_VALUE;
- public int maxX = Integer.MIN_VALUE;
- public int maxZ = Integer.MIN_VALUE;
- public Dimension dimension;
- }
-}
diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaCapturingWriter.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaCapturingWriter.java
new file mode 100644
index 00000000..e6827d17
--- /dev/null
+++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaCapturingWriter.java
@@ -0,0 +1,104 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import com.hivemc.chunker.conversion.encoding.EncodingType;
+import com.hivemc.chunker.conversion.encoding.base.Version;
+import com.hivemc.chunker.conversion.encoding.base.writer.ColumnWriter;
+import com.hivemc.chunker.conversion.encoding.base.writer.LevelWriter;
+import com.hivemc.chunker.conversion.encoding.base.writer.WorldWriter;
+import com.hivemc.chunker.conversion.intermediate.column.ChunkerColumn;
+import com.hivemc.chunker.conversion.intermediate.column.biome.ChunkerBiome;
+import com.hivemc.chunker.conversion.intermediate.column.chunk.identifier.ChunkerBlockIdentifier;
+import com.hivemc.chunker.conversion.intermediate.level.ChunkerLevel;
+import com.hivemc.chunker.conversion.intermediate.world.ChunkerWorld;
+import it.unimi.dsi.fastutil.Pair;
+
+import java.util.Set;
+
+/**
+ * A minimal LevelWriter that captures per-column ARGB pixels into a flat int[262144] array
+ * representing a single 512×512 region image (row-major, 512 stride).
+ *
+ * Only columns whose chunk position falls inside the target region {@code (rx, rz)} are
+ * processed. All other writer lifecycle methods are no-ops.
+ */
+public class RegionRgbaCapturingWriter implements LevelWriter {
+
+ private final int rx;
+ private final int rz;
+ private final int[] dst;
+
+ /**
+ * Create a new capturing writer.
+ *
+ * @param rx the region X coordinate to capture.
+ * @param rz the region Z coordinate to capture.
+ * @param dst a pre-allocated {@code int[262144]} destination array (row-major, 512 stride).
+ */
+ public RegionRgbaCapturingWriter(int rx, int rz, int[] dst) {
+ this.rx = rx;
+ this.rz = rz;
+ this.dst = dst;
+ }
+
+ @Override
+ public WorldWriter writeLevel(ChunkerLevel chunkerLevel) {
+ return new CapturingWorldWriter();
+ }
+
+ @Override
+ public EncodingType getEncodingType() {
+ return EncodingType.PREVIEW;
+ }
+
+ @Override
+ public Version getVersion() {
+ return new Version(1, 0, 0);
+ }
+
+ @Override
+ public Set getSupportedBiomes() {
+ return Set.of(); // Biomes aren't used in the preview capturing writer
+ }
+
+ // -------------------------------------------------------------------------
+ // Inner classes implementing the writer chain
+ // -------------------------------------------------------------------------
+
+ private class CapturingWorldWriter implements WorldWriter {
+ @Override
+ public ColumnWriter writeWorld(ChunkerWorld chunkerWorld) {
+ return new CapturingColumnWriter();
+ }
+ }
+
+ private class CapturingColumnWriter implements ColumnWriter {
+ @Override
+ public void writeColumn(ChunkerColumn chunkerColumn) {
+ // Only process columns that belong to the target region
+ if (chunkerColumn.getPosition().regionX() != rx
+ || chunkerColumn.getPosition().regionZ() != rz) {
+ return;
+ }
+
+ int chunkX = chunkerColumn.getPosition().chunkX();
+ int chunkZ = chunkerColumn.getPosition().chunkZ();
+
+ // Pixel origin of this 16×16 chunk block inside the 512×512 image
+ int pxBaseX = (chunkX & 31) << 4; // column in the image [0, 511]
+ int pxBaseZ = (chunkZ & 31) << 4; // row in the image [0, 511]
+
+ for (int localX = 0; localX < 16; localX++) {
+ for (int localZ = 0; localZ < 16; localZ++) {
+ Pair block =
+ chunkerColumn.getHighestBlock(localX, localZ, ChunkerBlockIdentifier::hasRGBColor);
+ if (block != null) {
+ int rgb = block.value().getRGBColor();
+ int argb = rgb == 0 ? 0 : 0xFF000000 | rgb;
+ // Row-major layout: row = (pxBaseZ + localZ), col = (pxBaseX + localX)
+ dst[(pxBaseZ + localZ) * 512 + (pxBaseX + localX)] = argb;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaSource.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaSource.java
new file mode 100644
index 00000000..05614faf
--- /dev/null
+++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaSource.java
@@ -0,0 +1,14 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import java.io.IOException;
+
+/**
+ * Source of decoded ARGB pixel data for one region at LOD 0.
+ */
+public interface RegionRgbaSource {
+ /**
+ * @return an int[262144] ARGB array for the region (row-major, 512 width),
+ * or null if no chunks are present in that region.
+ */
+ int[] loadRegion(String world, int rx, int rz) throws IOException;
+}
diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSourceTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSourceTests.java
new file mode 100644
index 00000000..c8fac840
--- /dev/null
+++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSourceTests.java
@@ -0,0 +1,22 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Lightweight tests for ChunkerWorldRegionRgbaSource. Full integration validation
+ * against real fixture worlds is exercised manually via Task 20 smoke testing.
+ */
+public class ChunkerWorldRegionRgbaSourceTests {
+ @Test
+ public void testReturnsNullForMissingWorldDirectory(@TempDir Path tmp) throws Exception {
+ File worldDir = tmp.resolve("does-not-exist").toFile();
+ ChunkerWorldRegionRgbaSource source = new ChunkerWorldRegionRgbaSource(worldDir);
+ assertNull(source.loadRegion("minecraft:overworld", 0, 0));
+ }
+}
diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBinTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBinTests.java
new file mode 100644
index 00000000..44741979
--- /dev/null
+++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBinTests.java
@@ -0,0 +1,73 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.BitSet;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Round-trip tests for the map.bin reader/writer and empty-tile lookups.
+ */
+public class PreviewMapBinTests {
+ @Test
+ public void testRoundTrip(@TempDir Path tmp) throws Exception {
+ PreviewMapBin.Builder b = new PreviewMapBin.Builder();
+ BitSet present = new BitSet(1024);
+ present.set(0);
+ present.set(17);
+ b.addWorld(0, "minecraft:overworld", -5, -3, 2, 4)
+ .addRegion(0, 0, 0, present);
+
+ File out = tmp.resolve("map.bin").toFile();
+ b.writeTo(out);
+
+ PreviewMapBin loaded = PreviewMapBin.read(out);
+ PreviewMapBin.WorldData ow = loaded.findByIdentifier("minecraft:overworld");
+ assertEquals(-5, ow.minX());
+ assertEquals(2, ow.maxX());
+ assertEquals(-3, ow.minZ());
+ assertEquals(4, ow.maxZ());
+ assertTrue(ow.regionHasAnyChunk(0, 0));
+ assertFalse(ow.regionHasAnyChunk(1, 0));
+ }
+
+ @Test
+ public void testTileEmptinessAtLodZero(@TempDir Path tmp) throws Exception {
+ // Region (0,0) has at least one chunk, region (1,0) does not exist.
+ PreviewMapBin.Builder b = new PreviewMapBin.Builder();
+ BitSet present = new BitSet(1024);
+ present.set(5);
+ b.addWorld(0, "minecraft:overworld", 0, 0, 0, 0).addRegion(0, 0, 0, present);
+
+ File out = tmp.resolve("map.bin").toFile();
+ b.writeTo(out);
+
+ PreviewMapBin loaded = PreviewMapBin.read(out);
+ assertFalse(loaded.isTileEmpty("minecraft:overworld", 0, 0, 0));
+ assertTrue(loaded.isTileEmpty("minecraft:overworld", 0, 1, 0));
+ }
+
+ @Test
+ public void testTileEmptinessAtNegativeLodAggregatesChildren(@TempDir Path tmp) throws Exception {
+ // At LOD -1 a single tile covers a 2x2 block of LOD-0 regions.
+ // Region (0,0) present, (1,0), (0,1), (1,1) absent -> LOD -1 tile (0,0) is NOT empty.
+ // Regions (2..3, 2..3) all absent -> LOD -1 tile (1,1) IS empty.
+ PreviewMapBin.Builder b = new PreviewMapBin.Builder();
+ BitSet present = new BitSet(1024);
+ present.set(0);
+ b.addWorld(0, "minecraft:overworld", 0, 0, 3, 3).addRegion(0, 0, 0, present);
+
+ File out = tmp.resolve("map.bin").toFile();
+ b.writeTo(out);
+
+ PreviewMapBin loaded = PreviewMapBin.read(out);
+ assertFalse(loaded.isTileEmpty("minecraft:overworld", -1, 0, 0));
+ assertTrue(loaded.isTileEmpty("minecraft:overworld", -1, 1, 1));
+ }
+}
diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReaderTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReaderTests.java
new file mode 100644
index 00000000..bc99d729
--- /dev/null
+++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReaderTests.java
@@ -0,0 +1,165 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.*;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests the fast metadata-only scan for Java Edition (Anvil) worlds and Bedrock Edition worlds.
+ */
+public class PreviewMetadataReaderTests {
+ private static void writeRegionWithChunks(File regionFile, int... chunkLocalIndices) throws IOException {
+ // Minimum viable Anvil region: 8 KB header. We only set the location-table entries (first 4 KB).
+ // A non-zero 4-byte entry (we write 0x00000201 -> offset=2 sectors, sectorCount=1) marks the chunk as present.
+ byte[] header = new byte[8192];
+ for (int idx : chunkLocalIndices) {
+ int off = idx * 4;
+ header[off] = 0x00;
+ header[off + 1] = 0x00;
+ header[off + 2] = 0x02;
+ header[off + 3] = 0x01;
+ }
+ try (FileOutputStream fos = new FileOutputStream(regionFile)) {
+ fos.write(header);
+ }
+ }
+
+ @Test
+ public void testReadsRegionHeadersAndProducesMapBin(@TempDir Path tmp) throws Exception {
+ File world = tmp.resolve("world").toFile();
+ File regionDir = new File(world, "region");
+ assertTrue(regionDir.mkdirs());
+
+ // Region (0,0) with chunks at local indices 0 and 17
+ writeRegionWithChunks(new File(regionDir, "r.0.0.mca"), 0, 17);
+ // Region (1,0) is empty (header all zeros)
+ writeRegionWithChunks(new File(regionDir, "r.1.0.mca"));
+ // Region (-1,2) with one chunk
+ writeRegionWithChunks(new File(regionDir, "r.-1.2.mca"), 42);
+
+ File out = tmp.resolve("preview").toFile();
+ assertTrue(out.mkdirs());
+
+ new PreviewMetadataReader().readJavaWorld(world, out);
+
+ PreviewMapBin loaded = PreviewMapBin.read(new File(out, "map.bin"));
+ PreviewMapBin.WorldData ow = loaded.findByIdentifier("minecraft:overworld");
+ assertNotNull(ow);
+ assertTrue(ow.regionHasAnyChunk(0, 0));
+ assertFalse(ow.regionHasAnyChunk(1, 0));
+ assertTrue(ow.regionHasAnyChunk(-1, 2));
+ }
+
+ @Test
+ public void testSkipsCorruptRegionFile(@TempDir Path tmp) throws Exception {
+ File world = tmp.resolve("world").toFile();
+ File regionDir = new File(world, "region");
+ assertTrue(regionDir.mkdirs());
+
+ writeRegionWithChunks(new File(regionDir, "r.0.0.mca"), 0);
+ // Truncated (only 10 bytes) — must be silently skipped, not abort the whole pass.
+ try (FileOutputStream fos = new FileOutputStream(new File(regionDir, "r.1.0.mca"))) {
+ fos.write(new byte[10]);
+ }
+
+ File out = tmp.resolve("preview").toFile();
+ assertTrue(out.mkdirs());
+
+ new PreviewMetadataReader().readJavaWorld(world, out);
+
+ PreviewMapBin loaded = PreviewMapBin.read(new File(out, "map.bin"));
+ PreviewMapBin.WorldData ow = loaded.findByIdentifier("minecraft:overworld");
+ assertTrue(ow.regionHasAnyChunk(0, 0));
+ assertFalse(ow.regionHasAnyChunk(1, 0));
+ }
+
+ @Test
+ public void testReadsBedrockWorldKeysOnly(@TempDir Path tmp) throws Exception {
+ File worldDir = tmp.resolve("bedrock_world").toFile();
+ File dbDir = new File(worldDir, "db");
+ assertTrue(dbDir.mkdirs());
+
+ // Create a fresh LevelDB with the same options the converter uses.
+ org.iq80.leveldb.Options options = new org.iq80.leveldb.Options();
+ options.compressionType(org.iq80.leveldb.CompressionType.ZLIB_RAW);
+ options.blockSize(160 * 1024);
+ options.filterPolicy(new org.iq80.leveldb.table.BloomFilterPolicy(10));
+ options.createIfMissing(true);
+
+ org.iq80.leveldb.DBFactory factory = new org.iq80.leveldb.impl.Iq80DBFactory();
+ try (org.iq80.leveldb.DB db = factory.open(dbDir, options)) {
+ // Overworld chunk (10, 20), SubChunkPrefix tag (0x2F), subChunkY 0.
+ // Key length = 10: x(4) z(4) tag(1) subY(1)
+ db.put(bedrockKey(10, 20, 0, true, 0x2F), new byte[]{0x01});
+ // Overworld chunk (-3, 7), Data2D tag (0x2D), no subchunk.
+ // Key length = 9: x(4) z(4) tag(1)
+ db.put(bedrockKeyNoSub(-3, 7, 0, 0x2D), new byte[]{0x02});
+ // Nether chunk (0, 0) with Version tag (0x2C). Dimension 1, no subchunk.
+ // Key length = 13: x(4) z(4) dim(4) tag(1)
+ // 0x2C (44 = VERSION) is NOT in our "counts as present" set; this chunk should be IGNORED.
+ db.put(bedrockKeyWithDim(0, 0, 1, 0x2C), new byte[]{0x03});
+ // Nether chunk (0, 0) with Data3D tag (0x2B). Dimension 1, no subchunk.
+ // Key length = 13: x(4) z(4) dim(4) tag(1)
+ db.put(bedrockKeyWithDim(0, 0, 1, 0x2B), new byte[]{0x04});
+ // End chunk (5, -5) with SubChunkPrefix. Dimension 2, with subChunkY 3.
+ // Key length = 14: x(4) z(4) dim(4) tag(1) subY(1)
+ db.put(bedrockKeyDimSub(5, -5, 2, 3, 0x2F), new byte[]{0x05});
+ // Junk key that doesn't fit any chunk key length — should be ignored.
+ db.put(new byte[]{0x01, 0x02, 0x03}, new byte[]{0x00});
+ }
+
+ File out = tmp.resolve("preview").toFile();
+ assertTrue(out.mkdirs());
+
+ new PreviewMetadataReader().readBedrockWorld(worldDir, out);
+
+ PreviewMapBin loaded = PreviewMapBin.read(new File(out, "map.bin"));
+ PreviewMapBin.WorldData ow = loaded.findByIdentifier("minecraft:overworld");
+ PreviewMapBin.WorldData nether = loaded.findByIdentifier("minecraft:the_nether");
+ PreviewMapBin.WorldData end = loaded.findByIdentifier("minecraft:the_end");
+
+ assertNotNull(ow);
+ assertNotNull(nether);
+ assertNotNull(end);
+
+ // Overworld: chunk (10, 20) and (-3, 7) should be marked.
+ assertTrue(ow.regionHasAnyChunk(0, 0), "Overworld region (0,0) should contain chunk (10,20)");
+ assertTrue(ow.regionHasAnyChunk(-1, 0), "Overworld region (-1,0) should contain chunk (-3,7)");
+
+ // Nether: only (0,0) via Data3D — the Version tag-only chunk is ignored.
+ assertTrue(nether.regionHasAnyChunk(0, 0), "Nether region (0,0) should contain chunk (0,0) via Data3D");
+
+ // End: chunk (5, -5) → region (0, -1)
+ assertTrue(end.regionHasAnyChunk(0, -1), "End region (0,-1) should contain chunk (5,-5)");
+ }
+
+ // Bedrock key builders (little-endian). The type tag precedes the optional sub-chunk Y,
+ // matching the real on-disk layout produced by LevelDBKey.
+ private static byte[] bedrockKey(int x, int z, int subY, boolean withSub, int tag) {
+ java.nio.ByteBuffer buf = java.nio.ByteBuffer.allocate(withSub ? 10 : 9).order(java.nio.ByteOrder.LITTLE_ENDIAN);
+ buf.putInt(x).putInt(z);
+ buf.put((byte) tag);
+ if (withSub) buf.put((byte) subY);
+ return buf.array();
+ }
+ private static byte[] bedrockKeyNoSub(int x, int z, int dim, int tag) {
+ // Overworld: no dimension prefix in the key, length 9.
+ java.nio.ByteBuffer buf = java.nio.ByteBuffer.allocate(9).order(java.nio.ByteOrder.LITTLE_ENDIAN);
+ buf.putInt(x).putInt(z).put((byte) tag);
+ return buf.array();
+ }
+ private static byte[] bedrockKeyWithDim(int x, int z, int dim, int tag) {
+ java.nio.ByteBuffer buf = java.nio.ByteBuffer.allocate(13).order(java.nio.ByteOrder.LITTLE_ENDIAN);
+ buf.putInt(x).putInt(z).putInt(dim).put((byte) tag);
+ return buf.array();
+ }
+ private static byte[] bedrockKeyDimSub(int x, int z, int dim, int subY, int tag) {
+ java.nio.ByteBuffer buf = java.nio.ByteBuffer.allocate(14).order(java.nio.ByteOrder.LITTLE_ENDIAN);
+ buf.putInt(x).putInt(z).putInt(dim).put((byte) tag).put((byte) subY);
+ return buf.array();
+ }
+}
diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileCacheTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileCacheTests.java
new file mode 100644
index 00000000..5475f1e1
--- /dev/null
+++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileCacheTests.java
@@ -0,0 +1,54 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Tests the bounded LRU used by the preview tile service.
+ */
+public class PreviewTileCacheTests {
+ private static int[] tile(int marker) {
+ int[] a = new int[262144];
+ a[0] = marker;
+ return a;
+ }
+
+ @Test
+ public void testPutAndGetSameInstance() {
+ PreviewTileCache cache = new PreviewTileCache(4);
+ PreviewTileKey k = new PreviewTileKey("minecraft:overworld", 0, 1, 1);
+ int[] data = tile(7);
+ cache.put(k, data);
+ assertNotNull(cache.get(k));
+ assertEquals(7, cache.get(k)[0]);
+ }
+
+ @Test
+ public void testEvictsLeastRecentlyUsed() {
+ PreviewTileCache cache = new PreviewTileCache(2);
+ PreviewTileKey a = new PreviewTileKey("w", 0, 1, 1);
+ PreviewTileKey b = new PreviewTileKey("w", 0, 2, 2);
+ PreviewTileKey c = new PreviewTileKey("w", 0, 3, 3);
+ cache.put(a, tile(1));
+ cache.put(b, tile(2));
+ // touch a so b becomes the LRU
+ assertNotNull(cache.get(a));
+ cache.put(c, tile(3));
+ assertNotNull(cache.get(a));
+ assertNull(cache.get(b));
+ assertNotNull(cache.get(c));
+ }
+
+ @Test
+ public void testCapacityFromHeapClampedToFloorCeiling() {
+ // 64 MB at minimum, 512 MB at maximum, otherwise ~10% of max heap.
+ // Each entry is 1 MB so capacity entries == capacity MB.
+ assertEquals(64, PreviewTileCache.computeCapacityEntries(100L * 1024 * 1024)); // 10MB < 64MB floor
+ assertEquals(64, PreviewTileCache.computeCapacityEntries(640L * 1024 * 1024)); // exactly 64MB at 10%, equal to floor
+ assertEquals(200, PreviewTileCache.computeCapacityEntries(2000L * 1024 * 1024)); // 200MB
+ assertEquals(512, PreviewTileCache.computeCapacityEntries(8000L * 1024 * 1024)); // capped at 512MB ceiling
+ }
+}
diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileDownsamplerTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileDownsamplerTests.java
new file mode 100644
index 00000000..c2e2bb64
--- /dev/null
+++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileDownsamplerTests.java
@@ -0,0 +1,72 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Tests for alpha-aware 2x downsampling used to build higher LOD preview tiles.
+ */
+public class PreviewTileDownsamplerTests {
+ private static final int W = 512;
+
+ private static int[] solid(int argb) {
+ int[] a = new int[W * W];
+ for (int i = 0; i < a.length; i++) a[i] = argb;
+ return a;
+ }
+
+ @Test
+ public void testAllTransparentInputsProduceTransparentOutput() {
+ int[] zero = new int[W * W];
+ int[] out = PreviewTileDownsampler.aggregate(zero, zero, zero, zero);
+ for (int v : out) assertEquals(0, v);
+ }
+
+ @Test
+ public void testSolidColorRoundTrips() {
+ int red = 0xFFFF0000;
+ int[] r = solid(red);
+ int[] out = PreviewTileDownsampler.aggregate(r, r, r, r);
+ for (int v : out) assertEquals(red, v);
+ }
+
+ @Test
+ public void testTopLeftGoesToUpperLeftQuadrant() {
+ int red = 0xFFFF0000;
+ int blue = 0xFF0000FF;
+ int[] tl = solid(red);
+ int[] tr = solid(blue);
+ int[] bl = solid(blue);
+ int[] br = solid(blue);
+ int[] out = PreviewTileDownsampler.aggregate(tl, tr, bl, br);
+ // Upper-left quadrant: red. Upper-right: blue. Lower-left: blue. Lower-right: blue.
+ assertEquals(red, out[(10 << 9) | 10]);
+ assertEquals(blue, out[(10 << 9) | 300]);
+ assertEquals(blue, out[(300 << 9) | 10]);
+ assertEquals(blue, out[(300 << 9) | 300]);
+ }
+
+ @Test
+ public void testAlphaZeroInputsDoNotPolluteAverage() {
+ // 3 transparent, 1 red 0xFFFF0000 inside a 2x2 block of the top-left source.
+ int[] tl = new int[W * W];
+ tl[(0 << 9) | 0] = 0xFFFF0000; // one opaque red pixel
+ int[] zero = new int[W * W];
+ int[] out = PreviewTileDownsampler.aggregate(tl, zero, zero, zero);
+ // The output pixel covering (0,0)..(1,1) of tl lives at output (0,0).
+ assertEquals(0xFFFF0000, out[0]);
+ }
+
+ @Test
+ public void testMixedColorAverage() {
+ // 2x2 block: red, blue, transparent, transparent. Expected: average of red and blue, opaque.
+ int[] tl = new int[W * W];
+ tl[(0 << 9) | 0] = 0xFFFF0000;
+ tl[(0 << 9) | 1] = 0xFF0000FF;
+ int[] zero = new int[W * W];
+ int[] out = PreviewTileDownsampler.aggregate(tl, zero, zero, zero);
+ // Average of R=(255+0)/2=127, G=0, B=(0+255)/2=127, A=255
+ assertEquals(0xFF7F007F, out[0]);
+ }
+}
diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileKeyTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileKeyTests.java
new file mode 100644
index 00000000..bd744c09
--- /dev/null
+++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileKeyTests.java
@@ -0,0 +1,40 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests for the PreviewTileKey value type used to identify a tile in the preview pyramid.
+ */
+public class PreviewTileKeyTests {
+ @Test
+ public void testEqualityByValue() {
+ PreviewTileKey a = new PreviewTileKey("minecraft:overworld", -3, 2, -1);
+ PreviewTileKey b = new PreviewTileKey("minecraft:overworld", -3, 2, -1);
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ }
+
+ @Test
+ public void testInequalityOnAnyField() {
+ PreviewTileKey base = new PreviewTileKey("minecraft:overworld", -3, 2, -1);
+ assertNotEquals(base, new PreviewTileKey("minecraft:nether", -3, 2, -1));
+ assertNotEquals(base, new PreviewTileKey("minecraft:overworld", -2, 2, -1));
+ assertNotEquals(base, new PreviewTileKey("minecraft:overworld", -3, 3, -1));
+ assertNotEquals(base, new PreviewTileKey("minecraft:overworld", -3, 2, 0));
+ }
+
+ @Test
+ public void testToFileNameReplacesColonAndIncludesSignedLod() {
+ PreviewTileKey k = new PreviewTileKey("minecraft:overworld", -3, 2, -1);
+ assertEquals("minecraft_overworld.-3.2.-1.png", k.toFileName());
+ }
+
+ @Test
+ public void testNullWorldRejected() {
+ assertThrows(NullPointerException.class, () -> new PreviewTileKey(null, 0, 0, 0));
+ }
+}
diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileServiceTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileServiceTests.java
new file mode 100644
index 00000000..1ccc9960
--- /dev/null
+++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileServiceTests.java
@@ -0,0 +1,156 @@
+package com.hivemc.chunker.conversion.encoding.preview;
+
+import com.hivemc.chunker.cli.messenger.messaging.response.TileErrorResponse;
+import com.hivemc.chunker.cli.messenger.messaging.response.TileReadyResponse;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests the lazy preview tile service at LOD 0: enqueueRange, dedup, empty-tile skipping.
+ */
+public class PreviewTileServiceTests {
+
+ private static class FakeSource implements RegionRgbaSource {
+ final Map data = new HashMap<>();
+ final AtomicInteger loads = new AtomicInteger();
+
+ @Override
+ public int[] loadRegion(String world, int rx, int rz) {
+ loads.incrementAndGet();
+ return data.get(world + ":" + rx + ":" + rz);
+ }
+ }
+
+ private static int[] solid(int argb) {
+ int[] a = new int[262144];
+ Arrays.fill(a, argb);
+ return a;
+ }
+
+ private static PreviewMapBin oneRegionMap(Path tmp) throws Exception {
+ PreviewMapBin.Builder b = new PreviewMapBin.Builder();
+ BitSet present = new BitSet(1024);
+ present.set(0);
+ b.addWorld(0, "minecraft:overworld", 0, 0, 0, 0).addRegion(0, 0, 0, present);
+ File out = tmp.resolve("map.bin").toFile();
+ b.writeTo(out);
+ return PreviewMapBin.read(out);
+ }
+
+ @Test
+ public void testEmitsTileReadyForLodZeroRequest(@TempDir Path tmp) throws Exception {
+ FakeSource src = new FakeSource();
+ src.data.put("minecraft:overworld:0:0", solid(0xFF00FF00));
+
+ File out = tmp.resolve("preview").toFile();
+ assertTrue(out.mkdirs());
+ BlockingQueue events = new LinkedBlockingQueue<>();
+
+ PreviewTileService svc = new PreviewTileService(out, oneRegionMap(tmp), src, new PreviewTileCache(8), 2);
+ svc.setEventListener(new PreviewTileService.EventListener() {
+ @Override public void onTileReady(TileReadyResponse r) { events.add(r); }
+ @Override public void onTileError(TileErrorResponse r) { }
+ });
+
+ svc.enqueueRange("minecraft:overworld", 0, 0, 0, 0, 0);
+
+ TileReadyResponse r = events.poll(2, TimeUnit.SECONDS);
+ assertNotNull(r);
+ assertEquals("minecraft:overworld", r.world());
+ assertEquals(0, r.lod());
+ assertEquals(0, r.tx());
+ assertEquals(0, r.tz());
+ assertTrue(new File(out, "minecraft_overworld.0.0.0.png").exists());
+ svc.shutdown();
+ }
+
+ @Test
+ public void testSkipsTilesEmptyPerMapBin(@TempDir Path tmp) throws Exception {
+ FakeSource src = new FakeSource();
+ File out = tmp.resolve("preview").toFile();
+ assertTrue(out.mkdirs());
+ BlockingQueue events = new LinkedBlockingQueue<>();
+
+ PreviewMapBin map = oneRegionMap(tmp);
+ PreviewTileService svc = new PreviewTileService(out, map, src, new PreviewTileCache(8), 2);
+ svc.setEventListener(new PreviewTileService.EventListener() {
+ @Override public void onTileReady(TileReadyResponse r) { events.add(r); }
+ @Override public void onTileError(TileErrorResponse r) { }
+ });
+
+ // Region (1,0) is empty. Requesting tile (lod=0, tx=1, tz=0) must not load anything.
+ svc.enqueueRange("minecraft:overworld", 0, 1, 0, 1, 0);
+ Thread.sleep(150);
+ assertEquals(0, src.loads.get());
+ assertTrue(events.isEmpty());
+ svc.shutdown();
+ }
+
+ @Test
+ public void testDedupsConcurrentRequestsForSameTile(@TempDir Path tmp) throws Exception {
+ FakeSource src = new FakeSource();
+ src.data.put("minecraft:overworld:0:0", solid(0xFFFF0000));
+ File out = tmp.resolve("preview").toFile();
+ assertTrue(out.mkdirs());
+
+ PreviewTileService svc = new PreviewTileService(out, oneRegionMap(tmp), src, new PreviewTileCache(8), 2);
+ for (int i = 0; i < 10; i++) {
+ svc.enqueueRange("minecraft:overworld", 0, 0, 0, 0, 0);
+ }
+ Thread.sleep(300);
+ assertEquals(1, src.loads.get());
+ svc.shutdown();
+ }
+
+ @Test
+ public void testLodMinusOneAggregatesFourChildren(@TempDir Path tmp) throws Exception {
+ FakeSource src = new FakeSource();
+ src.data.put("minecraft:overworld:0:0", solid(0xFFFF0000));
+ src.data.put("minecraft:overworld:1:0", solid(0xFF00FF00));
+ src.data.put("minecraft:overworld:0:1", solid(0xFF0000FF));
+ src.data.put("minecraft:overworld:1:1", solid(0xFFFFFFFF));
+
+ PreviewMapBin.Builder b = new PreviewMapBin.Builder();
+ BitSet present = new BitSet(1024);
+ present.set(0);
+ b.addWorld(0, "minecraft:overworld", 0, 0, 1, 1);
+ b.addRegion(0, 0, 0, present);
+ b.addRegion(0, 1, 0, present);
+ b.addRegion(0, 0, 1, present);
+ b.addRegion(0, 1, 1, present);
+ File mapFile = tmp.resolve("map.bin").toFile();
+ b.writeTo(mapFile);
+ PreviewMapBin map = PreviewMapBin.read(mapFile);
+
+ File out = tmp.resolve("preview").toFile();
+ assertTrue(out.mkdirs());
+ BlockingQueue events = new LinkedBlockingQueue<>();
+
+ PreviewTileService svc = new PreviewTileService(out, map, src, new PreviewTileCache(16), 2);
+ svc.setEventListener(new PreviewTileService.EventListener() {
+ @Override public void onTileReady(TileReadyResponse r) { events.add(r); }
+ @Override public void onTileError(TileErrorResponse r) { }
+ });
+
+ svc.enqueueRange("minecraft:overworld", -1, 0, 0, 0, 0);
+
+ // Expect the LOD -1 tile_ready (children may also be emitted; drain until we see it).
+ boolean sawAggregated = false;
+ long deadline = System.currentTimeMillis() + 3000;
+ while (System.currentTimeMillis() < deadline) {
+ TileReadyResponse r = events.poll(100, TimeUnit.MILLISECONDS);
+ if (r != null && r.lod() == -1) { sawAggregated = true; break; }
+ }
+ assertTrue(sawAggregated, "Expected a tile_ready at LOD -1");
+ assertTrue(new File(out, "minecraft_overworld.-1.0.0.png").exists());
+ svc.shutdown();
+ }
+}
diff --git a/cli/src/test/java/com/hivemc/chunker/conversion/integration/WorldConversionIntegrationTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/integration/WorldConversionIntegrationTests.java
index 2c2ec890..06ed3a54 100644
--- a/cli/src/test/java/com/hivemc/chunker/conversion/integration/WorldConversionIntegrationTests.java
+++ b/cli/src/test/java/com/hivemc/chunker/conversion/integration/WorldConversionIntegrationTests.java
@@ -207,36 +207,6 @@ public void testWorldSettings(String inputWorldName) throws IOException {
}
}
- @ParameterizedTest
- @MethodSource("getWorldNames")
- public void testWorldPreview(String inputWorldName) throws IOException {
- URL worldZip = Resources.getResource("integration/worlds/" + inputWorldName + ".zip");
- Path unzipped = unzip(worldZip);
- Path output = tempFolder();
- try {
- // Create a new world converter for our world
- WorldConverter converter = new WorldConverter(UUID.randomUUID()) {
- @Override
- public void logMissingMapping(MissingMappingType type, String identifier) {
- // Don't log missing mappings for our tests
- }
- };
- convertWorld(converter, unzipped, output, EncodingType.PREVIEW, new Version(1, 0, 0));
-
- // Assert that no errors happened
- assertFalse(converter.isExceptions());
-
- // Assert that map data was written
- assertTrue(output.resolve("map.bin").toFile().exists());
-
- // Assert that image data was written
- assertTrue(Objects.requireNonNull(output.toFile().listFiles((dir, file) -> file.endsWith(".png"))).length > 0);
- } finally {
- remove(unzipped);
- remove(output);
- }
- }
-
public void convertWorld(WorldConverter converter, Path input, Path output, EncodingType outputType, Version outputVersion) {
// Create the reader / writer (note: converter settings cannot be set after construction)
Optional extends LevelReader> reader = EncodingType.findReader(input.toFile(), converter);