From a0dfcb28f0bc51bcf46089354e70b9906f42560b Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 14:52:50 +0200 Subject: [PATCH 01/40] Add PreviewTileKey value type --- .../encoding/preview/PreviewTileKey.java | 24 +++++++++++ .../encoding/preview/PreviewTileKeyTests.java | 40 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileKey.java create mode 100644 cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileKeyTests.java 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/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)); + } +} From 027bc9c77b045cb4fcb11cbd49eec135e7ff633a Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 15:02:50 +0200 Subject: [PATCH 02/40] Add alpha-aware tile downsampler for preview LOD pyramid --- .../preview/PreviewTileDownsampler.java | 62 ++++++++++++++++ .../preview/PreviewTileDownsamplerTests.java | 72 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileDownsampler.java create mode 100644 cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileDownsamplerTests.java 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/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]); + } +} From 4f4ce42123afbc37f51aa8d95c18041c72f69981 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 15:15:10 +0200 Subject: [PATCH 03/40] Add bounded LRU cache for preview tile pyramid --- .../encoding/preview/PreviewTileCache.java | 50 +++++++++++++++++ .../preview/PreviewTileCacheTests.java | 54 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileCache.java create mode 100644 cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileCacheTests.java 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/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 + } +} From a1029ed162005212b00a3856046df23528da7da5 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 15:23:52 +0200 Subject: [PATCH 04/40] Add PreviewMapBin reader/writer with empty-tile lookups --- .../encoding/preview/PreviewMapBin.java | 167 ++++++++++++++++++ .../encoding/preview/PreviewMapBinTests.java | 73 ++++++++ 2 files changed, 240 insertions(+) create mode 100644 cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBin.java create mode 100644 cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBinTests.java 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..52aef06a --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMapBin.java @@ -0,0 +1,167 @@ +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. + */ + public boolean isTileEmpty(String world, int lod, int tx, int tz) { + WorldData w = findByIdentifier(world); + if (w == null) return true; + 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/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)); + } +} From 2d3c32ce084cd66adfe5611cb05fe0b673e3155d Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 15:40:12 +0200 Subject: [PATCH 05/40] Add fast metadata-only scan for Java Edition worlds --- .../preview/PreviewMetadataReader.java | 104 ++++++++++++++++++ .../preview/PreviewMetadataReaderTests.java | 79 +++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReader.java create mode 100644 cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReaderTests.java 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..9da0b6c3 --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReader.java @@ -0,0 +1,104 @@ +package com.hivemc.chunker.conversion.encoding.preview; + +import java.io.*; +import java.util.BitSet; +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; + } + } +} 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..d35aa8fa --- /dev/null +++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReaderTests.java @@ -0,0 +1,79 @@ +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. + */ +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)); + } +} From 7315afcbe9c60be4caf1fb9276137d1cf22a6971 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 15:50:22 +0200 Subject: [PATCH 06/40] Add Bedrock key-only scan to preview metadata reader --- .../preview/PreviewMetadataReader.java | 126 +++++++++++++++++- .../preview/PreviewMetadataReaderTests.java | 87 +++++++++++- 2 files changed, 211 insertions(+), 2 deletions(-) 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 index 9da0b6c3..94f8da36 100644 --- 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 @@ -1,7 +1,13 @@ 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.util.BitSet; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -101,4 +107,122 @@ private BitSet readRegionPresence(File regionFile) { 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"); + new File(dbDir, "LOCK").delete(); + + 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; + + try (DB db = new Iq80DBFactory().open(dbDir, options); + 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; + if (containsSubChunk) buffer.get(); + 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/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReaderTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewMetadataReaderTests.java index d35aa8fa..225662e5 100644 --- 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 @@ -9,7 +9,7 @@ import static org.junit.jupiter.api.Assertions.*; /** - * Tests the fast metadata-only scan for Java Edition (Anvil) worlds. + * 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 { @@ -76,4 +76,89 @@ public void testSkipsCorruptRegionFile(@TempDir Path tmp) throws Exception { 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) subY(1) tag(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) subY(1) tag(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). + 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); + if (withSub) buf.put((byte) subY); + buf.put((byte) tag); + 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) subY).put((byte) tag); + return buf.array(); + } } From 6dbf4371cbb45a3503065c454c4f0af91116061b Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 16:00:07 +0200 Subject: [PATCH 07/40] Add lazy preview tile service with LOD 0 generation --- .../messaging/response/TileErrorResponse.java | 32 +++++ .../messaging/response/TileReadyResponse.java | 31 ++++ .../encoding/preview/PreviewTileService.java | 132 ++++++++++++++++++ .../encoding/preview/RegionRgbaSource.java | 14 ++ .../preview/PreviewTileServiceTests.java | 112 +++++++++++++++ 5 files changed, 321 insertions(+) create mode 100644 cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/response/TileErrorResponse.java create mode 100644 cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/response/TileReadyResponse.java create mode 100644 cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileService.java create mode 100644 cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaSource.java create mode 100644 cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileServiceTests.java 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/preview/PreviewTileService.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileService.java new file mode 100644 index 00000000..8107ade1 --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileService.java @@ -0,0 +1,132 @@ +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.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; + if (key.lod() == 0) { + rgba = loadLodZero(key.world(), key.tx(), key.tz()); + } else { + // LOD < 0 not yet supported in this task — return error. + emitError(key, "lod-not-supported"); + return; + } + if (rgba == null) { + emitError(key, "missing-data"); + return; + } + File outFile = new File(outputFolder, key.toFileName()); + writePng(rgba, outFile); + writtenTiles.add(key); + emitReady(key); + } catch (Throwable t) { + emitError(key, t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage()); + } finally { + inFlight.remove(key); + } + } + + 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); + ImageIO.write(image, "png", outFile); + } + + 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/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/PreviewTileServiceTests.java b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileServiceTests.java new file mode 100644 index 00000000..d9aaf1a0 --- /dev/null +++ b/cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/PreviewTileServiceTests.java @@ -0,0 +1,112 @@ +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(); + } +} From e42f403e5c6f9db275a2ea71233c07a654aae56e Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 16:11:38 +0200 Subject: [PATCH 08/40] Support LOD aggregation in preview tile service --- .../encoding/preview/PreviewTileService.java | 43 ++++++++++++++---- .../preview/PreviewTileServiceTests.java | 44 +++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) 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 index 8107ade1..65cc43ca 100644 --- 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 @@ -73,14 +73,7 @@ public void shutdown() { private void processTile(PreviewTileKey key) { try { if (!inFlight.contains(key)) return; // cancelled before start - int[] rgba; - if (key.lod() == 0) { - rgba = loadLodZero(key.world(), key.tx(), key.tz()); - } else { - // LOD < 0 not yet supported in this task — return error. - emitError(key, "lod-not-supported"); - return; - } + int[] rgba = loadOrAggregate(key); if (rgba == null) { emitError(key, "missing-data"); return; @@ -88,6 +81,7 @@ private void processTile(PreviewTileKey key) { 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()); @@ -96,6 +90,39 @@ private void processTile(PreviewTileKey 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); 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 index d9aaf1a0..1ccc9960 100644 --- 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 @@ -109,4 +109,48 @@ public void testDedupsConcurrentRequestsForSameTile(@TempDir Path tmp) throws Ex 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(); + } } From 3e3770cc2020e34f0e165c2a0892e1bd5b9f9b30 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 16:29:08 +0200 Subject: [PATCH 09/40] Add production region RGBA source backed by Chunker world reader --- .../preview/ChunkerWorldRegionRgbaSource.java | 107 ++++++++++++++++++ .../preview/RegionRgbaCapturingWriter.java | 96 ++++++++++++++++ .../ChunkerWorldRegionRgbaSourceTests.java | 22 ++++ 3 files changed, 225 insertions(+) create mode 100644 cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSource.java create mode 100644 cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaCapturingWriter.java create mode 100644 cli/src/test/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSourceTests.java 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..6a7a8e81 --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/ChunkerWorldRegionRgbaSource.java @@ -0,0 +1,107 @@ +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; + + /** + * 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 { + // 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 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/RegionRgbaCapturingWriter.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaCapturingWriter.java new file mode 100644 index 00000000..e3221ab8 --- /dev/null +++ b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/RegionRgbaCapturingWriter.java @@ -0,0 +1,96 @@ +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.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; + +/** + * 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); + } + + // ------------------------------------------------------------------------- + // 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/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)); + } +} From e35f04ee7557458cc10115081b94d88f01da7112 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 16:42:03 +0200 Subject: [PATCH 10/40] Add request/cancel preview tile messenger types --- .../cli/messenger/messaging/BasicMessage.java | 12 +- .../request/CancelPreviewTilesRequest.java | 72 ++++++++++ .../request/RequestPreviewTilesRequest.java | 126 ++++++++++++++++++ 3 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/request/CancelPreviewTilesRequest.java create mode 100644 cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/request/RequestPreviewTilesRequest.java diff --git a/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/BasicMessage.java b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/BasicMessage.java index c70371eb..ca16de79 100644 --- a/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/BasicMessage.java +++ b/cli/src/main/java/com/hivemc/chunker/cli/messenger/messaging/BasicMessage.java @@ -6,6 +6,8 @@ import com.hivemc.chunker.cli.messenger.messaging.response.OutputResponse; import com.hivemc.chunker.cli.messenger.messaging.response.ProgressResponse; import com.hivemc.chunker.cli.messenger.messaging.response.ProgressStateResponse; +import com.hivemc.chunker.cli.messenger.messaging.response.TileErrorResponse; +import com.hivemc.chunker.cli.messenger.messaging.response.TileReadyResponse; import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import java.util.Map; @@ -69,6 +71,10 @@ public enum BasicMessageType { CONVERT(ConvertRequest.class), @SerializedName("kill") KILL(KillRequest.class), + @SerializedName("request_preview_tiles") + REQUEST_PREVIEW_TILES(RequestPreviewTilesRequest.class), + @SerializedName("cancel_preview_tiles") + CANCEL_PREVIEW_TILES(CancelPreviewTilesRequest.class), // Output @SerializedName("response") @@ -78,7 +84,11 @@ public enum BasicMessageType { @SerializedName("progress_state") PROGRESS_STATE(ProgressStateResponse.class), @SerializedName("error") - ERROR(ErrorResponse.class); + ERROR(ErrorResponse.class), + @SerializedName("tile_ready") + TILE_READY(TileReadyResponse.class), + @SerializedName("tile_error") + TILE_ERROR(TileErrorResponse.class); private static final Map, 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; + } +} From ed90fc1a4f677ac54c4ec1fe98a3885564dd2f39 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 16:47:40 +0200 Subject: [PATCH 11/40] Switch preview command to metadata pass and add tile service handlers --- .../chunker/cli/messenger/Messenger.java | 102 +++++++++++------- 1 file changed, 65 insertions(+), 37 deletions(-) diff --git a/cli/src/main/java/com/hivemc/chunker/cli/messenger/Messenger.java b/cli/src/main/java/com/hivemc/chunker/cli/messenger/Messenger.java index a84fa180..70cdd3b4 100644 --- a/cli/src/main/java/com/hivemc/chunker/cli/messenger/Messenger.java +++ b/cli/src/main/java/com/hivemc/chunker/cli/messenger/Messenger.java @@ -9,13 +9,20 @@ import com.hivemc.chunker.cli.messenger.messaging.response.OutputResponse; import com.hivemc.chunker.cli.messenger.messaging.response.ProgressResponse; import com.hivemc.chunker.cli.messenger.messaging.response.ProgressStateResponse; +import com.hivemc.chunker.cli.messenger.messaging.response.TileErrorResponse; +import com.hivemc.chunker.cli.messenger.messaging.response.TileReadyResponse; import com.hivemc.chunker.conversion.WorldConverter; import com.hivemc.chunker.conversion.encoding.EncodingType; import com.hivemc.chunker.conversion.encoding.base.Converter; import com.hivemc.chunker.conversion.encoding.base.Version; import com.hivemc.chunker.conversion.encoding.base.reader.LevelReader; import com.hivemc.chunker.conversion.encoding.base.writer.LevelWriter; -import com.hivemc.chunker.conversion.encoding.preview.PreviewLevelWriter; +import com.hivemc.chunker.conversion.encoding.preview.ChunkerWorldRegionRgbaSource; +import com.hivemc.chunker.conversion.encoding.preview.PreviewMapBin; +import com.hivemc.chunker.conversion.encoding.preview.PreviewMetadataReader; +import com.hivemc.chunker.conversion.encoding.preview.PreviewTileCache; +import com.hivemc.chunker.conversion.encoding.preview.PreviewTileService; +import com.hivemc.chunker.conversion.encoding.preview.RegionRgbaSource; import com.hivemc.chunker.conversion.encoding.settings.SettingsLevelWriter; import com.hivemc.chunker.conversion.intermediate.column.biome.ChunkerBiome; import com.hivemc.chunker.conversion.intermediate.column.biome.ChunkerCustomBiome; @@ -41,12 +48,14 @@ import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; /** * Messenger handles JSON communication between ChunkerWeb and itself through System.out. */ public class Messenger { private static final Map> SESSION_ID_TO_WORLD_CONVERTERS = new Object2ObjectOpenHashMap<>(); + private static final Map SESSION_ID_TO_PREVIEW_SERVICE = new ConcurrentHashMap<>(); private static final Gson GSON = new GsonBuilder() .registerTypeHierarchyAdapter(BasicMessage.class, new BasicMessageTypeAdapter()) .create(); @@ -129,53 +138,68 @@ public static void main(String[] args) { } case PREVIEW -> { PreviewRequest previewRequest = (PreviewRequest) message; - WorldConverter worldConverter = createWorldConverter(previewRequest.getAnonymousId(), previewRequest.getRequestId()); - boolean started = startConversionRequest( - previewRequest.getAnonymousId(), - previewRequest.getRequestId(), - worldConverter, - new File(previewRequest.getInputPath()), - new PreviewLevelWriter(new File(previewRequest.getOutputPath())) - ); - - worldConverter.setDimensionMapping(previewRequest.getInputToOutputDimension()); - - // Turn the String identifiers into a Dimension based map - if (previewRequest.getPruningList() != null && previewRequest.getPruningList().getConfigs() != null && !previewRequest.getPruningList().getConfigs().isEmpty()) { - DimensionRegistry registry = worldConverter.getDimensionRegistry(); - Map pruning = previewRequest.getPruningList().getConfigs(); - - Map pruningConfigs = new Object2ObjectOpenHashMap<>(pruning.size()); - for (String key : pruning.keySet()) { - pruningConfigs.put(registry.getByIdentifier(key), pruning.get(key)); + UUID anonymousId = previewRequest.getAnonymousId(); + UUID requestId = previewRequest.getRequestId(); + File inputDir = new File(previewRequest.getInputPath()); + File outputDir = new File(previewRequest.getOutputPath()); + + try { + // 1) Decide which metadata pass to run based on what's in the world directory. + PreviewMetadataReader metadataReader = new PreviewMetadataReader(); + if (new File(inputDir, "db/CURRENT").isFile()) { + metadataReader.readBedrockWorld(inputDir, outputDir); + } else { + metadataReader.readJavaWorld(inputDir, outputDir); } - worldConverter.setPruningConfigs(pruningConfigs); - } + // 2) Spin up a long-lived tile service for this session. + PreviewMapBin mapBin = PreviewMapBin.read(new File(outputDir, "map.bin")); + RegionRgbaSource source = new ChunkerWorldRegionRgbaSource(inputDir); + int cacheEntries = PreviewTileCache.computeCapacityEntries(Runtime.getRuntime().maxMemory()); + int workers = Math.min(4, Math.max(1, Runtime.getRuntime().availableProcessors() / 2)); + PreviewTileService service = new PreviewTileService(outputDir, mapBin, source, new PreviewTileCache(cacheEntries), workers); + service.setEventListener(new PreviewTileService.EventListener() { + @Override public void onTileReady(TileReadyResponse r) { write(r); } + @Override public void onTileError(TileErrorResponse r) { write(r); } + }); - // Turn off certain features for preview - worldConverter.setProcessMaps(false); - worldConverter.setProcessLighting(false); - worldConverter.setProcessHeightMap(false); - worldConverter.setProcessBlockEntities(false); - worldConverter.setProcessColumnPreTransform(false); - worldConverter.setProcessEntities(false); - worldConverter.setProcessBiomes(false); - worldConverter.setProcessItems(false); + // Stop and replace any pre-existing service for this session. + PreviewTileService previous = SESSION_ID_TO_PREVIEW_SERVICE.put(anonymousId, service); + if (previous != null) previous.shutdown(); - // Write an error if it failed to start - if (!started) { - removeWorldConverter(previewRequest.getAnonymousId(), previewRequest.getRequestId()); + // 3) Tell the client we're done with the metadata pass — the map.bin file is at outputDir/map.bin. + write(new OutputResponse(requestId, null)); + } catch (Exception e) { write(new ErrorResponse( - previewRequest.getRequestId(), + requestId, false, "Failed to start preview.", null, - null, - null + e.getMessage(), + printStackTrace(e) )); } } + case REQUEST_PREVIEW_TILES -> { + RequestPreviewTilesRequest req = (RequestPreviewTilesRequest) message; + PreviewTileService svc = SESSION_ID_TO_PREVIEW_SERVICE.get(req.getAnonymousId()); + if (svc == null) { + write(new ErrorResponse(req.getRequestId(), false, + "No active preview session.", null, null, null)); + } else { + svc.enqueueRange(req.getWorld(), req.getLod(), + req.getMinTx(), req.getMinTz(), req.getMaxTx(), req.getMaxTz()); + write(new OutputResponse(req.getRequestId(), null)); + } + } + case CANCEL_PREVIEW_TILES -> { + CancelPreviewTilesRequest req = (CancelPreviewTilesRequest) message; + PreviewTileService svc = SESSION_ID_TO_PREVIEW_SERVICE.get(req.getAnonymousId()); + if (svc != null) { + svc.cancel(req.getWorld(), req.getLod()); + } + write(new OutputResponse(req.getRequestId(), null)); + } case CONVERT -> { ConvertRequest convertRequest = (ConvertRequest) message; WorldConverter worldConverter = createWorldConverter(convertRequest.getAnonymousId(), convertRequest.getRequestId()); @@ -360,6 +384,10 @@ public static void main(String[] args) { case KILL -> { KillRequest killRequest = (KillRequest) message; + // Shutdown any preview tile service for this session. + PreviewTileService previewService = SESSION_ID_TO_PREVIEW_SERVICE.remove(killRequest.getAnonymousId()); + if (previewService != null) previewService.shutdown(); + // Loop through all the converters under this anonymous ID and cancel them Map converters = SESSION_ID_TO_WORLD_CONVERTERS.get(killRequest.getAnonymousId()); if (converters != null) { From 53ac7b05f58cc70c7237e7a4e406a701c3ebb815 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 17:50:33 +0200 Subject: [PATCH 12/40] Remove legacy upfront preview writers --- .../conversion/encoding/EncodingType.java | 3 +- .../encoding/preview/PreviewColumnWriter.java | 131 ------------------ .../encoding/preview/PreviewLevelWriter.java | 41 ------ .../encoding/preview/PreviewWorldWriter.java | 99 ------------- .../WorldConversionIntegrationTests.java | 30 ---- 5 files changed, 1 insertion(+), 303 deletions(-) delete mode 100644 cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewColumnWriter.java delete mode 100644 cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelWriter.java delete mode 100644 cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewWorldWriter.java 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/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/PreviewLevelWriter.java b/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelWriter.java deleted file mode 100644 index f91a37e5..00000000 --- a/cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelWriter.java +++ /dev/null @@ -1,41 +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.level.ChunkerLevel; - -import java.io.File; - -/** - * 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); - } -} 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/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 reader = EncodingType.findReader(input.toFile(), converter); From 3ad350adfab2f3885e7646851815dd3c08ce3287 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 17:54:35 +0200 Subject: [PATCH 13/40] Forward preview tile flow messages and add typed listener support --- app/electron/src/session.js | 30 ++++++++++++++++++++++++++ app/ui/src/api.js | 42 ++++++++++++++++++++----------------- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/app/electron/src/session.js b/app/electron/src/session.js index 650eaa4e..9dfae5a0 100644 --- a/app/electron/src/session.js +++ b/app/electron/src/session.js @@ -277,6 +277,12 @@ export class Session { case "generate_preview": await this.generatePreview(data.requestId); break; + case "request_preview_tiles": + await this.requestPreviewTiles(data); + break; + case "cancel_preview_tiles": + await this.cancelPreviewTiles(data); + break; case "convert": await this.convertWorld(data.outputType, data.requestId, data); break; @@ -662,6 +668,30 @@ export class Session { }); } + requestPreviewTiles(data) { + this.sendToProcess({ + type: "request_preview_tiles", + requestId: data.requestId, + anonymousId: this._sessionID, + world: data.world, + lod: data.lod, + minTx: data.minTx, + minTz: data.minTz, + maxTx: data.maxTx, + maxTz: data.maxTz + }); + } + + cancelPreviewTiles(data) { + this.sendToProcess({ + type: "cancel_preview_tiles", + requestId: data.requestId, + anonymousId: this._sessionID, + world: data.world, + lod: data.lod + }); + } + async convertWorld(outputType, requestId, data) { let worldInputPath = path.join(this._sessionPath, "input"); let worldOutputPath = path.join(this._sessionPath, "output"); diff --git a/app/ui/src/api.js b/app/ui/src/api.js index 492df8d6..1f3c6c0e 100644 --- a/app/ui/src/api.js +++ b/app/ui/src/api.js @@ -1,51 +1,48 @@ let api = { connection: undefined, replyHandlers: {}, + listeners: {}, connect: function (connectHandler) { let handlers = {}; - // Connection open handler handlers.onopen = function () { connectHandler(); }; - // Connection close handler handlers.onclose = function (e) { api.connection = undefined; - if (e.code === 1000) return; // Don't show error, as it was completed cleanly (and successful!) + if (e.code === 1000) return; if (e.code === 200) { - // Connection closed intentionally (eg. inactive) - } else { - // Connection closed for other reason, should reconnect? + // intentional close } connectHandler(e.code); }; - // Connection message handler handlers.onmessage = function (e) { let msg = JSON.parse(e.data); let requestId = msg.requestId; - if (api.replyHandlers[requestId] === undefined) { - console.warn("No reply handler found: ", msg); - } else { - let handler = api.replyHandlers[requestId]; - - // Check if handler needs removing + let handler = api.replyHandlers[requestId]; + if (handler !== undefined) { if (msg.continue === undefined || msg.continue === false) { - // Remove as it's done delete api.replyHandlers[requestId]; } - - // Call handler handler(msg); + return; + } + + let typed = api.listeners[msg.type]; + if (typed && typed.length > 0) { + for (let cb of typed) cb(msg); + return; } + + console.warn("No reply handler or listener found: ", msg); }; - // Set connection this.connection = window.chunker.connect(handlers); }, send: function (obj, replyHandler) { - obj.requestId = crypto.randomUUID();// Generate random id + obj.requestId = crypto.randomUUID(); if (this.connection !== undefined) { this.replyHandlers[obj.requestId] = replyHandler; this.connection.send(JSON.stringify(obj)); @@ -53,6 +50,13 @@ let api = { throw Error("Not connected!"); } }, + addListener: function (type, callback) { + if (!this.listeners[type]) this.listeners[type] = []; + this.listeners[type].push(callback); + return () => { + this.listeners[type] = (this.listeners[type] || []).filter(cb => cb !== callback); + }; + }, isConnected: function () { return this.connection !== undefined; }, @@ -63,4 +67,4 @@ let api = { } }; -export default api; \ No newline at end of file +export default api; From b2d9ec5d0f19639c98fc90057f4220446b5f3500 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 17:57:34 +0200 Subject: [PATCH 14/40] Parse full map.bin client-side and expose empty-tile oracle --- app/ui/src/components/app.js | 34 ++-------- .../screen/settings/tab/preview/mapBin.js | 65 +++++++++++++++++++ 2 files changed, 69 insertions(+), 30 deletions(-) create mode 100644 app/ui/src/components/screen/settings/tab/preview/mapBin.js diff --git a/app/ui/src/components/app.js b/app/ui/src/components/app.js index 1f510596..fed9809a 100644 --- a/app/ui/src/components/app.js +++ b/app/ui/src/components/app.js @@ -6,8 +6,8 @@ import {Header} from "./page/header"; import {StepDisplay} from "./page/stepDisplay"; import {ErrorDisplay} from "./modal/errorDisplay"; import {Footer} from "./page/footer"; -import {decode} from "base64-arraybuffer" import {getVersionName} from "./screen/mode/modeOption"; +import {parseMapBin} from "./screen/settings/tab/preview/mapBin"; export class App extends Component { errorModal = React.createRef(); @@ -202,36 +202,10 @@ export class App extends Component { let base64 = message.output; if (base64.length > 0) { - let buffer = decode(base64); - let worlds = []; - let dataView = new DataView(buffer); - let bufferIndex = 0; - let worldCount = dataView.getInt32(bufferIndex, true); - bufferIndex += 4; - - for (let i = 0; i < worldCount; i++) { - let worldIndex = dataView.getInt32(bufferIndex, true); - bufferIndex += 4; - - // World Index - worlds[worldIndex] = {}; - worlds[worldIndex].minX = dataView.getInt32(bufferIndex, true); - bufferIndex += 4; - worlds[worldIndex].minZ = dataView.getInt32(bufferIndex, true); - bufferIndex += 4; - worlds[worldIndex].maxX = dataView.getInt32(bufferIndex, true); - bufferIndex += 4; - worlds[worldIndex].maxZ = dataView.getInt32(bufferIndex, true); - bufferIndex += 4; - let regionCount = dataView.getInt32(bufferIndex, true); - bufferIndex += 4; - bufferIndex += regionCount * (8 + 128); // Skip region bytes - } - - self.setState({previewData: worlds}); + let parsed = parseMapBin(base64); + self.setState({previewData: parsed}); } else { - // Set to empty data (meaning preview is streamed) - self.setState({previewData: []}); + self.setState({previewData: undefined}); } } else { console.info("Unknown response", message); diff --git a/app/ui/src/components/screen/settings/tab/preview/mapBin.js b/app/ui/src/components/screen/settings/tab/preview/mapBin.js new file mode 100644 index 00000000..c8d88592 --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/mapBin.js @@ -0,0 +1,65 @@ +import {decode} from "base64-arraybuffer"; + +const BEDROCK_ID_TO_IDENTIFIER = { + 0: "minecraft:overworld", + 1: "minecraft:the_nether", + 2: "minecraft:the_end" +}; + +export function parseMapBin(base64) { + const buffer = decode(base64); + const view = new DataView(buffer); + let offset = 0; + const worldCount = view.getInt32(offset, true); offset += 4; + const worlds = new Map(); + for (let w = 0; w < worldCount; w++) { + const bedrockId = view.getInt32(offset, true); offset += 4; + const minX = view.getInt32(offset, true); offset += 4; + const minZ = view.getInt32(offset, true); offset += 4; + const maxX = view.getInt32(offset, true); offset += 4; + const maxZ = view.getInt32(offset, true); offset += 4; + const regionCount = view.getInt32(offset, true); offset += 4; + const regionPresence = new Map(); + for (let r = 0; r < regionCount; r++) { + const rx = view.getInt32(offset, true); offset += 4; + const rz = view.getInt32(offset, true); offset += 4; + // Copy the 128-byte bitset slice into its own Uint8Array so it remains valid after the + // backing ArrayBuffer is GC'd or sliced elsewhere. + const bits = new Uint8Array(buffer.slice(offset, offset + 128)); + offset += 128; + regionPresence.set(`${rx},${rz}`, bits); + } + const identifier = BEDROCK_ID_TO_IDENTIFIER[bedrockId] || `custom:${bedrockId}`; + worlds.set(identifier, {bedrockId, minX, minZ, maxX, maxZ, regionPresence}); + } + return {worlds}; +} + +function regionHasAnyChunk(entry, rx, rz) { + const bits = entry.regionPresence.get(`${rx},${rz}`); + if (!bits) return false; + for (let i = 0; i < bits.length; i++) if (bits[i] !== 0) return true; + return false; +} + +export function isTileEmpty(parsed, world, lod, tx, tz) { + const entry = parsed.worlds.get(world); + if (!entry) return true; + const scale = 1 << (-lod); + const minRx = tx * scale; + const minRz = tz * scale; + const maxRx = minRx + scale; + const maxRz = minRz + scale; + for (let rx = minRx; rx < maxRx; rx++) { + for (let rz = minRz; rz < maxRz; rz++) { + if (regionHasAnyChunk(entry, rx, rz)) return false; + } + } + return true; +} + +export function worldBoundsForAutoFit(parsed, world) { + const entry = parsed.worlds.get(world); + if (!entry) return null; + return {minX: entry.minX, minZ: entry.minZ, maxX: entry.maxX, maxZ: entry.maxZ}; +} From 8dc3c7fa98ccaea7419f68ab047d9b32df0ab3fe Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 18:10:28 +0200 Subject: [PATCH 15/40] Add auto-fit minZoom calculation with -12 floor --- .../screen/settings/tab/preview/autoFit.js | 12 ++++++++++ .../settings/tab/preview/autoFit.test.js | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 app/ui/src/components/screen/settings/tab/preview/autoFit.js create mode 100644 app/ui/src/components/screen/settings/tab/preview/autoFit.test.js diff --git a/app/ui/src/components/screen/settings/tab/preview/autoFit.js b/app/ui/src/components/screen/settings/tab/preview/autoFit.js new file mode 100644 index 00000000..e8c89c6a --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/autoFit.js @@ -0,0 +1,12 @@ +export const MIN_ZOOM_FLOOR = -12; +const TILE_SIZE_PX = 512; + +export function computeMinZoom(bounds) { + if (!bounds) return MIN_ZOOM_FLOOR; + const chunksX = bounds.maxX - bounds.minX + 1; + const chunksZ = bounds.maxZ - bounds.minZ + 1; + const worldPx = Math.max(chunksX, chunksZ) * 16; + if (worldPx <= 0) return MIN_ZOOM_FLOOR; + const neededLod = Math.max(0, Math.ceil(Math.log2(worldPx / TILE_SIZE_PX))); + return -Math.min(neededLod, -MIN_ZOOM_FLOOR); +} diff --git a/app/ui/src/components/screen/settings/tab/preview/autoFit.test.js b/app/ui/src/components/screen/settings/tab/preview/autoFit.test.js new file mode 100644 index 00000000..72dc90ff --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/autoFit.test.js @@ -0,0 +1,22 @@ +import {computeMinZoom} from "./autoFit"; + +describe("computeMinZoom", () => { + test("small world: ~200x200 chunks -> -3", () => { + expect(computeMinZoom({minX: 0, minZ: 0, maxX: 199, maxZ: 199})).toBe(-3); + }); + test("medium world: 1000x1000 chunks -> -5", () => { + expect(computeMinZoom({minX: 0, minZ: 0, maxX: 999, maxZ: 999})).toBe(-5); + }); + test("very large world: 10000x10000 chunks -> -9", () => { + expect(computeMinZoom({minX: 0, minZ: 0, maxX: 9999, maxZ: 9999})).toBe(-9); + }); + test("huge world: 50000x50000 chunks -> -11", () => { + expect(computeMinZoom({minX: 0, minZ: 0, maxX: 49999, maxZ: 49999})).toBe(-11); + }); + test("massive world capped at -12", () => { + expect(computeMinZoom({minX: 0, minZ: 0, maxX: 200000, maxZ: 200000})).toBe(-12); + }); + test("null bounds yields the cap", () => { + expect(computeMinZoom(null)).toBe(-12); + }); +}); From c4520d08569cd1635b3709eaf720b8ce6b1aa97a Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 18:11:48 +0200 Subject: [PATCH 16/40] Add LRU client tile cache with explicit DOM unmount --- .../settings/tab/preview/clientTileCache.js | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 app/ui/src/components/screen/settings/tab/preview/clientTileCache.js diff --git a/app/ui/src/components/screen/settings/tab/preview/clientTileCache.js b/app/ui/src/components/screen/settings/tab/preview/clientTileCache.js new file mode 100644 index 00000000..23f08c2c --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/clientTileCache.js @@ -0,0 +1,67 @@ +const MIN_BYTES = 64 * 1024 * 1024; +const MAX_BYTES = 384 * 1024 * 1024; + +export function computeBudgetBytes() { + const deviceMemoryGb = (typeof navigator !== "undefined" && navigator.deviceMemory) || 4; + const tenPercent = deviceMemoryGb * 1024 * 1024 * 1024 * 0.10; + return Math.max(MIN_BYTES, Math.min(MAX_BYTES, tenPercent)); +} + +export class ClientTileCache { + constructor(budgetBytes = computeBudgetBytes()) { + this.budgetBytes = budgetBytes; + this.bytesInUse = 0; + this.map = new Map(); // insertion order doubles as LRU; re-insert on access + } + + keyFor(world, lod, tx, tz) { + return `${world}|${lod}|${tx}|${tz}`; + } + + get(key) { + const entry = this.map.get(key); + if (!entry) return null; + // Touch for LRU. + this.map.delete(key); + this.map.set(key, entry); + return entry; + } + + put(key, entry) { + const existing = this.map.get(key); + if (existing) { + this.bytesInUse -= existing.sizeBytes; + this.map.delete(key); + } + this.map.set(key, entry); + this.bytesInUse += entry.sizeBytes; + while (this.bytesInUse > this.budgetBytes && this.map.size > 1) { + const firstKey = this.map.keys().next().value; + this.evict(firstKey); + } + } + + evict(key) { + const entry = this.map.get(key); + if (!entry) return; + this.map.delete(key); + this.bytesInUse -= entry.sizeBytes; + if (entry.blobUrl) { + // Only revoke if it's actually a blob: URL we created. + if (typeof entry.blobUrl === "string" && entry.blobUrl.startsWith("blob:")) { + URL.revokeObjectURL(entry.blobUrl); + } + } + if (entry.img) { + entry.img.src = ""; + if (entry.img.parentNode) entry.img.parentNode.removeChild(entry.img); + } + } + + evictAllExcept(keepKeys) { + const keep = new Set(keepKeys); + for (const key of Array.from(this.map.keys())) { + if (!keep.has(key)) this.evict(key); + } + } +} From 9586a98092b277d2efdf13dbc9616c66f1eb845b Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 18:13:58 +0200 Subject: [PATCH 17/40] Add custom GridLayer with batched padded tile requests --- .../tab/preview/chunkerPreviewLayer.js | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js diff --git a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js new file mode 100644 index 00000000..849af100 --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js @@ -0,0 +1,161 @@ +import L from "leaflet"; +import {isTileEmpty} from "./mapBin"; + +const PADDING_FACTOR = 1.5; +const DISPATCH_DEBOUNCE_MS = 50; +const TRANSPARENT_PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + +export const ChunkerPreviewLayer = L.GridLayer.extend({ + initialize(options) { + L.setOptions(this, {tileSize: 512, noWrap: true, ...options}); + this._mapBin = options.mapBin; + this._sessionId = options.session; + this._ipc = options.ipc; + this._cache = options.tileCache; + this._pending = new Map(); + this._zoomTransitionActive = false; + this._dispatchTimer = null; + this._unsubReady = this._ipc.onTileReady((e) => this._handleTileReady(e)); + this._unsubError = this._ipc.onTileError((e) => this._handleTileError(e)); + }, + + onAdd(map) { + L.GridLayer.prototype.onAdd.call(this, map); + map.on("zoomstart", this._onZoomStart, this); + map.on("zoomend", this._onZoomEnd, this); + return this; + }, + + onRemove(map) { + if (this._unsubReady) this._unsubReady(); + if (this._unsubError) this._unsubError(); + map.off("zoomstart", this._onZoomStart, this); + map.off("zoomend", this._onZoomEnd, this); + if (this._dispatchTimer) { + clearTimeout(this._dispatchTimer); + this._dispatchTimer = null; + } + this._ipc.cancelTiles({world: this.options.identifier}); + L.GridLayer.prototype.onRemove.call(this, map); + return this; + }, + + createTile(coords, done) { + const tx = coords.x; + const tz = coords.y; + const lod = coords.z; + const cacheKey = this._cache.keyFor(this.options.identifier, lod, tx, tz); + + if (isTileEmpty(this._mapBin, this.options.identifier, lod, tx, tz)) { + const div = document.createElement("div"); + div.className = "chunker-tile chunker-tile-empty"; + setTimeout(() => done(null), 0); + return div; + } + + const cached = this._cache.get(cacheKey); + if (cached && cached.blobUrl) { + const img = new Image(); + img.src = cached.blobUrl; + img.className = "chunker-tile"; + setTimeout(() => done(null), 0); + return img; + } + + const placeholder = new Image(); + placeholder.src = TRANSPARENT_PIXEL; + placeholder.className = "chunker-tile chunker-tile-pending"; + this._pending.set(cacheKey, {tile: placeholder, done, coords: {tx, tz, lod}}); + this._scheduleDispatch(); + return placeholder; + }, + + _scheduleDispatch() { + if (this._dispatchTimer) return; + this._dispatchTimer = setTimeout(() => { + this._dispatchTimer = null; + this._dispatch(); + }, DISPATCH_DEBOUNCE_MS); + }, + + _dispatch() { + if (this._pending.size === 0) return; + const grouped = new Map(); + for (const [, p] of this._pending) { + const groupKey = `${p.coords.lod}`; + const g = grouped.get(groupKey) || { + lod: p.coords.lod, + minTx: Infinity, minTz: Infinity, + maxTx: -Infinity, maxTz: -Infinity + }; + g.minTx = Math.min(g.minTx, p.coords.tx); + g.maxTx = Math.max(g.maxTx, p.coords.tx); + g.minTz = Math.min(g.minTz, p.coords.tz); + g.maxTz = Math.max(g.maxTz, p.coords.tz); + grouped.set(groupKey, g); + } + const factor = this._zoomTransitionActive ? 0 : PADDING_FACTOR; + for (const g of grouped.values()) { + const width = g.maxTx - g.minTx + 1; + const height = g.maxTz - g.minTz + 1; + const padX = Math.floor((width * factor) / 2); + const padZ = Math.floor((height * factor) / 2); + this._ipc.requestTiles({ + world: this.options.identifier, + lod: g.lod, + minTx: g.minTx - padX, + minTz: g.minTz - padZ, + maxTx: g.maxTx + padX, + maxTz: g.maxTz + padZ + }); + } + }, + + _handleTileReady(event) { + if (event.world !== this.options.identifier) return; + const cacheKey = this._cache.keyFor(event.world, event.lod, event.tx, event.tz); + const pending = this._pending.get(cacheKey); + const resolvedUrl = event.path.startsWith("session://") + ? event.path + : `session://${this._sessionId}/preview/${event.path}`; + + const img = new Image(); + img.className = "chunker-tile"; + img.src = resolvedUrl; + img.onload = () => { + this._cache.put(cacheKey, {img, blobUrl: resolvedUrl, sizeBytes: 512 * 512 * 4}); + if (pending) { + pending.tile.src = resolvedUrl; + pending.tile.className = "chunker-tile"; + this._pending.delete(cacheKey); + pending.done(null); + } + }; + img.onerror = () => { + if (pending) { + pending.tile.className = "chunker-tile chunker-tile-error"; + this._pending.delete(cacheKey); + pending.done(null); + } + }; + }, + + _handleTileError(event) { + if (event.world !== this.options.identifier) return; + const cacheKey = this._cache.keyFor(event.world, event.lod, event.tx, event.tz); + const pending = this._pending.get(cacheKey); + if (pending) { + pending.tile.className = "chunker-tile chunker-tile-error"; + this._pending.delete(cacheKey); + pending.done(null); + } + }, + + _onZoomStart() { + this._zoomTransitionActive = true; + }, + + _onZoomEnd() { + this._zoomTransitionActive = false; + } +}); From c7a347befea59c8a0dedcfd245371816594a1d35 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 18:15:47 +0200 Subject: [PATCH 18/40] Add editable center-coordinate control for preview map --- .../tab/preview/centerCoordsControl.js | 68 +++++++++++++++++++ .../settings/tab/preview/previewComponent.css | 29 ++++++++ 2 files changed, 97 insertions(+) create mode 100644 app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js diff --git a/app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js b/app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js new file mode 100644 index 00000000..f6f9cffc --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js @@ -0,0 +1,68 @@ +import L from "leaflet"; + +export const CenterCoordsControl = L.Control.extend({ + options: {position: "topleft"}, + + initialize(options) { + L.setOptions(this, options); + this._getBounds = options.getBounds; // optional () => {minX, minZ, maxX, maxZ} + }, + + onAdd(map) { + const container = L.DomUtil.create("div", "leaflet-bar chunker-coords-control"); + L.DomEvent.disableClickPropagation(container); + L.DomEvent.disableScrollPropagation(container); + + const labelX = L.DomUtil.create("span", "chunker-coords-label", container); + labelX.textContent = "X:"; + this._inputX = L.DomUtil.create("input", "chunker-coords-input", container); + this._inputX.type = "number"; + + const labelZ = L.DomUtil.create("span", "chunker-coords-label", container); + labelZ.textContent = "Z:"; + this._inputZ = L.DomUtil.create("input", "chunker-coords-input", container); + this._inputZ.type = "number"; + + this._map = map; + map.on("moveend", this._syncFromMap, this); + + const onCommit = () => this._commit(); + this._inputX.addEventListener("keydown", (e) => { if (e.key === "Enter") onCommit(); }); + this._inputZ.addEventListener("keydown", (e) => { if (e.key === "Enter") onCommit(); }); + this._inputX.addEventListener("blur", onCommit); + this._inputZ.addEventListener("blur", onCommit); + + this._syncFromMap(); + return container; + }, + + onRemove(map) { + map.off("moveend", this._syncFromMap, this); + }, + + _syncFromMap() { + if (document.activeElement === this._inputX || document.activeElement === this._inputZ) return; + const c = this._map.getCenter(); + const x = Math.round(c.lng); + const z = Math.round(-c.lat); + this._inputX.value = x; + this._inputZ.value = z; + }, + + _commit() { + const x = parseInt(this._inputX.value, 10); + const z = parseInt(this._inputZ.value, 10); + if (Number.isNaN(x) || Number.isNaN(z)) { + this._syncFromMap(); + return; + } + const bounds = this._getBounds ? this._getBounds() : null; + let clampedX = x; + let clampedZ = z; + if (bounds) { + clampedX = Math.max(bounds.minX * 16, Math.min((bounds.maxX + 1) * 16 - 1, x)); + clampedZ = Math.max(bounds.minZ * 16, Math.min((bounds.maxZ + 1) * 16 - 1, z)); + } + this._map.setView([-clampedZ, clampedX], this._map.getZoom(), {animate: false}); + } +}); diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.css b/app/ui/src/components/screen/settings/tab/preview/previewComponent.css index d2da900e..96e1a6a2 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.css +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.css @@ -26,4 +26,33 @@ image-rendering: crisp-edges; /* CSS4 Proposed */ image-rendering: pixelated; /* CSS4 Proposed */ -ms-interpolation-mode: nearest-neighbor; /* IE8+ */ +} + +.chunker-coords-control { + background: rgba(255, 255, 255, 0.95); + padding: 4px 8px; + display: flex; + align-items: center; + gap: 4px; + font: 12px sans-serif; +} +.chunker-coords-label { + color: #333; + user-select: none; +} +.chunker-coords-input { + width: 70px; + padding: 1px 4px; + border: 1px solid #ccc; + border-radius: 3px; +} +.chunker-tile-empty { + background: transparent; +} +.chunker-tile-pending { + background: rgba(0, 0, 0, 0.05); +} +.chunker-tile-error { + background: #ddd; + background-image: linear-gradient(45deg, transparent 45%, #aaa 45% 55%, transparent 55%); } \ No newline at end of file From ce445069e6800e75cbed2075ec4f7ad001ceb3dc Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 18:23:13 +0200 Subject: [PATCH 19/40] Switch preview to streaming tile layer with center coords control --- .../settings/tab/preview/previewComponent.js | 149 ++++++++++-------- 1 file changed, 79 insertions(+), 70 deletions(-) diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js index 0e0ac8bd..fd299606 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js @@ -1,15 +1,21 @@ import React, {Component} from "react"; -import "./previewComponent.css" -import "leaflet/dist/leaflet.css" +import "./previewComponent.css"; +import "leaflet/dist/leaflet.css"; import L from "leaflet"; import "leaflet-draw"; import {ProgressComponent} from "../../../../progress"; import "leaflet-mouse-position/src/L.Control.MousePosition.css"; import "leaflet-fullscreen/dist/leaflet.fullscreen.css"; import {getDimensionDisplayName} from "../dimensionPruningTab"; +import {worldBoundsForAutoFit} from "./mapBin"; +import {computeMinZoom} from "./autoFit"; +import {ClientTileCache} from "./clientTileCache"; +import {ChunkerPreviewLayer} from "./chunkerPreviewLayer"; +import {CenterCoordsControl} from "./centerCoordsControl"; +import api from "../../../../../api"; -require("leaflet-mouse-position/src/L.Control.MousePosition"); // As it adds new controls, need to be required -require("leaflet-fullscreen/dist/Leaflet.fullscreen"); // As it adds new controls, need to be required +require("leaflet-mouse-position/src/L.Control.MousePosition"); +require("leaflet-fullscreen/dist/Leaflet.fullscreen"); // Fix leaflet delete L.Icon.Default.prototype._getIconUrl; @@ -46,79 +52,96 @@ export class Map extends Component { app = this.props.app; componentDidMount() { - let self = this; - // Render map + const self = this; + // this.props.data is the already-parsed map.bin (Task 14). + this._mapBin = this.props.data; + this._cache = new ClientTileCache(); + + const dimensions = this.app.state.settings.dimensions; + const defaultIdentifier = (this.app.state.previewState && this.app.state.previewState.layer) || dimensions[0]; + const minZoom = computeMinZoom(worldBoundsForAutoFit(this._mapBin, defaultIdentifier)); + this.mymap = L.map("map", { crs: L.CRS.Simple, - minZoom: -5, + minZoom, maxZoom: 5, attributionControl: false }); - let worlds = this.app.state.settings.dimensions.map((a, k) => { - return L.tileLayer("session://{session}/preview/{world}.{x}.{y}.png", { - maxNativeZoom: 0, - minNativeZoom: 0, - minZoom: -5, - maxZoom: 5, - world: a.replace(":", "_"), - id: "blocks", - session: self.props.session, - tileSize: 512, - noWrap: true, - identifier: a, - index: k, - continuousWorld: true - }); - }); + const worlds = dimensions.map((identifier, k) => new ChunkerPreviewLayer({ + id: "blocks", + identifier, + world: identifier.replace(":", "_"), + session: self.props.session, + index: k, + continuousWorld: true, + mapBin: this._mapBin, + tileCache: this._cache, + ipc: { + requestTiles: (req) => api.send({type: "flow", method: "request_preview_tiles", ...req}, () => {}), + cancelTiles: (req) => api.send({type: "flow", method: "cancel_preview_tiles", ...req}, () => {}), + onTileReady: (cb) => api.addListener("tile_ready", cb), + onTileError: (cb) => api.addListener("tile_error", cb) + } + })); if (this.app.state.previewState === undefined) { - // Default - let defaultWorld = worlds.length > 0 ? worlds[0] : undefined; + const defaultWorld = worlds.length > 0 ? worlds[0] : undefined; if (defaultWorld !== undefined) { defaultWorld.addTo(this.mymap); - let centerX = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnX")[0].value; - let centerZ = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnZ")[0].value; - - // L.marker(xy(centerX * 16, centerZ * 16)).addTo(mymap).bindPopup("Center (" + (centerX * 16) + "," + (centerZ * 16) + ")"); + const centerX = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnX")[0].value; + const centerZ = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnZ")[0].value; this.mymap.setView(xy(centerX, centerZ), 2); } } else { - let defaultWorld = worlds.filter(a => this.app.state.previewState.layer === a.options.identifier)[0]; + const defaultWorld = worlds.filter(a => this.app.state.previewState.layer === a.options.identifier)[0]; defaultWorld.addTo(this.mymap); this.mymap.setView(this.app.state.previewState.center, this.app.state.previewState.zoom, {animate: false}); } this.renderPruningRegion(); - // Add controls - let baseMaps = {}; + const baseMaps = {}; worlds.forEach(a => { baseMaps[getDimensionDisplayName(a.options.identifier)] = a; }); - // Mouse position L.control.mousePosition({ emptyString: "0, 0 (0, 0)", formatter: this.formatCoords, wrapLng: false }).addTo(this.mymap); - // Zoom + Layers this.mymap.zoomControl.setPosition("bottomleft"); L.control.fullscreen({position: "bottomright"}).addTo(this.mymap); L.control.layers(baseMaps, null, {position: "bottomright"}).addTo(this.mymap); - // Add handler for pruning render + new CenterCoordsControl({ + getBounds: () => worldBoundsForAutoFit(this._mapBin, this._currentWorld()) + }).addTo(this.mymap); + this.mymap.on("baselayerchange", function (e) { self.renderPruningRegion(); }); + + this.mymap.on("fullscreenchange", () => { + // Aggressive evict before the renderer holds two viewports' worth of tiles at once. + self._cache.evictAllExcept([]); + }); + } + + _currentWorld() { + let id; + this.mymap.eachLayer((layer) => { + if (layer.options && layer.options.identifier) id = layer.options.identifier; + }); + return id || this.app.state.settings.dimensions[0]; } formatCoords = (long, lat) => { - let x = long; - let z = -lat; - return Math.floor(x) + ", " + Math.floor(z) + " (" + Math.floor(x / 16) + ", " + Math.floor(z / 16) + ")" + const x = long; + const z = -lat; + return Math.floor(x) + ", " + Math.floor(z) + " (" + Math.floor(x / 16) + ", " + Math.floor(z / 16) + ")"; }; componentWillUnmount() { @@ -140,58 +163,49 @@ export class Map extends Component { moveRegion = (world, regionIndex, minX, minZ, maxX, maxZ) => { this.app.setState((prevState) => { - let pruningSettingsClone = JSON.parse(JSON.stringify(prevState.pruningSettings)); - - // Add new state + const pruningSettingsClone = JSON.parse(JSON.stringify(prevState.pruningSettings)); pruningSettingsClone[world].regions[regionIndex] = { - ...pruningSettingsClone[world].regions[regionIndex], // Merge old settings + ...pruningSettingsClone[world].regions[regionIndex], minChunkX: Math.floor(Math.min(minX, maxX)), minChunkZ: Math.floor(Math.min(minZ, maxZ)), maxChunkX: Math.ceil(Math.max(minX, maxX)) - 1, maxChunkZ: Math.ceil(Math.max(minZ, maxZ)) - 1 }; - return {pruningSettings: pruningSettingsClone}; }, () => { - // Force re-render this.renderPruningRegion(); }); } - renderPruningRegion = () => { - let self = this; - // Get selected map + renderPruningRegion = () => { + const self = this; this.mymap.eachLayer(function (layer) { if (layer.options.identifier !== undefined) { self.renderWorldPruningRegion(layer.options.identifier); } if (layer.options.region) { - // Remove old polygons self.mymap.removeLayer(layer); } }); }; renderWorldPruningRegion = (world) => { - if (!(this.app.state.pruningSettings[world] && this.app.state.pruningSettings[world].regions)) return; // No regions + if (!(this.app.state.pruningSettings[world] && this.app.state.pruningSettings[world].regions)) return; - let pruningRegions = this.app.state.pruningSettings[world]; + const pruningRegions = this.app.state.pruningSettings[world]; pruningRegions.regions.forEach((region, index) => { - // Drawing the region - let minX = Math.min(region.minChunkX, region.maxChunkX) * 16; - let minZ = Math.min(region.minChunkZ, region.maxChunkZ) * 16; + const minX = Math.min(region.minChunkX, region.maxChunkX) * 16; + const minZ = Math.min(region.minChunkZ, region.maxChunkZ) * 16; - let maxX = (Math.max(region.minChunkX, region.maxChunkX) * 16) + 16; - let maxZ = (Math.max(region.minChunkZ, region.maxChunkZ) * 16) + 16; + const maxX = (Math.max(region.minChunkX, region.maxChunkX) * 16) + 16; + const maxZ = (Math.max(region.minChunkZ, region.maxChunkZ) * 16) + 16; - // Draw rectangle - let rect = L.rectangle([xy(minX, minZ), xy(minX, maxZ), xy(maxX, maxZ), xy(maxX, minZ)], { + const rect = L.rectangle([xy(minX, minZ), xy(minX, maxZ), xy(maxX, maxZ), xy(maxX, minZ)], { color: pruningRegions.include ? "green" : "red", region: true }); - // Add a label for the region if there are multiple (or if it's named) if (pruningRegions.regions.length > 1 || region.name) { rect.bindTooltip(region.name ?? ("Region " + (index + 1)), { direction: "center", @@ -199,13 +213,10 @@ export class Map extends Component { }); } - // Add event for edit - let self = this; + const self = this; rect.on("edit", function (e) { - let max = rect.getBounds().getNorthEast(); - let min = rect.getBounds().getSouthWest(); - - // Reverse lat long into co-ords + const max = rect.getBounds().getNorthEast(); + const min = rect.getBounds().getSouthWest(); self.moveRegion(world, index, min.lng / 16, -min.lat / 16, max.lng / 16, -max.lat / 16); }); rect.addTo(this.mymap); @@ -218,11 +229,9 @@ export class Map extends Component { } } -let xy = function (x, y) { - if (L.Util.isArray(x)) { // When doing xy([x, y]); +const xy = function (x, y) { + if (L.Util.isArray(x)) { return xy(x[0], x[1]); } - - // Modified specifically for this leaflet scale of 512 - return [-y, x]; // When doing xy(x, y); -}; \ No newline at end of file + return [-y, x]; +}; From 5d6be23f5dad45b1c1ed19b1005f62e5782e6a67 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 18:32:43 +0200 Subject: [PATCH 20/40] Run preview metadata pass asynchronously with progress feedback --- .../chunker/cli/messenger/Messenger.java | 79 +++++++++++-------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/cli/src/main/java/com/hivemc/chunker/cli/messenger/Messenger.java b/cli/src/main/java/com/hivemc/chunker/cli/messenger/Messenger.java index 70cdd3b4..33438787 100644 --- a/cli/src/main/java/com/hivemc/chunker/cli/messenger/Messenger.java +++ b/cli/src/main/java/com/hivemc/chunker/cli/messenger/Messenger.java @@ -143,42 +143,53 @@ public static void main(String[] args) { File inputDir = new File(previewRequest.getInputPath()); File outputDir = new File(previewRequest.getOutputPath()); - try { - // 1) Decide which metadata pass to run based on what's in the world directory. - PreviewMetadataReader metadataReader = new PreviewMetadataReader(); - if (new File(inputDir, "db/CURRENT").isFile()) { - metadataReader.readBedrockWorld(inputDir, outputDir); - } else { - metadataReader.readJavaWorld(inputDir, outputDir); - } + // Immediately signal that we have started — moves the renderer off the queue screen. + write(new ProgressResponse(requestId, 0.0)); - // 2) Spin up a long-lived tile service for this session. - PreviewMapBin mapBin = PreviewMapBin.read(new File(outputDir, "map.bin")); - RegionRgbaSource source = new ChunkerWorldRegionRgbaSource(inputDir); - int cacheEntries = PreviewTileCache.computeCapacityEntries(Runtime.getRuntime().maxMemory()); - int workers = Math.min(4, Math.max(1, Runtime.getRuntime().availableProcessors() / 2)); - PreviewTileService service = new PreviewTileService(outputDir, mapBin, source, new PreviewTileCache(cacheEntries), workers); - service.setEventListener(new PreviewTileService.EventListener() { - @Override public void onTileReady(TileReadyResponse r) { write(r); } - @Override public void onTileError(TileErrorResponse r) { write(r); } - }); - - // Stop and replace any pre-existing service for this session. - PreviewTileService previous = SESSION_ID_TO_PREVIEW_SERVICE.put(anonymousId, service); - if (previous != null) previous.shutdown(); + // Run the metadata pass + tile service init off the messenger thread so the main loop stays responsive. + CompletableFuture.runAsync(() -> { + try { + // 1) Run the metadata pass. Choose Bedrock vs Java by what's in the world directory. + PreviewMetadataReader metadataReader = new PreviewMetadataReader(); + if (new File(inputDir, "db/CURRENT").isFile()) { + metadataReader.readBedrockWorld(inputDir, outputDir); + } else { + metadataReader.readJavaWorld(inputDir, outputDir); + } - // 3) Tell the client we're done with the metadata pass — the map.bin file is at outputDir/map.bin. - write(new OutputResponse(requestId, null)); - } catch (Exception e) { - write(new ErrorResponse( - requestId, - false, - "Failed to start preview.", - null, - e.getMessage(), - printStackTrace(e) - )); - } + // Coarse progress signal halfway through. + write(new ProgressResponse(requestId, 0.5)); + + // 2) Spin up the long-lived tile service for this session. + PreviewMapBin mapBin = PreviewMapBin.read(new File(outputDir, "map.bin")); + RegionRgbaSource source = new ChunkerWorldRegionRgbaSource(inputDir); + int cacheEntries = PreviewTileCache.computeCapacityEntries(Runtime.getRuntime().maxMemory()); + int workers = Math.min(4, Math.max(1, Runtime.getRuntime().availableProcessors() / 2)); + PreviewTileService service = new PreviewTileService(outputDir, mapBin, source, new PreviewTileCache(cacheEntries), workers); + service.setEventListener(new PreviewTileService.EventListener() { + @Override public void onTileReady(TileReadyResponse r) { write(r); } + @Override public void onTileError(TileErrorResponse r) { write(r); } + }); + + // Stop and replace any pre-existing service for this session. + PreviewTileService previous = SESSION_ID_TO_PREVIEW_SERVICE.put(anonymousId, service); + if (previous != null) previous.shutdown(); + + // 3) Tell the client we're done with the metadata pass. + write(new OutputResponse(requestId, null)); + } catch (Throwable t) { + // Use Throwable: catches any unexpected runtime issue including OOMs, so we always + // surface an error to the client instead of leaving it stuck on the progress screen. + write(new ErrorResponse( + requestId, + false, + "Failed to start preview.", + null, + t.getMessage(), + printStackTrace(t) + )); + } + }); } case REQUEST_PREVIEW_TILES -> { RequestPreviewTilesRequest req = (RequestPreviewTilesRequest) message; From e23218b5ea3176dafbeea631253cb40d12699d16 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 18:42:20 +0200 Subject: [PATCH 21/40] Fetch preview map.bin via session protocol to avoid IPC blocking --- app/electron/src/session.js | 10 ++----- app/ui/src/components/app.js | 29 ++++++++++++++----- .../screen/settings/tab/preview/mapBin.js | 11 +++---- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/app/electron/src/session.js b/app/electron/src/session.js index 9dfae5a0..4f9ff985 100644 --- a/app/electron/src/session.js +++ b/app/electron/src/session.js @@ -659,13 +659,9 @@ export class Session { outputPath: previewOutputPath } - // Send the preview request - this.sendToProcess(request, async (response) => { - // Use the base64 of the map.bin for output - response.output = (await fs.readFile(path.join(previewOutputPath, "map.bin"))).toString("base64"); - - return response; - }); + // Send the preview request. The renderer fetches map.bin via session:// URL after + // receiving the success response — no base64 payload on the wire. + this.sendToProcess(request); } requestPreviewTiles(data) { diff --git a/app/ui/src/components/app.js b/app/ui/src/components/app.js index fed9809a..e103c76f 100644 --- a/app/ui/src/components/app.js +++ b/app/ui/src/components/app.js @@ -197,16 +197,29 @@ export class App extends Component { self.showError("Failed to render preview", message.error, message.errorId, message.stackTrace, true); // Preview isn't required } } else if (message.type === "response") { - // Doesn't need to do anything, as progress.js will mark as complete :) - // Decode minX, minZ, maxX, maxZ - let base64 = message.output; - - if (base64.length > 0) { - let parsed = parseMapBin(base64); - self.setState({previewData: parsed}); - } else { + let sessionId = self.state.sessionData && self.state.sessionData.session; + if (!sessionId) { + console.info("Preview response received but no session id available"); self.setState({previewData: undefined}); + return; } + fetch(`session://${sessionId}/preview/map.bin`) + .then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.arrayBuffer(); + }) + .then((buffer) => { + if (buffer.byteLength > 0) { + self.setState({previewData: parseMapBin(buffer)}); + } else { + self.setState({previewData: undefined}); + } + }) + .catch((err) => { + console.info("Failed to fetch map.bin", err); + self.showError("Failed to load preview metadata", err.message || String(err), undefined, undefined, true); + self.setState({previewData: undefined}); + }); } else { console.info("Unknown response", message); } diff --git a/app/ui/src/components/screen/settings/tab/preview/mapBin.js b/app/ui/src/components/screen/settings/tab/preview/mapBin.js index c8d88592..a4437b24 100644 --- a/app/ui/src/components/screen/settings/tab/preview/mapBin.js +++ b/app/ui/src/components/screen/settings/tab/preview/mapBin.js @@ -1,13 +1,10 @@ -import {decode} from "base64-arraybuffer"; - const BEDROCK_ID_TO_IDENTIFIER = { 0: "minecraft:overworld", 1: "minecraft:the_nether", 2: "minecraft:the_end" }; -export function parseMapBin(base64) { - const buffer = decode(base64); +export function parseMapBin(buffer) { const view = new DataView(buffer); let offset = 0; const worldCount = view.getInt32(offset, true); offset += 4; @@ -23,9 +20,9 @@ export function parseMapBin(base64) { for (let r = 0; r < regionCount; r++) { const rx = view.getInt32(offset, true); offset += 4; const rz = view.getInt32(offset, true); offset += 4; - // Copy the 128-byte bitset slice into its own Uint8Array so it remains valid after the - // backing ArrayBuffer is GC'd or sliced elsewhere. - const bits = new Uint8Array(buffer.slice(offset, offset + 128)); + // Slice once into a view backed by the same buffer — avoid the heavy buffer.slice + new ArrayBuffer + // allocation per region. The view stays valid because we hold the buffer reference via DataView. + const bits = new Uint8Array(buffer, offset, 128); offset += 128; regionPresence.set(`${rx},${rz}`, bits); } From 8e4bacec3a7a90c6a5238cd7a0d50d6991ae030b Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 18:56:11 +0200 Subject: [PATCH 22/40] Cap preview layer native zoom to LOD 0 and guard isTileEmpty against positive LOD --- .../settings/tab/preview/chunkerPreviewLayer.js | 13 ++++++++++++- .../screen/settings/tab/preview/mapBin.js | 4 ++++ .../conversion/encoding/preview/PreviewMapBin.java | 5 +++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js index 849af100..979d94a3 100644 --- a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js +++ b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js @@ -1,5 +1,6 @@ import L from "leaflet"; import {isTileEmpty} from "./mapBin"; +import {MIN_ZOOM_FLOOR} from "./autoFit"; const PADDING_FACTOR = 1.5; const DISPATCH_DEBOUNCE_MS = 50; @@ -7,7 +8,17 @@ const TRANSPARENT_PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAE export const ChunkerPreviewLayer = L.GridLayer.extend({ initialize(options) { - L.setOptions(this, {tileSize: 512, noWrap: true, ...options}); + L.setOptions(this, { + tileSize: 512, + noWrap: true, + // Native tiles only exist at LOD 0 down to the auto-fit floor. For zoom > 0 Leaflet + // stretches LOD 0 tiles client-side; for zoom < MIN_ZOOM_FLOOR it stretches the floor tiles. + // Without these, Leaflet would call createTile for any zoom and isTileEmpty would + // be invoked with positive lod, where `1 << -lod` wraps to a huge value and hangs. + maxNativeZoom: 0, + minNativeZoom: MIN_ZOOM_FLOOR, + ...options + }); this._mapBin = options.mapBin; this._sessionId = options.session; this._ipc = options.ipc; diff --git a/app/ui/src/components/screen/settings/tab/preview/mapBin.js b/app/ui/src/components/screen/settings/tab/preview/mapBin.js index a4437b24..78c15456 100644 --- a/app/ui/src/components/screen/settings/tab/preview/mapBin.js +++ b/app/ui/src/components/screen/settings/tab/preview/mapBin.js @@ -42,6 +42,10 @@ function regionHasAnyChunk(entry, rx, rz) { export function isTileEmpty(parsed, world, lod, tx, tz) { const entry = parsed.worlds.get(world); if (!entry) return true; + // Only defined for lod <= 0. The native tile pyramid bottoms out at lod 0; higher zooms + // are produced by Leaflet scaling native tiles. Guarding against `lod > 0` here also + // avoids `1 << -lod` wrapping to a huge value (JS bit-shift uses low 5 bits of the operand). + if (lod > 0) return false; const scale = 1 << (-lod); const minRx = tx * scale; const minRz = tz * scale; 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 index 52aef06a..7e8e4222 100644 --- 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 @@ -27,10 +27,15 @@ public WorldData findByIdentifier(String identifier) { /** * 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; From 586700649683ff28f4dc7b09d97d15386e3529b3 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 19:04:54 +0200 Subject: [PATCH 23/40] Add zoom indicator and refine center coords control --- .../tab/preview/centerCoordsControl.js | 6 ++- .../settings/tab/preview/previewComponent.css | 54 ++++++++++++++++--- .../settings/tab/preview/previewComponent.js | 3 ++ .../settings/tab/preview/zoomIndicator.js | 41 ++++++++++++++ 4 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js diff --git a/app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js b/app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js index f6f9cffc..7fe925e5 100644 --- a/app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js +++ b/app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js @@ -24,7 +24,9 @@ export const CenterCoordsControl = L.Control.extend({ this._inputZ.type = "number"; this._map = map; - map.on("moveend", this._syncFromMap, this); + // `move` fires continuously during a drag, so the inputs update in real time + // rather than only when the user releases the mouse. + map.on("move", this._syncFromMap, this); const onCommit = () => this._commit(); this._inputX.addEventListener("keydown", (e) => { if (e.key === "Enter") onCommit(); }); @@ -37,7 +39,7 @@ export const CenterCoordsControl = L.Control.extend({ }, onRemove(map) { - map.off("moveend", this._syncFromMap, this); + map.off("move", this._syncFromMap, this); }, _syncFromMap() { diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.css b/app/ui/src/components/screen/settings/tab/preview/previewComponent.css index 96e1a6a2..1737eb86 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.css +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.css @@ -29,23 +29,63 @@ } .chunker-coords-control { - background: rgba(255, 255, 255, 0.95); - padding: 4px 8px; + background: rgba(255, 255, 255, 0.9); + padding: 1px 4px; display: flex; align-items: center; - gap: 4px; - font: 12px sans-serif; + gap: 3px; + font: 11px monospace; + z-index: 400; } .chunker-coords-label { - color: #333; + color: #555; user-select: none; } .chunker-coords-input { - width: 70px; - padding: 1px 4px; + width: 55px; + padding: 0 2px; + border: 1px solid #ccc; + border-radius: 2px; + font: 11px monospace; +} +.chunker-coords-input::-webkit-inner-spin-button, +.chunker-coords-input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +.chunker-zoom-indicator { + background: rgba(255, 255, 255, 0.9); + padding: 4px 6px; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + font: 11px monospace; + color: #555; + user-select: none; + z-index: 400; +} +.chunker-zoom-indicator-track { + position: relative; + width: 6px; + height: 80px; + background: #eee; border: 1px solid #ccc; border-radius: 3px; } +.chunker-zoom-indicator-fill { + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: #6aa84f; + border-radius: 2px; + transition: height 60ms linear; +} +.chunker-zoom-indicator-label { + font-weight: bold; + color: #333; +} .chunker-tile-empty { background: transparent; } diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js index fd299606..2d070d1d 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js @@ -12,6 +12,7 @@ import {computeMinZoom} from "./autoFit"; import {ClientTileCache} from "./clientTileCache"; import {ChunkerPreviewLayer} from "./chunkerPreviewLayer"; import {CenterCoordsControl} from "./centerCoordsControl"; +import {ZoomIndicator} from "./zoomIndicator"; import api from "../../../../../api"; require("leaflet-mouse-position/src/L.Control.MousePosition"); @@ -120,6 +121,8 @@ export class Map extends Component { getBounds: () => worldBoundsForAutoFit(this._mapBin, this._currentWorld()) }).addTo(this.mymap); + new ZoomIndicator().addTo(this.mymap); + this.mymap.on("baselayerchange", function (e) { self.renderPruningRegion(); }); diff --git a/app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js b/app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js new file mode 100644 index 00000000..a7cb19ad --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js @@ -0,0 +1,41 @@ +import L from "leaflet"; + +export const ZoomIndicator = L.Control.extend({ + options: {position: "topright"}, + + initialize(options) { + L.setOptions(this, options); + }, + + onAdd(map) { + const container = L.DomUtil.create("div", "chunker-zoom-indicator"); + L.DomEvent.disableClickPropagation(container); + L.DomEvent.disableScrollPropagation(container); + + const label = L.DomUtil.create("span", "chunker-zoom-indicator-label", container); + const track = L.DomUtil.create("div", "chunker-zoom-indicator-track", container); + const fill = L.DomUtil.create("div", "chunker-zoom-indicator-fill", track); + + this._map = map; + this._label = label; + this._fill = fill; + + this._update(); + map.on("zoom zoomend", this._update, this); + return container; + }, + + onRemove(map) { + map.off("zoom zoomend", this._update, this); + }, + + _update() { + const z = this._map.getZoom(); + const minZ = this._map.getMinZoom(); + const maxZ = this._map.getMaxZoom(); + this._label.textContent = z >= 0 ? "+" + z : String(z); + const span = maxZ - minZ; + const frac = span > 0 ? (z - minZ) / span : 0; + this._fill.style.height = (Math.max(0, Math.min(1, frac)) * 100) + "%"; + } +}); From 358370139e4e5042249276b976d30364e9bc9a27 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 19:08:02 +0200 Subject: [PATCH 24/40] Expose preview layer at negative zoom levels and keep more tiles in DOM --- .../screen/settings/tab/preview/chunkerPreviewLayer.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js index 979d94a3..e366ac1c 100644 --- a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js +++ b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js @@ -11,12 +11,20 @@ export const ChunkerPreviewLayer = L.GridLayer.extend({ L.setOptions(this, { tileSize: 512, noWrap: true, + // L.GridLayer's defaults hide the layer at zoom < 0. Explicitly extend the visible + // zoom range to match the map's autoFit floor; otherwise createTile is never called + // for negative zoom levels and the user sees an empty viewport on dezoom. + minZoom: MIN_ZOOM_FLOOR, + maxZoom: 5, // Native tiles only exist at LOD 0 down to the auto-fit floor. For zoom > 0 Leaflet // stretches LOD 0 tiles client-side; for zoom < MIN_ZOOM_FLOOR it stretches the floor tiles. // Without these, Leaflet would call createTile for any zoom and isTileEmpty would // be invoked with positive lod, where `1 << -lod` wraps to a huge value and hangs. maxNativeZoom: 0, minNativeZoom: MIN_ZOOM_FLOOR, + // Keep more off-viewport tiles in DOM so a fast pan or zoom-out doesn't reveal blank + // areas while the new tiles are still being generated. + keepBuffer: 4, ...options }); this._mapBin = options.mapBin; From 86f1edc101cfb75eab3b6e5b1439df294cc5a121 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 19:12:23 +0200 Subject: [PATCH 25/40] Make zoom indicator interactive and add tile loading badge --- .../settings/tab/preview/loadingIndicator.js | 77 +++++++++++++++++++ .../settings/tab/preview/previewComponent.css | 57 +++++++++----- .../settings/tab/preview/previewComponent.js | 5 ++ .../settings/tab/preview/zoomIndicator.js | 51 ++++++++++-- 4 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 app/ui/src/components/screen/settings/tab/preview/loadingIndicator.js diff --git a/app/ui/src/components/screen/settings/tab/preview/loadingIndicator.js b/app/ui/src/components/screen/settings/tab/preview/loadingIndicator.js new file mode 100644 index 00000000..120b4708 --- /dev/null +++ b/app/ui/src/components/screen/settings/tab/preview/loadingIndicator.js @@ -0,0 +1,77 @@ +import L from "leaflet"; + +/** + * Tiny "Loading tiles…" badge in the corner of the map. Reflects whether any GridLayer + * currently has outstanding tiles. We listen on the map's `layeradd` so the badge stays + * in sync as the user switches dimensions via the layer picker. + */ +export const LoadingIndicator = L.Control.extend({ + options: {position: "topright"}, + + initialize(options) { + L.setOptions(this, options); + this._activeLoaders = 0; + }, + + onAdd(map) { + const container = L.DomUtil.create("div", "chunker-loading-indicator"); + container.textContent = "Loading tiles…"; + L.DomEvent.disableClickPropagation(container); + + this._container = container; + this._map = map; + this._refreshVisibility(); + + // Attach to any GridLayer already on the map, and to any added later. + map.eachLayer((l) => this._attachIfGridLayer(l)); + map.on("layeradd", (e) => this._attachIfGridLayer(e.layer), this); + map.on("layerremove", (e) => this._detachIfGridLayer(e.layer), this); + + return container; + }, + + onRemove(map) { + map.off("layeradd", null, this); + map.off("layerremove", null, this); + map.eachLayer((l) => this._detachIfGridLayer(l)); + }, + + _attachIfGridLayer(layer) { + if (!(layer instanceof L.GridLayer)) return; + if (layer._chunkerLoadingHooked) return; + layer._chunkerLoadingHooked = true; + + const onLoading = () => { + this._activeLoaders++; + this._refreshVisibility(); + }; + const onLoad = () => { + this._activeLoaders = Math.max(0, this._activeLoaders - 1); + this._refreshVisibility(); + }; + layer._chunkerLoadingHandlers = {onLoading, onLoad}; + layer.on("loading", onLoading); + layer.on("load", onLoad); + }, + + _detachIfGridLayer(layer) { + if (!layer || !layer._chunkerLoadingHooked) return; + const handlers = layer._chunkerLoadingHandlers; + if (handlers) { + layer.off("loading", handlers.onLoading); + layer.off("load", handlers.onLoad); + } + layer._chunkerLoadingHooked = false; + layer._chunkerLoadingHandlers = null; + // If the removed layer was mid-load, our counter would never decrement; clamp. + this._refreshVisibility(); + }, + + _refreshVisibility() { + if (this._activeLoaders > 0) { + this._container.classList.add("chunker-loading-indicator-visible"); + } else { + this._container.classList.remove("chunker-loading-indicator-visible"); + } + } +}); diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.css b/app/ui/src/components/screen/settings/tab/preview/previewComponent.css index 1737eb86..bf0ed1d8 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.css +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.css @@ -35,7 +35,8 @@ align-items: center; gap: 3px; font: 11px monospace; - z-index: 400; + /* Below Leaflet's default control z-index (700) so any other control overlays it. */ + z-index: 100; } .chunker-coords-label { color: #555; @@ -55,23 +56,22 @@ } .chunker-zoom-indicator { background: rgba(255, 255, 255, 0.9); - padding: 4px 6px; - display: flex; - flex-direction: column; - align-items: center; - gap: 3px; - font: 11px monospace; - color: #555; - user-select: none; - z-index: 400; + padding: 4px; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 3px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); } .chunker-zoom-indicator-track { position: relative; - width: 6px; - height: 80px; + width: 8px; + height: 100px; background: #eee; - border: 1px solid #ccc; - border-radius: 3px; + border: 1px solid #bbb; + border-radius: 4px; + cursor: pointer; +} +.chunker-zoom-indicator-track:hover { + background: #f4f4f4; } .chunker-zoom-indicator-fill { position: absolute; @@ -79,12 +79,33 @@ right: 0; bottom: 0; background: #6aa84f; - border-radius: 2px; + border-radius: 3px 3px 0 0; transition: height 60ms linear; + pointer-events: none; +} +.chunker-zoom-indicator-handle { + position: absolute; + left: -4px; + right: -4px; + height: 4px; + transform: translateY(50%); + background: #2c2c2c; + border-radius: 2px; + transition: bottom 60ms linear; + pointer-events: none; +} +.chunker-loading-indicator { + background: rgba(0, 0, 0, 0.7); + color: #fff; + padding: 3px 8px; + border-radius: 3px; + font: 11px sans-serif; + opacity: 0; + transition: opacity 120ms ease-in; + pointer-events: none; } -.chunker-zoom-indicator-label { - font-weight: bold; - color: #333; +.chunker-loading-indicator-visible { + opacity: 1; } .chunker-tile-empty { background: transparent; diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js index 2d070d1d..4ddbffe5 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js @@ -13,6 +13,7 @@ import {ClientTileCache} from "./clientTileCache"; import {ChunkerPreviewLayer} from "./chunkerPreviewLayer"; import {CenterCoordsControl} from "./centerCoordsControl"; import {ZoomIndicator} from "./zoomIndicator"; +import {LoadingIndicator} from "./loadingIndicator"; import api from "../../../../../api"; require("leaflet-mouse-position/src/L.Control.MousePosition"); @@ -121,8 +122,12 @@ export class Map extends Component { getBounds: () => worldBoundsForAutoFit(this._mapBin, this._currentWorld()) }).addTo(this.mymap); + // Adding the zoom indicator after the (relocated) zoom control causes it to stack + // above the +/- buttons in the bottom-left corner. new ZoomIndicator().addTo(this.mymap); + new LoadingIndicator().addTo(this.mymap); + this.mymap.on("baselayerchange", function (e) { self.renderPruningRegion(); }); diff --git a/app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js b/app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js index a7cb19ad..994dbeae 100644 --- a/app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js +++ b/app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js @@ -1,7 +1,7 @@ import L from "leaflet"; export const ZoomIndicator = L.Control.extend({ - options: {position: "topright"}, + options: {position: "bottomleft"}, initialize(options) { L.setOptions(this, options); @@ -12,30 +12,71 @@ export const ZoomIndicator = L.Control.extend({ L.DomEvent.disableClickPropagation(container); L.DomEvent.disableScrollPropagation(container); - const label = L.DomUtil.create("span", "chunker-zoom-indicator-label", container); const track = L.DomUtil.create("div", "chunker-zoom-indicator-track", container); const fill = L.DomUtil.create("div", "chunker-zoom-indicator-fill", track); + const handle = L.DomUtil.create("div", "chunker-zoom-indicator-handle", track); this._map = map; - this._label = label; + this._track = track; this._fill = fill; + this._handle = handle; this._update(); map.on("zoom zoomend", this._update, this); + + // Click on the track jumps to the zoom level at that position. + L.DomEvent.on(track, "mousedown", this._onPointerDown, this); + return container; }, onRemove(map) { map.off("zoom zoomend", this._update, this); + L.DomEvent.off(this._track, "mousedown", this._onPointerDown, this); }, _update() { const z = this._map.getZoom(); const minZ = this._map.getMinZoom(); const maxZ = this._map.getMaxZoom(); - this._label.textContent = z >= 0 ? "+" + z : String(z); const span = maxZ - minZ; const frac = span > 0 ? (z - minZ) / span : 0; - this._fill.style.height = (Math.max(0, Math.min(1, frac)) * 100) + "%"; + const pct = Math.max(0, Math.min(1, frac)) * 100; + this._fill.style.height = pct + "%"; + // Handle visually sits at the same fraction. + this._handle.style.bottom = pct + "%"; + }, + + _zoomFromClientY(clientY) { + const rect = this._track.getBoundingClientRect(); + const fromBottom = rect.bottom - clientY; + const frac = Math.max(0, Math.min(1, fromBottom / rect.height)); + const minZ = this._map.getMinZoom(); + const maxZ = this._map.getMaxZoom(); + return Math.round(minZ + frac * (maxZ - minZ)); + }, + + _onPointerDown(e) { + L.DomEvent.preventDefault(e); + L.DomEvent.stopPropagation(e); + const moveHandler = (ev) => this._onPointerMove(ev); + const upHandler = () => { + document.removeEventListener("mousemove", moveHandler); + document.removeEventListener("mouseup", upHandler); + }; + document.addEventListener("mousemove", moveHandler); + document.addEventListener("mouseup", upHandler); + this._applyClientY(e.clientY); + }, + + _onPointerMove(e) { + this._applyClientY(e.clientY); + }, + + _applyClientY(clientY) { + const target = this._zoomFromClientY(clientY); + if (target !== this._map.getZoom()) { + this._map.setZoom(target, {animate: false}); + } } }); From 66bc78355f089f4fb21c78c3b291d8dcc16dccb4 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 19:16:49 +0200 Subject: [PATCH 26/40] Polish preview controls: leaflet-bar contour, smaller coords inline with zoom buttons, loading badge top-left --- .../tab/preview/centerCoordsControl.js | 2 +- .../settings/tab/preview/loadingIndicator.js | 2 +- .../settings/tab/preview/previewComponent.css | 43 +++++++++++-------- .../settings/tab/preview/zoomIndicator.js | 2 +- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js b/app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js index 7fe925e5..2f8284e7 100644 --- a/app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js +++ b/app/ui/src/components/screen/settings/tab/preview/centerCoordsControl.js @@ -1,7 +1,7 @@ import L from "leaflet"; export const CenterCoordsControl = L.Control.extend({ - options: {position: "topleft"}, + options: {position: "bottomleft"}, initialize(options) { L.setOptions(this, options); diff --git a/app/ui/src/components/screen/settings/tab/preview/loadingIndicator.js b/app/ui/src/components/screen/settings/tab/preview/loadingIndicator.js index 120b4708..1534f435 100644 --- a/app/ui/src/components/screen/settings/tab/preview/loadingIndicator.js +++ b/app/ui/src/components/screen/settings/tab/preview/loadingIndicator.js @@ -6,7 +6,7 @@ import L from "leaflet"; * in sync as the user switches dimensions via the layer picker. */ export const LoadingIndicator = L.Control.extend({ - options: {position: "topright"}, + options: {position: "topleft"}, initialize(options) { L.setOptions(this, options); diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.css b/app/ui/src/components/screen/settings/tab/preview/previewComponent.css index bf0ed1d8..676bf8d5 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.css +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.css @@ -28,26 +28,36 @@ -ms-interpolation-mode: nearest-neighbor; /* IE8+ */ } -.chunker-coords-control { +/* Anchor the coords control to the right side of the zoom +/- buttons rather than stacking + * it above them. The bottomleft corner already contains the zoom control + the zoom indicator + * stacked vertically; absolute positioning lifts the coords box out of that stack and pins it + * next to the - button. Chained selector wins over leaflet.css's `.leaflet-control { position: relative }`. + */ +.leaflet-control.chunker-coords-control { + position: absolute; + left: 44px; + bottom: 10px; + margin: 0; background: rgba(255, 255, 255, 0.9); - padding: 1px 4px; + padding: 1px 3px; display: flex; align-items: center; - gap: 3px; - font: 11px monospace; - /* Below Leaflet's default control z-index (700) so any other control overlays it. */ - z-index: 100; + gap: 2px; + font: 9px monospace; } .chunker-coords-label { color: #555; user-select: none; } .chunker-coords-input { - width: 55px; - padding: 0 2px; + width: 30px; + padding: 0 1px; border: 1px solid #ccc; border-radius: 2px; - font: 11px monospace; + font: 9px monospace; + height: 12px; + line-height: 12px; + box-sizing: content-box; } .chunker-coords-input::-webkit-inner-spin-button, .chunker-coords-input::-webkit-outer-spin-button { @@ -57,9 +67,6 @@ .chunker-zoom-indicator { background: rgba(255, 255, 255, 0.9); padding: 4px; - border: 1px solid rgba(0, 0, 0, 0.2); - border-radius: 3px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); } .chunker-zoom-indicator-track { position: relative; @@ -94,12 +101,14 @@ transition: bottom 60ms linear; pointer-events: none; } +/* Match the existing leaflet-mouse-position control styling that lives at bottom-left. */ .chunker-loading-indicator { - background: rgba(0, 0, 0, 0.7); - color: #fff; - padding: 3px 8px; - border-radius: 3px; - font: 11px sans-serif; + background-color: rgba(255, 255, 255, 0.7); + box-shadow: 0 0 5px #bbb; + padding: 0 5px; + margin: 0; + color: #333; + font: 11px / 1.5 "Helvetica Neue", Arial, Helvetica, sans-serif; opacity: 0; transition: opacity 120ms ease-in; pointer-events: none; diff --git a/app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js b/app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js index 994dbeae..7aa5a8ef 100644 --- a/app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js +++ b/app/ui/src/components/screen/settings/tab/preview/zoomIndicator.js @@ -8,7 +8,7 @@ export const ZoomIndicator = L.Control.extend({ }, onAdd(map) { - const container = L.DomUtil.create("div", "chunker-zoom-indicator"); + const container = L.DomUtil.create("div", "leaflet-bar chunker-zoom-indicator"); L.DomEvent.disableClickPropagation(container); L.DomEvent.disableScrollPropagation(container); From 508c9b7468459980318b5d7784e1b9f02a027b67 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 19:36:23 +0200 Subject: [PATCH 27/40] Refine coords control sizing and reinvalidate map size on container resize --- .../settings/tab/preview/previewComponent.css | 36 ++++++++++++------- .../settings/tab/preview/previewComponent.js | 21 +++++++++++ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.css b/app/ui/src/components/screen/settings/tab/preview/previewComponent.css index 676bf8d5..3a4defdb 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.css +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.css @@ -34,30 +34,40 @@ * next to the - button. Chained selector wins over leaflet.css's `.leaflet-control { position: relative }`. */ .leaflet-control.chunker-coords-control { + /* Match the zoom control's vertical position and height (53px = two 26px buttons + 1px border) + * so the coords bar visually sits as an extension of the +/- stack to its right. */ position: absolute; left: 44px; - bottom: 10px; + bottom: 16px; + height: 35px; + box-sizing: border-box; margin: 0; background: rgba(255, 255, 255, 0.9); - padding: 1px 3px; + padding: 0 6px; display: flex; align-items: center; - gap: 2px; - font: 9px monospace; + gap: 4px; + font: 12px monospace; } .chunker-coords-label { color: #555; user-select: none; } -.chunker-coords-input { - width: 30px; - padding: 0 1px; +/* Higher specificity than the app's global `input[type="number"]` style so width/height/font stick. */ +input[type="number"].chunker-coords-input { + width: 90px; + height: 20px; + line-height: 20px; + padding: 0 3px; + margin: 0; border: 1px solid #ccc; border-radius: 2px; - font: 9px monospace; - height: 12px; - line-height: 12px; + font: 12px monospace; box-sizing: content-box; + background: #fff; +} +input[type="number"].chunker-coords-input:focus { + outline: 1px solid #6aa84f; } .chunker-coords-input::-webkit-inner-spin-button, .chunker-coords-input::-webkit-outer-spin-button { @@ -101,19 +111,19 @@ transition: bottom 60ms linear; pointer-events: none; } -/* Match the existing leaflet-mouse-position control styling that lives at bottom-left. */ +/* Match the existing leaflet-mouse-position control styling that lives at bottom-left, + * but anchored to top-left with Leaflet's normal corner margin. */ .chunker-loading-indicator { background-color: rgba(255, 255, 255, 0.7); box-shadow: 0 0 5px #bbb; padding: 0 5px; - margin: 0; color: #333; font: 11px / 1.5 "Helvetica Neue", Arial, Helvetica, sans-serif; opacity: 0; transition: opacity 120ms ease-in; pointer-events: none; } -.chunker-loading-indicator-visible { +.chunker-loading-indicator.chunker-loading-indicator-visible { opacity: 1; } .chunker-tile-empty { diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js index 4ddbffe5..e949004b 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js @@ -136,6 +136,22 @@ export class Map extends Component { // Aggressive evict before the renderer holds two viewports' worth of tiles at once. self._cache.evictAllExcept([]); }); + + // Leaflet measures the container size at construction time and only loads tiles for + // that viewport. If the #map element wasn't fully laid out yet (mid-React-mount, or the + // preview tab was hidden when the layer was added) the initial viewport can be 0px + // wide and almost no tiles get requested. Force one recomputation now, then keep + // watching for size changes so the same fix applies when the user switches tabs and + // comes back, resizes the window, or toggles fullscreen. + const mapEl = document.getElementById("map"); + const invalidate = () => { + if (this.mymap) this.mymap.invalidateSize({pan: false}); + }; + setTimeout(invalidate, 0); + if (typeof ResizeObserver !== "undefined" && mapEl) { + this._resizeObserver = new ResizeObserver(invalidate); + this._resizeObserver.observe(mapEl); + } } _currentWorld() { @@ -167,6 +183,11 @@ export class Map extends Component { layer: currentLayer }); } + + if (this._resizeObserver) { + this._resizeObserver.disconnect(); + this._resizeObserver = null; + } } moveRegion = (world, regionIndex, minX, minZ, maxX, maxZ) => { From f00c8b6c4ad62264e09ad9e53887c34127ce7495 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 19:40:24 +0200 Subject: [PATCH 28/40] Recover orphan tiles via Leaflet registry and redraw layers after mount --- .../tab/preview/chunkerPreviewLayer.js | 23 ++++++++++++++++--- .../settings/tab/preview/previewComponent.js | 17 +++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js index e366ac1c..eccb3cd5 100644 --- a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js +++ b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js @@ -137,23 +137,40 @@ export const ChunkerPreviewLayer = L.GridLayer.extend({ const resolvedUrl = event.path.startsWith("session://") ? event.path : `session://${this._sessionId}/preview/${event.path}`; + const layer = this; const img = new Image(); img.className = "chunker-tile"; img.src = resolvedUrl; img.onload = () => { - this._cache.put(cacheKey, {img, blobUrl: resolvedUrl, sizeBytes: 512 * 512 * 4}); + layer._cache.put(cacheKey, {img, blobUrl: resolvedUrl, sizeBytes: 512 * 512 * 4}); if (pending) { pending.tile.src = resolvedUrl; pending.tile.className = "chunker-tile"; - this._pending.delete(cacheKey); + layer._pending.delete(cacheKey); pending.done(null); + return; + } + // No pending entry for this tile. Either createTile hasn't run yet, OR Leaflet pruned + // our placeholder during initial layout but still tracks the tile in its registry. + // Look it up in `_tiles` (Leaflet uses `x:y:z` as the key) and refresh the live DOM + // element so the image becomes visible without waiting for a zoom/pan to retrigger. + const leafletKey = event.tx + ":" + event.tz + ":" + event.lod; + const tile = layer._tiles && layer._tiles[leafletKey]; + if (tile && tile.el) { + if (tile.el.tagName === "IMG") { + tile.el.src = resolvedUrl; + tile.el.className = "chunker-tile"; + } + if (!tile.loaded && typeof layer._tileReady === "function") { + layer._tileReady(tile.coords, null, tile.el); + } } }; img.onerror = () => { if (pending) { pending.tile.className = "chunker-tile chunker-tile-error"; - this._pending.delete(cacheKey); + layer._pending.delete(cacheKey); pending.done(null); } }; diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js index e949004b..7d6e9150 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js @@ -142,14 +142,25 @@ export class Map extends Component { // preview tab was hidden when the layer was added) the initial viewport can be 0px // wide and almost no tiles get requested. Force one recomputation now, then keep // watching for size changes so the same fix applies when the user switches tabs and - // comes back, resizes the window, or toggles fullscreen. + // comes back, resizes the window, or toggles fullscreen. Also force a layer redraw so + // any placeholder Leaflet pruned during the initial layout gets recreated and picks up + // the cached image without requiring the user to zoom around. const mapEl = document.getElementById("map"); const invalidate = () => { - if (this.mymap) this.mymap.invalidateSize({pan: false}); + if (!this.mymap) return; + this.mymap.invalidateSize({pan: false}); + this.mymap.eachLayer((layer) => { + if (layer instanceof L.GridLayer && typeof layer.redraw === "function") { + layer.redraw(); + } + }); }; setTimeout(invalidate, 0); + setTimeout(invalidate, 200); if (typeof ResizeObserver !== "undefined" && mapEl) { - this._resizeObserver = new ResizeObserver(invalidate); + this._resizeObserver = new ResizeObserver(() => { + if (this.mymap) this.mymap.invalidateSize({pan: false}); + }); this._resizeObserver.observe(mapEl); } } From 41da377549a8c446cf521c279671bcb14e0ead2a Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 21:27:11 +0200 Subject: [PATCH 29/40] Use additive _update instead of redraw to avoid tile flicker --- .../settings/tab/preview/previewComponent.js | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js index 7d6e9150..af8a6953 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js @@ -142,25 +142,23 @@ export class Map extends Component { // preview tab was hidden when the layer was added) the initial viewport can be 0px // wide and almost no tiles get requested. Force one recomputation now, then keep // watching for size changes so the same fix applies when the user switches tabs and - // comes back, resizes the window, or toggles fullscreen. Also force a layer redraw so - // any placeholder Leaflet pruned during the initial layout gets recreated and picks up - // the cached image without requiring the user to zoom around. + // comes back, resizes the window, or toggles fullscreen. `_update()` is the additive + // version of `redraw()`: it requests any tiles that are currently missing from the + // viewport without disturbing the ones already on screen — no visible flicker. const mapEl = document.getElementById("map"); - const invalidate = () => { + const refreshTiles = () => { if (!this.mymap) return; this.mymap.invalidateSize({pan: false}); this.mymap.eachLayer((layer) => { - if (layer instanceof L.GridLayer && typeof layer.redraw === "function") { - layer.redraw(); + if (layer instanceof L.GridLayer && typeof layer._update === "function") { + layer._update(); } }); }; - setTimeout(invalidate, 0); - setTimeout(invalidate, 200); + setTimeout(refreshTiles, 0); + setTimeout(refreshTiles, 200); if (typeof ResizeObserver !== "undefined" && mapEl) { - this._resizeObserver = new ResizeObserver(() => { - if (this.mymap) this.mymap.invalidateSize({pan: false}); - }); + this._resizeObserver = new ResizeObserver(refreshTiles); this._resizeObserver.observe(mapEl); } } From da3098d5dbf0f438c15d348c1cdce984a64b0806 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 21:28:09 +0200 Subject: [PATCH 30/40] Prevent Leaflet from pruning preview tiles still awaiting their tile_ready --- .../tab/preview/chunkerPreviewLayer.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js index eccb3cd5..4ac5311f 100644 --- a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js +++ b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js @@ -59,6 +59,25 @@ export const ChunkerPreviewLayer = L.GridLayer.extend({ return this; }, + // Keep tiles alive while their tile_ready hasn't arrived yet. Leaflet's default _pruneTiles + // evicts any tile whose viewport rectangle is no longer current — including pending + // placeholders. Those placeholders would then become orphan DOM nodes that never receive + // the eventual tile_ready update, leaving holes in the map until the user zooms/pans to + // trigger a fresh createTile. By refusing to remove still-pending tiles we let the + // tile_ready handler swap the real image in-place when it arrives, without any flicker. + _removeTile(key) { + const tile = this._tiles && this._tiles[key]; + if (tile && tile.coords) { + const cacheKey = this._cache.keyFor( + this.options.identifier, tile.coords.z, tile.coords.x, tile.coords.y + ); + if (this._pending.has(cacheKey)) { + return; + } + } + L.GridLayer.prototype._removeTile.call(this, key); + }, + createTile(coords, done) { const tx = coords.x; const tz = coords.y; From bba88c0ad3a3ab3c9c2433c0f5ed0008cb4e89e5 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 21:32:18 +0200 Subject: [PATCH 31/40] Scope tile-prune protection to current zoom level only --- .../tab/preview/chunkerPreviewLayer.js | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js index 4ac5311f..120aed66 100644 --- a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js +++ b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js @@ -59,12 +59,16 @@ export const ChunkerPreviewLayer = L.GridLayer.extend({ return this; }, - // Keep tiles alive while their tile_ready hasn't arrived yet. Leaflet's default _pruneTiles - // evicts any tile whose viewport rectangle is no longer current — including pending - // placeholders. Those placeholders would then become orphan DOM nodes that never receive - // the eventual tile_ready update, leaving holes in the map until the user zooms/pans to - // trigger a fresh createTile. By refusing to remove still-pending tiles we let the - // tile_ready handler swap the real image in-place when it arrives, without any flicker. + // Keep placeholders alive while their tile_ready hasn't arrived yet — but only at the + // CURRENT zoom level. Leaflet's default _pruneTiles evicts any tile whose viewport + // rectangle is no longer current, which would orphan a pending placeholder and turn it + // into a permanent hole in the map. Protecting current-zoom pending tiles lets the + // tile_ready handler swap the real image in-place without flicker. + // + // For old-zoom pending tiles (left over from before the user zoomed), we delete the + // pending entry and let Leaflet remove them normally. Otherwise the upcoming tile_ready + // would update a placeholder that's positioned at the wrong scale, briefly flashing the + // image in the wrong place before the next prune cycle disposes of it. _removeTile(key) { const tile = this._tiles && this._tiles[key]; if (tile && tile.coords) { @@ -72,7 +76,10 @@ export const ChunkerPreviewLayer = L.GridLayer.extend({ this.options.identifier, tile.coords.z, tile.coords.x, tile.coords.y ); if (this._pending.has(cacheKey)) { - return; + if (tile.coords.z === this._tileZoom) { + return; + } + this._pending.delete(cacheKey); } } L.GridLayer.prototype._removeTile.call(this, key); From 910da9f1174eae26f69746282488382b445d16e1 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 21:43:51 +0200 Subject: [PATCH 32/40] Write preview tiles atomically and set initial map view at construction --- .../settings/tab/preview/previewComponent.js | 39 ++++++++++++------- .../encoding/preview/PreviewTileService.java | 19 ++++++++- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js index af8a6953..a181eea0 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js @@ -63,11 +63,33 @@ export class Map extends Component { const defaultIdentifier = (this.app.state.previewState && this.app.state.previewState.layer) || dimensions[0]; const minZoom = computeMinZoom(worldBoundsForAutoFit(this._mapBin, defaultIdentifier)); + // Resolve the initial view BEFORE constructing the map so we can pass it to L.map(). + // Without this, the layer's addTo() runs while the map is at its default state + // (no view set) and triggers tile loads at the wrong zoom, then setView() reshuffles + // everything — that race left some tiles in flight at the old LOD that never get + // their tile_ready honoured, manifesting as "2 of 4 tiles missing on first open". + let initialCenter; + let initialZoom; + let defaultLayerIdentifier; + if (this.app.state.previewState !== undefined) { + initialCenter = this.app.state.previewState.center; + initialZoom = this.app.state.previewState.zoom; + defaultLayerIdentifier = this.app.state.previewState.layer; + } else { + const centerX = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnX")[0].value; + const centerZ = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnZ")[0].value; + initialCenter = xy(centerX, centerZ); + initialZoom = 2; + defaultLayerIdentifier = dimensions[0]; + } + this.mymap = L.map("map", { crs: L.CRS.Simple, minZoom, maxZoom: 5, - attributionControl: false + attributionControl: false, + center: initialCenter, + zoom: initialZoom }); const worlds = dimensions.map((identifier, k) => new ChunkerPreviewLayer({ @@ -87,19 +109,8 @@ export class Map extends Component { } })); - if (this.app.state.previewState === undefined) { - const defaultWorld = worlds.length > 0 ? worlds[0] : undefined; - if (defaultWorld !== undefined) { - defaultWorld.addTo(this.mymap); - const centerX = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnX")[0].value; - const centerZ = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnZ")[0].value; - this.mymap.setView(xy(centerX, centerZ), 2); - } - } else { - const defaultWorld = worlds.filter(a => this.app.state.previewState.layer === a.options.identifier)[0]; - defaultWorld.addTo(this.mymap); - this.mymap.setView(this.app.state.previewState.center, this.app.state.previewState.zoom, {animate: false}); - } + const defaultWorld = worlds.find(a => a.options.identifier === defaultLayerIdentifier) || worlds[0]; + if (defaultWorld) defaultWorld.addTo(this.mymap); this.renderPruningRegion(); 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 index 65cc43ca..8e172799 100644 --- 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 @@ -7,6 +7,9 @@ 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.*; @@ -137,7 +140,21 @@ 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); - ImageIO.write(image, "png", outFile); + // 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) { From 45abb35ec38fce8956e092d271d43b1640a86384 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Mon, 18 May 2026 21:48:06 +0200 Subject: [PATCH 33/40] Update tile placeholder src directly on tile_ready to avoid dual-image race --- .../tab/preview/chunkerPreviewLayer.js | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js index 120aed66..40928cc9 100644 --- a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js +++ b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js @@ -165,22 +165,38 @@ export const ChunkerPreviewLayer = L.GridLayer.extend({ : `session://${this._sessionId}/preview/${event.path}`; const layer = this; - const img = new Image(); - img.className = "chunker-tile"; - img.src = resolvedUrl; - img.onload = () => { - layer._cache.put(cacheKey, {img, blobUrl: resolvedUrl, sizeBytes: 512 * 512 * 4}); - if (pending) { - pending.tile.src = resolvedUrl; - pending.tile.className = "chunker-tile"; - layer._pending.delete(cacheKey); - pending.done(null); - return; - } - // No pending entry for this tile. Either createTile hasn't run yet, OR Leaflet pruned - // our placeholder during initial layout but still tracks the tile in its registry. - // Look it up in `_tiles` (Leaflet uses `x:y:z` as the key) and refresh the live DOM - // element so the image becomes visible without waiting for a zoom/pan to retrigger. + if (pending) { + // Update the placeholder directly. Browser fetches the PNG asynchronously and + // fires onload on the in the DOM; that's when we mark the tile loaded for + // Leaflet. Previously we routed through a separate internal Image() and only + // updated the placeholder once THAT had loaded — which created a race where the + // internal image could fail or fire out of order, leaving the live tile stuck + // on its transparent placeholder src. + const tileEl = pending.tile; + const done = pending.done; + layer._pending.delete(cacheKey); + tileEl.onload = () => { + tileEl.className = "chunker-tile"; + layer._cache.put(cacheKey, {img: tileEl, blobUrl: resolvedUrl, sizeBytes: 512 * 512 * 4}); + done(null); + }; + tileEl.onerror = () => { + tileEl.className = "chunker-tile chunker-tile-error"; + done(null); + }; + tileEl.src = resolvedUrl; + return; + } + + // No pending entry: cache for future createTile lookups, and if Leaflet still tracks + // this tile (the placeholder was created but pruned mid-load, leaving a stale entry in + // its `_tiles` registry), refresh that DOM element so the image becomes visible + // without waiting for a zoom/pan to retrigger createTile. + const cached = new Image(); + cached.className = "chunker-tile"; + cached.src = resolvedUrl; + cached.onload = () => { + layer._cache.put(cacheKey, {img: cached, blobUrl: resolvedUrl, sizeBytes: 512 * 512 * 4}); const leafletKey = event.tx + ":" + event.tz + ":" + event.lod; const tile = layer._tiles && layer._tiles[leafletKey]; if (tile && tile.el) { @@ -193,13 +209,6 @@ export const ChunkerPreviewLayer = L.GridLayer.extend({ } } }; - img.onerror = () => { - if (pending) { - pending.tile.className = "chunker-tile chunker-tile-error"; - layer._pending.delete(cacheKey); - pending.done(null); - } - }; }, _handleTileError(event) { From 485e6becdd39d69b076d6a6bd0afb3adec8740c9 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Tue, 19 May 2026 14:23:14 +0200 Subject: [PATCH 34/40] Serialize preview region loads to avoid LevelDB lock contention --- .../preview/ChunkerWorldRegionRgbaSource.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 index 6a7a8e81..6eea4a70 100644 --- 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 @@ -31,6 +31,17 @@ public class ChunkerWorldRegionRgbaSource implements RegionRgbaSource { 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. * @@ -42,6 +53,12 @@ public ChunkerWorldRegionRgbaSource(File 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 { // Compute the chunk range covered by this region int minCx = rx * 32; int minCz = rz * 32; From 398694c5a721114a1899e0d4321d8506aacfdb62 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Tue, 26 May 2026 10:56:05 +0200 Subject: [PATCH 35/40] Pre-load default world tiles into a shared cache before mounting the map --- app/ui/src/components/app.js | 172 +++++++++++++++++- .../settings/tab/preview/previewComponent.js | 41 ++++- 2 files changed, 202 insertions(+), 11 deletions(-) diff --git a/app/ui/src/components/app.js b/app/ui/src/components/app.js index e103c76f..2b4a7ad6 100644 --- a/app/ui/src/components/app.js +++ b/app/ui/src/components/app.js @@ -7,13 +7,27 @@ import {StepDisplay} from "./page/stepDisplay"; import {ErrorDisplay} from "./modal/errorDisplay"; import {Footer} from "./page/footer"; import {getVersionName} from "./screen/mode/modeOption"; -import {parseMapBin} from "./screen/settings/tab/preview/mapBin"; +import {parseMapBin, worldBoundsForAutoFit, isTileEmpty} from "./screen/settings/tab/preview/mapBin"; +import {computeMinZoom} from "./screen/settings/tab/preview/autoFit"; +import {ClientTileCache} from "./screen/settings/tab/preview/clientTileCache"; export class App extends Component { errorModal = React.createRef(); previewProgress = new ProgressTracker("Generating world preview", (newState) => this.setState({previewProgress: newState}), () => { this.cancelTask() }); + // Tracks the second preview phase — pre-loading default-LOD tiles of the world bounds + // so the Map mounts with content already on disk/cached. queuePosition is pinned to -1 + // so ProgressComponent renders the percentage bar from the start, not the "queue" UI. + previewTileProgress = (() => { + const t = new ProgressTracker("Loading map tiles", (newState) => this.setState({previewTileProgress: newState})); + t.state.queuePosition = -1; + return t; + })(); + // Shared between the App-level preloader and the Map's ChunkerPreviewLayer. Tiles fetched + // before Map mounts stay resident in this cache, so createTile finds them on first paint. + clientTileCache = new ClientTileCache(); + _preloadUnsub = null; settingsProgress = new ProgressTracker("Grabbing world information", (newState) => this.setState({settingsProgress: newState})); screen = React.createRef(); defaultConverterSettings = { @@ -28,6 +42,7 @@ export class App extends Component { }; state = { previewProgress: this.previewProgress.state, + previewTileProgress: this.previewTileProgress.state, settingsProgress: this.previewProgress.state, previewData: undefined, requestedPreview: false, @@ -190,17 +205,35 @@ export class App extends Component { generatePreview = () => { let self = this; + // Reset all three preview signals so a second generatePreview (different world / + // re-trigger) re-runs the full cycle cleanly. Order matters under legacy React's + // non-batched setState: kick previewProgress to 0 first so PreviewComponent flips to + // the generation branch before previewData is cleared — otherwise we'd flash + // "Loading map tiles" or an empty render in between. + if (this._preloadUnsub) { + this._preloadUnsub(); + this._preloadUnsub = null; + } + this.clientTileCache.evictAllExcept([]); + this.previewProgress.setProgress(0); + this.previewTileProgress.setProgress(0); + this.setState({previewData: undefined}); + api.send({type: "flow", method: "generate_preview"}, this.previewProgress.pipe(function (message) { if (message.type === "error") { if (!message.cancelled) { console.info("Failed to preview: " + message.error); self.showError("Failed to render preview", message.error, message.errorId, message.stackTrace, true); // Preview isn't required } + // Unblock the tile-loading branch so PreviewComponent doesn't hang there + // (previewData stays undefined so the Map branch won't fire either). + self.previewTileProgress.setProgress(100); } else if (message.type === "response") { let sessionId = self.state.sessionData && self.state.sessionData.session; if (!sessionId) { console.info("Preview response received but no session id available"); self.setState({previewData: undefined}); + self.previewTileProgress.setProgress(100); return; } fetch(`session://${sessionId}/preview/map.bin`) @@ -210,15 +243,19 @@ export class App extends Component { }) .then((buffer) => { if (buffer.byteLength > 0) { - self.setState({previewData: parseMapBin(buffer)}); + const parsed = parseMapBin(buffer); + self.setState({previewData: parsed}); + self.preloadDefaultWorldTiles(parsed, sessionId); } else { self.setState({previewData: undefined}); + self.previewTileProgress.setProgress(100); } }) .catch((err) => { console.info("Failed to fetch map.bin", err); self.showError("Failed to load preview metadata", err.message || String(err), undefined, undefined, true); self.setState({previewData: undefined}); + self.previewTileProgress.setProgress(100); }); } else { console.info("Unknown response", message); @@ -226,6 +263,137 @@ export class App extends Component { })); }; + /** + * Pre-loads the default world's tiles before the Map mounts so it paints from a warm + * cache. We enqueue tiles across multiple LODs — starting at the auto-fit LOD and + * stepping toward LOD 0 — so the progress bar has enough granularity to look smooth. + * The Java side's {@code PreviewTileCache} reuses the lower-LOD tiles when aggregating + * higher-LOD ones, so adding intermediate LODs costs essentially no extra region work; + * it only exposes more {@code tile_ready} events for us to count. + */ + preloadDefaultWorldTiles = (parsedMapBin, sessionId) => { + const dims = this.state.settings && this.state.settings.dimensions; + if (!dims || dims.length === 0) { + this.previewTileProgress.setProgress(100); + return; + } + const layer = (this.state.previewState && this.state.previewState.layer) || dims[0]; + const bounds = worldBoundsForAutoFit(parsedMapBin, layer); + if (!bounds) { + this.previewTileProgress.setProgress(100); + return; + } + const autoFitLod = computeMinZoom(bounds); + + // Soft cap on tracked tiles. At auto-fit LOD alone the world is 1–4 tiles, which + // makes the bar jump in 25–100% steps. We grow the tracked set by stepping toward + // LOD 0 (4x more tiles each step) until the next LOD would blow past this budget, + // which lands us around 80–120 tracked events regardless of world size — fine + // granularity without unbounded enqueue for huge worlds. + const MAX_TRACKED = 120; + const expected = new Set(); // keys: "lod,tx,tz" + const lodRequests = []; + for (let l = autoFitLod; l <= 0; l++) { + const s = 1 << (-l); + const chunksPerTile = 32 * s; // 1 native tile = 512 px = 32 chunks at LOD 0 + const minTx = Math.floor(bounds.minX / chunksPerTile); + const maxTx = Math.floor(bounds.maxX / chunksPerTile); + const minTz = Math.floor(bounds.minZ / chunksPerTile); + const maxTz = Math.floor(bounds.maxZ / chunksPerTile); + const candidates = []; + for (let tx = minTx; tx <= maxTx; tx++) { + for (let tz = minTz; tz <= maxTz; tz++) { + if (!isTileEmpty(parsedMapBin, layer, l, tx, tz)) { + candidates.push(`${l},${tx},${tz}`); + } + } + } + if (candidates.length === 0) continue; + // Stop expanding to deeper LODs if adding them would push past the budget — but + // only once we already have a coarser LOD enqueued. For the very first LOD we + // accept its tiles unconditionally, otherwise a huge world would pre-load nothing. + if (lodRequests.length > 0 && expected.size + candidates.length > MAX_TRACKED) break; + for (const k of candidates) expected.add(k); + lodRequests.push({lod: l, minTx, maxTx, minTz, maxTz}); + } + + if (expected.size === 0) { + this.previewTileProgress.setProgress(100); + return; + } + + const total = expected.size; + let received = 0; + this.previewTileProgress.setProgress(0); + + // Lerp the displayed progress toward the real one. With ~85 tracked tiles the bar + // moves in ~1% steps anyway, but tile_ready events arrive in bursts (a slow region + // load releases a flurry of cheap aggregated tiles right after), so the raw bar + // stutters. The lerp + the existing 300 ms CSS ease on .progress_fill gives a smooth + // continuous animation between bursts. + let displayedProgress = 0; + let targetProgress = 0; + const lerpTimer = setInterval(() => { + const diff = targetProgress - displayedProgress; + if (Math.abs(diff) < 0.1) return; + displayedProgress += diff * 0.15; + this.previewTileProgress.setProgress(displayedProgress); + }, 100); + + const finish = () => { + clearInterval(lerpTimer); + if (this._preloadUnsub) { + this._preloadUnsub(); + this._preloadUnsub = null; + } + this.previewTileProgress.setProgress(100); + }; + const advance = () => { + received++; + if (received >= total) { + finish(); + } else { + targetProgress = (received / total) * 100; + } + }; + const onTile = (event) => { + if (event.world !== layer) return; + const key = `${event.lod},${event.tx},${event.tz}`; + if (!expected.has(key)) return; + expected.delete(key); + // Populate the shared cache for every preloaded LOD so ChunkerPreviewLayer.createTile + // finds a hit when the user zooms within the preloaded range (not just at autoFit). + const cacheKey = this.clientTileCache.keyFor(layer, event.lod, event.tx, event.tz); + const resolvedUrl = event.path && event.path.startsWith("session://") + ? event.path + : `session://${sessionId}/preview/${event.path}`; + this.clientTileCache.put(cacheKey, {blobUrl: resolvedUrl, sizeBytes: 512 * 512 * 4}); + advance(); + }; + const onTileError = (event) => { + if (event.world !== layer) return; + const key = `${event.lod},${event.tx},${event.tz}`; + if (!expected.has(key)) return; + expected.delete(key); + advance(); + }; + const unsubReady = api.addListener("tile_ready", onTile); + const unsubError = api.addListener("tile_error", onTileError); + this._preloadUnsub = () => { + clearInterval(lerpTimer); + if (unsubReady) unsubReady(); + if (unsubError) unsubError(); + }; + + // One rect request per LOD. Java's PreviewTileService dedupes via inFlight, so + // overlapping work between LODs (e.g. LOD -3 internally aggregating cached LOD -2 + // children) won't cause duplicated region loads. + for (const r of lodRequests) { + api.send({type: "flow", method: "request_preview_tiles", + world: layer, lod: r.lod, minTx: r.minTx, minTz: r.minTz, maxTx: r.maxTx, maxTz: r.maxTz}, () => {}); + } + }; + cancelTask = (callback) => { api.send({type: "flow", method: "cancel"}, function (message) { if (callback) { diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js index a181eea0..baf43429 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js @@ -9,7 +9,6 @@ import "leaflet-fullscreen/dist/leaflet.fullscreen.css"; import {getDimensionDisplayName} from "../dimensionPruningTab"; import {worldBoundsForAutoFit} from "./mapBin"; import {computeMinZoom} from "./autoFit"; -import {ClientTileCache} from "./clientTileCache"; import {ChunkerPreviewLayer} from "./chunkerPreviewLayer"; import {CenterCoordsControl} from "./centerCoordsControl"; import {ZoomIndicator} from "./zoomIndicator"; @@ -32,16 +31,27 @@ export class PreviewComponent extends Component { app = this.props.app; render() { + const genDone = this.app.previewProgress.isComplete(); + const data = this.app.state.previewData; + const tilesDone = this.app.previewTileProgress.isComplete(); return ( - {!this.app.previewProgress.isComplete() && + {!genDone &&

} - {this.app.previewProgress.isComplete() && this.app.state.previewData !== undefined && + {/* Covers the gap between generation completing and previewData arriving from + * the map.bin fetch, as well as the actual tile pre-load phase. */} + {genDone && !tilesDone && +
+ +
+ } + {genDone && tilesDone && data !== undefined && } @@ -57,7 +67,8 @@ export class Map extends Component { const self = this; // this.props.data is the already-parsed map.bin (Task 14). this._mapBin = this.props.data; - this._cache = new ClientTileCache(); + // App owns the cache so tiles pre-loaded before mount survive into the layer here. + this._cache = this.props.clientTileCache; const dimensions = this.app.state.settings.dimensions; const defaultIdentifier = (this.app.state.previewState && this.app.state.previewState.layer) || dimensions[0]; @@ -76,11 +87,23 @@ export class Map extends Component { initialZoom = this.app.state.previewState.zoom; defaultLayerIdentifier = this.app.state.previewState.layer; } else { - const centerX = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnX")[0].value; - const centerZ = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnZ")[0].value; - initialCenter = xy(centerX, centerZ); - initialZoom = 2; defaultLayerIdentifier = dimensions[0]; + const bounds = worldBoundsForAutoFit(this._mapBin, defaultLayerIdentifier); + if (bounds) { + // Frame the map on the world bounds. Centring on SpawnX/SpawnZ at a fixed + // zoom of 2 leaves worlds with content built far from spawn opening onto an + // empty viewport — the tiles exist, just off-screen. Auto-fit puts the actual + // content in front of the user from the start. + const centerX = (bounds.minX + bounds.maxX + 1) * 8; + const centerZ = (bounds.minZ + bounds.maxZ + 1) * 8; + initialCenter = xy(centerX, centerZ); + initialZoom = computeMinZoom(bounds); + } else { + const centerX = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnX")[0].value; + const centerZ = self.app.state.settings.settings["World Settings"].filter(a => a.name === "SpawnZ")[0].value; + initialCenter = xy(centerX, centerZ); + initialZoom = 2; + } } this.mymap = L.map("map", { From b04d2fd519f7b3087a830613a63fcf918905d097 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Tue, 26 May 2026 11:02:54 +0200 Subject: [PATCH 36/40] Warm adjacent LOD tiles on moveend so zoom steps paint from cache --- .../tab/preview/chunkerPreviewLayer.js | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js index 40928cc9..a93de1f2 100644 --- a/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js +++ b/app/ui/src/components/screen/settings/tab/preview/chunkerPreviewLayer.js @@ -42,6 +42,11 @@ export const ChunkerPreviewLayer = L.GridLayer.extend({ L.GridLayer.prototype.onAdd.call(this, map); map.on("zoomstart", this._onZoomStart, this); map.on("zoomend", this._onZoomEnd, this); + map.on("moveend", this._onMoveEnd, this); + // Initial warm: fires once the layer has its tile range settled. setTimeout(0) defers + // past L.GridLayer.onAdd's internal _resetView so this._tileZoom and the px bounds + // are valid when we read them. + setTimeout(() => this._warmAdjacentLods(), 0); return this; }, @@ -50,6 +55,7 @@ export const ChunkerPreviewLayer = L.GridLayer.extend({ if (this._unsubError) this._unsubError(); map.off("zoomstart", this._onZoomStart, this); map.off("zoomend", this._onZoomEnd, this); + map.off("moveend", this._onMoveEnd, this); if (this._dispatchTimer) { clearTimeout(this._dispatchTimer); this._dispatchTimer = null; @@ -228,5 +234,57 @@ export const ChunkerPreviewLayer = L.GridLayer.extend({ _onZoomEnd() { this._zoomTransitionActive = false; + }, + + _onMoveEnd() { + // moveend fires after both pan and zoom completion (Leaflet emits move events even + // during a zoom), so one handler covers both cases for the adjacent-LOD warm. + this._warmAdjacentLods(); + }, + + // Background-renders the lod ± 1 tiles for the current viewport so the next user zoom + // step paints from a warm cache instead of placeholders. Same IPC plumbing as the + // foreground dispatch — tile_ready lands in _handleTileReady's else branch, which + // populates the shared clientTileCache without disturbing any visible tile. + _warmAdjacentLods() { + if (!this._map || this._zoomTransitionActive) return; + const currentLod = this._tileZoom; + if (currentLod === undefined || currentLod === null) return; + const pxBounds = this._getTiledPixelBounds(this._map.getCenter()); + if (!pxBounds) return; + const range = this._pxBoundsToTileRange(pxBounds); + if (!range) return; + const sourceMinTx = range.min.x; + const sourceMaxTx = range.max.x; + const sourceMinTz = range.min.y; + const sourceMaxTz = range.max.y; + this._dispatchAdjacentLod(currentLod, sourceMinTx, sourceMaxTx, sourceMinTz, sourceMaxTz, currentLod + 1); + this._dispatchAdjacentLod(currentLod, sourceMinTx, sourceMaxTx, sourceMinTz, sourceMaxTz, currentLod - 1); + }, + + _dispatchAdjacentLod(sourceLod, sourceMinTx, sourceMaxTx, sourceMinTz, sourceMaxTz, targetLod) { + if (targetLod > this.options.maxNativeZoom) return; + if (targetLod < this.options.minNativeZoom) return; + // Tile coords scale with LOD: each step toward 0 (finer) doubles the tile count per + // side over the same world area; each step away from 0 (coarser) halves it. + let minTx, maxTx, minTz, maxTz; + if (targetLod > sourceLod) { + const f = 1 << (targetLod - sourceLod); + minTx = sourceMinTx * f; + maxTx = (sourceMaxTx + 1) * f - 1; + minTz = sourceMinTz * f; + maxTz = (sourceMaxTz + 1) * f - 1; + } else { + const f = 1 << (sourceLod - targetLod); + minTx = Math.floor(sourceMinTx / f); + maxTx = Math.floor(sourceMaxTx / f); + minTz = Math.floor(sourceMinTz / f); + maxTz = Math.floor(sourceMaxTz / f); + } + this._ipc.requestTiles({ + world: this.options.identifier, + lod: targetLod, + minTx, minTz, maxTx, maxTz + }); } }); From 0fe4665c12007f10d084f24e1ab30b51ec0bcaad Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Tue, 26 May 2026 11:17:37 +0200 Subject: [PATCH 37/40] Unify preview loading wrapper and abort Java tile work on cancel --- app/ui/src/components/app.js | 42 ++++++++++++++++++- .../settings/tab/preview/previewComponent.js | 14 +++++-- .../screen/settings/tab/previewTab.js | 16 +++---- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/app/ui/src/components/app.js b/app/ui/src/components/app.js index 2b4a7ad6..99515f7e 100644 --- a/app/ui/src/components/app.js +++ b/app/ui/src/components/app.js @@ -19,8 +19,14 @@ export class App extends Component { // Tracks the second preview phase — pre-loading default-LOD tiles of the world bounds // so the Map mounts with content already on disk/cached. queuePosition is pinned to -1 // so ProgressComponent renders the percentage bar from the start, not the "queue" UI. + // cancelTilePreload aborts the Java-side rendering for the world and leaves the bar in + // its cancelled state so the Map never mounts; the user explicitly opted out of the preview. previewTileProgress = (() => { - const t = new ProgressTracker("Loading map tiles", (newState) => this.setState({previewTileProgress: newState})); + const t = new ProgressTracker( + "Loading map tiles", + (newState) => this.setState({previewTileProgress: newState}), + () => this.cancelTilePreload() + ); t.state.queuePosition = -1; return t; })(); @@ -28,6 +34,7 @@ export class App extends Component { // before Map mounts stay resident in this cache, so createTile finds them on first paint. clientTileCache = new ClientTileCache(); _preloadUnsub = null; + _preloadingWorld = null; settingsProgress = new ProgressTracker("Grabbing world information", (newState) => this.setState({settingsProgress: newState})); screen = React.createRef(); defaultConverterSettings = { @@ -214,9 +221,12 @@ export class App extends Component { this._preloadUnsub(); this._preloadUnsub = null; } + this._preloadingWorld = null; this.clientTileCache.evictAllExcept([]); this.previewProgress.setProgress(0); - this.previewTileProgress.setProgress(0); + // Reset progress + clear any cancelled/errored flags left from a previous run so + // ProgressComponent doesn't render "This task was cancelled." for the new generation. + this.previewTileProgress.updateState({progress: 0, queuePosition: -1, cancelled: false, errored: false}); this.setState({previewData: undefined}); api.send({type: "flow", method: "generate_preview"}, this.previewProgress.pipe(function (message) { @@ -272,12 +282,20 @@ export class App extends Component { * it only exposes more {@code tile_ready} events for us to count. */ preloadDefaultWorldTiles = (parsedMapBin, sessionId) => { + // User clicked Cancel before the fetch returned: respect that decision instead of + // resetting progress to 0 and resuming the pre-load. Check both isComplete (the + // legacy "skip-to-Map" cancel path) and isCancelled (the current path where the + // bar stays at the cancelled message instead of jumping to 100). + if (this.previewTileProgress.isComplete() || this.previewTileProgress.isCancelled()) { + return; + } const dims = this.state.settings && this.state.settings.dimensions; if (!dims || dims.length === 0) { this.previewTileProgress.setProgress(100); return; } const layer = (this.state.previewState && this.state.previewState.layer) || dims[0]; + this._preloadingWorld = layer; // cancelTilePreload needs this to address the right Java queue const bounds = worldBoundsForAutoFit(parsedMapBin, layer); if (!bounds) { this.previewTileProgress.setProgress(100); @@ -394,6 +412,26 @@ export class App extends Component { } }; + /** + * Cancels the tile pre-load: tells Java to drop all queued tile work for the world and + * releases the UI listeners. Progress stays where it was — ProgressTracker.cancel() has + * already flipped {@code cancelled} on, so ProgressComponent renders "This task was + * cancelled." and PreviewComponent stays in the tile-loading branch (the Map does not + * mount). The user explicitly opted out, so we don't auto-recover. + */ + cancelTilePreload = () => { + if (this._preloadingWorld) { + // No `lod` field → Java drops all in-flight tiles for the world across every LOD + // we enqueued during the pre-load. + api.send({type: "flow", method: "cancel_preview_tiles", world: this._preloadingWorld}, () => {}); + this._preloadingWorld = null; + } + if (this._preloadUnsub) { + this._preloadUnsub(); + this._preloadUnsub = null; + } + }; + cancelTask = (callback) => { api.send({type: "flow", method: "cancel"}, function (message) { if (callback) { diff --git a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js index baf43429..b19af0eb 100644 --- a/app/ui/src/components/screen/settings/tab/preview/previewComponent.js +++ b/app/ui/src/components/screen/settings/tab/preview/previewComponent.js @@ -31,24 +31,30 @@ export class PreviewComponent extends Component { app = this.props.app; render() { + const settingsDone = this.app.settingsProgress.isComplete(); const genDone = this.app.previewProgress.isComplete(); const data = this.app.state.previewData; const tilesDone = this.app.previewTileProgress.isComplete(); return ( - {!genDone && + {!settingsDone && +
+ +
+ } + {settingsDone && !genDone &&
} {/* Covers the gap between generation completing and previewData arriving from * the map.bin fetch, as well as the actual tile pre-load phase. */} - {genDone && !tilesDone && + {settingsDone && genDone && !tilesDone &&
- +
} - {genDone && tilesDone && data !== undefined && + {settingsDone && genDone && tilesDone && data !== undefined && - {(!this.app.settingsProgress.isComplete() && -
- -
- )} - {(this.app.settingsProgress.isComplete() && - - )} -
+ ); } } \ No newline at end of file From ffa70ad818b6a24f0d89f12bb37539d0b948fe02 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Fri, 5 Jun 2026 13:27:31 +0200 Subject: [PATCH 38/40] Pick newest CLI jar when multiple builds exist Sort dev build files by name using numeric localeCompare and choose the first entry so the newest versioned chunker-cli jar is launched when multiple jars are present (prevents launching an older build due to OS file ordering). Modified app/electron/src/session.js. --- app/electron/src/session.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/electron/src/session.js b/app/electron/src/session.js index ad9a3102..2e78503b 100644 --- a/app/electron/src/session.js +++ b/app/electron/src/session.js @@ -66,6 +66,12 @@ export class Session { if (files.length === 0) { throw new Error("chunker-cli executable is missing!"); } + + // The dev build directory can contain multiple versioned jars (e.g. an older + // chunker-cli-1.17.0.jar left next to chunker-cli-1.18.1.jar). Sort by name using + // numeric ordering so we always launch the newest build instead of whichever the OS + // happens to list first. + files.sort((a, b) => b.name.localeCompare(a.name, undefined, {numeric: true})); executable = path.join(cliDirectory, files[0].name); } From b59308a9da36341a4f780cae59617739c154673f Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Fri, 5 Jun 2026 14:27:55 +0200 Subject: [PATCH 39/40] Retry preview region reads when the LevelDB lock is briefly unavailable Each preview region opens the world's LevelDB afresh, since the converter frees (and so closes) the reader after every region. A brief overlap with the previous reader's not-yet-released handle, or a stale db/LOCK left by a crashed run, makes iq80 throw "Unable to acquire lock" and the tile is lost. Add PreviewLevelDb.withLockRetry, which clears a stale LOCK and retries on lock-acquisition failures, and wrap the per-region Bedrock reads with it. --- .../preview/ChunkerWorldRegionRgbaSource.java | 12 +++ .../encoding/preview/PreviewLevelDb.java | 76 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 cli/src/main/java/com/hivemc/chunker/conversion/encoding/preview/PreviewLevelDb.java 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 index 6eea4a70..bd33575e 100644 --- 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 @@ -59,6 +59,18 @@ public int[] loadRegion(String world, int rx, int rz) throws IOException { } 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; 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; + } +} From 7120eeee4bc62f6984770ffb9403d9690831eb79 Mon Sep 17 00:00:00 2001 From: Fabrizio La Rosa Date: Fri, 5 Jun 2026 14:28:20 +0200 Subject: [PATCH 40/40] Fix Bedrock sub-chunk key parsing in the preview metadata scan The scan read the sub-chunk Y byte as the chunk type and skipped the real type byte, so SUB_CHUNK_PREFIX keys were discarded. The on-disk layout is [x][z][(dim)][type][y] (see LevelDBKey) with the type before the Y, so read the type directly. Also open the database through PreviewLevelDb so the metadata pass survives a transient lock instead of leaving the preview with no map.bin. The test built its keys with the Y and type swapped, matching the old bug; correct it to the real layout. --- .../encoding/preview/PreviewMetadataReader.java | 10 ++++++---- .../encoding/preview/PreviewMetadataReaderTests.java | 11 ++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) 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 index 94f8da36..3817a547 100644 --- 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 @@ -114,7 +114,6 @@ public void readBedrockWorld(File worldDir, File outputFolder) throws IOExceptio } File dbDir = new File(worldDir, "db"); - new File(dbDir, "LOCK").delete(); Options options = new Options(); options.compressionType(CompressionType.ZLIB_RAW); @@ -136,8 +135,10 @@ public void readBedrockWorld(File worldDir, File outputFolder) throws IOExceptio final byte TYPE_BLOCK_ENTITY = 49; final byte TYPE_ENTITY = 50; - try (DB db = new Iq80DBFactory().open(dbDir, options); - DBIterator iterator = db.iterator()) { + // 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; @@ -151,7 +152,8 @@ public void readBedrockWorld(File worldDir, File outputFolder) throws IOExceptio int x = buffer.getInt(); int z = buffer.getInt(); int dimensionID = containsDimension ? buffer.getInt() : 0; - if (containsSubChunk) buffer.get(); + // 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 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 index 225662e5..bc99d729 100644 --- 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 @@ -93,7 +93,7 @@ public void testReadsBedrockWorldKeysOnly(@TempDir Path tmp) throws Exception { 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) subY(1) tag(1) + // 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) @@ -106,7 +106,7 @@ public void testReadsBedrockWorldKeysOnly(@TempDir Path tmp) throws Exception { // 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) subY(1) tag(1) + // 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}); @@ -137,12 +137,13 @@ public void testReadsBedrockWorldKeysOnly(@TempDir Path tmp) throws Exception { assertTrue(end.regionHasAnyChunk(0, -1), "End region (0,-1) should contain chunk (5,-5)"); } - // Bedrock key builders (little-endian). + // 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); - if (withSub) buf.put((byte) subY); 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) { @@ -158,7 +159,7 @@ private static byte[] bedrockKeyWithDim(int x, int z, int dim, int tag) { } 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) subY).put((byte) tag); + buf.putInt(x).putInt(z).putInt(dim).put((byte) tag).put((byte) subY); return buf.array(); } }